@sogni-ai/sogni-creative-agent-skill
Version:
Sogni Creative Agent Skill: agent skill and CLI for Sogni AI image, video, and music generation.
1,288 lines (1,200 loc) • 508 kB
JavaScript
#!/usr/bin/env node
/**
* sogni-agent - Generate images, videos, and music using Sogni AI
* Usage: sogni-agent [options] "prompt"
*/
// Must be first: a zero-dependency Node.js version guard that runs before
// `sharp` / the Sogni SDK load, so an unsupported Node prints a clear message
// instead of a cryptic native/ESM crash.
import './node-version-check.mjs';
import JSON5 from 'json5';
import { createHash, randomBytes } from 'crypto';
import { createRequire } from 'module';
import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, statSync, readdirSync, realpathSync, lstatSync, unlinkSync, rmdirSync, rmSync, renameSync } from 'fs';
import { join, dirname, basename, extname, sep, resolve } from 'path';
import { homedir, tmpdir } from 'os';
import sharp from 'sharp';
import { getEnv, hasEnv } from './env.mjs';
import { getOrCreateSogniAppId } from './sogni-app-id.mjs';
import { PACKAGE_VERSION } from './version.mjs';
import {
SOGNI_APP_SOURCE,
attributionHeaders,
clientAttribution,
createInvocationLineage,
resolveAgentAttribution,
semanticWorkloadAttribution,
} from './attribution.mjs';
import { assertSafeUrl, fetchSafeUrl } from './ssrf-guard.mjs';
import {
INTERNAL_FLAG as UPDATE_CHECK_INTERNAL_FLAG,
runForegroundCheck as runUpdateCheckForeground,
maybeSpawnBackgroundCheck as maybeSpawnUpdateCheck,
getQueuedNotice as getUpdateCheckNotice,
runSelfUpdate as runSogniSelfUpdate,
snoozeUpdate as snoozeSogniUpdate,
runWhatsNew as runSogniWhatsNew,
readState as readUpdateCheckState,
compareSemver as compareSogniSemver,
} from './update-check.mjs';
import { fileURLToPath } from 'url';
import {
LTX23_WORKFLOW_MODELS,
PUBLIC_SKILL_DEFAULT_TOOL_DEFINITIONS,
PUBLIC_SKILL_DEFAULT_TOOL_NAMES,
QUALITY_TIERS,
SEEDANCE_V2V_REFERENCE_MAX_DURATION_SECONDS,
VIDEO_WORKFLOW_DEFAULT_MODELS,
buildStoryboardProject,
buildStoryboardVideoHostedToolSequenceInput,
classifyPublicSkillTurn,
classifySkillError,
compileForModel,
compilePublicSkillToolSurface,
composeAdapterPromptGuidance,
createPublicSkillDefaultContractRuntime,
detectReferenceAudioFormat,
dimensionsForAspectRatio,
dimensionsWithShortSide,
dispatchPublicSkillToolCall,
getModelDefaults as getSharedModelDefaults,
getVideoPromptGuardrailPlan,
inferVideoWorkflowFromAssets,
inferVideoWorkflowFromModel,
isHappyHorseModel,
isHappyHorseModelSelection,
isLtx2Model,
isSeedanceModel,
isSeedanceModelSelection,
normalizeReferenceAudioMimeType,
normalizeVideoWorkflow,
planCliVideoBrain,
resolveVideoControlNetStrength,
resolveVideoModelAlias,
resolveVideoSteps,
sanitizeMessagesForLlm,
sanitizeBatchPrompt,
selectDefaultVideoModel,
shouldTrimSeedanceV2VSourceVideo,
workflowRequiresImage
} from '@sogni-ai/sogni-intelligence-client/public-skill-runtime';
import {
redactPayload,
redactRunRecord
} from '@sogni-ai/sogni-intelligence-client/replay';
import {
extractToolCallProgressUpdate
} from '@sogni-ai/sogni-intelligence-client/chatRun';
import {
buildImageEditExecutionControls,
isKreaIdentityEditModel,
SEEDANCE_R2V_REFERENCE_AUDIO_MAX_DURATION_SECONDS,
prepareSeedanceV2VSourceVideo as prepareSharedSeedanceV2VSourceVideo
} from '@sogni-ai/sogni-intelligence-client/media';
import {
HAPPYHORSE_REFERENCE_LIMITS,
HappyHorseReferenceLimitError,
SEEDANCE_REFERENCE_LIMITS,
SeedanceReferenceLimitError,
getHappyHorseReferenceLimits,
happyhorseTerminalGenerationFailurePayloadFromError,
happyhorseTerminalPolicyPayloadFromError,
seedanceTerminalGenerationFailurePayloadFromError,
seedanceTerminalPolicyPayloadFromError,
validateHappyHorseReferenceCounts,
validateSeedanceReferenceCounts
} from '@sogni-ai/sogni-intelligence-client/tools';
const SPARK_PACKS_PURCHASE_URL = 'https://docs.sogni.ai/pricing/#spark-packs';
const SPARK_PACKS_PURCHASE_HINT = `Buy Spark Packs to continue: ${SPARK_PACKS_PURCHASE_URL}`;
const UNLIMITED_PLAN_URL = 'https://docs.sogni.ai/pricing/unlimited-plan-details';
const SOGNI_MODEL_CATALOG_MAX_BYTES = 5 * 1024 * 1024;
const SOGNI_MODEL_CATALOG_TIMEOUT_MS = 10000;
const KNOWN_MODEL_CATALOG_TAGS = new Set([
'spicy',
'uncensored',
'community',
'new',
'popular',
'fast',
'free-tier',
'standard',
'premium'
]);
// Skill-local fallback guidance for Sogni Unlimited subscription billing errors,
// keyed on the canonical socket error code (a structured fact, not an English
// message). Mirrors the `insufficient_credits` enrichment below: it adds an
// actionable hint/category so an agent responds correctly instead of looping a
// covered job that cannot bill. Codes are the source of truth in
// sogni-socket/constants/errorCodes.js (4078-4081).
const SUBSCRIPTION_BILLING_ERROR_CODES = new Set(['4078', '4079', '4080', '4081']);
const APP_ID_LIMIT_ERROR_CODE = '4061';
const APP_ID_LIMIT_HINT =
'This CLI persists and reuses one installation app ID. Keep ~/.config/sogni/app-id between sessions; for ephemeral/container homes, set one stable SOGNI_APP_ID or persist SOGNI_APP_ID_PATH. If the address is already blocked, preserve the stable ID and wait before retrying.';
function subscriptionBillingFallback(code) {
if (code === undefined || code === null) return null;
const key = String(code);
if (!SUBSCRIPTION_BILLING_ERROR_CODES.has(key)) return null;
switch (key) {
case '4078':
// Vendor model the subscription never covers, or no verified entitlement.
return {
retryable: false,
hint: 'This generation is not covered by Sogni Unlimited. Vendor models (GPT Image 2, Seedance, HappyHorse) always require Premium Spark; otherwise reconnect and try again. ' + SPARK_PACKS_PURCHASE_HINT,
purchaseLabel: 'Get Premium Spark',
purchaseUrl: SPARK_PACKS_PURCHASE_URL,
};
case '4079':
// Per-plan queued-job ceiling reached.
return {
retryable: true,
hint: 'Unlimited plan: maximum queued jobs reached. Wait for queued jobs to finish before submitting more.',
};
case '4080':
// Renewal payment retry — Unlimited access paused. Do NOT auto-retry the
// covered job; it will keep failing until billing recovers.
return {
retryable: false,
hint: 'Unlimited renewal payment is being retried and access is paused. Render now with Spark or SOGNI (--token-type spark|sogni); Unlimited resumes automatically once the renewal succeeds. Do not auto-retry the covered job.',
};
case '4081':
// Feature requires a higher subscription tier.
return {
retryable: false,
hint: `This feature requires a higher subscription plan. Upgrade to Unlimited Pro: ${UNLIMITED_PLAN_URL}`,
purchaseLabel: 'Upgrade to Unlimited Pro',
purchaseUrl: UNLIMITED_PLAN_URL,
};
default:
return null;
}
}
// Apply subscription-billing fallback enrichment to an error payload. Returns
// true when the code matched (so callers skip the generic insufficient_credits
// branch — subscription denials get subscription-specific guidance, never a bare
// "Buy Spark Packs"). Extracts the code from the canonical shapes the SDK and
// socket surface it in.
function applySubscriptionBillingEnrichment(payload, code) {
const fallback = subscriptionBillingFallback(code);
if (!fallback) return false;
payload.errorCode = String(code);
payload.errorCategory = 'subscription_billing';
payload.retryable = fallback.retryable;
if (!payload.hint) payload.hint = fallback.hint;
if (fallback.purchaseUrl) {
payload.purchaseAction = true;
payload.purchaseLabel = fallback.purchaseLabel;
payload.purchaseUrl = fallback.purchaseUrl;
payload.purchaseReason = fallback.hint;
}
return true;
}
function subscriptionBillingCodeFromError(error) {
if (!error || typeof error !== 'object') return undefined;
const record = error;
return record.code
?? record.errorCode
?? record.error_code
?? record.payload?.errorCode
?? record.payload?.error_code;
}
function isAppIdLimitError(error) {
if (!error) return false;
const code = error.code
?? error.errorCode
?? error.error_code
?? error.payload?.errorCode
?? error.payload?.error_code;
if (String(code) === APP_ID_LIMIT_ERROR_CODE) return true;
const message = `${error.message || ''} ${error.reason || ''} ${error.payload?.message || ''}`.toLowerCase();
return message.includes('too many app-ids') || message.includes('too many app ids');
}
function enrichAppIdLimitError(error) {
if (!isAppIdLimitError(error)) return;
error.code = APP_ID_LIMIT_ERROR_CODE;
if (!error.hint) error.hint = APP_ID_LIMIT_HINT;
}
const require = createRequire(import.meta.url);
const rootClientModule = process.env.SOGNI_AGENT_TEST_STATE_PATH
? await import('@sogni-ai/sogni-intelligence-client')
: require('@sogni-ai/sogni-intelligence-client');
const {
SogniClientWrapper,
ClientEvent,
// Re-exported from @sogni-ai/sogni-client. Used for public REST catalog reads
// (`projects.availableLoras`) that need neither a socket nor credentials.
SogniClient,
getMaxContextImages: getWrapperMaxContextImages,
parseCreativeWorkflowSseChunk,
// Model-aware wrapper dimension rules (intelligence-client > 3.15.1). On
// older pinned clients this is undefined and the CLI falls back to the
// historical mirror constants below.
getVideoDimensionRules: getWrapperVideoDimensionRules
} = rootClientModule;
// ---------------------------------------------------------------------------
// Path sanitization — defense-in-depth for any value that becomes a file path
// or process argument. execaSync runs argument arrays without shell expansion,
// so classic shell injection is not possible. These checks guard against:
// • null-byte injection (can truncate paths at the C level)
// • control-character injection
// • FFMPEG_PATH pointing to a non-ffmpeg binary
// ---------------------------------------------------------------------------
/**
* Reject null bytes and control characters in a path string.
* Returns the path unchanged when valid; throws otherwise.
*/
function sanitizePath(p, label) {
if (typeof p !== 'string') {
const err = new Error(`${label || 'Path'} must be a string.`);
err.code = 'INVALID_PATH';
throw err;
}
if (p.includes('\0')) {
const err = new Error(`${label || 'Path'} contains a null byte.`);
err.code = 'INVALID_PATH';
throw err;
}
// Reject ASCII control characters except tab (\x09), newline (\x0a), carriage return (\x0d)
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(p)) {
const err = new Error(`${label || 'Path'} contains invalid control characters.`);
err.code = 'INVALID_PATH';
throw err;
}
// Expand a leading `~`/`~/` so quoted paths (where the shell didn't expand it,
// e.g. --ref "~/face.jpg") and agent-passed literals resolve to the home dir.
return expandHomePath(p);
}
const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.config', 'sogni', 'credentials');
const DEFAULT_LAST_RENDER_PATH = join(homedir(), '.config', 'sogni', 'last-render.json');
const DEFAULT_MODEL_CATALOG_CACHE_PATH = join(homedir(), '.config', 'sogni', 'model-catalog-cache.json');
const DEFAULT_OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
// Current OpenClaw home, with a fallback to the legacy clawdbot-era directory
// for installs that predate the rename. Only used when neither
// SOGNI_MEDIA_INBOUND_DIR nor the OpenClaw plugin config overrides it.
const OPENCLAW_MEDIA_INBOUND_DIR = join(homedir(), '.openclaw', 'media', 'inbound');
const LEGACY_MEDIA_INBOUND_DIR = join(homedir(), '.clawdbot', 'media', 'inbound');
const DEFAULT_MEDIA_INBOUND_DIR =
!existsSync(OPENCLAW_MEDIA_INBOUND_DIR) && existsSync(LEGACY_MEDIA_INBOUND_DIR)
? LEGACY_MEDIA_INBOUND_DIR
: OPENCLAW_MEDIA_INBOUND_DIR;
const DEFAULT_MEMORIES_PATH = join(homedir(), '.config', 'sogni', 'memories.json');
const DEFAULT_PERSONALITY_PATH = join(homedir(), '.config', 'sogni', 'personality.txt');
const DEFAULT_PERSONAS_DIR = join(homedir(), '.config', 'sogni', 'personas');
const DEFAULT_PERSONAS_INDEX_PATH = join(homedir(), '.config', 'sogni', 'personas', 'index.json');
const DEFAULT_API_MEDIA_REFERENCE_MAX_BYTES = 100 * 1024 * 1024;
const DEFAULT_API_BASE_URL = 'https://api.sogni.ai';
const DEFAULT_SAFE_API_HOSTS = Object.freeze(['api.sogni.ai']);
const LOOPBACK_API_HOSTS = Object.freeze(['localhost', '127.0.0.1', '::1']);
const DEFAULT_LLM_MODEL = 'qwen3.6-35b-a3b-gguf-iq4xs';
const VALID_API_TASK_PROFILES = new Set(['general', 'coding', 'reasoning']);
const OPENCLAW_CONFIG_PATH = getEnv('OPENCLAW_CONFIG_PATH') || DEFAULT_OPENCLAW_CONFIG_PATH;
const IS_OPENCLAW_INVOCATION = Boolean(getEnv('OPENCLAW_PLUGIN_CONFIG'));
const AGENT_ATTRIBUTION = resolveAgentAttribution({ surfaceVersion: PACKAGE_VERSION });
const INVOCATION_LINEAGE = createInvocationLineage();
const RAW_ARGS = process.argv.slice(2);
const CLI_WANTS_JSON = RAW_ARGS.includes('--json');
const JSON_ERROR_MODE = CLI_WANTS_JSON || IS_OPENCLAW_INVOCATION;
// --- Update-check entry points --------------------------------------------
// Internal mode: the detached background child that fetches the npm registry.
if (RAW_ARGS[0] === UPDATE_CHECK_INTERNAL_FLAG) {
await runUpdateCheckForeground({ currentVersion: PACKAGE_VERSION });
process.exit(0);
}
// User-facing subcommand: `sogni-agent self-update`
if (RAW_ARGS[0] === 'self-update') {
process.exit(runSogniSelfUpdate({}));
}
// `--snooze-update`: pause reminders for the currently pending update
// (escalating backoff: 1 day → 2 days → 1 week; a newer release resets it).
if (RAW_ARGS[0] === '--snooze-update') {
const result = snoozeSogniUpdate({ currentVersion: PACKAGE_VERSION });
if (result.snoozed) {
console.error(`Update reminders for v${result.version} snoozed until ${new Date(result.until).toISOString()}.`);
} else {
console.error('No pending update to snooze.');
}
process.exit(0);
}
// `--whats-new [since-version]`: print the bundled CHANGELOG entries for the
// installed version, or everything after <since-version>.
if (RAW_ARGS[0] === '--whats-new') {
const sinceVersion = RAW_ARGS[1] && !RAW_ARGS[1].startsWith('-') ? RAW_ARGS[1] : null;
process.exit(runSogniWhatsNew({
changelogPath: join(dirname(fileURLToPath(import.meta.url)), 'CHANGELOG.md'),
currentVersion: PACKAGE_VERSION,
sinceVersion,
}));
}
// Fire-and-forget background check (no-op when throttled or skipped)
try { maybeSpawnUpdateCheck({ cliPath: process.argv[1] }); } catch { /* never break the CLI */ }
// Trailing notice on exit, if a newer version is on file
process.on('exit', () => {
try {
const notice = getUpdateCheckNotice({ currentVersion: PACKAGE_VERSION });
if (notice) process.stderr.write(notice + '\n');
} catch { /* never break exit */ }
});
// --- Temp-dir lifecycle ------------------------------------------------------
// Every transient directory the CLI creates is registered here and removed on
// normal exit, fatal error, or signal. Ctrl-C during a long video job is the
// common case that used to orphan directories under os.tmpdir().
const TRACKED_TEMP_DIRS = new Set();
function createTrackedTempDir(prefix) {
const dir = mkdtempSync(join(tmpdir(), prefix));
TRACKED_TEMP_DIRS.add(dir);
return dir;
}
function cleanupTrackedTempDirs() {
for (const dir of TRACKED_TEMP_DIRS) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
TRACKED_TEMP_DIRS.delete(dir);
}
}
process.on('exit', cleanupTrackedTempDirs);
// 128 + signal number is the conventional shell exit code for a signal death.
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
for (const signal of Object.keys(SIGNAL_EXIT_CODES)) {
process.on(signal, () => {
// process.exit() fires the 'exit' handlers above (temp cleanup + update
// notice); the OS tears down any open SDK socket with the process.
process.exit(SIGNAL_EXIT_CODES[signal]);
});
}
const SOCKET_EVENT_SUBSCRIPTIONS = Object.freeze({
modelAvailability: false
});
const MUSIC_MODEL_IDS = {
turbo: 'ace_step_1.5_xl_turbo',
speed: 'ace_step_1.5_xl_turbo',
fast: 'ace_step_1.5_xl_turbo',
sft: 'ace_step_1.5_xl_sft',
lyrics: 'ace_step_1.5_xl_sft',
lyric: 'ace_step_1.5_xl_sft'
};
const MUSIC_MODEL_DEFAULTS = {
'ace_step_1.5_xl_turbo': {
steps: { min: 4, max: 16, default: 8 },
shift: { min: 1, max: 6, default: 3 },
sampler: { allowed: ['euler', 'euler_ancestral'], default: 'euler' },
scheduler: { allowed: ['simple'], default: 'simple' }
},
'ace_step_1.5_xl_sft': {
steps: { min: 10, max: 100, default: 50 },
guidance: { min: 1, max: 15, default: 5 },
shift: { min: 1, max: 6, default: 3 },
sampler: { allowed: ['euler', 'euler_ancestral', 'er_sde'], default: 'er_sde' },
scheduler: { allowed: ['simple', 'linear_quadratic'], default: 'linear_quadratic' }
},
'ace_step_1.5_turbo': {
steps: { min: 4, max: 16, default: 8 },
shift: { min: 1, max: 6, default: 3 },
sampler: { allowed: ['euler', 'euler_ancestral'], default: 'euler' },
scheduler: { allowed: ['simple'], default: 'simple' }
},
'ace_step_1.5_sft': {
steps: { min: 10, max: 100, default: 50 },
guidance: { min: 1, max: 15, default: 5 },
shift: { min: 1, max: 6, default: 3 },
sampler: { allowed: ['euler', 'euler_ancestral', 'er_sde'], default: 'er_sde' },
scheduler: { allowed: ['simple', 'linear_quadratic'], default: 'linear_quadratic' }
}
};
const DEFAULT_MODEL_CATALOG_URL = 'https://api.sogni.ai/v1/model-catalog';
const MODEL_CATALOG_CACHE_TTL_MS = 5 * 60 * 1000;
let liveModelDefaults = null;
function getModelDefaults(modelId, config) {
const sharedDefaults = getSharedModelDefaults(modelId, config);
const catalogDefaults = liveModelDefaults?.[modelId];
const configuredDefaults = config?.modelDefaults?.[modelId];
const fixedDefaults = modelId === LTX23_10EROS_MODEL_ID
? LTX23_10EROS_FIXED_SETTINGS
: null;
if (!catalogDefaults && !configuredDefaults && !fixedDefaults) return sharedDefaults;
// The live catalog supersedes bundled registry data. Explicit user config
// remains the final override except where a workflow has immutable settings.
return {
...(sharedDefaults || {}),
...(catalogDefaults || {}),
...(configuredDefaults || {}),
...(fixedDefaults || {})
};
}
function defaultsFromModelTier(card) {
const defaults = {};
if (Number.isFinite(card?.steps?.default)) defaults.steps = card.steps.default;
if (Number.isFinite(card?.guidance?.default)) defaults.guidance = card.guidance.default;
if (card?.comfySampler?.default) defaults.sampler = card.comfySampler.default;
if (card?.comfyScheduler?.default) defaults.scheduler = card.comfyScheduler.default;
if (Number.isFinite(card?.width?.default)) defaults.defaultWidth = card.width.default;
if (Number.isFinite(card?.height?.default)) defaults.defaultHeight = card.height.default;
if (Number.isFinite(card?.width?.min)) defaults.minDimension = card.width.min;
if (Number.isFinite(card?.width?.max)) defaults.maxDimension = card.width.max;
if (Number.isFinite(card?.width?.step)) defaults.dimensionMultiple = card.width.step;
const defaultSize = String(card?.defaultSize || '').match(/^(\d+)x(\d+)$/i);
if (defaultSize) {
defaults.defaultWidth = Number(defaultSize[1]);
defaults.defaultHeight = Number(defaultSize[2]);
}
return defaults;
}
function validateModelTierSelections(modelId, card) {
const validateRange = (source, value, range) => {
if (value === null || value === undefined || !range) return;
if (
(Number.isFinite(range.min) && value < range.min) ||
(Number.isFinite(range.max) && value > range.max)
) {
const error = new Error(
`${source} ${value} is outside the live catalog range for "${modelId}" ` +
`(${range.min}–${range.max}).`
);
error.code = 'INVALID_MODEL_PARAMETER';
error.hint = `Use a value between ${range.min} and ${range.max}. Catalog: ${MODEL_CATALOG_URL}`;
throw error;
}
};
validateRange('--steps', options.steps, card.steps);
validateRange('--guidance', options.guidance, card.guidance);
const configuredDefaults = openclawConfig?.modelDefaults?.[modelId];
validateRange('Configured steps', configuredDefaults?.steps, card.steps);
validateRange('Configured guidance', configuredDefaults?.guidance, card.guidance);
}
function persistModelCatalogCache(catalog, cachePath = MODEL_CATALOG_CACHE_PATH) {
const tempPath = `${cachePath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
try {
mkdirSync(dirname(cachePath), { recursive: true });
writeFileSync(tempPath, JSON.stringify(catalog), { mode: 0o600 });
renameSync(tempPath, cachePath);
} catch (error) {
if (!options.quiet) {
console.error(`Warning: could not persist model catalog cache (${error?.message || error}).`);
}
} finally {
try {
if (existsSync(tempPath)) unlinkSync(tempPath);
} catch {
// Cache cleanup is best-effort and must never block generation.
}
}
}
async function loadLiveModelDefaults(modelId) {
// Existing CLI tests mock the SDK rather than the public catalog API.
// A supplied JSON fixture opts a test into exercising this lookup.
const testFixture = getEnv('SOGNI_AGENT_TEST_MODEL_TIERS_JSON');
let cachedCatalog = null;
try {
if (existsSync(MODEL_CATALOG_CACHE_PATH)) {
const cached = JSON.parse(readFileSync(MODEL_CATALOG_CACHE_PATH, 'utf8'));
const cacheAge = Date.now() - cached?.fetchedAt;
if (
Number.isFinite(cached?.fetchedAt) &&
cacheAge >= 0 &&
cached?.modelId === modelId &&
cached?.descriptor?.parameters &&
typeof cached.descriptor.parameters === 'object'
) {
cachedCatalog = cached;
if (cacheAge < MODEL_CATALOG_CACHE_TTL_MS) {
const card = cached.descriptor.parameters;
validateModelTierSelections(modelId, card);
liveModelDefaults = { [modelId]: defaultsFromModelTier(card) };
return;
}
}
}
} catch {
// A corrupt cache is treated as a miss and replaced by the live response.
}
if (
!testFixture &&
getEnv('SOGNI_AGENT_TEST_STATE_PATH') &&
!getEnv('SOGNI_MODEL_CATALOG_URL')
) return;
let catalog;
try {
if (testFixture) {
const fixture = JSON.parse(testFixture);
const descriptor = fixture?.data?.model || fixture?.model || (() => {
const model = fixture?.models?.find?.((entry) => entry?.id === modelId);
const tierId = model?.tier || modelId;
const parameters = fixture?.tiers?.[tierId] || (!fixture?.tiers ? fixture?.[tierId] : null);
return parameters ? { id: modelId, parameters } : null;
})();
catalog = { fetchedAt: Date.now(), modelId, etag: null, descriptor };
} else {
const url = `${MODEL_CATALOG_URL}/${encodeURIComponent(modelId)}`;
const headers = { accept: 'application/json' };
if (cachedCatalog?.etag) headers['if-none-match'] = cachedCatalog.etag;
const response = await fetch(url, {
headers,
signal: AbortSignal.timeout(10_000)
});
if (response.status === 304 && cachedCatalog) {
catalog = { ...cachedCatalog, fetchedAt: Date.now() };
} else {
if (response.status === 404) {
const error = new Error(`Model "${modelId}" is not present in the live Sogni model catalog.`);
error.code = 'MODEL_NOT_FOUND';
error.hint = `Choose a model currently listed by ${MODEL_CATALOG_URL}`;
throw error;
}
if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`);
const payload = await response.json();
catalog = {
fetchedAt: Date.now(),
modelId,
etag: response.headers.get('etag'),
descriptor: payload?.data?.model
};
}
}
persistModelCatalogCache(catalog);
} catch (cause) {
if (cause?.code === 'MODEL_NOT_FOUND') throw cause;
const error = new Error(`Could not load the live Sogni model catalog (${cause?.message || cause}).`);
error.code = 'MODEL_CATALOG_UNAVAILABLE';
error.hint = `Check Sogni platform status and retry. Catalog: ${MODEL_CATALOG_URL}`;
throw error;
}
const card = catalog?.descriptor?.parameters;
if (!card || typeof card !== 'object') {
const error = new Error(`Model "${modelId}" is not present in the live Sogni model catalog.`);
error.code = 'MODEL_NOT_FOUND';
error.hint = `Choose a model currently listed by ${MODEL_CATALOG_URL}`;
throw error;
}
validateModelTierSelections(modelId, card);
liveModelDefaults = { [modelId]: defaultsFromModelTier(card) };
}
const MUSIC_DURATION_LIMITS = { min: 10, max: 600, default: 30 };
const MUSIC_BPM_LIMITS = { min: 30, max: 300, default: 120 };
const MUSIC_PROMPT_STRENGTH_LIMITS = { min: 0, max: 10 };
const MUSIC_CREATIVITY_LIMITS = { min: 0, max: 2 };
const MUSIC_OUTPUT_FORMATS = new Set(['mp3', 'flac', 'wav']);
const MUSIC_TIME_SIGNATURES = new Set(['2', '3', '4', '6']);
function expandHomePath(rawPath) {
if (typeof rawPath !== 'string') return rawPath;
if (rawPath === '~') return homedir();
if (rawPath.startsWith('~/') || rawPath.startsWith('~\\')) {
return join(homedir(), rawPath.slice(2));
}
return rawPath;
}
function resolveConfiguredPath(rawPath, fallbackPath, label) {
const candidate = expandHomePath(rawPath) || fallbackPath;
return sanitizePath(candidate, label);
}
async function disableLiveModelAvailabilityEvents(wrapper) {
const sdkClient = wrapper?.client;
try {
if (typeof sdkClient?.setSocketEventSubscriptions === 'function') {
await sdkClient.setSocketEventSubscriptions(SOCKET_EVENT_SUBSCRIPTIONS);
}
} catch (err) {
// Subscription optimization is best-effort and must not block generation.
}
}
function isPathWithinBase(basePath, targetPath) {
return targetPath === basePath || targetPath.startsWith(`${basePath}${sep}`);
}
function buildCliErrorPayload({ message, code, details, hint, prompt }) {
const classified = classifyCliError({ message, code });
const payload = {
success: false,
error: classified.message || message || 'Unknown error',
errorType: classified.error_type,
errorCategory: classified.category,
retryable: classified.retryable,
prompt: prompt ?? null
};
if (classified.metadata) payload.metadata = classified.metadata;
if (classified.technicalError && classified.technicalError !== payload.error) {
payload.technicalError = classified.technicalError;
}
if (code) payload.errorCode = code;
if (details) payload.errorDetails = details;
if (hint) payload.hint = hint;
if (applySubscriptionBillingEnrichment(payload, code)) {
// Subscription-billing code (4078-4081) — handled with subscription-specific
// guidance; skip the generic "Buy Spark Packs" credits branch.
} else if (classified.category === 'insufficient_credits') {
payload.purchaseAction = true;
payload.purchaseLabel = 'Buy Spark Packs';
payload.purchaseUrl = SPARK_PACKS_PURCHASE_URL;
payload.purchaseReason = SPARK_PACKS_PURCHASE_HINT;
if (!payload.hint) payload.hint = SPARK_PACKS_PURCHASE_HINT;
}
payload.timestamp = new Date().toISOString();
payload.node = process.versions.node;
payload.cwd = process.cwd();
if (IS_OPENCLAW_INVOCATION) payload.openclaw = true;
return payload;
}
function unwrapGenericProjectError(error) {
let current = error;
const seen = new Set();
while (current && typeof current === 'object' && !seen.has(current)) {
seen.add(current);
const message = typeof current.message === 'string' ? current.message.trim() : '';
if (message !== 'Project creation failed') break;
const next = current.originalError || current.cause;
if (!next) break;
current = next;
}
return current;
}
function cliErrorMessage(error) {
error = unwrapGenericProjectError(error);
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message || String(error);
if (error && typeof error === 'object') {
const record = error;
if (typeof record.message === 'string') return record.message;
if (typeof record.error === 'string') return record.error;
}
return String(error ?? 'Unknown error');
}
function seedanceFriendlyGenerationMessage(payload) {
const raw = [
payload?.message,
payload?.vendorError,
payload?.vendorErrorCode
].filter(Boolean).join(' ');
if (/\baudio\s+format\b[\s\S]{0,120}\b(?:not valid|invalid)\b/i.test(raw)) {
return 'Seedance rejected the audio reference format for this model. Try a different audio file, trim/convert the clip, or use a non-Seedance audio-driven workflow such as LTX sound-to-video.';
}
return payload?.message || 'Seedance could not complete this video.';
}
// Classify a HappyHorse terminal policy / generation-failure error into the
// canonical CLI error shape, or null when neither HappyHorse matcher applies.
// Factored out so it can run either AFTER the Seedance matchers (default order)
// or BEFORE them when the failing model is known to be HappyHorse (see
// classifyCliError).
function classifyHappyHorseCliError(error, rawMessage) {
const happyhorsePolicyPayload = happyhorseTerminalPolicyPayloadFromError(error);
if (happyhorsePolicyPayload) {
return {
error_type: 'SAFETY_REJECTED',
category: 'content_refused',
message: happyhorsePolicyPayload.message,
retryable: false,
metadata: happyhorsePolicyPayload,
technicalError: rawMessage
};
}
const happyhorseGenerationPayload = happyhorseTerminalGenerationFailurePayloadFromError(error);
if (happyhorseGenerationPayload) {
const vendorCode = happyhorseGenerationPayload.vendorErrorCode;
const isInvalidParameter = vendorCode === 'InvalidParameter' ||
happyhorseGenerationPayload.error === 'happyhorse_input_download_failed';
return {
error_type: isInvalidParameter ? 'PARAMETER_INVALID' : 'GPU_WORKER_FAILED',
category: isInvalidParameter ? 'schema_validation' : 'transient_failure',
message: happyhorseGenerationPayload.message || 'HappyHorse could not complete this video.',
retryable: !isInvalidParameter,
metadata: happyhorseGenerationPayload,
technicalError: rawMessage
};
}
return null;
}
function classifyCliError(error, context = {}) {
error = unwrapGenericProjectError(error);
const rawMessage = cliErrorMessage(error);
// Client-side CLI rejections are the plain `{ message, code }` shape built by
// buildCliErrorPayload (never an Error instance and never a vendor poll body).
// They are argument-validation failures, so skip the HappyHorse terminal
// matchers — the HappyHorse generation matcher keys off a bare "happyhorse"
// mention and would otherwise re-wrap a validation message (e.g. "HappyHorse
// models do not support ControlNet") as a retryable vendor generation failure.
const isClientSideCliPayload = error
&& typeof error === 'object'
&& !(error instanceof Error)
&& typeof error.message === 'string'
&& !('output' in error)
&& !('vendorError' in error)
&& !('vendorErrorCode' in error);
// When the failing model is HappyHorse, run the HappyHorse matchers BEFORE the
// generic Seedance generation matcher. That matcher keys off vendor-agnostic
// socket failure text ("All N video generation jobs failed", "Vendor task ...
// status=failed", "Vendor job failed"), which a HappyHorse failure also
// produces, so without this model-aware reordering a HappyHorse vendor failure
// would be misattributed to Seedance. The client-side-payload guard still
// applies so a CLI validation message that merely names HappyHorse is not
// re-wrapped as a retryable vendor failure.
const modelHint = context?.modelId;
const preferHappyHorse = isHappyHorseModel(modelHint) || isHappyHorseModelSelectionLocal(modelHint);
if (preferHappyHorse && !isClientSideCliPayload) {
const happyhorseClassified = classifyHappyHorseCliError(error, rawMessage);
if (happyhorseClassified) return happyhorseClassified;
}
const seedancePolicyPayload = seedanceTerminalPolicyPayloadFromError(error);
if (seedancePolicyPayload) {
return {
error_type: 'SAFETY_REJECTED',
category: 'content_refused',
message: seedancePolicyPayload.message,
retryable: false,
metadata: seedancePolicyPayload,
technicalError: rawMessage
};
}
const seedanceGenerationPayload = seedanceTerminalGenerationFailurePayloadFromError(error);
if (seedanceGenerationPayload) {
const vendorCode = seedanceGenerationPayload.vendorErrorCode;
const isInvalidParameter = vendorCode === 'InvalidParameter' ||
seedanceGenerationPayload.error === 'seedance_reference_audio_too_long';
return {
error_type: isInvalidParameter ? 'PARAMETER_INVALID' : 'GPU_WORKER_FAILED',
category: isInvalidParameter ? 'schema_validation' : 'transient_failure',
message: seedanceFriendlyGenerationMessage(seedanceGenerationPayload),
retryable: !isInvalidParameter,
metadata: seedanceGenerationPayload,
technicalError: rawMessage
};
}
// Default order: when the model is not known to be HappyHorse, run the
// HappyHorse matchers after the Seedance matchers (preferHappyHorse already
// ran them above when the model hint matched).
if (!preferHappyHorse && !isClientSideCliPayload) {
const happyhorseClassified = classifyHappyHorseCliError(error, rawMessage);
if (happyhorseClassified) return happyhorseClassified;
}
return classifySkillError(error);
}
function addCanonicalErrorFields(payload, error, context = {}) {
const classified = classifyCliError(error, context);
payload.error = classified.message;
payload.errorType = classified.error_type;
payload.errorCategory = classified.category;
payload.retryable = classified.retryable;
if (classified.metadata) payload.metadata = classified.metadata;
if (classified.technicalError && classified.technicalError !== classified.message) {
payload.technicalError = classified.technicalError;
}
const subscriptionCode = subscriptionBillingCodeFromError(error);
if (applySubscriptionBillingEnrichment(payload, subscriptionCode)) {
// Subscription-billing code (4078-4081) — handled with subscription-specific
// guidance; skip the generic "Buy Spark Packs" credits branch.
} else if (classified.category === 'insufficient_credits') {
payload.purchaseAction = true;
payload.purchaseLabel = 'Buy Spark Packs';
payload.purchaseUrl = SPARK_PACKS_PURCHASE_URL;
payload.purchaseReason = SPARK_PACKS_PURCHASE_HINT;
if (!payload.hint) payload.hint = SPARK_PACKS_PURCHASE_HINT;
}
return payload;
}
// Human-facing twin of addCanonicalErrorFields: print the classified, friendly
// message (with the raw message as a detail line when it differs) so human
// users get the same quality of error JSON consumers already receive.
function printHumanError(error, context = {}) {
let classified = null;
try { classified = classifyCliError(error, context); } catch { /* fall back to raw */ }
const message = classified?.message || error?.message || String(error);
console.error(`Error: ${message}`);
if (classified?.technicalError && classified.technicalError !== message) {
console.error(`Details: ${classified.technicalError}`);
}
const hint = error?.hint
|| (classified?.category === 'insufficient_credits' ? SPARK_PACKS_PURCHASE_HINT : null);
if (hint) console.error(`Hint: ${hint}`);
}
function fatalCliError(message, opts = {}) {
let prompt = opts.prompt;
if (prompt === undefined) {
try {
// If parsing already populated options, include prompt for better downstream reporting.
prompt = options?.prompt ?? null;
} catch (e) {
prompt = null;
}
}
const payload = buildCliErrorPayload({
message,
code: opts.code,
details: opts.details,
hint: opts.hint,
prompt
});
if (JSON_ERROR_MODE) {
console.log(JSON.stringify(payload));
if (!CLI_WANTS_JSON) {
// OpenClaw expects JSON, but humans still benefit from stderr.
console.error(`Error: ${payload.error}`);
if (payload.hint) console.error(`Hint: ${payload.hint}`);
}
} else {
console.error(`Error: ${payload.error}`);
if (payload.hint) console.error(`Hint: ${payload.hint}`);
}
process.exit(1);
}
// Friendly guidance shown when the Sogni API key is missing or rejected.
const INVALID_API_KEY_HINT =
'Your Sogni API key was rejected. Verify it — or generate a new one — by ' +
'logging into https://dashboard.sogni.ai and opening the account menu. ' +
"If you don't have a Sogni account yet, create one there first, then add its API key.";
// Detect an invalid/rejected API key across the several shapes the SDK can
// surface it in. The SDK reports the REST 401 directly (ApiError with
// status/errorCode), but it can also cascade: a 401 triggers
// ApiKeyAuthManager.clear(), which tears down the socket and re-throws as an
// unhandled "WebSocket was closed before the connection was established"
// error whose only auth fingerprint is the stack frame.
function isInvalidApiKeyError(error) {
if (!error) return false;
const status = error.status ?? error.statusCode ?? error?.payload?.status;
const apiCode = error?.payload?.errorCode ?? error?.errorCode;
if (status === 401 || apiCode === 101) return true;
const message = (cliErrorMessage(error) || '').toLowerCase();
if (message.includes('invalid api key')) return true;
const stack = (typeof error?.stack === 'string' ? error.stack : '').toLowerCase();
if (stack.includes('apikeyauthmanager') || stack.includes('handleauthupdated')) return true;
return false;
}
// Last line of defense. The SDK can reject from a detached promise or emit an
// unhandled 'error' event during connect, which escapes main()'s try/catch and
// crashes the process with a raw stack trace. These handlers turn any such
// fatal into the same clean `Error:`/`Hint:` (or JSON) output as every other
// CLI error path, and exit 1.
let __fatalReported = false;
function reportFatalError(error) {
if (__fatalReported) {
try { process.exit(1); } catch (_) { /* already exiting */ }
return;
}
__fatalReported = true;
if (getEnv('SOGNI_DEBUG') || getEnv('DEBUG')) {
console.error(error?.stack || String(error));
}
enrichAppIdLimitError(error);
if (isInvalidApiKeyError(error)) {
fatalCliError('Invalid Sogni API key.', {
code: 'INVALID_API_KEY',
hint: INVALID_API_KEY_HINT
});
return;
}
fatalCliError(cliErrorMessage(error), {
code: error?.code,
details: error?.details,
hint: error?.hint
});
}
process.on('uncaughtException', reportFatalError);
process.on('unhandledRejection', reportFatalError);
// Connect to Sogni, mapping a rejected connection into a clean auth error
// where we can. (Detached SDK failures that never reach this await are caught
// by the global handlers above.)
async function connectSogniClient(client) {
try {
await client.connect();
} catch (error) {
enrichAppIdLimitError(error);
if (isInvalidApiKeyError(error) && !error.hint) {
error.hint = INVALID_API_KEY_HINT;
if (!error.code) error.code = 'INVALID_API_KEY';
}
throw error;
}
}
function applyVideoPromptGuardrails() {
if (!options.video || !options.prompt) return;
if (options._literalPrompt) return;
const plan = getVideoPromptGuardrailPlan({
prompt: options.prompt,
duration: options.duration,
frames: options.frames,
fps: options.fps,
durationExplicit: cliSet.duration,
referenceAudioIdentity: options.referenceAudioIdentity,
voiceName: options._voicePersonaResolvedName || options.voicePersonaName || 'SPEAKER'
});
options.prompt = plan.prompt;
options.duration = plan.duration;
if (!options.quiet) {
for (const warning of plan.warnings) {
console.error(warning.message);
}
}
}
function applyCreativeBrainPreflight() {
if (!options.video || !options.prompt) return;
const plan = planCliVideoBrain({
video: options.video,
prompt: options.prompt,
model: options.model,
workflow: options.videoWorkflow,
width: options.width,
height: options.height,
duration: options.duration,
frames: options.frames,
targetResolution: options.targetResolution,
refImage: options.refImage,
refImageEnd: options.refImageEnd,
refAudio: options.refAudio,
refVideo: options.refVideo,
cliSet: {
model: cliSet.model,
workflow: cliSet.workflow,
width: cliSet.width,
height: cliSet.height,
targetResolution: cliSet.targetResolution,
duration: cliSet.duration,
frames: cliSet.frames
}
});
if (plan.literalPrompt) {
options._literalPrompt = true;
}
if (plan.prompt && plan.prompt !== options.prompt) {
options.prompt = plan.prompt;
}
if (plan.model && !cliSet.model) {
options.model = plan.model;
}
if (plan.workflow && !cliSet.workflow) {
options.videoWorkflow = plan.workflow;
}
if (Number.isFinite(plan.duration) && !cliSet.duration && !cliSet.frames) {
options.duration = plan.duration;
durationFromPrompt = true;
}
if (
plan.dimensionSource === 'exact' &&
Number.isFinite(plan.width) &&
Number.isFinite(plan.height) &&
!cliSet.width &&
!cliSet.height
) {
options.width = plan.width;
options.height = plan.height;
widthFromPrompt = true;
heightFromPrompt = true;
}
if (plan.dimensionSource === 'aspect' && plan.aspectRatio && !cliSet.width && !cliSet.height) {
aspectRatioFromPrompt = plan.aspectRatio;
}
if (
Number.isFinite(plan.targetResolution) &&
!cliSet.targetResolution &&
!cliSet.width &&
!cliSet.height &&
!widthFromPrompt &&
!heightFromPrompt
) {
options.targetResolution = plan.targetResolution;
targetResolutionFromPrompt = true;
}
if (plan.storyboard) {
options._seedanceStoryboardPlan = plan.storyboard;
}
}
function normalizeSeedStrategy(value) {
if (!value) return null;
const normalized = value.toLowerCase();
if (normalized === 'random') return 'random';
if (normalized === 'prompt-hash' || normalized === 'prompt_hash') return 'prompt-hash';
return null;
}
function normalizeApiToolMode(value) {
const normalized = String(value || 'creative-agent').toLowerCase();
if (normalized === 'creative-agent') return 'creative-agent';
if (normalized === 'creative-tools') return 'creative-tools';
if (normalized === 'true') return true;
if (normalized === 'none' || normalized === 'false') return false;
return null;
}
function normalizeApiWorkflowTemplate(value) {
const normalized = String(value || '').toLowerCase().replace(/-/g, '_');
if (normalized === 'storyboard_video' || normalized === 'storyboard_to_video' || normalized === 'gpt_image_2_seedance' || normalized === 'gpt_image_seedance') {
return 'storyboard_video';
}
return null;
}
function appendApiPath(baseUrl, path) {
const base = String(baseUrl || DEFAULT_API_BASE_URL).replace(/\/+$/, '');
const suffix = path.startsWith('/') ? path : `/${path}`;
return `${base}${suffix}`;
}
function getApiBaseUrl() {
return options.apiBaseUrl || getEnv('SOGNI_API_BASE_URL') || getEnv('SOGNI_REST_ENDPOINT') || DEFAULT_API_BASE_URL;
}
function getApiAllowedHosts() {
const configured = String(getEnv('SOGNI_API_ALLOWED_HOSTS') || '')
.split(',')
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
return Array.from(new Set([...DEFAULT_SAFE_API_HOSTS, ...configured]));
}
function allowUnsafeApiBaseUrl() {
return getEnv('SOGNI_ALLOW_UNSAFE_API_BASE_URL') === '1';
}
function isLoopbackApiUrl(parsed) {
return LOOPBACK_API_HOSTS.includes(parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase());
}
async function buildSafeApiUrl(path) {
const url = appendApiPath(getApiBaseUrl(), path);
const unsafeAllowed = allowUnsafeApiBaseUrl();
let parsed;
try {
parsed = new URL(url);
} catch {
const err = new Error('Invalid Sogni API base URL.');
err.code = 'INVALID_API_BASE_URL';
throw err;
}
const hasEmbeddedCredentials = Boolean(parsed['user' + 'name'] || parsed['pass' + 'word']);
if (hasEmbeddedCredentials) {
const err = new Error('Sogni API base URL must not contain credentials.');
err.code = 'UNSAFE_API_BASE_URL';
throw err;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
const err = new Error(`Sogni API URL protocol ${parsed.protocol} is not allowed.`);
err.code = 'UNSAFE_API_BASE_URL';
throw err;
}
if (unsafeAllowed) return url;
if (isLoopbackApiUrl(parsed)) {
const err = new Error('Loopback Sogni API base URLs require SOGNI_ALLOW_UNSAFE_API_BASE_URL=1 for isolated local testing.');
err.code = 'UNSAFE_API_BASE_URL';
throw err;
}
try {
await assertSafeUrl(url, {
allowedProtocols: ['https:'],
allowedHosts: getApiAllowedHosts()
});
} catch (err) {
const wrapped = new Error(
`${err.message}. Set SOGNI_API_ALLOWED_HOSTS for a trusted custom API host, or SOGNI_ALLOW_UNSAFE_API_BASE_URL=1 for isolated local testing.`
);
wrapped.code = 'UNSAFE_API_BASE_URL';
wrapped.cause = err;
throw wrapped;
}
return url;
}
function generateRandomSeed() {
return randomBytes(4).readUInt32BE(0);
}
// ---------------------------------------------------------------------------
// Dynamic prompt variations — {option1|option2|option3} syntax
// For count > 1, cycles through options sequentially per image.
// ---------------------------------------------------------------------------
const VARIATION_PATTERN = /\{([^}]+)\}/g;
function hasPromptVariations(prompt) {
return /\{[^}]+\}/.test(prompt);
}
function expandPromptVariation(prompt, index) {
return prompt.replace(VARIATION_PATTERN, (_match, group) => {
const options = group.split('|').map(s => s.trim());
return options[index % options.length];
});
}
function computePromptHashSeed(opts) {
const payload = {
prompt: opts.prompt || '',
model: opts.model || '',
workflow: opts.video ? opts.videoWorkflow : opts.music ? 'music' : 'image',
width: opts.width,
height: opts.height,
azimuth: opts.azimuth || '',
elevation: opts.elevation || '',
distance: opts.distance || '',
angleDescription: opts.angleDescription || '',
outputFormat: opts.outputFormat || '',
sampler: opts.sampler || '',
scheduler: opts.scheduler || '',
musicLyrics: opts.musicLyrics || '',
musicLanguage: opts.musicLanguage || '',
musicBpm: opts.musicBpm ?? null,
musicKeyscale: opts.musicKeyscale || '',
musicTimesig: opts.musicTimesig || '',
musicComposerMode: opts.musicComposerMode ?? null,
musicPromptStrength: opts.musicPromptStrength ?? null,
musicCreativity: opts.musicCreativity ?? null,
musicShift: opts.musicShift ?? null,
targetResolution: opts.targetResolution ?? null,
loras: opts.loras || [],
loraStrengths: opts.loraStrengths || [],
refImage: opts.refImage || '',
refImageEnd: opts.refImageEnd || '',
refAudio: opts.refAudio || '',
audioStart: opts.audioStart ?? null,
audioDuration: opts.audioDuration ?? null,
referenceAudioIdentity: opts.referenceAudioIdentity || '',
refVideo: opts.refVideo || '',
videoStart: opts.videoStart ?? null,
contextImages: opts.contextImages || [],
autoResizeVideoAssets: opts.autoResizeVideoAssets,
tokenType: opts.tokenType || '',
steps: opts.steps ?? null,
guidance: opts.guidance ?? null
};
const hash = createHash('sha256').update(JSON.stringify(payload)).digest();
return hash.readUInt32BE(0);
}
function parseCsv(value) {
if (!value) return [];
return value.split(',').map((entry) => entry.trim()).filter(Boolean);
}
function parseNumberValue(raw, flagName) {
const num = Number(raw);
if (!Number.isFinite(num)) {
fatalCliError(`${flagName} must be a number.`, {
code: 'INVALID_ARGUMENT',
details: { flag: flagName, value: raw }
});
}
return num;
}
function parseNonNegativeNumberValue(raw, flagName) {
const num = parseNumberValue(raw, flagName);
if (num < 0) {
fatalCliError(`${flagName} must be >= 0.`, {
code: 'INVALID_ARGUMENT',
details: { flag: flagName, value: raw, min: 0 }
});
}
return num;
}
function parseNumberList(raw, flagName) {
const entries = parseCsv(raw);
return entries.map((entry) => parseNumberValue(entry, flagName));
}
function parseBoundedNumberValue(raw, flagName, limits) {
const num = parseNumberValue(raw, flagName);
if (num < limits.min || num > limits.max) {
fatalCliError(`${flagName} must be between ${limits.min} and ${limits.max}.`, {
code: 'INVALID_ARGUMENT',
details: { flag: flagName, value: raw, min: limits.min, max: limits.max }
});
}
return num;
}
function requireFlagValue(argv, index, flagName) {
const value = argv[index + 1];
if (value === undefined) {
fatalCliError(`${flagName} requires a value.`, {
code: 'INVALID_ARGUMENT',
details: { flag: flagName }
});
}
return value;
}
function parseIntegerValue(raw, flagName) {
const num = Number(raw);
if (!Number.isInteger(num)) {
fatalCliError(`${flagName} must be an integer.`, {
code: 'INVALID_ARGUMENT',
details: { flag: flagName, value: raw }
});
}
return num;
}
function parsePositiveIntegerValue(raw, flagName, min = 1, max = Infinity) {
const num = parseIntegerValue(raw, flagName);
if (num < min) {
fatalCliError(`${flagName} must be >= ${min}.`, {
code: 'INV