328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
#!/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 <text> [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 <id> [--download] # Query/download
|
||
|
||
Submit (common):
|
||
--prompt Image description (required). Omni: <<<image_1>>> / <<<element_1>>>
|
||
--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);
|
||
});
|
||
}
|