@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/README.md +26 -4
- package/SKILL.md +79 -10
- package/generated/creative-agent-runtime.mjs +50 -18
- package/node-version-check.mjs +37 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -3
- package/scripts/check-creative-agent-runtime.mjs +1 -3
- package/scripts/sync-creative-agent-runtime.mjs +98 -0
- package/skill-package.json +1 -1
- package/sogni-agent.mjs +698 -91
- package/version.mjs +1 -1
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
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
7554
|
-
await client.createImageProject(editConfig)
|
|
7555
|
-
|
|
7556
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|