#!/usr/bin/env node /** * Kling AI image generation — text-to-image, image-to-image, 4K, series, subject * Node.js 18+, zero external deps */ import { submitTask, queryTask, pollTask, downloadFile } from './shared/task.mjs'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs, getTokenOrExit, readMediaAsValue, resolveAllowedOutputDir } from './shared/args.mjs'; const API_GEN = '/v1/images/generations'; const API_OMNI = '/v1/images/omni-image'; function normalizeModelName(v) { return String(v || '').trim(); } function normalizeAliasKey(v) { return String(v || '').trim().toLowerCase().replace(/[\s_]+/g, '-'); } function getImageModelAliasTarget(v) { const key = normalizeAliasKey(v); const aliasMap = new Map([ ['omni3', 'kling-v3-omni'], ['omni-3', 'kling-v3-omni'], ['omni-v3', 'kling-v3-omni'], ['v3-omni', 'kling-v3-omni'], ['o3', 'kling-v3-omni'], ['O3', 'kling-v3-omni'], ['kling-image-o3', 'kling-v3-omni'], ['kling-o3', 'kling-v3-omni'], ['omni1', 'kling-image-o1'], ['omni-1', 'kling-image-o1'], ['o1', 'kling-image-o1'], ['kling-o1', 'kling-image-o1'], ]); return aliasMap.get(key) || ''; } function validateModelAliasInput(rawModel) { if (!rawModel) return; const model = normalizeModelName(rawModel).toLowerCase(); const target = getImageModelAliasTarget(rawModel); if (!target || model === target) return; throw new Error( `Invalid --model alias / --model 使用了别名: ${rawModel}\n` + `Use canonical name / 请改用标准名: ${target}\n` + 'Alias mapping / 别名映射: omni3 | omni v3 | o3 -> kling-v3-omni; image o1/omni1 -> kling-image-o1', ); } function validateModelForRoute(apiPath, args) { validateModelAliasInput(args.model); const model = normalizeModelName(args.model); if (!model) return; // We only validate what we can be sure about from public enums. // - omni-image: only kling-v3-omni / kling-image-o1 // - generations: must not use omni-only models if (apiPath === API_OMNI) { const allowed = new Set(['kling-v3-omni', 'kling-image-o1']); if (!allowed.has(model)) { throw new Error( `Invalid --model for omni-image / omni-image 不支持该模型: ${model}\n` + `Allowed / 允许: kling-v3-omni, kling-image-o1`, ); } } else { const forbidden = new Set(['kling-v3-omni', 'kling-image-o1', 'kling-video-o1']); if (forbidden.has(model)) { throw new Error( `Invalid --model for generations / generations 不支持该模型: ${model}\n` + `Hint / 提示: remove --model or use kling-v3`, ); } } } function parseImageInputs(rawImageArg) { if (!rawImageArg) return []; const parts = String(rawImageArg).split(',').map(s => s.trim()); if (parts.some(p => !p)) { throw new Error( 'Invalid --image list / --image 列表中存在空值;请移除空项并确保每个 image 非空。', ); } return parts; } function parseElementIds(rawElementIdsArg) { if (!rawElementIdsArg) return []; const parts = String(rawElementIdsArg).split(',').map(s => s.trim()); if (parts.some(p => !p)) { throw new Error( 'Invalid --element_ids list / --element_ids 列表中存在空值;请移除空项并确保每个 element_id 非空。', ); } return parts; } function validateOmniRefCount(imageInputs, elementIds) { const totalRefs = imageInputs.length + elementIds.length; if (totalRefs > 10) { throw new Error( `Too many refs for omni-image / omni-image 参考图与主体总数超限: max 10 (current ${totalRefs})`, ); } } function printHelp() { console.log(`Kling AI image generation Usage: node kling.mjs image --prompt [options] # Text/image-to-image node kling.mjs image --prompt "..." [--resolution 4k] # 4K / series / subject → Omni node kling.mjs image --model kling-v3-omni --prompt "..." # explicit Omni model → omni-image (t2i / i2i) node kling.mjs image --task_id [--download] # Query/download Submit (common): --prompt Image description (required). Omni: <<>> / <<>> --resolution 1k / 2k / 4k (4k uses Omni) --aspect_ratio Aspect ratio (default: 16:9 basic, auto for Omni) --n Number of images 1-9 --output_dir Output dir (default: ./output) --no-wait Submit only, do not wait --wait Wait for completion (default) Basic API: --negative_prompt Negative prompt --model Model (default: kling-v3) Omni (4K/series/subject): --model kling-v3-omni / kling-image-o1 --result_type single / series (default: single) --series_amount Series count 2-9 (when result_type=series) --image Reference image path or URL, comma-separated for multiple --element_ids Subject IDs, comma-separated (omni refs) image count + element count <= 10 Query/download: --task_id Task ID --download Download if task succeeded Env: credentials file ~/.config/kling/.credentials (access_key_id, secret_access_key) KLING_TOKEN Session-only Bearer (optional override) KLING_MEDIA_ROOTS Comma-separated extra dirs for --image / --output_dir (default: cwd only) KLING_ALLOW_ABSOLUTE_PATHS=1 Allow any local path (e.g. WSL downloads)`); } function useOmniApi(args) { // Match video.mjs chooseApiPath: explicit Omni image models → omni-image (incl. plain text-to-image). const m = normalizeModelName(args.model).toLowerCase(); if (m === 'kling-v3-omni' || m === 'kling-image-o1') return true; if (args.element_ids) return true; if (args.result_type === 'series') return true; if ((args.resolution || '').toLowerCase() === '4k') return true; if ((args.aspect_ratio || '').toLowerCase() === 'auto') return true; if (args.image && args.image.includes(',')) return true; return false; } async function queryTaskAnyPath(taskId, token) { for (const apiPath of [API_OMNI, API_GEN]) { try { const data = await queryTask(apiPath, taskId, token); if (data && (data.task_status === 'succeed' || data.task_status === 'failed' || data.task_status === 'processing' || data.task_status === 'submitted')) { return { apiPath, data }; } } catch (_) { /* try next */ } } throw new Error(`Task not found / 未找到任务: ${taskId}`); } function collectImageUrls(taskResult) { const urls = []; const append = (list) => { if (!Array.isArray(list)) return; for (const item of list) { if (item?.url) urls.push(item.url); } }; append(taskResult?.images); append(taskResult?.series_images); if (urls.length === 0 && taskResult?.url) urls.push(taskResult.url); return urls; } async function pollAndDownloadImages(apiPath, taskId, outputDir, opts = {}) { const data = await pollTask(apiPath, taskId, opts); const urls = collectImageUrls(data?.task_result || {}); if (urls.length === 0) { throw new Error('Task succeeded but missing image urls / 任务成功但未返回图片 URL'); } const outPaths = []; for (let i = 0; i < urls.length; i++) { const outPath = join(outputDir, urls.length === 1 ? `${taskId}.png` : `${taskId}_${i}.png`); await downloadFile(urls[i], outPath); outPaths.push(outPath); } return outPaths; } export async function main() { const args = parseArgs(process.argv); if (args.help) { printHelp(); return; } validateModelAliasInput(args.model); const token = await getTokenOrExit(); const outputDir = resolveAllowedOutputDir(args.output_dir || './output'); const queryHint = `node kling.mjs image --task_id`; if (args.task_id && !args.prompt) { try { const { apiPath, data } = await queryTaskAnyPath(args.task_id, token); console.log(`Task ID / 任务 ID: ${args.task_id}`); console.log(`Status / 状态: ${data?.task_status || 'unknown'}`); const result = data?.task_result || {}; const imageUrls = collectImageUrls(result); imageUrls.forEach((url, i) => { console.log(`Image / 图片[${i}]: ${url}`); }); if (args.download && imageUrls.length > 0) { const { mkdir } = await import('node:fs/promises'); await mkdir(outputDir, { recursive: true }); for (let i = 0; i < imageUrls.length; i++) { const outPath = join(outputDir, imageUrls.length === 1 ? `${args.task_id}.png` : `${args.task_id}_${i}.png`); await downloadFile(imageUrls[i], outPath); } } } catch (e) { console.error(`Error / 错误: ${e.message}`); process.exit(1); } return; } if (!args.prompt) { console.error('Error / 错误: --prompt or --task_id required'); console.error('Use --help / 使用 --help 查看帮助'); process.exit(1); } const apiPath = useOmniApi(args) ? API_OMNI : API_GEN; const imageInputs = parseImageInputs(args.image); const elementIds = parseElementIds(args.element_ids); try { validateModelForRoute(apiPath, args); if (apiPath === API_GEN) { const payload = { model_name: args.model || 'kling-v3', prompt: args.prompt, negative_prompt: args.negative_prompt || '', n: parseInt(args.n || '1', 10), aspect_ratio: args.aspect_ratio || '16:9', resolution: args.resolution || '1k', callback_url: '', }; if (imageInputs.length > 0) { payload.image = await readMediaAsValue(imageInputs[0]); } const result = await submitTask(API_GEN, payload, token); console.log(`\nTask ID / 任务 ID: ${result.taskId}`); console.log(`Query / 查询: ${queryHint} ${result.taskId} [--download]`); if (args.wait !== false) { console.log(); const outPaths = await pollAndDownloadImages(API_GEN, result.taskId, outputDir, { token }); console.log(`\n✓ Done / 完成: ${outPaths.length} image(s)`); outPaths.forEach((p) => console.log(` - ${p}`)); } return; } const payload = { model_name: args.model || 'kling-v3-omni', prompt: args.prompt, resolution: (args.resolution || '1k').toLowerCase(), aspect_ratio: (args.aspect_ratio || 'auto').toLowerCase(), result_type: args.result_type || 'single', callback_url: '', }; if (payload.result_type === 'series') { if (imageInputs.length === 0) { throw new Error( 'Invalid --result_type series without --image / 组图仅支持 i2i,请提供 --image(t2i 不支持 series)。', ); } payload.series_amount = parseInt(args.series_amount || '4', 10); } else { payload.n = parseInt(args.n || '1', 10); } validateOmniRefCount(imageInputs, elementIds); if (imageInputs.length > 0) { payload.image_list = []; for (const img of imageInputs) { payload.image_list.push({ image: await readMediaAsValue(img) }); } } if (elementIds.length > 0) { payload.element_list = elementIds.map(id => ({ element_id: id })); } const result = await submitTask(API_OMNI, payload, token); console.log(`\nTask ID / 任务 ID: ${result.taskId}`); console.log(`Query / 查询: ${queryHint} ${result.taskId} [--download]`); if (args.wait !== false) { console.log(); const outPaths = await pollAndDownloadImages(API_OMNI, result.taskId, outputDir, { token }); console.log(`\n✓ Done / 完成: ${outPaths.length} image(s)`); outPaths.forEach((p) => console.log(` - ${p}`)); } } catch (e) { console.error(`Error / 错误: ${e.message}`); process.exit(1); } } const __filename = fileURLToPath(import.meta.url); if (process.argv[1] && resolve(__filename) === resolve(process.argv[1])) { main().catch((e) => { console.error(`Error / 错误: ${e?.message || e}`); process.exit(1); }); }