UNPKG

@sogni-ai/sogni-creative-agent-skill

Version:

Sogni Creative Agent Skill: agent skill and CLI for Sogni AI image, video, and music generation.

1,348 lines (1,220 loc) 365 kB
#!/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 } 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 { PACKAGE_VERSION } from './version.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, getVideoPromptGuardrailPlan, inferVideoWorkflowFromAssets, inferVideoWorkflowFromModel, 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 { SEEDANCE_R2V_REFERENCE_AUDIO_MAX_DURATION_SECONDS, prepareSeedanceV2VSourceVideo as prepareSharedSeedanceV2VSourceVideo } from '@sogni-ai/sogni-intelligence-client/media'; import { SEEDANCE_REFERENCE_LIMITS, SeedanceReferenceLimitError, seedanceTerminalGenerationFailurePayloadFromError, seedanceTerminalPolicyPayloadFromError, 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 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, getMaxContextImages: getWrapperMaxContextImages, parseCreativeWorkflowSseChunk } = 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_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 SOGNI_APP_SOURCE = 'sogni-creative-agent-skill'; const OPENCLAW_CONFIG_PATH = getEnv('OPENCLAW_CONFIG_PATH') || DEFAULT_OPENCLAW_CONFIG_PATH; const IS_OPENCLAW_INVOCATION = Boolean(getEnv('OPENCLAW_PLUGIN_CONFIG')); 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_turbo', speed: 'ace_step_1.5_turbo', fast: 'ace_step_1.5_turbo', sft: 'ace_step_1.5_sft', lyrics: 'ace_step_1.5_sft', lyric: 'ace_step_1.5_sft' }; const MUSIC_MODEL_DEFAULTS = { '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 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 (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 cliErrorMessage(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.'; } function classifyCliError(error) { const rawMessage = cliErrorMessage(error); 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 }; } return classifySkillError(error); } function addCanonicalErrorFields(payload, error) { const classified = classifyCliError(error); 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; } 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) { let classified = null; try { classified = classifyCliError(error); } 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)); } 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) { 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: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw, min } }); } if (num > max) { fatalCliError(`${flagName} must be <= ${max}.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw, max } }); } return num; } // Sanity ceiling for image dimensions — well above any model's real maximum, // just large enough to catch obvious typos (e.g. a stray extra zero) before // they waste a round-trip or blow up local memory. const MAX_IMAGE_DIMENSION = 8192; // Safety cap for -n/--count: every output is a paid generation, so a typo like // `-n 1000` (meant `-n 10`) must not launch a thousand-render batch. Raise // deliberately with SOGNI_MAX_COUNT when a bigger batch is really wanted. const DEFAULT_MAX_COUNT = 16; const MAX_COUNT = (() => { const raw = Number.parseInt(getEnv('SOGNI_MAX_COUNT') || '', 10); return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_COUNT; })(); function parseSeedValue(raw, flagName) { const num = parseIntegerValue(raw, flagName); if (num < 0 || num > 0xFFFFFFFF) { fatalCliError(`${flagName} must be between 0 and 4294967295.`, { code: 'INVALID_ARGUMENT', details: { flag: flagName, value: raw } }); } return num; } function formatTokenValue(value) { if (!Number.isFinite(value)) return 'unknown'; return value.toFixed(2); } function parseCostEstimate(estimate, tokenType) { if (!estimate) return null; const raw = tokenType === 'sogni' ? estimate.sogni ?? estimate.token : estimate.spark ?? estimate.token; const value = Number.parseFloat(raw); return Number.isFinite(value) ? value : null; } function buildBalanceError(message, details) { const err = new Error(message); err.code = 'INSUFFICIENT_BALANCE'; err.details = details || null; err.hint = SPARK_PACKS_PURCHASE_HINT; return err; } function isStructuredInsufficientBalanceError(error) { return Boolean(error && typeof error === 'object' && error.code === 'INSUFFICIENT_BALANCE'); } /** * Build an Error from an SDK project result that signals failure via * `{ error, message, code, details, hint }` fields instead of throwing. * Preserving `code` is critical — without it, downstream classification * (auto-fallback retry via `isStructuredInsufficientBalanceError`, and * the `insufficient_credits` payload enrichment in `buildCliErrorPayload` * / `addCanonicalErrorFields`) cannot tell that the failure is e.g. * `INSUFFICIENT_BALANCE`, so the "Buy Spark Packs" CTA silently no-ops. */ function buildProjectResultError(projectResult) { const message = projectResult?.error || projectResult?.message || 'Project failed'; const err = new Error(message); if (projectResult?.code) err.code = projectResult.code; if (projectResult?.details) err.details = projectResult.details; if (projectResult?.hint) err.hint = projectResult.hint; if (classifyCliError(err).category === 'insufficient_credits' && !err.hint) { err.hint = SPARK_PACKS_PURCHASE_HINT; } return err; } function gcdInt(a, b) { let x = Math.abs(Math.trunc(a)); let y = Math.abs(Math.trunc(b)); while (y !== 0) { const t = y; y = x % y; x = t; } return x || 1; } function isHttpUrl(value) { return typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://')); } function isHttpsUrl(value) { if (typeof value !== 'string') return false; try { return new URL(value).protocol === 'https:'; } catch { return false; } } function getPngDimensions(buffer) { if (!buffer || buffer.length < 24) return null; // PNG signature: 89 50 4E 47 0D 0A 1A 0A if ( buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47 || buffer[4] !== 0x0D || buffer[5] !== 0x0A || buffer[6] !== 0x1A || buffer[7] !== 0x0A ) { return null; } try { const width = buffer.readUInt32BE(16); const height = buffer.readUInt32BE(20); if (!width || !height) return null; return { width, height, type: 'png' }; } catch { return null; } } function getJpegDimensions(buffer) { if (!buffer || buffer.length < 4) return null; // JPEG SOI: FF D8 if (buffer[0] !== 0xFF || buffer[1] !== 0xD8) return null; // Walk segments until we find a Start Of Frame marker that contains dimensions. // Common SOF markers: C0 (baseline), C1, C2 (progressive), C3, C5-C7, C9-CB, CD-CF let i = 2; while (i + 9 < buffer.length) { // Find marker prefix 0xFF if (buffer[i] !== 0xFF) { i++; continue; } // Skip fill bytes 0xFF while (i < buffer.length && buffer[i] === 0xFF) i++; if (i >= buffer.length) break; const marker = buffer[i]; i++; // Markers without a length field if (marker === 0xD9 || marker === 0xDA) break; // EOI or SOS if (marker >= 0xD0 && marker <= 0xD7) continue; // RSTn if (i + 1 >= buffer.length) break; const segmentLength = buffer.readUInt16BE(i); if (segmentLength < 2) break; const segmentStart = i + 2; const isSof = (marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) || (marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF); if (isSof) { if (segmentStart + 7 >= buffer.length) break; try { const height = buffer.readUInt16BE(segmentStart + 1); const width = buffer.readUInt16BE(segmentStart + 3); if (!width || !height) return null; return { width, height, type: 'jpg' }; } catch { return null; } } i = segmentStart + (segmentLength - 2); } return null; } function getImageDimensionsFromBuffer(buffer) { return getPngDimensions(buffer) || getJpegDimensions(buffer); } const DEFAULT_VIDEO_DIMENSION_RULES = { minDimension: 480, maxDimension: 1536, dimensionMultiple: 16 }; const WRAPPER_MAX_VIDEO_DIMENSION = 2048; const WRAPPER_MAX_WAN_VIDEO_DIMENSION = 1536; const VIDEO_DIMENSION_MULTIPLE = DEFAULT_VIDEO_DIMENSION_RULES.dimensionMultiple; function isWanVideoModelId(modelId) { return typeof modelId === 'string' && modelId.startsWith('wan_'); } function isWanAnimateVideoModelId(modelId) { return typeof modelId === 'string' && ( modelId.includes('_animate-move') || modelId.includes('_animate-replace') || modelId.includes('_animate_move') || modelId.includes('_animate_replace') ); } function isGptImage2ModelSelection(modelId) { const normalized = String(modelId || '').trim().toLowerCase(); return ['gpt-image-2', 'gptimage2', 'gpt-image', 'gpt_image_2'].includes(normalized); } function normalizeMusicModelId(value) { const raw = String(value || '').trim(); if (!raw) return null; const normalized = raw.toLowerCase().replace(/-/g, '_').replace(/ace_step_1_5/g, 'ace_step_1.5'); return MUSIC_MODEL_IDS[normalized] || (MUSIC_MODEL_DEFAULTS[normalized] ? normalized : null); } function getMusicModelDefaults(modelId) { return MUSIC_MODEL_DEFAULTS[normalizeMusicModelId(modelId)] || null; } function normalizeMusicTimeSignature(value) { const raw = String(value || '').trim(); if (!raw) return null; const match = raw.match(/^([2346])(?:\s*\/\s*(?:4|8))?$/); return match ? match[1] : raw; } function requiresSparkOnlyToken(modelId) { return isGptImage2ModelSelection(modelId) || isSeedanceModel(modelId); } function getMaxContextImages(modelId) { if (isGptImage2ModelSelection(modelId)) return 16; return getWrapperMaxContextImages(modelId); } function videoDurationLimitsLikeWrapper(modelId) { if (isSeedanceModel(modelId)) return { min: 4, max: 15 }; if (isLtx2Model(modelId) || isWanAnimateVideoModelId(modelId)) return { min: 1, max: 20 }; return { min: 1, max: 10 }; } function wrapperMaxVideoDimension(modelId) { return isWanVideoModelId(modelId) ? WRAPPER_MAX_WAN_VIDEO_DIMENSION : WRAPPER_MAX_VIDEO_DIMENSION; } function videoDimensionRulesFromDefaults(modelDefaults, modelId) { const wrapperMax = wrapperMaxVideoDimension(modelId); const configuredMax = modelDefaults?.maxDimension || DEFAULT_VIDEO_DIMENSION_RULES.maxDimension; return { minDimension: modelDefaults?.minDimension || DEFAULT_VIDEO_DIMENSION_RULES.minDimension, maxDimension: Math.min(configuredMax, wrapperMax), dimensionMultiple: modelDefaults?.dimensionMultiple || DEFAULT_VIDEO_DIMENSION_RULES.dimensionMultiple }; } /** * Resizes an image buffer to model-compatible dimensions while maintaining aspect ratio. * Uses sharp's fit:inside to preserve aspect, then rounds to the model divisor. */ async function resizeImageBufferForVideo(buffer, originalWidth, originalHeight, rules = DEFAULT_VIDEO_DIMENSION_RULES) { const multiple = rules.dimensionMultiple || VIDEO_DIMENSION_MULTIPLE; const roundToMultiple = (n) => Math.max(multiple, Math.round(n / multiple) * multiple); const targetWidth = Math.max(rules.minDimension, Math.min(rules.maxDimension, roundToMultiple(originalWidth))); const targetHeight = Math.max(rules.minDimension, Math.min(rules.maxDimension, roundToMultiple(originalHeight))); // Resize using sharp with fit:inside (maintains aspect ratio) const resizedBuffer = await sharp(buffer) .resize(targetWidth, targetHeight, { fit: 'inside', withoutEnlargement: false }) .toBuffer(); // Get actual dimensions after resize const metadata = await sharp(resizedBuffer).metadata(); const actualWidth = roundToMultiple(metadata.width); const actualHeight = roundToMultiple(metadata.height); // If dimensions aren't exactly model-compatible, do a final resize/crop. if (metadata.width !== actualWidth || metadata.height !== actualHeight) { return await sharp(resizedBuffer) .resize(actualWidth, actualHeight, { fit: 'cover' }) .toBuffer(); } return resizedBuffer; } function normalizeVideoDimensionsLikeWrapper(width, height, rules = DEFAULT_VIDEO_DIMENSION_RULES) { let targetWidth = Number(width); let targetHeight = Number(height); let adjusted = false; const effectiveMin = rules.minDimension || DEFAULT_VIDEO_DIMENSION_RULES.minDimension; const effectiveMax = rules.maxDimension || DEFAULT_VIDEO_DIMENSION_RULES.maxDimension; const effectiveMultiple = rules.dimensionMultiple || DEFAULT_VIDEO_DIMENSION_RULES.dimensionMultiple; if (!Number.isFinite(targetWidth) || !Number.isFinite(targetHeight)) { return { width: targetWidth, height: targetHeight, adjusted: false }; } if (targetWidth > effectiveMax || targetHeight > effectiveMax) { const scaleFactor = Math.min(effectiveMax / targetWidth, effectiveMax / targetHeight); targetWidth = Math.floor(targetWidth * scaleFactor); targetHeight = Math.floor(targetHeight * scaleFactor); adjusted = true; } if (targetWidth < effectiveMin || targetHeight < effectiveMin) { const scaleFactor = Math.max(effectiveMin / targetWidth, effectiveMin / targetHeight); targetWidth = Math.floor(targetWidth * scaleFactor); targetHeight = Math.floor(targetHeight * scaleFactor); adjusted = true; if (targetWidth > effectiveMax || targetHeight > effectiveMax) { const downscaleFactor = Math.min(effectiveMax / targetWidth, effectiveMax / targetHeight); targetWidth = Math.floor(targetWidth * downscaleFactor); targetHeight = Math.floor(targetHeight * downscaleFactor); } } const roundedWidth = Math.floor(targetWidth / effectiveMultiple) * effectiveMultiple; const roundedHeight = Math.floor(targetHeight / effectiveMultiple) * effectiveMultiple; if (roundedWidth !== targetWidth || roundedHeight !== targetHeight) { adjusted = true; } targetWidth = roundedWidth; targetHeight = roundedHeight; if (targetWidth < effectiveMin) { targetWidth = Math.ceil(effectiveMin / effectiveMultiple) * effectiveMultiple; adjusted = true; } if (targetHeight < effectiveMin) { targetHeight = Math.ceil(effectiveMin / effectiveMultiple) * effectiveMultiple; adjusted = true; } return { width: targetWidth, height: targetHeight, adjusted }; } function predictSharpInsideResizeDims(refWidth, refHeight, targetWidth, targetHeight) { const rw = Number(refWidth); const rh = Number(refHeight); const tw = Number(targetWidth); const th = Number(targetHeight); if (!Number.isFinite(rw) || !Number.isFinite(rh) || !Number.isFinite(tw) || !Number.isFinite(th) || rw <= 0 || rh <= 0 || tw <= 0 || th <= 0) { return null; } // Matches sharp(vips) behavior in SogniClientWrapper.resizeImageBuffer(..., fit: 'inside'): // Choose limiting dimension; keep it exact; compute the other dimension with Math.round(). const scaleW = tw / rw; const scaleH = th / rh; const widthLimited = scaleW <= scaleH; if (widthLimited) { return { width: tw, height: Math.round(rh * tw / rw) }; } return { width: Math.round(rw * th / rh), height: th }; } function pickCompatibleI2vBoundingBox(refWidth, refHeight, desiredWidth, desiredHeight, { allowImperfect = false, rules = DEFAULT_VIDEO_DIMENSION_RULES } = {}) { const effectiveMin = rules.minDimension || DEFAULT_VIDEO_DIMENSION_RULES.minDimension; const effectiveMax = rules.maxDimension || DEFAULT_VIDEO_DIMENSION_RULES.maxDimension; const effectiveMultiple = rules.dimensionMultiple || DEFAULT_VIDEO_DIMENSION_RULES.dimensionMultiple; const desiredW = Number.isFinite(Number(desiredWidth)) ? Number(desiredWidth) : 512; const desiredH = Number.isFinite(Number(desiredHeight)) ? Number(desiredHeight) : 512; const desiredMax = Math.max(effectiveMin, Math.min(effectiveMax, Math.max(desiredW, desiredH))); let best = null; let bestImperfect = null; for (let w = effectiveMin; w <= effectiveMax; w += effectiveMultiple) { for (let h = effectiveMin; h <= effectiveMax; h += effectiveMultiple) { const normalized = normalizeVideoDimensionsLikeWrapper(w, h, rules); if (!Number.isFinite(normalized.width) || !Number.isFinite(normalized.height)) continue; const out = predictSharpInsideResizeDims(refWidth, refHeight, normalized.width, normalized.height); if (!out) continue; // Require both output dimensions >= model minimum for API compatibility. if (out.width < effectiveMin || out.height < effectiveMin) continue; const isPerfect = out.width % effectiveMultiple === 0 && out.height % effectiveMultiple === 0; const outMax = Math.max(out.width, out.height); const distance = Math.abs(normalized.width - desiredW) + Math.abs(normalized.height - desiredH); // Prefer a bounding box close to what the user asked for, then output close to requested max, then maximize output area. const score = -distance * 1e9 - Math.abs(outMax - desiredMax) * 1e8 + out.width * out.height * 1e3 - (normalized.width * normalized.height); if (isPerfect) { if (!best || score > best.score) { best = { width: normalized.width, height: normalized.height, output: out, score, perfect: true }; } } else if (allowImperfect) { // Track imperfect candidates: prefer those closest to the model divisor. const widthRemainder = out.width % effectiveMultiple; const heightRemainder = out.height % effectiveMultiple; const divisorDistance = Math.min(widthRemainder, effectiveMultiple - widthRemainder) + Math.min(heightRemainder, effectiveMultiple - heightRemainder); const imperfectScore = -divisorDistance * 1e10 + score; if (!bestImperfect || imperfectScore > bestImperfect.score) { const adjustedWidth = Math.round(out.width / effectiveMultiple) * effectiveMultiple; const adjustedHeight = Math.round(out.height / effectiveMultiple) * effectiveMultiple; bestImperfect = { width: normalized.width, height: normalized.height, output: out, adjustedOutput: { width: adjustedWidth, height: adjustedHeight }, score: imperfectScore, perfect: false }; } } } } return best || (allowImperfect ? bestImperfect : null); } const MULTI_ANGLE_AZIMUTHS = [ { key: 'front', prompt: 'front view' }, { key: 'front-right', prompt: 'front-right quarter view' }, { key: 'right', prompt: 'right side view' }, { key: 'back-right', prompt: 'back-right quarter view' }, { key: 'back', prompt: 'back view' }, { key: 'back-left', prompt: 'back-left quarter view' }, { key: 'left', prompt: 'left side view' }, { key: 'front-left', prompt: 'front-left quarter view' } ]; const MULTI_ANGLE_ELEVATIONS = [ { key: 'low-angle', prompt: 'low-angle shot' }, { key: 'eye-level', prompt: 'eye-level shot' }, { key: 'elevated', prompt: 'elevated shot' }, { key: 'high-angle', prompt: 'high-angle shot' } ]; const MULTI_ANGLE_DISTANCES = [ { key: 'close-up', prompt: 'close-up' }, { key: 'medium', prompt: 'medium shot' }, { key: 'wide', prompt: 'wide shot' } ]; const MULTI_ANGLE_AZIMUTH_ALIASES = new Map([ ['front-right quarter', 'front-right'], ['front right quarter', 'front-right'], ['back-right quarter', 'back-right'], ['back right quarter', 'back-right'], ['back-left quarter', 'back-left'], ['back left quarter', 'back-left'], ['front-left quarter', 'front-left'], ['front left quarter', 'front-left'] ]); const MULTI_ANGLE_ELEVATION_ALIASES = new Map([ ['low angle', 'low-angle'], ['eye level', 'eye-level'], ['high angle', 'high-angle'] ]); const MULTI_ANGLE_DISTANCE_ALIASES = new Map([ ['close up', 'close-up'], ['medium shot', 'medium'], ['wide shot', 'wide'] ]); function normalizeMultiAngleValue(value, aliases, allowedKeys, label) { if (!value) return null; const normalized = value.toLowerCase().replace(/_/g, '-').replace(/\s+/g, ' ').trim(); const aliased = aliases.get(normalized) || normalized; if (!allowedKeys.includes(aliased)) { fatalCliError(`Invalid ${label} "${value}".`, { code: 'INVALID_ARGUMENT', details: { field: label, value, allowed: allowedKeys } }); } return aliased; } function buildMultiAnglePrompt({ azimuth, elevation, distance, description }) { const azimuthPrompt = MULTI_ANGLE_AZIMUTHS.find((a) => a.key === azimuth)?.prompt; const elevationPrompt = MULTI_ANGLE_ELEVATIONS.find((e) => e.key === elevation)?.prompt; const distancePrompt = MULTI_ANGLE_DISTANCES.find((d) => d.key === distance)?.prompt; const parts = ['<sks>', azimuthPrompt, elevationPrompt, distancePrompt].filter(Boolean); if (description) parts.push(description); return parts.join(' '); } function loadOpenClawPluginConfig() { const openclawPluginConfig = getEnv('OPENCLAW_PLUGIN_CONFIG'); if (openclawPluginConfig) { try {