@sogni-ai/sogni-creative-agent-skill 3.3.5 → 3.4.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/sogni-agent.mjs CHANGED
@@ -4,11 +4,15 @@
4
4
  * Usage: sogni-agent [options] "prompt"
5
5
  */
6
6
 
7
+ // Must be first: a zero-dependency Node.js version guard that runs before
8
+ // `sharp` / the Sogni SDK load, so an unsupported Node prints a clear message
9
+ // instead of a cryptic native/ESM crash.
10
+ import './node-version-check.mjs';
7
11
  import JSON5 from 'json5';
8
12
  import { createHash, randomBytes } from 'crypto';
9
13
  import { createRequire } from 'module';
10
14
  import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, statSync, readdirSync, realpathSync, lstatSync, unlinkSync, rmdirSync } from 'fs';
11
- import { join, dirname, basename, extname, sep } from 'path';
15
+ import { join, dirname, basename, extname, sep, resolve } from 'path';
12
16
  import { homedir, tmpdir } from 'os';
13
17
  import sharp from 'sharp';
14
18
  import { getEnv, hasEnv } from './env.mjs';
@@ -78,6 +82,9 @@ import {
78
82
  validateSeedanceReferenceCounts
79
83
  } from '@sogni-ai/sogni-intelligence-client/tools';
80
84
 
85
+ const SPARK_PACKS_PURCHASE_URL = 'https://docs.sogni.ai/pricing/#spark-packs';
86
+ const SPARK_PACKS_PURCHASE_HINT = `Buy Spark Packs to continue: ${SPARK_PACKS_PURCHASE_URL}`;
87
+
81
88
  const require = createRequire(import.meta.url);
82
89
  const rootClientModule = process.env.SOGNI_AGENT_TEST_STATE_PATH
83
90
  ? await import('@sogni-ai/sogni-intelligence-client')
@@ -119,7 +126,9 @@ function sanitizePath(p, label) {
119
126
  err.code = 'INVALID_PATH';
120
127
  throw err;
121
128
  }
122
- return p;
129
+ // Expand a leading `~`/`~/` so quoted paths (where the shell didn't expand it,
130
+ // e.g. --ref "~/face.jpg") and agent-passed literals resolve to the home dir.
131
+ return expandHomePath(p);
123
132
  }
124
133
 
125
134
  const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.config', 'sogni', 'credentials');
@@ -242,6 +251,13 @@ function buildCliErrorPayload({ message, code, details, hint, prompt }) {
242
251
  if (code) payload.errorCode = code;
243
252
  if (details) payload.errorDetails = details;
244
253
  if (hint) payload.hint = hint;
254
+ if (classified.category === 'insufficient_credits') {
255
+ payload.purchaseAction = true;
256
+ payload.purchaseLabel = 'Buy Spark Packs';
257
+ payload.purchaseUrl = SPARK_PACKS_PURCHASE_URL;
258
+ payload.purchaseReason = SPARK_PACKS_PURCHASE_HINT;
259
+ if (!payload.hint) payload.hint = SPARK_PACKS_PURCHASE_HINT;
260
+ }
245
261
  payload.timestamp = new Date().toISOString();
246
262
  payload.node = process.versions.node;
247
263
  payload.cwd = process.cwd();
@@ -314,6 +330,13 @@ function addCanonicalErrorFields(payload, error) {
314
330
  if (classified.technicalError && classified.technicalError !== classified.message) {
315
331
  payload.technicalError = classified.technicalError;
316
332
  }
333
+ if (classified.category === 'insufficient_credits') {
334
+ payload.purchaseAction = true;
335
+ payload.purchaseLabel = 'Buy Spark Packs';
336
+ payload.purchaseUrl = SPARK_PACKS_PURCHASE_URL;
337
+ payload.purchaseReason = SPARK_PACKS_PURCHASE_HINT;
338
+ if (!payload.hint) payload.hint = SPARK_PACKS_PURCHASE_HINT;
339
+ }
317
340
  return payload;
318
341
  }
319
342
 
@@ -349,6 +372,76 @@ function fatalCliError(message, opts = {}) {
349
372
  process.exit(1);
350
373
  }
351
374
 
375
+ // Friendly guidance shown when the Sogni API key is missing or rejected.
376
+ const INVALID_API_KEY_HINT =
377
+ 'Your Sogni API key was rejected. Verify it — or generate a new one — by ' +
378
+ 'logging into https://dashboard.sogni.ai and opening the account menu. ' +
379
+ "If you don't have a Sogni account yet, create one there first, then add its API key.";
380
+
381
+ // Detect an invalid/rejected API key across the several shapes the SDK can
382
+ // surface it in. The SDK reports the REST 401 directly (ApiError with
383
+ // status/errorCode), but it can also cascade: a 401 triggers
384
+ // ApiKeyAuthManager.clear(), which tears down the socket and re-throws as an
385
+ // unhandled "WebSocket was closed before the connection was established"
386
+ // error whose only auth fingerprint is the stack frame.
387
+ function isInvalidApiKeyError(error) {
388
+ if (!error) return false;
389
+ const status = error.status ?? error.statusCode ?? error?.payload?.status;
390
+ const apiCode = error?.payload?.errorCode ?? error?.errorCode;
391
+ if (status === 401 || apiCode === 101) return true;
392
+ const message = (cliErrorMessage(error) || '').toLowerCase();
393
+ if (message.includes('invalid api key')) return true;
394
+ const stack = (typeof error?.stack === 'string' ? error.stack : '').toLowerCase();
395
+ if (stack.includes('apikeyauthmanager') || stack.includes('handleauthupdated')) return true;
396
+ return false;
397
+ }
398
+
399
+ // Last line of defense. The SDK can reject from a detached promise or emit an
400
+ // unhandled 'error' event during connect, which escapes main()'s try/catch and
401
+ // crashes the process with a raw stack trace. These handlers turn any such
402
+ // fatal into the same clean `Error:`/`Hint:` (or JSON) output as every other
403
+ // CLI error path, and exit 1.
404
+ let __fatalReported = false;
405
+ function reportFatalError(error) {
406
+ if (__fatalReported) {
407
+ try { process.exit(1); } catch (_) { /* already exiting */ }
408
+ return;
409
+ }
410
+ __fatalReported = true;
411
+ if (getEnv('SOGNI_DEBUG') || getEnv('DEBUG')) {
412
+ console.error(error?.stack || String(error));
413
+ }
414
+ if (isInvalidApiKeyError(error)) {
415
+ fatalCliError('Invalid Sogni API key.', {
416
+ code: 'INVALID_API_KEY',
417
+ hint: INVALID_API_KEY_HINT
418
+ });
419
+ return;
420
+ }
421
+ fatalCliError(cliErrorMessage(error), {
422
+ code: error?.code,
423
+ details: error?.details,
424
+ hint: error?.hint
425
+ });
426
+ }
427
+ process.on('uncaughtException', reportFatalError);
428
+ process.on('unhandledRejection', reportFatalError);
429
+
430
+ // Connect to Sogni, mapping a rejected connection into a clean auth error
431
+ // where we can. (Detached SDK failures that never reach this await are caught
432
+ // by the global handlers above.)
433
+ async function connectSogniClient(client) {
434
+ try {
435
+ await client.connect();
436
+ } catch (error) {
437
+ if (isInvalidApiKeyError(error) && !error.hint) {
438
+ error.hint = INVALID_API_KEY_HINT;
439
+ if (!error.code) error.code = 'INVALID_API_KEY';
440
+ }
441
+ throw error;
442
+ }
443
+ }
444
+
352
445
  function applyVideoPromptGuardrails() {
353
446
  if (!options.video || !options.prompt) return;
354
447
  if (options._literalPrompt) return;
@@ -678,7 +771,7 @@ function parseIntegerValue(raw, flagName) {
678
771
  return num;
679
772
  }
680
773
 
681
- function parsePositiveIntegerValue(raw, flagName, min = 1) {
774
+ function parsePositiveIntegerValue(raw, flagName, min = 1, max = Infinity) {
682
775
  const num = parseIntegerValue(raw, flagName);
683
776
  if (num < min) {
684
777
  fatalCliError(`${flagName} must be >= ${min}.`, {
@@ -686,9 +779,20 @@ function parsePositiveIntegerValue(raw, flagName, min = 1) {
686
779
  details: { flag: flagName, value: raw, min }
687
780
  });
688
781
  }
782
+ if (num > max) {
783
+ fatalCliError(`${flagName} must be <= ${max}.`, {
784
+ code: 'INVALID_ARGUMENT',
785
+ details: { flag: flagName, value: raw, max }
786
+ });
787
+ }
689
788
  return num;
690
789
  }
691
790
 
791
+ // Sanity ceiling for image dimensions — well above any model's real maximum,
792
+ // just large enough to catch obvious typos (e.g. a stray extra zero) before
793
+ // they waste a round-trip or blow up local memory.
794
+ const MAX_IMAGE_DIMENSION = 8192;
795
+
692
796
  function parseSeedValue(raw, flagName) {
693
797
  const num = parseIntegerValue(raw, flagName);
694
798
  if (num < 0 || num > 0xFFFFFFFF) {
@@ -718,6 +822,7 @@ function buildBalanceError(message, details) {
718
822
  const err = new Error(message);
719
823
  err.code = 'INSUFFICIENT_BALANCE';
720
824
  err.details = details || null;
825
+ err.hint = SPARK_PACKS_PURCHASE_HINT;
721
826
  return err;
722
827
  }
723
828
 
@@ -725,6 +830,27 @@ function isStructuredInsufficientBalanceError(error) {
725
830
  return Boolean(error && typeof error === 'object' && error.code === 'INSUFFICIENT_BALANCE');
726
831
  }
727
832
 
833
+ /**
834
+ * Build an Error from an SDK project result that signals failure via
835
+ * `{ error, message, code, details, hint }` fields instead of throwing.
836
+ * Preserving `code` is critical — without it, downstream classification
837
+ * (auto-fallback retry via `isStructuredInsufficientBalanceError`, and
838
+ * the `insufficient_credits` payload enrichment in `buildCliErrorPayload`
839
+ * / `addCanonicalErrorFields`) cannot tell that the failure is e.g.
840
+ * `INSUFFICIENT_BALANCE`, so the "Buy Spark Packs" CTA silently no-ops.
841
+ */
842
+ function buildProjectResultError(projectResult) {
843
+ const message = projectResult?.error || projectResult?.message || 'Project failed';
844
+ const err = new Error(message);
845
+ if (projectResult?.code) err.code = projectResult.code;
846
+ if (projectResult?.details) err.details = projectResult.details;
847
+ if (projectResult?.hint) err.hint = projectResult.hint;
848
+ if (classifyCliError(err).category === 'insufficient_credits' && !err.hint) {
849
+ err.hint = SPARK_PACKS_PURCHASE_HINT;
850
+ }
851
+ return err;
852
+ }
853
+
728
854
  function gcdInt(a, b) {
729
855
  let x = Math.abs(Math.trunc(a));
730
856
  let y = Math.abs(Math.trunc(b));
@@ -1132,6 +1258,9 @@ function loadOpenClawPluginConfig() {
1132
1258
  try {
1133
1259
  return JSON5.parse(openclawPluginConfig);
1134
1260
  } catch (e) {
1261
+ // Warn (don't crash): a malformed inline config silently dropping all the
1262
+ // user's defaults is a confusing trap.
1263
+ console.error(`Warning: OPENCLAW_PLUGIN_CONFIG is not valid JSON5 (${e?.message || e}); ignoring it and using defaults.`);
1135
1264
  return null;
1136
1265
  }
1137
1266
  }
@@ -1141,6 +1270,7 @@ function loadOpenClawPluginConfig() {
1141
1270
  const parsed = JSON5.parse(raw);
1142
1271
  return parsed?.plugins?.entries?.['sogni-creative-agent-skill']?.config || null;
1143
1272
  } catch (e) {
1273
+ console.error(`Warning: could not parse ${OPENCLAW_CONFIG_PATH} (${e?.message || e}); ignoring it and using defaults.`);
1144
1274
  return null;
1145
1275
  }
1146
1276
  }
@@ -1243,6 +1373,19 @@ const options = {
1243
1373
  concatVideosClips: null,
1244
1374
  concatAudio: null, // Optional audio file to mux over concatenated clips
1245
1375
  concatAudioStart: null,
1376
+ concatFps: null, // --concat-fps <n>: override target fps for concat normalization
1377
+ extractFirstFrame: null, // --extract-first-frame <video> <image>
1378
+ extractFirstFrameOutput: null,
1379
+ // Audio remix (--remix-audio <in_video> <out_video>): loop/fade/mix without re-encoding video
1380
+ remixAudio: null,
1381
+ remixAudioOutput: null,
1382
+ bedAudio: null, // --bed-audio <path|video>: audio bed (defaults to input video's own audio)
1383
+ audioLoop: false, // --audio-loop: loop the bed to cover the full video duration
1384
+ audioFadeIn: null, // --audio-fade-in <sec>
1385
+ audioFadeOut: null, // --audio-fade-out <sec>
1386
+ mixAudio: null, // --mix-audio <path|video>: one extra track to overlay
1387
+ mixAt: null, // --mix-at <sec>: offset for the mix track (default 0)
1388
+ mixGain: null, // --mix-gain <db>: gain applied to the mix track (default 0)
1246
1389
  listMedia: null, // --list-media [images|audio|all]
1247
1390
  // Memory, personality, persona commands
1248
1391
  memoryAction: null, // set|get|list|remove
@@ -1394,7 +1537,7 @@ for (let i = 0; i < args.length; i++) {
1394
1537
  if (arg === '-o' || arg === '--output') {
1395
1538
  const raw = requireFlagValue(args, i, arg);
1396
1539
  i++;
1397
- options.output = raw;
1540
+ options.output = expandHomePath(raw);
1398
1541
  cliSet.output = true;
1399
1542
  } else if (arg === '-m' || arg === '--model') {
1400
1543
  const raw = requireFlagValue(args, i, arg);
@@ -1404,12 +1547,12 @@ for (let i = 0; i < args.length; i++) {
1404
1547
  } else if (arg === '-w' || arg === '--width') {
1405
1548
  const raw = requireFlagValue(args, i, arg);
1406
1549
  i++;
1407
- options.width = parsePositiveIntegerValue(raw, arg);
1550
+ options.width = parsePositiveIntegerValue(raw, arg, 1, MAX_IMAGE_DIMENSION);
1408
1551
  cliSet.width = true;
1409
1552
  } else if (arg === '-h' || arg === '--height') {
1410
1553
  const raw = requireFlagValue(args, i, arg);
1411
1554
  i++;
1412
- options.height = parsePositiveIntegerValue(raw, arg);
1555
+ options.height = parsePositiveIntegerValue(raw, arg, 1, MAX_IMAGE_DIMENSION);
1413
1556
  cliSet.height = true;
1414
1557
  } else if (arg === '-n' || arg === '--count') {
1415
1558
  const raw = requireFlagValue(args, i, arg);
@@ -1630,17 +1773,17 @@ for (let i = 0; i < args.length; i++) {
1630
1773
  options.autoResizeVideoAssets = false;
1631
1774
  cliSet.autoResizeVideoAssets = true;
1632
1775
  } else if (arg === '--ref' || arg === '--reference') {
1633
- const raw = requireFlagValue(args, i, arg);
1776
+ const raw = expandHomePath(requireFlagValue(args, i, arg));
1634
1777
  i++;
1635
1778
  options.refImage = raw;
1636
1779
  cliSet.refImage = true;
1637
1780
  } else if (arg === '--ref-end' || arg === '--end') {
1638
- const raw = requireFlagValue(args, i, arg);
1781
+ const raw = expandHomePath(requireFlagValue(args, i, arg));
1639
1782
  i++;
1640
1783
  options.refImageEnd = raw;
1641
1784
  cliSet.refImageEnd = true;
1642
1785
  } else if (arg === '--ref-audio' || arg === '--audio') {
1643
- const raw = requireFlagValue(args, i, arg);
1786
+ const raw = expandHomePath(requireFlagValue(args, i, arg));
1644
1787
  i++;
1645
1788
  if (!options.refAudio) {
1646
1789
  options.refAudio = raw;
@@ -1660,7 +1803,7 @@ for (let i = 0; i < args.length; i++) {
1660
1803
  options.audioDuration = parseNonNegativeNumberValue(raw, arg);
1661
1804
  cliSet.audioDuration = true;
1662
1805
  } else if (arg === '--reference-audio-identity' || arg === '--voice-identity') {
1663
- const raw = requireFlagValue(args, i, arg);
1806
+ const raw = expandHomePath(requireFlagValue(args, i, arg));
1664
1807
  i++;
1665
1808
  options.referenceAudioIdentity = raw;
1666
1809
  cliSet.referenceAudioIdentity = true;
@@ -1670,7 +1813,7 @@ for (let i = 0; i < args.length; i++) {
1670
1813
  options.voicePersonaName = raw;
1671
1814
  cliSet.voicePersonaName = true;
1672
1815
  } else if (arg === '--ref-video') {
1673
- const raw = requireFlagValue(args, i, arg);
1816
+ const raw = expandHomePath(requireFlagValue(args, i, arg));
1674
1817
  i++;
1675
1818
  if (!options.refVideo) {
1676
1819
  options.refVideo = raw;
@@ -1688,7 +1831,7 @@ for (let i = 0; i < args.length; i++) {
1688
1831
  options.looping = true;
1689
1832
  cliSet.looping = true;
1690
1833
  } else if (arg === '-c' || arg === '--context') {
1691
- const raw = requireFlagValue(args, i, arg);
1834
+ const raw = expandHomePath(requireFlagValue(args, i, arg));
1692
1835
  i++;
1693
1836
  options.contextImages.push(raw);
1694
1837
  cliSet.context = true;
@@ -1775,6 +1918,50 @@ for (let i = 0; i < args.length; i++) {
1775
1918
  const raw = requireFlagValue(args, i, arg);
1776
1919
  i++;
1777
1920
  options.concatAudioStart = parseNonNegativeNumberValue(raw, arg);
1921
+ } else if (arg === '--concat-fps') {
1922
+ const raw = requireFlagValue(args, i, arg);
1923
+ i++;
1924
+ options.concatFps = parseNumberValue(raw, arg);
1925
+ } else if (arg === '--extract-first-frame') {
1926
+ const videoArg = requireFlagValue(args, i, arg);
1927
+ i++;
1928
+ const imageArg = requireFlagValue(args, i, arg + ' (output image)');
1929
+ i++;
1930
+ options.extractFirstFrame = videoArg;
1931
+ options.extractFirstFrameOutput = imageArg;
1932
+ } else if (arg === '--remix-audio') {
1933
+ const inArg = requireFlagValue(args, i, arg + ' (input video)');
1934
+ i++;
1935
+ const outArg = requireFlagValue(args, i, arg + ' (output video)');
1936
+ i++;
1937
+ options.remixAudio = inArg;
1938
+ options.remixAudioOutput = outArg;
1939
+ } else if (arg === '--bed-audio') {
1940
+ const raw = requireFlagValue(args, i, arg);
1941
+ i++;
1942
+ options.bedAudio = raw;
1943
+ } else if (arg === '--audio-loop') {
1944
+ options.audioLoop = true;
1945
+ } else if (arg === '--audio-fade-in') {
1946
+ const raw = requireFlagValue(args, i, arg);
1947
+ i++;
1948
+ options.audioFadeIn = parseNonNegativeNumberValue(raw, arg);
1949
+ } else if (arg === '--audio-fade-out') {
1950
+ const raw = requireFlagValue(args, i, arg);
1951
+ i++;
1952
+ options.audioFadeOut = parseNonNegativeNumberValue(raw, arg);
1953
+ } else if (arg === '--mix-audio') {
1954
+ const raw = requireFlagValue(args, i, arg);
1955
+ i++;
1956
+ options.mixAudio = raw;
1957
+ } else if (arg === '--mix-at') {
1958
+ const raw = requireFlagValue(args, i, arg);
1959
+ i++;
1960
+ options.mixAt = parseNonNegativeNumberValue(raw, arg);
1961
+ } else if (arg === '--mix-gain') {
1962
+ const raw = requireFlagValue(args, i, arg);
1963
+ i++;
1964
+ options.mixGain = parseNumberValue(raw, arg);
1778
1965
  } else if (arg === '--list-media') {
1779
1966
  // Optional type argument (images|audio|all), default: images
1780
1967
  const next = args[i + 1];
@@ -2030,7 +2217,7 @@ for (let i = 0; i < args.length; i++) {
2030
2217
  } else if (arg === '--voice') {
2031
2218
  options.personaVoice = requireFlagValue(args, i, arg); i++;
2032
2219
  } else if (arg === '--voice-clip') {
2033
- options.personaVoiceClip = requireFlagValue(args, i, arg); i++;
2220
+ options.personaVoiceClip = expandHomePath(requireFlagValue(args, i, arg)); i++;
2034
2221
  // --- Content filter ---
2035
2222
  } else if (arg === '--no-filter') {
2036
2223
  options.noFilter = true;
@@ -2221,9 +2408,21 @@ General:
2221
2408
  --no-update-check Skip the once-daily npm update check for this run
2222
2409
  self-update Upgrade sogni-agent in place (npm/pnpm/yarn/bun auto-detected)
2223
2410
  --extract-last-frame <video> <image> Extract last frame from a video (safe ffmpeg wrapper)
2224
- --concat-videos <out> <clips...> Concatenate video clips (safe ffmpeg wrapper, min 2 clips)
2411
+ --extract-first-frame <video> <image> Extract first frame from a video (safe ffmpeg wrapper)
2412
+ --concat-videos <out> <clips...> Concatenate video clips (safe ffmpeg wrapper, min 2 clips).
2413
+ Normalizes fps/size and fills silent audio so mismatched clips stitch cleanly.
2414
+ --concat-fps <n> Override target fps for --concat-videos (default: highest clip fps)
2225
2415
  --concat-audio <path> Optional audio track to mux over --concat-videos output
2226
2416
  --concat-audio-start <sec> Start offset into --concat-audio
2417
+ --remix-audio <in> <out> Rebuild a video's audio without re-encoding video (safe ffmpeg wrapper).
2418
+ Combine with the audio flags below.
2419
+ --bed-audio <path> Audio bed for --remix-audio (path or video; defaults to input's own audio)
2420
+ --audio-loop Loop the bed to cover the full video duration (--remix-audio)
2421
+ --audio-fade-in <sec> Fade the bed in over <sec> seconds (--remix-audio)
2422
+ --audio-fade-out <sec> Fade the bed out over <sec> seconds at the tail (--remix-audio)
2423
+ --mix-audio <path> Overlay one extra audio track, mixed with the bed (--remix-audio)
2424
+ --mix-at <sec> Start offset for --mix-audio (default: 0)
2425
+ --mix-gain <db> Gain in dB applied to --mix-audio (default: 0)
2227
2426
  --list-media [type] List recent inbound media files (images|audio|all, default: images)
2228
2427
  --no-filter Disable NSFW content filter
2229
2428
  --last Show last render info (JSON)
@@ -2944,7 +3143,9 @@ const commandUsesGenerationSeed = !options.apiChat &&
2944
3143
  !options.showBalance &&
2945
3144
  !options.showVersion &&
2946
3145
  !options.extractLastFrame &&
3146
+ !options.extractFirstFrame &&
2947
3147
  !options.concatVideos &&
3148
+ !options.remixAudio &&
2948
3149
  !options.listMedia &&
2949
3150
  !options.memoryAction &&
2950
3151
  !options.personalityAction &&
@@ -2955,7 +3156,12 @@ if (apiWorkflowStartAction && apiWorkflowTemplate === 'generated_keyframe_video'
2955
3156
  if (apiWorkflowStartAction && apiWorkflowTemplate === 'storyboard_video' && !options.prompt && !apiWorkflowStartHasExternalInput) {
2956
3157
  fatalCliError('--api-workflow storyboard-video preset requires a prompt or --workflow-input JSON.', { code: 'INVALID_ARGUMENT' });
2957
3158
  }
2958
- if (!options.prompt && !options.apiChat && !apiWorkflowUtilityAction && !apiWorkflowStartAction && !apiModelUtilityAction && !apiReplayUtilityAction && !contractUtilityAction && !storyboardPlanUtilityAction && !options.estimateVideoCost && !options.multiAngle && !options.showBalance && !options.showVersion && !options.extractLastFrame && !options.concatVideos && !options.listMedia && !options.memoryAction && !options.personalityAction && !personaUtilityAction) {
3159
+ // Normalize a whitespace-only prompt to empty so the guard below treats it as
3160
+ // "no prompt" rather than silently sending blank text to the server.
3161
+ if (typeof options.prompt === 'string' && options.prompt.trim() === '') {
3162
+ options.prompt = '';
3163
+ }
3164
+ if (!options.prompt && !options.apiChat && !apiWorkflowUtilityAction && !apiWorkflowStartAction && !apiModelUtilityAction && !apiReplayUtilityAction && !contractUtilityAction && !storyboardPlanUtilityAction && !options.estimateVideoCost && !options.multiAngle && !options.showBalance && !options.showVersion && !options.extractLastFrame && !options.extractFirstFrame && !options.concatVideos && !options.remixAudio && !options.listMedia && !options.memoryAction && !options.personalityAction && !personaUtilityAction) {
2959
3165
  fatalCliError('No prompt provided. Use --help for usage.', { code: 'INVALID_ARGUMENT' });
2960
3166
  }
2961
3167
 
@@ -3366,14 +3572,48 @@ if (commandUsesGenerationSeed && (options.seed === null || options.seed === unde
3366
3572
  }
3367
3573
 
3368
3574
  // Load credentials
3575
+ // Parse a `KEY=value` credentials file robustly. Tolerates: a UTF-8 BOM, an
3576
+ // optional `export ` prefix, `#` comments, blank lines, CRLF endings, surrounding
3577
+ // whitespace, surrounding single/double quotes, and `=` characters inside the
3578
+ // value (only the first `=` splits). Hand-edited files are the norm here.
3579
+ function parseCredentialsFile(content) {
3580
+ const creds = {};
3581
+ const text = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; // strip BOM
3582
+ for (const rawLine of text.split('\n')) {
3583
+ let line = rawLine.trim();
3584
+ if (!line || line.startsWith('#')) continue;
3585
+ if (line.startsWith('export ')) line = line.slice('export '.length).trim();
3586
+ const eq = line.indexOf('=');
3587
+ if (eq === -1) continue;
3588
+ const key = line.slice(0, eq).trim();
3589
+ let value = line.slice(eq + 1).trim();
3590
+ if (value.length >= 2 &&
3591
+ ((value.startsWith('"') && value.endsWith('"')) ||
3592
+ (value.startsWith("'") && value.endsWith("'")))) {
3593
+ value = value.slice(1, -1);
3594
+ }
3595
+ if (key) creds[key] = value;
3596
+ }
3597
+ return creds;
3598
+ }
3599
+
3369
3600
  function loadCredentials() {
3601
+ let credentialsFileExisted = false;
3370
3602
  if (existsSync(CREDENTIALS_PATH)) {
3371
- const content = readFileSync(CREDENTIALS_PATH, 'utf8');
3372
- const creds = {};
3373
- for (const line of content.split('\n')) {
3374
- const [key, val] = line.split('=');
3375
- if (key && val) creds[key.trim()] = val.trim();
3603
+ credentialsFileExisted = true;
3604
+ let content;
3605
+ try {
3606
+ content = readFileSync(CREDENTIALS_PATH, 'utf8');
3607
+ } catch (readErr) {
3608
+ const err = new Error(`Could not read Sogni credentials file at ${CREDENTIALS_PATH}.`);
3609
+ err.code = 'CREDENTIALS_UNREADABLE';
3610
+ err.hint = readErr?.code === 'EACCES'
3611
+ ? 'Fix the file permissions (e.g. `chmod 600 ' + CREDENTIALS_PATH + '`), or set SOGNI_API_KEY in the environment instead.'
3612
+ : 'Check the file, or set SOGNI_API_KEY in the environment instead.';
3613
+ err.details = { triedFile: CREDENTIALS_PATH, cause: readErr?.code || String(readErr) };
3614
+ throw err;
3376
3615
  }
3616
+ const creds = parseCredentialsFile(content);
3377
3617
  if (creds.SOGNI_API_KEY) {
3378
3618
  return {
3379
3619
  SOGNI_API_KEY: creds.SOGNI_API_KEY
@@ -3387,12 +3627,17 @@ function loadCredentials() {
3387
3627
  };
3388
3628
  }
3389
3629
 
3630
+ // Distinguish "file exists but has no usable key" from "no file at all" —
3631
+ // the former is a common hand-edit mistake (typo, wrong line, stray quotes).
3390
3632
  const err = new Error('No Sogni API key found.');
3391
3633
  err.code = 'MISSING_CREDENTIALS';
3392
- err.hint = 'Set SOGNI_API_KEY, or configure SOGNI_CREDENTIALS_PATH with SOGNI_API_KEY. You can find your API key by logging into https://dashboard.sogni.ai and opening the account menu.';
3634
+ err.hint = credentialsFileExisted
3635
+ ? `Found ${CREDENTIALS_PATH} but it has no usable "SOGNI_API_KEY=..." line. Check for typos/extra quotes, or set SOGNI_API_KEY in the environment. Get your key at https://dashboard.sogni.ai (account menu).`
3636
+ : 'Set SOGNI_API_KEY, or configure SOGNI_CREDENTIALS_PATH with SOGNI_API_KEY. You can find your API key by logging into https://dashboard.sogni.ai and opening the account menu.';
3393
3637
  err.details = {
3394
3638
  triedEnv: ['SOGNI_API_KEY'],
3395
- triedFile: CREDENTIALS_PATH
3639
+ triedFile: CREDENTIALS_PATH,
3640
+ credentialsFileExisted
3396
3641
  };
3397
3642
  throw err;
3398
3643
  }
@@ -3614,6 +3859,40 @@ async function dispatchMediaReferenceUrlViaSdk({ ref, file, index, jobId, action
3614
3859
  );
3615
3860
  }
3616
3861
 
3862
+ // Default HTTP timeout for plain REST calls and downloads. Without this, a
3863
+ // black-holing proxy / captive portal makes the CLI hang forever with no
3864
+ // output. Override via SOGNI_HTTP_TIMEOUT_MS. (The SDK generation wait is
3865
+ // governed separately by --timeout.)
3866
+ const DEFAULT_HTTP_TIMEOUT_MS = (() => {
3867
+ const raw = Number.parseInt(getEnv('SOGNI_HTTP_TIMEOUT_MS') || '', 10);
3868
+ return Number.isFinite(raw) && raw > 0 ? raw : 30000;
3869
+ })();
3870
+
3871
+ // Uploads keep the timer running for the whole request-body send (the fetch
3872
+ // promise only resolves once the server responds), so they get a longer budget
3873
+ // than the connect-phase default that suffices for GET/download/stream calls.
3874
+ const UPLOAD_HTTP_TIMEOUT_MS = Math.max(DEFAULT_HTTP_TIMEOUT_MS, 120000);
3875
+
3876
+ // fetch() with an AbortController-based timeout that maps a timeout/abort into a
3877
+ // clean, coded error instead of a hang or an opaque "aborted" stack.
3878
+ async function fetchWithTimeout(resource, init = {}, timeoutMs = DEFAULT_HTTP_TIMEOUT_MS) {
3879
+ const controller = new AbortController();
3880
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3881
+ try {
3882
+ return await fetch(resource, { ...init, signal: controller.signal });
3883
+ } catch (error) {
3884
+ if (error?.name === 'AbortError') {
3885
+ const err = new Error(`Sogni network request timed out after ${Math.round(timeoutMs / 1000)}s.`);
3886
+ err.code = 'NETWORK_TIMEOUT';
3887
+ err.hint = 'Check your internet connection. If you are behind a corporate proxy/VPN or firewall, it may be blocking api.sogni.ai. You can raise the limit with SOGNI_HTTP_TIMEOUT_MS.';
3888
+ throw err;
3889
+ }
3890
+ throw error;
3891
+ } finally {
3892
+ clearTimeout(timer);
3893
+ }
3894
+ }
3895
+
3617
3896
  async function fetchApiJson(path, { apiKey, method = 'GET', body = undefined, headers = {} } = {}) {
3618
3897
  const url = await buildSafeApiUrl(path);
3619
3898
  const init = {
@@ -3622,7 +3901,7 @@ async function fetchApiJson(path, { apiKey, method = 'GET', body = undefined, he
3622
3901
  ...(body === undefined ? {} : { body: JSON.stringify(body) })
3623
3902
  };
3624
3903
 
3625
- const response = await fetch(url, init);
3904
+ const response = await fetchWithTimeout(url, init);
3626
3905
  const text = await response.text();
3627
3906
  let payload = {};
3628
3907
  if (text) {
@@ -3692,6 +3971,13 @@ function mimeTypeForMediaReference(ref) {
3692
3971
 
3693
3972
  function localApiMediaReferenceFile(ref) {
3694
3973
  const filePath = sanitizePath(String(ref.value || ''), `${ref.flag} media reference`);
3974
+ if (!existsSync(filePath)) {
3975
+ const err = new Error(`${ref.flag} file not found: ${filePath}`);
3976
+ err.code = 'MEDIA_REFERENCE_NOT_FOUND';
3977
+ err.hint = `Check the path is correct and relative to your current directory (${process.cwd()}). Use ~ for your home directory, or pass an http(s) URL.`;
3978
+ err.details = { flag: ref.flag, path: filePath };
3979
+ throw err;
3980
+ }
3695
3981
  const stat = statSync(filePath);
3696
3982
  if (!stat.isFile()) {
3697
3983
  const err = new Error(`${ref.flag} must point to a file when using local API media references.`);
@@ -3792,10 +4078,10 @@ async function postApiMediaUploadForm(uploadPayload, file) {
3792
4078
  const body = file.buffer || readFileSync(file.filePath);
3793
4079
  form.append('file', new Blob([body], { type: file.mimeType }), file.filename);
3794
4080
 
3795
- const response = await fetch(url, {
4081
+ const response = await fetchWithTimeout(url, {
3796
4082
  method: 'POST',
3797
4083
  body: form,
3798
- });
4084
+ }, UPLOAD_HTTP_TIMEOUT_MS);
3799
4085
  if (!response.ok) {
3800
4086
  const err = new Error(`Failed to upload ${file.filename} (${response.status} ${response.statusText}).`);
3801
4087
  err.code = 'MEDIA_UPLOAD_FAILED';
@@ -3805,11 +4091,11 @@ async function postApiMediaUploadForm(uploadPayload, file) {
3805
4091
  }
3806
4092
 
3807
4093
  async function putApiMediaUpload(uploadUrl, file) {
3808
- const response = await fetch(uploadUrl, {
4094
+ const response = await fetchWithTimeout(uploadUrl, {
3809
4095
  method: 'PUT',
3810
4096
  headers: { 'Content-Type': file.mimeType },
3811
4097
  body: file.buffer || readFileSync(file.filePath),
3812
- });
4098
+ }, UPLOAD_HTTP_TIMEOUT_MS);
3813
4099
  if (!response.ok) {
3814
4100
  const err = new Error(`Failed to upload ${file.filename} (${response.status} ${response.statusText}).`);
3815
4101
  err.code = 'MEDIA_UPLOAD_FAILED';
@@ -5067,7 +5353,7 @@ function parseWorkflowSseChunk(raw) {
5067
5353
  async function streamApiWorkflowEvents(apiKey, workflowId) {
5068
5354
  const url = await buildSafeApiUrl(`/v1/creative-agent/workflows/${encodeURIComponent(workflowId)}/events/stream`);
5069
5355
 
5070
- const response = await fetch(url, {
5356
+ const response = await fetchWithTimeout(url, {
5071
5357
  method: 'GET',
5072
5358
  headers: apiRequestHeaders(apiKey, { Accept: 'text/event-stream' })
5073
5359
  });
@@ -5487,7 +5773,7 @@ function applyPersonaAndVoiceReferences() {
5487
5773
  async function fetchMediaBuffer(pathOrUrl) {
5488
5774
  if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) {
5489
5775
  await assertSafeUrl(pathOrUrl);
5490
- const response = await fetch(pathOrUrl);
5776
+ const response = await fetchWithTimeout(pathOrUrl);
5491
5777
  if (!response.ok) {
5492
5778
  const err = new Error(`Failed to fetch media (${response.status} ${response.statusText})`);
5493
5779
  err.code = 'FETCH_FAILED';
@@ -5510,7 +5796,7 @@ async function fetchMediaBuffer(pathOrUrl) {
5510
5796
  async function fetchMediaBlob(pathOrUrl, fallbackMimeType = 'application/octet-stream') {
5511
5797
  if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) {
5512
5798
  await assertSafeUrl(pathOrUrl);
5513
- const response = await fetch(pathOrUrl);
5799
+ const response = await fetchWithTimeout(pathOrUrl);
5514
5800
  if (!response.ok) {
5515
5801
  const err = new Error(`Failed to fetch media (${response.status} ${response.statusText})`);
5516
5802
  err.code = 'FETCH_FAILED';
@@ -5968,13 +6254,41 @@ function resolveMultiAngleOutputConfig(outputPath, outputFormat) {
5968
6254
  return { dir, prefix, ext: ext.replace('.', '') || desiredExt };
5969
6255
  }
5970
6256
 
6257
+ // Write a generated result to disk, mapping common filesystem errors into
6258
+ // clear, coded messages. Losing a paid-for render to a raw "EACCES" is exactly
6259
+ // the kind of cryptic failure a first-time user can't recover from.
6260
+ function writeOutputFileSafe(filePath, buffer, label = 'output') {
6261
+ try {
6262
+ const dir = dirname(filePath);
6263
+ if (dir && dir !== '.' && !existsSync(dir)) mkdirSync(dir, { recursive: true });
6264
+ writeFileSync(filePath, buffer);
6265
+ } catch (e) {
6266
+ const code = e?.code;
6267
+ const err = new Error(`Could not write ${label} to ${filePath}.`);
6268
+ err.code = 'OUTPUT_WRITE_FAILED';
6269
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
6270
+ err.hint = 'The output path is not writable. Choose a different --output location or fix the directory permissions.';
6271
+ } else if (code === 'ENOSPC') {
6272
+ err.hint = 'No space left on the device. Free up disk space or choose another --output location.';
6273
+ } else if (code === 'ENOENT') {
6274
+ err.hint = 'The output directory does not exist and could not be created. Check the --output path.';
6275
+ } else if (code === 'EISDIR') {
6276
+ err.hint = '--output points to a directory; pass a file path instead.';
6277
+ } else {
6278
+ err.hint = 'Check the --output path and permissions.';
6279
+ }
6280
+ err.details = { filePath, cause: code || String(e) };
6281
+ throw err;
6282
+ }
6283
+ }
6284
+
5971
6285
  async function downloadUrlToFile(url, filePath) {
5972
- const response = await fetch(url);
6286
+ const response = await fetchWithTimeout(url);
5973
6287
  if (!response.ok) {
5974
6288
  throw new Error(`Failed to download image: ${response.statusText}`);
5975
6289
  }
5976
6290
  const buffer = Buffer.from(await response.arrayBuffer());
5977
- writeFileSync(filePath, buffer);
6291
+ writeOutputFileSafe(filePath, buffer);
5978
6292
  }
5979
6293
 
5980
6294
  function removeClientListener(client, event, handler) {
@@ -5993,14 +6307,14 @@ async function loadExeca() {
5993
6307
  return execaPromise;
5994
6308
  }
5995
6309
 
5996
- async function ensureFfmpegAvailable() {
6310
+ async function ensureFfmpegAvailable(operation = 'this audio/video operation') {
5997
6311
  const ffmpegPath = getEnv('FFMPEG_PATH') || 'ffmpeg';
5998
6312
  sanitizePath(ffmpegPath, 'FFMPEG_PATH');
5999
6313
  const result = await runCommand(ffmpegPath, ['-version'], { captureOutput: true });
6000
6314
  if (result.error || result.status !== 0) {
6001
- const err = new Error('ffmpeg is required to assemble the 360 video.');
6315
+ const err = new Error(`ffmpeg is required for ${operation}.`);
6002
6316
  err.code = 'MISSING_FFMPEG';
6003
- err.hint = 'Install ffmpeg or set FFMPEG_PATH to a working ffmpeg binary.';
6317
+ err.hint = 'Install ffmpeg (e.g. `brew install ffmpeg` / `apt install ffmpeg`) or set FFMPEG_PATH to a working ffmpeg binary.';
6004
6318
  err.details = { ffmpegPath };
6005
6319
  throw err;
6006
6320
  }
@@ -6016,15 +6330,22 @@ async function ensureFfmpegAvailable() {
6016
6330
  return ffmpegPath;
6017
6331
  }
6018
6332
 
6333
+ // ffmpeg's concat demuxer resolves relative `file` entries against the list
6334
+ // file's own directory, so always write absolute paths to avoid path doubling
6335
+ // (e.g. ./dir/out.concat.txt referencing ./dir/clip.mp4 -> ./dir/./dir/clip.mp4).
6336
+ function escapeConcatPath(p) {
6337
+ return resolve(p).replace(/'/g, "'\\''");
6338
+ }
6339
+
6019
6340
  function writeConcatList(filePath, frames, frameDuration) {
6020
6341
  const lines = [];
6021
6342
  frames.forEach((frame) => {
6022
- lines.push(`file '${frame.replace(/'/g, "'\\''")}'`);
6343
+ lines.push(`file '${escapeConcatPath(frame)}'`);
6023
6344
  lines.push(`duration ${frameDuration}`);
6024
6345
  });
6025
6346
  if (frames.length > 0) {
6026
6347
  const last = frames[frames.length - 1];
6027
- lines.push(`file '${last.replace(/'/g, "'\\''")}'`);
6348
+ lines.push(`file '${escapeConcatPath(last)}'`);
6028
6349
  }
6029
6350
  writeFileSync(filePath, lines.join('\n'));
6030
6351
  }
@@ -6100,23 +6421,8 @@ async function buildAngles360Video(outputPath, frames, fps) {
6100
6421
  }
6101
6422
  }
6102
6423
 
6103
- async function extractLastFrameFromVideo(videoPath, outputImagePath) {
6104
- sanitizePath(videoPath, 'video path');
6105
- sanitizePath(outputImagePath, 'output image path');
6424
+ async function runFrameExtraction(args, { videoPath, outputImagePath, which }) {
6106
6425
  const ffmpegPath = await ensureFfmpegAvailable();
6107
-
6108
- // Extract the last frame by reading through the video with update mode
6109
- // This processes all frames but only keeps the last one
6110
- const args = [
6111
- '-i', videoPath,
6112
- '-vf', 'select=gte(n\\,0)', // Select all frames (just pass-through)
6113
- '-vsync', '0',
6114
- '-update', '1', // Update same output file (keeps only last frame)
6115
- '-q:v', '1', // Best quality
6116
- '-y',
6117
- outputImagePath
6118
- ];
6119
-
6120
6426
  const result = await runCommand(ffmpegPath, args, { captureOutput: true });
6121
6427
 
6122
6428
  if (result.error || result.status !== 0 || !isNonEmptyFile(outputImagePath)) {
@@ -6134,43 +6440,167 @@ async function extractLastFrameFromVideo(videoPath, outputImagePath) {
6134
6440
  console.error(' Output file size:', statSync(outputImagePath).size);
6135
6441
  }
6136
6442
 
6137
- const err = new Error('Failed to extract last frame from video.');
6443
+ const err = new Error(`Failed to extract ${which} frame from video.`);
6138
6444
  err.code = 'FFMPEG_EXTRACT_FAILED';
6139
6445
  err.details = { videoPath, outputImagePath, stderr, stdout, status: result.status };
6140
6446
  throw err;
6141
6447
  }
6142
6448
  }
6143
6449
 
6144
- async function buildConcatVideoFromClips(outputPath, clips, { audioPath = null, audioStart = null } = {}) {
6450
+ async function extractLastFrameFromVideo(videoPath, outputImagePath) {
6451
+ sanitizePath(videoPath, 'video path');
6452
+ sanitizePath(outputImagePath, 'output image path');
6453
+
6454
+ // Seek to ~1s before the end so we only decode the tail of the video
6455
+ // (vastly faster than decoding every frame), then keep updating the same
6456
+ // output so the final write is the genuine last frame.
6457
+ const args = [
6458
+ '-sseof', '-1',
6459
+ '-i', videoPath,
6460
+ '-update', '1', // Keep overwriting -> output is the last decoded frame
6461
+ '-q:v', '1', // Best quality
6462
+ '-y',
6463
+ outputImagePath
6464
+ ];
6465
+
6466
+ await runFrameExtraction(args, { videoPath, outputImagePath, which: 'last' });
6467
+ }
6468
+
6469
+ async function extractFirstFrameFromVideo(videoPath, outputImagePath) {
6470
+ sanitizePath(videoPath, 'video path');
6471
+ sanitizePath(outputImagePath, 'output image path');
6472
+
6473
+ // First decoded frame only.
6474
+ const args = [
6475
+ '-i', videoPath,
6476
+ '-frames:v', '1',
6477
+ '-q:v', '1', // Best quality
6478
+ '-y',
6479
+ outputImagePath
6480
+ ];
6481
+
6482
+ await runFrameExtraction(args, { videoPath, outputImagePath, which: 'first' });
6483
+ }
6484
+
6485
+ function parseFrameRate(raw) {
6486
+ if (typeof raw === 'number') return Number.isFinite(raw) && raw > 0 ? raw : null;
6487
+ if (typeof raw !== 'string') return null;
6488
+ if (!raw.includes('/')) {
6489
+ const n = Number(raw);
6490
+ return Number.isFinite(n) && n > 0 ? n : null;
6491
+ }
6492
+ const [num, den] = raw.split('/').map(Number);
6493
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return null;
6494
+ const v = num / den;
6495
+ return Number.isFinite(v) && v > 0 ? v : null;
6496
+ }
6497
+
6498
+ // Probe a media file's primary video stream + whether it has any audio.
6499
+ // Returns { width, height, fps, duration, hasAudio }. Fields are null when the
6500
+ // probe fails (e.g. ffprobe missing); callers fall back to safe defaults.
6501
+ async function probeVideoStreamInfo(filePath) {
6502
+ const info = { width: null, height: null, fps: null, duration: null, hasAudio: false };
6503
+ const ffprobePath = getEnv('FFPROBE_PATH') || 'ffprobe';
6504
+ sanitizePath(ffprobePath, 'FFPROBE_PATH');
6505
+ const result = await runCommand(ffprobePath, [
6506
+ '-v', 'error',
6507
+ '-show_entries', 'stream=codec_type,width,height,avg_frame_rate,r_frame_rate',
6508
+ '-show_entries', 'format=duration',
6509
+ '-of', 'json',
6510
+ filePath,
6511
+ ], { captureOutput: true });
6512
+ if (result.error || result.status !== 0) return info;
6513
+ let parsed;
6514
+ try { parsed = JSON.parse(result.stdout || '{}'); } catch { return info; }
6515
+ const streams = Array.isArray(parsed.streams) ? parsed.streams : [];
6516
+ const video = streams.find((s) => s.codec_type === 'video');
6517
+ info.hasAudio = streams.some((s) => s.codec_type === 'audio');
6518
+ if (video) {
6519
+ info.width = Number(video.width) || null;
6520
+ info.height = Number(video.height) || null;
6521
+ info.fps = parseFrameRate(video.avg_frame_rate) || parseFrameRate(video.r_frame_rate) || null;
6522
+ }
6523
+ const dur = Number(parsed?.format?.duration);
6524
+ info.duration = Number.isFinite(dur) && dur > 0 ? dur : null;
6525
+ return info;
6526
+ }
6527
+
6528
+ // Concatenate clips using the concat *filter* (not the concat demuxer). The
6529
+ // demuxer corrupts timestamps when clips differ in fps/timebase and desyncs
6530
+ // audio when a clip has no audio track. Here we probe each clip, normalize every
6531
+ // video stream to a common fps/size/sar/pixel-format, and synthesize silent
6532
+ // audio for clips that have none, so heterogeneous clips stitch cleanly.
6533
+ async function buildConcatVideoFromClips(outputPath, clips, { audioPath = null, audioStart = null, targetFps = null } = {}) {
6145
6534
  sanitizePath(outputPath, '--output path');
6146
6535
  clips.forEach((c, i) => sanitizePath(c, `clip[${i}]`));
6147
6536
  if (audioPath) sanitizePath(audioPath, '--concat-audio');
6148
6537
  const ffmpegPath = await ensureFfmpegAvailable();
6149
- const tempListPath = outputPath.replace(/\.mp4$/i, '') + '.concat.txt';
6150
- const lines = clips.map((clip) => `file '${clip.replace(/'/g, "'\\''")}'`);
6151
- writeFileSync(tempListPath, lines.join('\n'));
6152
6538
 
6153
- const args = [
6154
- '-y',
6155
- '-f', 'concat',
6156
- '-safe', '0',
6157
- '-i', tempListPath,
6158
- ];
6539
+ const infos = [];
6540
+ for (const clip of clips) {
6541
+ infos.push(await probeVideoStreamInfo(clip));
6542
+ }
6543
+ const widths = infos.map((x) => x.width).filter(Boolean);
6544
+ const heights = infos.map((x) => x.height).filter(Boolean);
6545
+ const fpsList = infos.map((x) => x.fps).filter(Boolean);
6546
+ const targetW = widths.length ? widths[0] : 1280;
6547
+ const targetH = heights.length ? heights[0] : 720;
6548
+ let fps = Number.isFinite(targetFps) && targetFps > 0
6549
+ ? targetFps
6550
+ : (fpsList.length ? Math.max(...fpsList) : 24);
6551
+ fps = Math.max(1, Math.round(fps));
6552
+ const totalDuration = infos.reduce((sum, x) => sum + (x.duration || 0), 0);
6553
+
6554
+ const filterParts = [];
6555
+ const concatInputs = [];
6556
+ infos.forEach((info, idx) => {
6557
+ filterParts.push(
6558
+ `[${idx}:v]fps=${fps},scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease,` +
6559
+ `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p[v${idx}]`
6560
+ );
6561
+ if (info.hasAudio) {
6562
+ filterParts.push(`[${idx}:a]aresample=async=1:first_pts=0,aformat=sample_rates=44100:channel_layouts=stereo[a${idx}]`);
6563
+ } else {
6564
+ const dur = info.duration && info.duration > 0 ? info.duration : (1 / fps);
6565
+ filterParts.push(`anullsrc=channel_layout=stereo:sample_rate=44100,atrim=duration=${dur.toFixed(6)},asetpts=PTS-STARTPTS[a${idx}]`);
6566
+ }
6567
+ concatInputs.push(`[v${idx}][a${idx}]`);
6568
+ });
6569
+ filterParts.push(`${concatInputs.join('')}concat=n=${infos.length}:v=1:a=1[cv][ca]`);
6570
+
6571
+ const args = ['-y'];
6572
+ clips.forEach((clip) => { args.push('-i', clip); });
6573
+
6574
+ let mapAudio = '[ca]';
6159
6575
  if (audioPath) {
6576
+ // External soundtrack replaces the stitched audio. Pad/trim it to the video
6577
+ // length so we never silently truncate the video (the old -shortest footgun).
6160
6578
  if (Number.isFinite(audioStart) && audioStart > 0) {
6161
6579
  args.push('-ss', String(audioStart));
6162
6580
  }
6163
- args.push('-i', audioPath, '-map', '0:v:0', '-map', '1:a:0');
6581
+ args.push('-i', audioPath);
6582
+ const extIdx = clips.length;
6583
+ let extChain = `[${extIdx}:a]aformat=sample_rates=44100:channel_layouts=stereo,apad`;
6584
+ if (totalDuration > 0) {
6585
+ extChain += `,atrim=duration=${totalDuration.toFixed(6)},asetpts=PTS-STARTPTS`;
6586
+ }
6587
+ extChain += '[xa]';
6588
+ filterParts.push(extChain);
6589
+ mapAudio = '[xa]';
6164
6590
  }
6591
+
6592
+ args.push('-filter_complex', filterParts.join(';'));
6593
+ args.push('-map', '[cv]', '-map', mapAudio);
6165
6594
  args.push(
6166
6595
  '-c:v', 'libx264',
6596
+ '-crf', '18',
6597
+ '-preset', 'medium',
6167
6598
  '-pix_fmt', 'yuv420p',
6168
6599
  '-c:a', 'aac',
6169
6600
  '-b:a', '192k',
6170
- '-movflags', '+faststart'
6601
+ '-movflags', '+faststart',
6602
+ outputPath
6171
6603
  );
6172
- if (audioPath) args.push('-shortest');
6173
- args.push(outputPath);
6174
6604
 
6175
6605
  const result = await runCommand(ffmpegPath, args);
6176
6606
  if (result.error || result.status !== 0) {
@@ -6178,13 +6608,108 @@ async function buildConcatVideoFromClips(outputPath, clips, { audioPath = null,
6178
6608
  console.warn('Warning: ffmpeg exited non-zero, but output video exists and is non-empty. Continuing.');
6179
6609
  return;
6180
6610
  }
6181
- const err = new Error('ffmpeg failed to concatenate 360 video clips.');
6611
+ const err = new Error('ffmpeg failed to concatenate video clips.');
6182
6612
  err.code = 'FFMPEG_FAILED';
6183
6613
  err.details = { outputPath, clips: clips?.length ?? null };
6184
6614
  throw err;
6185
6615
  }
6186
6616
  }
6187
6617
 
6618
+ // Rebuild a video's audio track without re-encoding the video stream. Supports
6619
+ // an optional looping bed (the input's own audio by default, or --bed-audio),
6620
+ // fade in/out, and overlaying one extra track at an offset/gain. The video is
6621
+ // stream-copied, so this is cheap and lossless on the picture.
6622
+ async function remixVideoAudio(inputVideo, outputVideo, opts = {}) {
6623
+ const {
6624
+ bedAudio = null, loop = false, fadeIn = null, fadeOut = null,
6625
+ mixAudio = null, mixAt = null, mixGain = null,
6626
+ } = opts;
6627
+ sanitizePath(inputVideo, '--remix-audio input');
6628
+ sanitizePath(outputVideo, '--remix-audio output');
6629
+ if (bedAudio) sanitizePath(bedAudio, '--bed-audio');
6630
+ if (mixAudio) sanitizePath(mixAudio, '--mix-audio');
6631
+ const ffmpegPath = await ensureFfmpegAvailable();
6632
+
6633
+ const info = await probeVideoStreamInfo(inputVideo);
6634
+ const totalDuration = info.duration && info.duration > 0 ? info.duration : null;
6635
+
6636
+ const args = ['-y', '-i', inputVideo];
6637
+
6638
+ // Resolve the bed source. With --audio-loop we re-open the source as a
6639
+ // -stream_loop input (the only robust, duration-based loop in ffmpeg).
6640
+ let bedRef;
6641
+ let nextIndex = 1;
6642
+ const bedSourceFile = bedAudio || inputVideo;
6643
+ if (loop) {
6644
+ args.push('-stream_loop', '-1', '-i', bedSourceFile);
6645
+ bedRef = `[${nextIndex}:a]`;
6646
+ nextIndex += 1;
6647
+ } else if (bedAudio) {
6648
+ args.push('-i', bedAudio);
6649
+ bedRef = `[${nextIndex}:a]`;
6650
+ nextIndex += 1;
6651
+ } else {
6652
+ bedRef = '[0:a]';
6653
+ }
6654
+
6655
+ let mixIndex = null;
6656
+ if (mixAudio) {
6657
+ mixIndex = nextIndex;
6658
+ args.push('-i', mixAudio);
6659
+ nextIndex += 1;
6660
+ }
6661
+
6662
+ const filterParts = [];
6663
+ let bed = `${bedRef}aformat=sample_rates=44100:channel_layouts=stereo`;
6664
+ if (loop && totalDuration) {
6665
+ bed += `,atrim=duration=${totalDuration.toFixed(6)},asetpts=PTS-STARTPTS`;
6666
+ }
6667
+ if (Number.isFinite(fadeIn) && fadeIn > 0) {
6668
+ bed += `,afade=t=in:st=0:d=${fadeIn}`;
6669
+ }
6670
+ if (Number.isFinite(fadeOut) && fadeOut > 0 && totalDuration) {
6671
+ const st = Math.max(0, totalDuration - fadeOut);
6672
+ bed += `,afade=t=out:st=${st.toFixed(6)}:d=${fadeOut}`;
6673
+ }
6674
+ bed += '[bed]';
6675
+ filterParts.push(bed);
6676
+
6677
+ let finalAudio = '[bed]';
6678
+ if (mixAudio) {
6679
+ let mix = `[${mixIndex}:a]aformat=sample_rates=44100:channel_layouts=stereo`;
6680
+ if (Number.isFinite(mixGain) && mixGain !== 0) {
6681
+ mix += `,volume=${mixGain}dB`;
6682
+ }
6683
+ const delayMs = Number.isFinite(mixAt) && mixAt > 0 ? Math.round(mixAt * 1000) : 0;
6684
+ if (delayMs > 0) {
6685
+ mix += `,adelay=${delayMs}|${delayMs}`;
6686
+ }
6687
+ mix += '[mix]';
6688
+ filterParts.push(mix);
6689
+ // normalize=0 keeps both tracks at full level; alimiter guards against clipping.
6690
+ filterParts.push('[bed][mix]amix=inputs=2:duration=longest:normalize=0,alimiter=limit=0.95[outa]');
6691
+ finalAudio = '[outa]';
6692
+ }
6693
+
6694
+ args.push('-filter_complex', filterParts.join(';'));
6695
+ args.push('-map', '0:v:0', '-map', finalAudio);
6696
+ args.push('-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart');
6697
+ if (totalDuration) args.push('-t', totalDuration.toFixed(6));
6698
+ args.push(outputVideo);
6699
+
6700
+ const result = await runCommand(ffmpegPath, args);
6701
+ if (result.error || result.status !== 0) {
6702
+ if (isNonEmptyFile(outputVideo)) {
6703
+ console.warn('Warning: ffmpeg exited non-zero, but output video exists and is non-empty. Continuing.');
6704
+ return;
6705
+ }
6706
+ const err = new Error('ffmpeg failed to remix audio.');
6707
+ err.code = 'FFMPEG_FAILED';
6708
+ err.details = { inputVideo, outputVideo };
6709
+ throw err;
6710
+ }
6711
+ }
6712
+
6188
6713
  async function runImageEditProjectWithEvents(client, editConfig, expectedCount, log, timeoutMs, label) {
6189
6714
  const results = [];
6190
6715
  let completed = 0;
@@ -6243,7 +6768,7 @@ async function runImageEditProjectWithEvents(client, editConfig, expectedCount,
6243
6768
  // Check for errors in the response (e.g., insufficient tokens)
6244
6769
  if (projectResult?.error || projectResult?.message) {
6245
6770
  cleanup();
6246
- throw new Error(projectResult.error || projectResult.message);
6771
+ throw buildProjectResultError(projectResult);
6247
6772
  }
6248
6773
  if (!projectId) {
6249
6774
  cleanup();
@@ -6542,7 +7067,7 @@ async function runMultiAngleFlow(client, log) {
6542
7067
 
6543
7068
  // Check for errors in the response (e.g., insufficient tokens)
6544
7069
  if (clipResult?.error || clipResult?.message) {
6545
- throw new Error(clipResult.error || clipResult.message);
7070
+ throw buildProjectResultError(clipResult);
6546
7071
  }
6547
7072
 
6548
7073
  const clipUrl = clipResult?.videoUrls?.[0];
@@ -6896,6 +7421,28 @@ async function main() {
6896
7421
  return;
6897
7422
  }
6898
7423
 
7424
+ if (options.extractFirstFrame) {
7425
+ const videoPath = sanitizePath(options.extractFirstFrame, '--extract-first-frame video');
7426
+ const outputPath = sanitizePath(options.extractFirstFrameOutput, '--extract-first-frame output');
7427
+ if (!existsSync(videoPath)) {
7428
+ const err = new Error(`Video file not found: ${videoPath}`);
7429
+ err.code = 'FILE_NOT_FOUND';
7430
+ throw err;
7431
+ }
7432
+ await extractFirstFrameFromVideo(videoPath, outputPath);
7433
+ if (options.json || JSON_ERROR_MODE) {
7434
+ console.log(JSON.stringify({
7435
+ success: true,
7436
+ type: 'extract-first-frame',
7437
+ outputPath,
7438
+ timestamp: new Date().toISOString()
7439
+ }));
7440
+ } else {
7441
+ console.log(`Extracted first frame to: ${outputPath}`);
7442
+ }
7443
+ return;
7444
+ }
7445
+
6899
7446
  if (options.concatVideos) {
6900
7447
  const outputPath = sanitizePath(options.concatVideos, '--concat-videos output');
6901
7448
  const clips = options.concatVideosClips.map((c, i) => sanitizePath(c, `clip[${i}]`));
@@ -6914,7 +7461,8 @@ async function main() {
6914
7461
  }
6915
7462
  await buildConcatVideoFromClips(outputPath, clips, {
6916
7463
  audioPath: concatAudio,
6917
- audioStart: options.concatAudioStart
7464
+ audioStart: options.concatAudioStart,
7465
+ targetFps: options.concatFps
6918
7466
  });
6919
7467
  if (options.json || JSON_ERROR_MODE) {
6920
7468
  console.log(JSON.stringify({
@@ -6924,6 +7472,7 @@ async function main() {
6924
7472
  clipCount: clips.length,
6925
7473
  audioPath: concatAudio || null,
6926
7474
  audioStart: options.concatAudioStart ?? null,
7475
+ targetFps: options.concatFps ?? null,
6927
7476
  timestamp: new Date().toISOString()
6928
7477
  }));
6929
7478
  } else {
@@ -6932,6 +7481,55 @@ async function main() {
6932
7481
  return;
6933
7482
  }
6934
7483
 
7484
+ if (options.remixAudio) {
7485
+ const inputVideo = sanitizePath(options.remixAudio, '--remix-audio input');
7486
+ const outputVideo = sanitizePath(options.remixAudioOutput, '--remix-audio output');
7487
+ const bedAudio = options.bedAudio ? sanitizePath(options.bedAudio, '--bed-audio') : null;
7488
+ const mixAudio = options.mixAudio ? sanitizePath(options.mixAudio, '--mix-audio') : null;
7489
+ if (!existsSync(inputVideo)) {
7490
+ const err = new Error(`Video file not found: ${inputVideo}`);
7491
+ err.code = 'FILE_NOT_FOUND';
7492
+ throw err;
7493
+ }
7494
+ if (bedAudio && !existsSync(bedAudio)) {
7495
+ const err = new Error(`Bed audio file not found: ${bedAudio}`);
7496
+ err.code = 'FILE_NOT_FOUND';
7497
+ throw err;
7498
+ }
7499
+ if (mixAudio && !existsSync(mixAudio)) {
7500
+ const err = new Error(`Mix audio file not found: ${mixAudio}`);
7501
+ err.code = 'FILE_NOT_FOUND';
7502
+ throw err;
7503
+ }
7504
+ await remixVideoAudio(inputVideo, outputVideo, {
7505
+ bedAudio,
7506
+ loop: options.audioLoop,
7507
+ fadeIn: options.audioFadeIn,
7508
+ fadeOut: options.audioFadeOut,
7509
+ mixAudio,
7510
+ mixAt: options.mixAt,
7511
+ mixGain: options.mixGain
7512
+ });
7513
+ if (options.json || JSON_ERROR_MODE) {
7514
+ console.log(JSON.stringify({
7515
+ success: true,
7516
+ type: 'remix-audio',
7517
+ outputPath: outputVideo,
7518
+ bedAudio: bedAudio || null,
7519
+ loop: Boolean(options.audioLoop),
7520
+ fadeIn: options.audioFadeIn ?? null,
7521
+ fadeOut: options.audioFadeOut ?? null,
7522
+ mixAudio: mixAudio || null,
7523
+ mixAt: options.mixAt ?? null,
7524
+ mixGain: options.mixGain ?? null,
7525
+ timestamp: new Date().toISOString()
7526
+ }));
7527
+ } else {
7528
+ console.log(`Remixed audio to: ${outputVideo}`);
7529
+ }
7530
+ return;
7531
+ }
7532
+
6935
7533
  if (options.listMedia) {
6936
7534
  const mediaType = options.listMedia;
6937
7535
  const baseDir = MEDIA_INBOUND_DIR;
@@ -7007,7 +7605,7 @@ async function main() {
7007
7605
  authType: 'apiKey'
7008
7606
  });
7009
7607
 
7010
- await client.connect();
7608
+ await connectSogniClient(client);
7011
7609
  await disableLiveModelAvailabilityEvents(client);
7012
7610
  log('Connected.');
7013
7611
 
@@ -7430,7 +8028,7 @@ async function main() {
7430
8028
 
7431
8029
  // Check for errors in the response (e.g., insufficient tokens)
7432
8030
  if (videoResult?.error || videoResult?.message) {
7433
- throw new Error(videoResult.error || videoResult.message);
8031
+ throw buildProjectResultError(videoResult);
7434
8032
  }
7435
8033
  } else if (options.music) {
7436
8034
  log(`Generating music with ${options.model}...`);
@@ -7491,7 +8089,7 @@ async function main() {
7491
8089
  const audioResult = await client.createAudioProject(projectConfig);
7492
8090
 
7493
8091
  if (audioResult?.error || audioResult?.message) {
7494
- throw new Error(audioResult.error || audioResult.message);
8092
+ throw buildProjectResultError(audioResult);
7495
8093
  }
7496
8094
  } else if (options.contextImages.length > 0) {
7497
8095
  // Image editing with context images
@@ -7550,10 +8148,11 @@ async function main() {
7550
8148
  editConfig.seed = options.seed;
7551
8149
  }
7552
8150
 
7553
- if (isGptImage2ModelSelection(options.model)) {
7554
- await client.createImageProject(editConfig);
7555
- } else {
7556
- await client.createImageEditProject(editConfig);
8151
+ const editResult = isGptImage2ModelSelection(options.model)
8152
+ ? await client.createImageProject(editConfig)
8153
+ : await client.createImageEditProject(editConfig);
8154
+ if (editResult?.error || editResult?.message) {
8155
+ throw buildProjectResultError(editResult);
7557
8156
  }
7558
8157
  } else if (options.photobooth) {
7559
8158
  // Photobooth: face transfer with InstantID ControlNet
@@ -7600,7 +8199,7 @@ async function main() {
7600
8199
 
7601
8200
  // Check for errors in the response (e.g., insufficient tokens)
7602
8201
  if (projectResult?.error || projectResult?.message) {
7603
- throw new Error(projectResult.error || projectResult.message);
8202
+ throw buildProjectResultError(projectResult);
7604
8203
  }
7605
8204
  } else {
7606
8205
  // Standard image generation
@@ -7665,7 +8264,10 @@ async function main() {
7665
8264
  projectConfig.seed = options.seed;
7666
8265
  }
7667
8266
 
7668
- await client.createImageProject(projectConfig);
8267
+ const imageResult = await client.createImageProject(projectConfig);
8268
+ if (imageResult?.error || imageResult?.message) {
8269
+ throw buildProjectResultError(imageResult);
8270
+ }
7669
8271
  }
7670
8272
  }
7671
8273
 
@@ -7774,7 +8376,7 @@ async function main() {
7774
8376
 
7775
8377
  // Save to file if requested
7776
8378
  if (options.output && urls[0]) {
7777
- const response = await fetch(urls[0]);
8379
+ const response = await fetchWithTimeout(urls[0]);
7778
8380
  const buffer = Buffer.from(await response.arrayBuffer());
7779
8381
 
7780
8382
  const dir = dirname(options.output);
@@ -7832,7 +8434,7 @@ async function main() {
7832
8434
  apiKey: creds.SOGNI_API_KEY,
7833
8435
  authType: 'apiKey'
7834
8436
  });
7835
- await client2.connect();
8437
+ await connectSogniClient(client2);
7836
8438
  await disableLiveModelAvailabilityEvents(client2);
7837
8439
 
7838
8440
  // Create second clip and wait for completion via events
@@ -7851,7 +8453,7 @@ async function main() {
7851
8453
  }
7852
8454
 
7853
8455
  // Download second clip
7854
- const response2 = await fetch(clip2Url);
8456
+ const response2 = await fetchWithTimeout(clip2Url);
7855
8457
  const buffer2 = Buffer.from(await response2.arrayBuffer());
7856
8458
  writeFileSync(clip2Path, buffer2);
7857
8459
 
@@ -7885,7 +8487,7 @@ async function main() {
7885
8487
 
7886
8488
  // Check for errors in the response (e.g., insufficient tokens)
7887
8489
  if (clip2Result?.error || clip2Result?.message) {
7888
- throw new Error(clip2Result.error || clip2Result.message);
8490
+ throw buildProjectResultError(clip2Result);
7889
8491
  }
7890
8492
 
7891
8493
  await clip2Promise;
@@ -7894,7 +8496,7 @@ async function main() {
7894
8496
  await buildConcatVideoFromClips(options.output, [clip1Path, clip2Path]);
7895
8497
  log(`Saved looping video to ${options.output}`);
7896
8498
  } else {
7897
- writeFileSync(options.output, buffer);
8499
+ writeOutputFileSafe(options.output, buffer, options.video ? 'video' : options.music ? 'audio' : 'image');
7898
8500
  log(`Saved to ${options.output}`);
7899
8501
  }
7900
8502
  }
@@ -8030,6 +8632,11 @@ async function main() {
8030
8632
  return main();
8031
8633
  }
8032
8634
 
8635
+ if (isInvalidApiKeyError(error)) {
8636
+ if (!error.hint) error.hint = INVALID_API_KEY_HINT;
8637
+ if (!error.code) error.code = 'INVALID_API_KEY';
8638
+ }
8639
+
8033
8640
  exitCode = 1;
8034
8641
  const shouldJson = options.json || IS_OPENCLAW_INVOCATION;
8035
8642
  if (shouldJson) {
@@ -8040,7 +8647,10 @@ async function main() {
8040
8647
  }, error);
8041
8648
  if (error.code) payload.errorCode = error.code;
8042
8649
  if (error.details) payload.errorDetails = error.details;
8043
- if (error.hint) payload.hint = error.hint;
8650
+ // Don't let a stale per-error hint overwrite the canonical
8651
+ // "Buy Spark Packs" hint that addCanonicalErrorFields already
8652
+ // stamped via the insufficient_credits enrichment branch.
8653
+ if (error.hint && !payload.purchaseAction) payload.hint = error.hint;
8044
8654
  payload.timestamp = new Date().toISOString();
8045
8655
  payload.node = process.versions.node;
8046
8656
  payload.cwd = process.cwd();
@@ -8091,8 +8701,5 @@ async function main() {
8091
8701
 
8092
8702
  main().then(
8093
8703
  () => process.exit(0),
8094
- (error) => {
8095
- console.error(`Error: ${error?.message || error}`);
8096
- process.exit(1);
8097
- }
8704
+ (error) => reportFatalError(error)
8098
8705
  );