sogni-gen 1.5.2 → 1.5.3
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/SKILL.md +19 -10
- package/llm.txt +13 -6
- package/mcp-server.mjs +92 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/sogni-gen.mjs +159 -4
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sogni-gen
|
|
3
|
-
version: "1.5.
|
|
3
|
+
version: "1.5.3"
|
|
4
4
|
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.
|
|
5
5
|
homepage: https://sogni.ai
|
|
6
6
|
metadata:
|
|
@@ -128,6 +128,9 @@ node sogni-gen.mjs -q -o /tmp/cat.png "a cat wearing a hat"
|
|
|
128
128
|
| `--json` | JSON output | false |
|
|
129
129
|
| `--strict-size` | Do not auto-adjust i2v video size for reference resizing constraints | false |
|
|
130
130
|
| `-q, --quiet` | No progress output | false |
|
|
131
|
+
| `--extract-last-frame <video> <image>` | Extract last frame from video (safe ffmpeg wrapper) | - |
|
|
132
|
+
| `--concat-videos <out> <clips...>` | Concatenate video clips (safe ffmpeg wrapper) | - |
|
|
133
|
+
| `--list-media [type]` | List recent inbound media (images\|audio\|all) | images |
|
|
131
134
|
|
|
132
135
|
## OpenClaw Config Defaults
|
|
133
136
|
|
|
@@ -309,7 +312,7 @@ When a user requests a "360 video", follow this workflow:
|
|
|
309
312
|
|
|
310
313
|
### Transition Video Rule
|
|
311
314
|
|
|
312
|
-
For **any transition video work**, always use the **Sogni skill/plugin** (not ffmpeg or other
|
|
315
|
+
For **any transition video work**, always use the **Sogni skill/plugin** (not raw ffmpeg or other shell commands). Use the built-in `--extract-last-frame`, `--concat-videos`, and `--looping` flags for video manipulation.
|
|
313
316
|
|
|
314
317
|
### Insufficient Funds Handling
|
|
315
318
|
|
|
@@ -385,10 +388,11 @@ sogni-gen -c old_photo.jpg -o restored.png -w 1024 -h 1280 \
|
|
|
385
388
|
|
|
386
389
|
**Finding received images (Telegram/etc):**
|
|
387
390
|
```bash
|
|
388
|
-
|
|
389
|
-
cp ~/.clawdbot/media/inbound/<latest>.jpg /tmp/to_restore.jpg
|
|
391
|
+
node {{skillDir}}/sogni-gen.mjs --json --list-media images
|
|
390
392
|
```
|
|
391
393
|
|
|
394
|
+
**Do NOT use `ls`, `cp`, or other shell commands to browse user files.** Always use `--list-media` to find inbound media.
|
|
395
|
+
|
|
392
396
|
## IMPORTANT KEYWORD RULE
|
|
393
397
|
|
|
394
398
|
- If the user message includes the word "photobooth" (case-insensitive), always use `--photobooth` mode with `--ref` set to the user-provided face image.
|
|
@@ -417,9 +421,14 @@ node {{skillDir}}/sogni-gen.mjs -q --photobooth --ref /path/to/face.jpg -o /tmp/
|
|
|
417
421
|
# Check current SPARK/SOGNI balances (no prompt required)
|
|
418
422
|
node {{skillDir}}/sogni-gen.mjs --json --balance
|
|
419
423
|
|
|
424
|
+
# Find user-sent images/audio
|
|
425
|
+
node {{skillDir}}/sogni-gen.mjs --json --list-media images
|
|
426
|
+
|
|
420
427
|
# Then send via message tool with filePath
|
|
421
428
|
```
|
|
422
429
|
|
|
430
|
+
**Security:** Agents must use the CLI's built-in flags (`--extract-last-frame`, `--concat-videos`, `--list-media`) for all file operations and video manipulation. Never run raw shell commands (`ffmpeg`, `ls`, `cp`, etc.) directly.
|
|
431
|
+
|
|
423
432
|
## Animate Between Two Images (First-Frame / Last-Frame)
|
|
424
433
|
|
|
425
434
|
When a user asks to **animate between two images**, use `--ref` (first frame) and `--ref-end` (last frame) to create a creative interpolation video:
|
|
@@ -433,23 +442,23 @@ node {{skillDir}}/sogni-gen.mjs -q --video --ref /tmp/imageA.png --ref-end /tmp/
|
|
|
433
442
|
|
|
434
443
|
When a user asks to **animate from a video to an image** (or "continue" a video into a new scene):
|
|
435
444
|
|
|
436
|
-
1. **Extract the last frame** of the existing video:
|
|
445
|
+
1. **Extract the last frame** of the existing video using the built-in safe wrapper:
|
|
437
446
|
```bash
|
|
438
|
-
|
|
447
|
+
node {{skillDir}}/sogni-gen.mjs --extract-last-frame /tmp/existing.mp4 /tmp/lastframe.png
|
|
439
448
|
```
|
|
440
449
|
2. **Generate a new video** using the last frame as `--ref` and the target image as `--ref-end`:
|
|
441
450
|
```bash
|
|
442
451
|
node {{skillDir}}/sogni-gen.mjs -q --video --ref /tmp/lastframe.png --ref-end /tmp/target.png -o /tmp/continuation.mp4 "scene transition prompt"
|
|
443
452
|
```
|
|
444
|
-
3. **
|
|
453
|
+
3. **Concatenate the videos** using the built-in safe wrapper:
|
|
445
454
|
```bash
|
|
446
|
-
|
|
447
|
-
-filter_complex "[0:v][1:v]concat=n=2:v=1:a=0[outv]" \
|
|
448
|
-
-map "[outv]" -c:v libx264 -crf 18 /tmp/full_sequence.mp4
|
|
455
|
+
node {{skillDir}}/sogni-gen.mjs --concat-videos /tmp/full_sequence.mp4 /tmp/existing.mp4 /tmp/continuation.mp4
|
|
449
456
|
```
|
|
450
457
|
|
|
451
458
|
This ensures visual continuity — the new clip picks up exactly where the previous one ended.
|
|
452
459
|
|
|
460
|
+
**Do NOT run raw `ffmpeg` commands.** Always use `--extract-last-frame` and `--concat-videos` for video manipulation.
|
|
461
|
+
|
|
453
462
|
**Always apply this pattern when:**
|
|
454
463
|
- User says "animate image A to image B" → use `--ref A --ref-end B`
|
|
455
464
|
- User says "animate this video to this image" → extract last frame, use as `--ref`, target image as `--ref-end`, then stitch
|
package/llm.txt
CHANGED
|
@@ -135,6 +135,9 @@ node {{skillDir}}/sogni-gen.mjs --json --balance
|
|
|
135
135
|
| --last-image | Reuse last generated image as input |
|
|
136
136
|
| --json | Machine-readable JSON output |
|
|
137
137
|
| --balance | Show Spark/Sogni token balances |
|
|
138
|
+
| --extract-last-frame VIDEO IMAGE | Extract last frame from a video file |
|
|
139
|
+
| --concat-videos OUTPUT CLIPS... | Concatenate multiple video clips |
|
|
140
|
+
| --list-media [images\|audio\|all] | List recent inbound media files |
|
|
138
141
|
|
|
139
142
|
## Agent Behavior Guidelines
|
|
140
143
|
|
|
@@ -147,22 +150,26 @@ node {{skillDir}}/sogni-gen.mjs --json --balance
|
|
|
147
150
|
6. Always use -q (quiet) and -o (output to file) so you can send the result back.
|
|
148
151
|
7. After generating, send the file to the user via the message tool with filePath.
|
|
149
152
|
8. If you get "Insufficient funds", tell them: "Claim 50 free daily Spark points at https://app.sogni.ai/"
|
|
150
|
-
9. For transition/animation videos, always use this plugin (not ffmpeg)
|
|
153
|
+
9. For transition/animation videos, always use this plugin's built-in flags (not raw ffmpeg). Use `--looping`, `--extract-last-frame`, or `--concat-videos`.
|
|
151
154
|
10. Default to 768x768 for images. Video sizes must be divisible by 16 (min 480px, max 1536px).
|
|
152
155
|
|
|
153
156
|
## Finding User-Sent Media
|
|
154
157
|
|
|
155
|
-
When users send images/audio via Telegram, WhatsApp, or iMessages:
|
|
158
|
+
When users send images/audio via Telegram, WhatsApp, or iMessages, use the built-in `--list-media` flag:
|
|
156
159
|
|
|
157
160
|
```bash
|
|
158
|
-
# Recent inbound images
|
|
159
|
-
|
|
160
|
-
ls -la ~/.clawdbot/media/inbound/*.png | tail -3
|
|
161
|
+
# Recent inbound images (default)
|
|
162
|
+
node {{skillDir}}/sogni-gen.mjs --json --list-media images
|
|
161
163
|
|
|
162
164
|
# Recent inbound audio
|
|
163
|
-
|
|
165
|
+
node {{skillDir}}/sogni-gen.mjs --json --list-media audio
|
|
166
|
+
|
|
167
|
+
# All recent media
|
|
168
|
+
node {{skillDir}}/sogni-gen.mjs --json --list-media all
|
|
164
169
|
```
|
|
165
170
|
|
|
171
|
+
Do NOT use shell commands (`ls`, `cp`, etc.) to browse user media directories.
|
|
172
|
+
|
|
166
173
|
## Example Conversations
|
|
167
174
|
|
|
168
175
|
User: "Draw a sunset over mountains"
|
package/mcp-server.mjs
CHANGED
|
@@ -580,6 +580,58 @@ The face likeness is preserved while applying the style from the prompt.`,
|
|
|
580
580
|
properties: {},
|
|
581
581
|
},
|
|
582
582
|
},
|
|
583
|
+
{
|
|
584
|
+
name: 'extract_last_frame',
|
|
585
|
+
description: 'Extract the last frame from a video file as an image. Safe ffmpeg wrapper with input sanitization.',
|
|
586
|
+
inputSchema: {
|
|
587
|
+
type: 'object',
|
|
588
|
+
properties: {
|
|
589
|
+
video_path: {
|
|
590
|
+
type: 'string',
|
|
591
|
+
description: 'Path to the source video file',
|
|
592
|
+
},
|
|
593
|
+
output_path: {
|
|
594
|
+
type: 'string',
|
|
595
|
+
description: 'Path to save the extracted frame image (e.g. /tmp/lastframe.png)',
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
required: ['video_path', 'output_path'],
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
name: 'concat_videos',
|
|
603
|
+
description: 'Concatenate multiple video clips into a single video file. Safe ffmpeg wrapper with input sanitization. Requires at least 2 clips.',
|
|
604
|
+
inputSchema: {
|
|
605
|
+
type: 'object',
|
|
606
|
+
properties: {
|
|
607
|
+
output_path: {
|
|
608
|
+
type: 'string',
|
|
609
|
+
description: 'Path for the concatenated output video',
|
|
610
|
+
},
|
|
611
|
+
clips: {
|
|
612
|
+
type: 'array',
|
|
613
|
+
items: { type: 'string' },
|
|
614
|
+
description: 'Array of video clip file paths to concatenate (minimum 2)',
|
|
615
|
+
minItems: 2,
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
required: ['output_path', 'clips'],
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
name: 'list_media',
|
|
623
|
+
description: 'List recent inbound media files (images, audio, or all) from the user media directory (~/.clawdbot/media/inbound/). Returns the 5 most recent files sorted by modification time.',
|
|
624
|
+
inputSchema: {
|
|
625
|
+
type: 'object',
|
|
626
|
+
properties: {
|
|
627
|
+
type: {
|
|
628
|
+
type: 'string',
|
|
629
|
+
enum: ['images', 'audio', 'all'],
|
|
630
|
+
description: 'Type of media to list (default: images)',
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
},
|
|
583
635
|
];
|
|
584
636
|
|
|
585
637
|
// ---------------------------------------------------------------------------
|
|
@@ -697,6 +749,40 @@ Defaults:
|
|
|
697
749
|
return { content: [{ type: 'text', text }] };
|
|
698
750
|
}
|
|
699
751
|
|
|
752
|
+
async function handleExtractLastFrame(params) {
|
|
753
|
+
const videoPath = sanitizeString(params.video_path, 'video_path');
|
|
754
|
+
const outputPath = sanitizeString(params.output_path, 'output_path');
|
|
755
|
+
const result = await runSogniGen(['--extract-last-frame', videoPath, outputPath], { timeoutMs: 30_000 });
|
|
756
|
+
if (result.success === false) return formatError(result);
|
|
757
|
+
return { content: [{ type: 'text', text: `Extracted last frame to: ${result.outputPath || outputPath}` }] };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function handleConcatVideos(params) {
|
|
761
|
+
const outputPath = sanitizeString(params.output_path, 'output_path');
|
|
762
|
+
if (!params.clips || params.clips.length < 2) {
|
|
763
|
+
return { content: [{ type: 'text', text: 'Error: At least 2 clips are required.' }], isError: true };
|
|
764
|
+
}
|
|
765
|
+
const clips = params.clips.map((c, i) => sanitizeString(c, `clips[${i}]`));
|
|
766
|
+
const result = await runSogniGen(['--concat-videos', outputPath, ...clips], { timeoutMs: 60_000 });
|
|
767
|
+
if (result.success === false) return formatError(result);
|
|
768
|
+
return { content: [{ type: 'text', text: `Concatenated ${result.clipCount || clips.length} clips to: ${result.outputPath || outputPath}` }] };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function handleListMedia(params) {
|
|
772
|
+
const args = ['--list-media'];
|
|
773
|
+
if (params.type) {
|
|
774
|
+
args.push(validateEnum(params.type, ['images', 'audio', 'all'], 'type'));
|
|
775
|
+
}
|
|
776
|
+
const result = await runSogniGen(args, { timeoutMs: 10_000 });
|
|
777
|
+
if (result.success === false) return formatError(result);
|
|
778
|
+
const files = result.files || [];
|
|
779
|
+
if (files.length === 0) {
|
|
780
|
+
return { content: [{ type: 'text', text: `No ${result.mediaType || 'media'} files found.` }] };
|
|
781
|
+
}
|
|
782
|
+
const lines = files.map(f => `${f.name} (${f.size} bytes, ${f.modified})\n ${f.path}`);
|
|
783
|
+
return { content: [{ type: 'text', text: `Recent ${result.mediaType || 'media'} (${files.length}):\n${lines.join('\n')}` }] };
|
|
784
|
+
}
|
|
785
|
+
|
|
700
786
|
// ---------------------------------------------------------------------------
|
|
701
787
|
// Server setup
|
|
702
788
|
// ---------------------------------------------------------------------------
|
|
@@ -726,6 +812,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
726
812
|
return handleListModels();
|
|
727
813
|
case 'get_version':
|
|
728
814
|
return await handleGetVersion();
|
|
815
|
+
case 'extract_last_frame':
|
|
816
|
+
return await handleExtractLastFrame(params);
|
|
817
|
+
case 'concat_videos':
|
|
818
|
+
return await handleConcatVideos(params);
|
|
819
|
+
case 'list_media':
|
|
820
|
+
return await handleListMedia(params);
|
|
729
821
|
default:
|
|
730
822
|
return {
|
|
731
823
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/sogni-gen.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { SogniClientWrapper, ClientEvent, getMaxContextImages } from '@sogni-ai/
|
|
|
8
8
|
import JSON5 from 'json5';
|
|
9
9
|
import { createHash, randomBytes } from 'crypto';
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, statSync } from 'fs';
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, statSync, readdirSync, realpathSync, lstatSync } from 'fs';
|
|
12
12
|
import { join, dirname, basename, extname } from 'path';
|
|
13
13
|
import { homedir, tmpdir } from 'os';
|
|
14
14
|
import sharp from 'sharp';
|
|
@@ -696,7 +696,12 @@ const options = {
|
|
|
696
696
|
sam2Coordinates: null, // SAM2 coordinates for animate-replace [{x,y}]
|
|
697
697
|
trimEndFrame: false, // Trim last frame for seamless stitching
|
|
698
698
|
firstFrameStrength: null, // Keyframe interpolation (0.0-1.0)
|
|
699
|
-
lastFrameStrength: null // Keyframe interpolation (0.0-1.0)
|
|
699
|
+
lastFrameStrength: null, // Keyframe interpolation (0.0-1.0)
|
|
700
|
+
extractLastFrame: null, // --extract-last-frame <video> <image>
|
|
701
|
+
extractLastFrameOutput: null,
|
|
702
|
+
concatVideos: null, // --concat-videos <out> <clip1> <clip2> [...]
|
|
703
|
+
concatVideosClips: null,
|
|
704
|
+
listMedia: null // --list-media [images|audio|all]
|
|
700
705
|
};
|
|
701
706
|
const cliSet = {
|
|
702
707
|
output: false,
|
|
@@ -993,6 +998,39 @@ for (let i = 0; i < args.length; i++) {
|
|
|
993
998
|
i++;
|
|
994
999
|
options.lastFrameStrength = parseNumberValue(raw, arg);
|
|
995
1000
|
cliSet.lastFrameStrength = true;
|
|
1001
|
+
} else if (arg === '--extract-last-frame') {
|
|
1002
|
+
const videoArg = requireFlagValue(args, i, arg);
|
|
1003
|
+
i++;
|
|
1004
|
+
const imageArg = requireFlagValue(args, i, arg + ' (output image)');
|
|
1005
|
+
i++;
|
|
1006
|
+
options.extractLastFrame = videoArg;
|
|
1007
|
+
options.extractLastFrameOutput = imageArg;
|
|
1008
|
+
} else if (arg === '--concat-videos') {
|
|
1009
|
+
// Consume remaining positional args: <output> <clip1> <clip2> [clip3...]
|
|
1010
|
+
const outArg = requireFlagValue(args, i, arg + ' (output path)');
|
|
1011
|
+
i++;
|
|
1012
|
+
const clips = [];
|
|
1013
|
+
while (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
1014
|
+
i++;
|
|
1015
|
+
clips.push(args[i]);
|
|
1016
|
+
}
|
|
1017
|
+
if (clips.length < 2) {
|
|
1018
|
+
fatalCliError('--concat-videos requires at least 2 clip paths after the output path.', {
|
|
1019
|
+
code: 'INVALID_ARGUMENT',
|
|
1020
|
+
details: { flag: '--concat-videos', clipsProvided: clips.length }
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
options.concatVideos = outArg;
|
|
1024
|
+
options.concatVideosClips = clips;
|
|
1025
|
+
} else if (arg === '--list-media') {
|
|
1026
|
+
// Optional type argument (images|audio|all), default: images
|
|
1027
|
+
const next = args[i + 1];
|
|
1028
|
+
if (next && !next.startsWith('-') && ['images', 'audio', 'all'].includes(next)) {
|
|
1029
|
+
i++;
|
|
1030
|
+
options.listMedia = next;
|
|
1031
|
+
} else {
|
|
1032
|
+
options.listMedia = 'images';
|
|
1033
|
+
}
|
|
996
1034
|
} else if (arg === '--last-image') {
|
|
997
1035
|
// Use image from last render as reference/context
|
|
998
1036
|
if (existsSync(LAST_RENDER_PATH)) {
|
|
@@ -1098,6 +1136,9 @@ General:
|
|
|
1098
1136
|
--token-type <type> Token type: spark|sogni (default: spark)
|
|
1099
1137
|
--balance, --balances Show SPARK/SOGNI balances and exit
|
|
1100
1138
|
--version, -V Show sogni-gen version and exit
|
|
1139
|
+
--extract-last-frame <video> <image> Extract last frame from a video (safe ffmpeg wrapper)
|
|
1140
|
+
--concat-videos <out> <clips...> Concatenate video clips (safe ffmpeg wrapper, min 2 clips)
|
|
1141
|
+
--list-media [type] List recent inbound media files (images|audio|all, default: images)
|
|
1101
1142
|
--last Show last render info (JSON)
|
|
1102
1143
|
--json Output JSON with all details
|
|
1103
1144
|
--strict-size Do not auto-adjust video size to satisfy i2v reference resizing constraints
|
|
@@ -1440,7 +1481,7 @@ if (options.video) {
|
|
|
1440
1481
|
options.model = options.model || openclawConfig?.defaultImageModel || 'z_image_turbo_bf16';
|
|
1441
1482
|
}
|
|
1442
1483
|
|
|
1443
|
-
if (!options.prompt && !options.estimateVideoCost && !options.multiAngle && !options.showBalance && !options.showVersion) {
|
|
1484
|
+
if (!options.prompt && !options.estimateVideoCost && !options.multiAngle && !options.showBalance && !options.showVersion && !options.extractLastFrame && !options.concatVideos && !options.listMedia) {
|
|
1444
1485
|
fatalCliError('No prompt provided. Use --help for usage.', { code: 'INVALID_ARGUMENT' });
|
|
1445
1486
|
}
|
|
1446
1487
|
|
|
@@ -1717,7 +1758,7 @@ if (options.lastSeed) {
|
|
|
1717
1758
|
}
|
|
1718
1759
|
}
|
|
1719
1760
|
|
|
1720
|
-
if (!options.estimateVideoCost && !options.showVersion && (options.seed === null || options.seed === undefined)) {
|
|
1761
|
+
if (!options.estimateVideoCost && !options.showVersion && !options.extractLastFrame && !options.concatVideos && !options.listMedia && (options.seed === null || options.seed === undefined)) {
|
|
1721
1762
|
const strategy = options.seedStrategy || openclawConfig?.seedStrategy || 'prompt-hash';
|
|
1722
1763
|
const normalized = normalizeSeedStrategy(strategy) || 'prompt-hash';
|
|
1723
1764
|
options.seedStrategy = normalized;
|
|
@@ -2459,6 +2500,120 @@ async function main() {
|
|
|
2459
2500
|
return;
|
|
2460
2501
|
}
|
|
2461
2502
|
|
|
2503
|
+
// --- Utility commands (no Sogni auth required) ---
|
|
2504
|
+
|
|
2505
|
+
if (options.extractLastFrame) {
|
|
2506
|
+
const videoPath = sanitizePath(options.extractLastFrame, '--extract-last-frame video');
|
|
2507
|
+
const outputPath = sanitizePath(options.extractLastFrameOutput, '--extract-last-frame output');
|
|
2508
|
+
if (!existsSync(videoPath)) {
|
|
2509
|
+
const err = new Error(`Video file not found: ${videoPath}`);
|
|
2510
|
+
err.code = 'FILE_NOT_FOUND';
|
|
2511
|
+
throw err;
|
|
2512
|
+
}
|
|
2513
|
+
extractLastFrameFromVideo(videoPath, outputPath);
|
|
2514
|
+
if (options.json || JSON_ERROR_MODE) {
|
|
2515
|
+
console.log(JSON.stringify({
|
|
2516
|
+
success: true,
|
|
2517
|
+
type: 'extract-last-frame',
|
|
2518
|
+
outputPath,
|
|
2519
|
+
timestamp: new Date().toISOString()
|
|
2520
|
+
}));
|
|
2521
|
+
} else {
|
|
2522
|
+
console.log(`Extracted last frame to: ${outputPath}`);
|
|
2523
|
+
}
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
if (options.concatVideos) {
|
|
2528
|
+
const outputPath = sanitizePath(options.concatVideos, '--concat-videos output');
|
|
2529
|
+
const clips = options.concatVideosClips.map((c, i) => sanitizePath(c, `clip[${i}]`));
|
|
2530
|
+
for (const clip of clips) {
|
|
2531
|
+
if (!existsSync(clip)) {
|
|
2532
|
+
const err = new Error(`Clip file not found: ${clip}`);
|
|
2533
|
+
err.code = 'FILE_NOT_FOUND';
|
|
2534
|
+
throw err;
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
buildConcatVideoFromClips(outputPath, clips);
|
|
2538
|
+
if (options.json || JSON_ERROR_MODE) {
|
|
2539
|
+
console.log(JSON.stringify({
|
|
2540
|
+
success: true,
|
|
2541
|
+
type: 'concat-videos',
|
|
2542
|
+
outputPath,
|
|
2543
|
+
clipCount: clips.length,
|
|
2544
|
+
timestamp: new Date().toISOString()
|
|
2545
|
+
}));
|
|
2546
|
+
} else {
|
|
2547
|
+
console.log(`Concatenated ${clips.length} clips to: ${outputPath}`);
|
|
2548
|
+
}
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
if (options.listMedia) {
|
|
2553
|
+
const mediaType = options.listMedia;
|
|
2554
|
+
const baseDir = join(homedir(), '.clawdbot', 'media', 'inbound');
|
|
2555
|
+
|
|
2556
|
+
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
|
2557
|
+
const AUDIO_EXTS = new Set(['.m4a', '.mp3', '.wav', '.ogg']);
|
|
2558
|
+
|
|
2559
|
+
let allowedExts;
|
|
2560
|
+
if (mediaType === 'images') allowedExts = IMAGE_EXTS;
|
|
2561
|
+
else if (mediaType === 'audio') allowedExts = AUDIO_EXTS;
|
|
2562
|
+
else allowedExts = new Set([...IMAGE_EXTS, ...AUDIO_EXTS]);
|
|
2563
|
+
|
|
2564
|
+
const files = [];
|
|
2565
|
+
if (existsSync(baseDir)) {
|
|
2566
|
+
// Validate the base directory itself isn't a symlink pointing outside ~/.clawdbot/
|
|
2567
|
+
const clawdbotRoot = join(homedir(), '.clawdbot');
|
|
2568
|
+
const resolvedBase = realpathSync(baseDir);
|
|
2569
|
+
if (!resolvedBase.startsWith(clawdbotRoot)) {
|
|
2570
|
+
const err = new Error('Media directory resolves outside of ~/.clawdbot/');
|
|
2571
|
+
err.code = 'INVALID_PATH';
|
|
2572
|
+
throw err;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
const entries = readdirSync(baseDir);
|
|
2576
|
+
for (const entry of entries) {
|
|
2577
|
+
const ext = extname(entry).toLowerCase();
|
|
2578
|
+
if (!allowedExts.has(ext)) continue;
|
|
2579
|
+
const fullPath = join(baseDir, entry);
|
|
2580
|
+
// Skip symlinks
|
|
2581
|
+
const lstats = lstatSync(fullPath);
|
|
2582
|
+
if (lstats.isSymbolicLink()) continue;
|
|
2583
|
+
if (!lstats.isFile()) continue;
|
|
2584
|
+
files.push({
|
|
2585
|
+
path: fullPath,
|
|
2586
|
+
name: entry,
|
|
2587
|
+
size: lstats.size,
|
|
2588
|
+
modified: lstats.mtime.toISOString()
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
2591
|
+
// Sort by mtime descending, return top 5
|
|
2592
|
+
files.sort((a, b) => b.modified.localeCompare(a.modified));
|
|
2593
|
+
files.splice(5);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
if (options.json || JSON_ERROR_MODE) {
|
|
2597
|
+
console.log(JSON.stringify({
|
|
2598
|
+
success: true,
|
|
2599
|
+
type: 'list-media',
|
|
2600
|
+
mediaType,
|
|
2601
|
+
files,
|
|
2602
|
+
timestamp: new Date().toISOString()
|
|
2603
|
+
}));
|
|
2604
|
+
} else {
|
|
2605
|
+
if (files.length === 0) {
|
|
2606
|
+
console.log(`No ${mediaType} files found in ${baseDir}`);
|
|
2607
|
+
} else {
|
|
2608
|
+
console.log(`Recent ${mediaType} (${files.length}):`);
|
|
2609
|
+
for (const f of files) {
|
|
2610
|
+
console.log(` ${f.name} (${f.size} bytes, ${f.modified})`);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2462
2617
|
const creds = loadCredentials();
|
|
2463
2618
|
log('Connecting to Sogni...');
|
|
2464
2619
|
client = new SogniClientWrapper({
|