init: video-create project with skills and accounts
This commit is contained in:
319
.claude/skills/klingai-1.1.0/scripts/account.mjs
Normal file
319
.claude/skills/klingai-1.1.0/scripts/account.mjs
Normal file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Kling AI — 账号:资源包查询、设备绑定、交互式配置 credentials
|
||||
*/
|
||||
import {
|
||||
klingGet,
|
||||
runDeviceBindFlow,
|
||||
KLING_CONSOLE_URLS,
|
||||
} from './shared/client.mjs';
|
||||
import {
|
||||
getActiveProfile,
|
||||
getCredentialsFilePath,
|
||||
getIdentityFilePath,
|
||||
hasStoredAccessKeys,
|
||||
promptInteractiveCredentialsFile,
|
||||
writeCredentialsProfile,
|
||||
} from './shared/auth.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { parseArgs, getTokenOrExit } from './shared/args.mjs';
|
||||
|
||||
const API_COSTS = '/account/costs';
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function maskSecret(secret) {
|
||||
const s = String(secret || '');
|
||||
if (!s) return '';
|
||||
if (s.length <= 6) return '***';
|
||||
return `${s.slice(0, 3)}***${s.slice(-2)}`;
|
||||
}
|
||||
|
||||
function maskAccessKey(accessKey) {
|
||||
const s = String(accessKey || '');
|
||||
if (!s) return '';
|
||||
if (s.length <= 8) return `${s.slice(0, 2)}***`;
|
||||
return `${s.slice(0, 4)}***${s.slice(-3)}`;
|
||||
}
|
||||
|
||||
function printConsoleUrls() {
|
||||
for (const [region, url] of Object.entries(KLING_CONSOLE_URLS || {})) {
|
||||
const label = region === 'cn' ? 'China / 国内' : (region === 'global' ? 'Global / 国际' : region);
|
||||
console.error(`${label}: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Kling AI account — quota, device bind, configure credentials
|
||||
|
||||
Usage:
|
||||
node kling.mjs account [options]
|
||||
node kling.mjs account --costs (default)
|
||||
node kling.mjs account --bind-url
|
||||
node kling.mjs account --bind (alias of --bind-url, kept for compatibility)
|
||||
node kling.mjs account --configure
|
||||
node kling.mjs account --import-env
|
||||
node kling.mjs account --import-credentials --access_key_id <ak> --secret_access_key <sk>
|
||||
|
||||
--costs (default)
|
||||
GET ${API_COSTS} (Bearer from credentials JWT or KLING_TOKEN)
|
||||
--days, --start_time, --end_time, --resource_pack_name
|
||||
|
||||
--bind-url
|
||||
init → verify → print URL (manual open) → poll
|
||||
--bind is equivalent to --bind-url (compatibility alias)
|
||||
--force Re-bind even if credentials already exist
|
||||
writes ~/.config/kling/.credentials after exchange succeeds
|
||||
|
||||
--import-env
|
||||
Read KLING_ACCESS_KEY_ID + KLING_SECRET_ACCESS_KEY from env and save (no prompt)
|
||||
|
||||
--import-credentials
|
||||
Write AK/SK via args in one step, no prompts
|
||||
|
||||
--configure
|
||||
Interactive prompts → credentials file (hidden SK on TTY, paste supported)
|
||||
|
||||
Env:
|
||||
KLING_STORAGE_ROOT Optional storage root for credentials/identity/env files
|
||||
KLING_TOKEN Session Bearer (not loaded from kling.env; export or agent env)
|
||||
KLING_API_BASE Optional API origin
|
||||
KLING_ACCESS_KEY_ID With KLING_SECRET_ACCESS_KEY: used by import-env (not echoed)
|
||||
KLING_SECRET_ACCESS_KEY (same)`);
|
||||
}
|
||||
|
||||
function saveCredentialsQuietly(ak, sk, source = 'input') {
|
||||
const savePath = writeCredentialsProfile(getActiveProfile(), String(ak || '').trim(), String(sk || '').trim());
|
||||
console.error(`✓ Credentials saved / 凭证已保存(来源: ${source};密钥未在日志中输出)`);
|
||||
console.error(` Path / 路径: ${savePath}\n`);
|
||||
return {
|
||||
savePath,
|
||||
accessKeyMasked: maskAccessKey(ak),
|
||||
secretKeyMasked: maskSecret(sk),
|
||||
};
|
||||
}
|
||||
|
||||
function getEnvCredentials() {
|
||||
const ak = (process.env.KLING_ACCESS_KEY_ID || '').trim();
|
||||
const sk = (process.env.KLING_SECRET_ACCESS_KEY || '').trim();
|
||||
return { ak, sk };
|
||||
}
|
||||
|
||||
export function importCredentialsFromEnv() {
|
||||
const { ak, sk } = getEnvCredentials();
|
||||
if (!ak || !sk) {
|
||||
throw new Error(
|
||||
'Set both KLING_ACCESS_KEY_ID and KLING_SECRET_ACCESS_KEY / '
|
||||
+ '请同时设置 KLING_ACCESS_KEY_ID 与 KLING_SECRET_ACCESS_KEY',
|
||||
);
|
||||
}
|
||||
return saveCredentialsQuietly(ak, sk, 'env');
|
||||
}
|
||||
|
||||
export function importCredentialsFromArgs(accessKey, secretKey) {
|
||||
const ak = String(accessKey || '').trim();
|
||||
const sk = String(secretKey || '').trim();
|
||||
if (!ak || !sk) {
|
||||
throw new Error(
|
||||
'import-credentials requires --access_key_id and --secret_access_key / '
|
||||
+ 'import-credentials 需要 --access_key_id 与 --secret_access_key',
|
||||
);
|
||||
}
|
||||
return saveCredentialsQuietly(ak, sk, 'args');
|
||||
}
|
||||
|
||||
function parseMs(name, raw) {
|
||||
const n = parseInt(String(raw).trim(), 10);
|
||||
if (!Number.isFinite(n)) {
|
||||
console.error(`Error / 错误: ${name} must be a valid integer (ms) / 须为有效整数(毫秒)`);
|
||||
process.exit(1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function buildCostsQueryPath(args) {
|
||||
let endMs;
|
||||
let startMs;
|
||||
|
||||
if (args.end_time != null) {
|
||||
endMs = parseMs('--end_time', args.end_time);
|
||||
} else {
|
||||
endMs = Date.now();
|
||||
}
|
||||
|
||||
if (args.start_time != null) {
|
||||
startMs = parseMs('--start_time', args.start_time);
|
||||
} else {
|
||||
const days = Math.max(1, parseInt(String(args.days ?? '30'), 10) || 30);
|
||||
startMs = endMs - days * MS_PER_DAY;
|
||||
}
|
||||
|
||||
if (startMs >= endMs) {
|
||||
console.error('Error / 错误: start_time must be < end_time / start_time 须小于 end_time');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('start_time', String(startMs));
|
||||
params.set('end_time', String(endMs));
|
||||
if (args.resource_pack_name) {
|
||||
params.set('resource_pack_name', String(args.resource_pack_name).trim());
|
||||
}
|
||||
|
||||
return `${API_COSTS}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function printAccountStateNoAccount(detail = '') {
|
||||
console.error('Account State / 账号状态: NO_ACCOUNT / 无可用账号凭证');
|
||||
if (detail) {
|
||||
console.error(` Detail / 详情: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPermissionOrServerIssue(errorMessage = '') {
|
||||
const msg = String(errorMessage || '').toLowerCase();
|
||||
return (
|
||||
msg.includes('http 401')
|
||||
|| msg.includes('http 403')
|
||||
|| msg.includes('code=1000')
|
||||
|| msg.includes('code=1002')
|
||||
|| msg.includes('permission')
|
||||
|| msg.includes('forbidden')
|
||||
|| msg.includes('unauthorized')
|
||||
|| msg.includes('api service error')
|
||||
|| msg.includes('http 500')
|
||||
|| msg.includes('http 502')
|
||||
|| msg.includes('http 503')
|
||||
|| msg.includes('http 504')
|
||||
|| msg.includes('server error')
|
||||
);
|
||||
}
|
||||
|
||||
async function runBindUrlAction(args, options = {}) {
|
||||
const viaAliasBind = options.viaAliasBind === true;
|
||||
if (!args.force && hasStoredAccessKeys()) {
|
||||
console.error('Credentials already present / 已存在凭证(使用 --force 重新绑定)');
|
||||
console.error(`Credentials file / 凭证文件: ${getCredentialsFilePath()}`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (viaAliasBind) {
|
||||
console.error('Info / 提示: --bind is an alias of --bind-url / --bind 与 --bind-url 等价');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runDeviceBindFlow();
|
||||
console.error('\n✓ Bind succeeded / 绑定成功');
|
||||
console.error(` Saved / 已写入: ${result.savePath || getCredentialsFilePath()}`);
|
||||
} catch (e) {
|
||||
console.error(`\nBind failed / 绑定失败: ${e?.message || e}\n`);
|
||||
console.error('Hint / 提示:');
|
||||
console.error(' 1) Check network/DNS/proxy / 检查网络、DNS、代理');
|
||||
console.error(' 2) Check configured API base in ~/.config/kling/kling.env / 检查 ~/.config/kling/kling.env 中的 API 基址配置');
|
||||
console.error(' 3) Re-probe business API base: remove KLING_API_BASE then run account --costs / 重新探测业务 API 基址:删除 KLING_API_BASE 后执行 account --costs');
|
||||
console.error('Fallback / 备选:');
|
||||
console.error(' 1) Create keys Manually / 手动创建密钥:');
|
||||
printConsoleUrls();
|
||||
console.error(' 2) Set env then: node skills/klingai/scripts/kling.mjs account --import-env');
|
||||
console.error(' 3) or Pass args: node skills/klingai/scripts/kling.mjs account --import-credentials --access_key_id <AK> --secret_access_key <SK>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
if (args.action != null) {
|
||||
console.error('Error / 错误: --action has been removed. Use one flag: --costs | --bind-url (or alias --bind) | --import-env | --import-credentials | --configure');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const modes = ['costs', 'bind', 'bind-url', 'configure', 'import-env', 'import-credentials'];
|
||||
const selected = modes.filter((m) => args[m]);
|
||||
if (selected.length > 1) {
|
||||
console.error(`Error / 错误: account mode flags are mutually exclusive / account 模式参数互斥: ${selected.map((s) => `--${s}`).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const action = selected[0] || 'costs';
|
||||
|
||||
if (action === 'bind') {
|
||||
await runBindUrlAction(args, { viaAliasBind: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'bind-url') {
|
||||
await runBindUrlAction(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'import-env') {
|
||||
try {
|
||||
importCredentialsFromEnv();
|
||||
} catch (e) {
|
||||
console.error(`Error / 错误: ${e?.message || e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'import-credentials') {
|
||||
try {
|
||||
importCredentialsFromArgs(args.access_key_id, args.secret_access_key);
|
||||
} catch (e) {
|
||||
console.error(`Error / 错误: ${e?.message || e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'configure') {
|
||||
try {
|
||||
console.error('Get keys / 获取密钥:');
|
||||
printConsoleUrls();
|
||||
await promptInteractiveCredentialsFile();
|
||||
} catch (e) {
|
||||
console.error(`Error / 错误: ${e?.message || e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let token;
|
||||
try {
|
||||
token = await getTokenOrExit();
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e);
|
||||
printAccountStateNoAccount(msg);
|
||||
console.error(`Error / 错误: ${msg}`);
|
||||
console.error('Get keys / 获取密钥:');
|
||||
printConsoleUrls();
|
||||
process.exit(1);
|
||||
}
|
||||
const pathWithQuery = buildCostsQueryPath(args);
|
||||
|
||||
try {
|
||||
const data = await klingGet(pathWithQuery, token, { contentType: 'application/json' });
|
||||
const infos = Array.isArray(data?.resource_pack_subscribe_infos) ? data.resource_pack_subscribe_infos : [];
|
||||
console.error(`Account State / 账号状态: ACCOUNT_OK / 账号正常(资源包 ${infos.length})`);
|
||||
console.log('Account / 账户资源 (API data):');
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return;
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e);
|
||||
if (isPermissionOrServerIssue(msg)) {
|
||||
console.error('Account State / 账号状态: BOUND_BUT_PERMISSION_OR_SERVER_ERROR / 已绑定但权限或服务异常');
|
||||
}
|
||||
console.error(`Error / 错误: ${msg}`);
|
||||
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);
|
||||
});
|
||||
}
|
||||
211
.claude/skills/klingai-1.1.0/scripts/element.mjs
Normal file
211
.claude/skills/klingai-1.1.0/scripts/element.mjs
Normal file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Kling AI subject management — create, query, list, delete custom subjects
|
||||
* Node.js 18+, zero external deps
|
||||
*/
|
||||
import { submitTask, queryTask, pollTask } from './shared/task.mjs';
|
||||
import { klingGet, klingPost } from './shared/client.mjs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs, getTokenOrExit, readMediaAsValue } from './shared/args.mjs';
|
||||
|
||||
const API_PATH = '/v1/general/advanced-custom-elements';
|
||||
const API_PATH_PRESETS = '/v1/general/advanced-presets-elements';
|
||||
const API_PATH_DELETE = '/v1/general/delete-elements';
|
||||
|
||||
function getElementType(el) {
|
||||
return el?.reference_type || el?.element_type || el?.ref_type || 'unknown';
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Kling AI subject management (create/query/list/delete)
|
||||
|
||||
Usage:
|
||||
node kling.mjs element --action create [create options]
|
||||
node kling.mjs element --action query --task_id <id>
|
||||
node kling.mjs element --action list [--page_num 1] [--page_size 30]
|
||||
node kling.mjs element --action list-presets [--page_num 1] [--page_size 30]
|
||||
node kling.mjs element --action delete --element_id <id>
|
||||
|
||||
Actions:
|
||||
--action create Create custom subject
|
||||
--action query Query creation task status
|
||||
--action list List custom subjects
|
||||
--action list-presets List preset subjects
|
||||
--action delete Delete subject
|
||||
|
||||
Create options:
|
||||
--name Subject name (required, ≤20 chars)
|
||||
--description Subject description (required, ≤100 chars)
|
||||
--ref_type image_refer / video_refer (required)
|
||||
--frontal_image Front reference image path or URL (required for image_refer)
|
||||
--refer_images Other reference images, comma-separated (optional, 1-3)
|
||||
--video Reference video path or URL (required for video_refer)
|
||||
--voice_id Voice ID (optional, video-based only)
|
||||
--tags Tag IDs, comma-separated (e.g. "o_102,o_108")
|
||||
--no-wait Submit only, do not wait
|
||||
|
||||
Query:
|
||||
--task_id Task ID
|
||||
|
||||
List:
|
||||
--page_num Page 1-1000 (default: 1)
|
||||
--page_size Page size 1-500 (default: 30)
|
||||
|
||||
Delete:
|
||||
--element_id Subject ID to delete
|
||||
|
||||
Env:
|
||||
credentials file ~/.config/kling/.credentials (access_key_id, secret_access_key)
|
||||
KLING_TOKEN Session-only Bearer (optional override)`);
|
||||
}
|
||||
|
||||
async function actionCreate(args, token) {
|
||||
if (!args.name) { console.error('Error / 错误: --name required'); process.exit(1); }
|
||||
if (!args.description) { console.error('Error / 错误: --description required'); process.exit(1); }
|
||||
if (!args.ref_type) { console.error('Error / 错误: --ref_type required (image_refer / video_refer)'); process.exit(1); }
|
||||
|
||||
const payload = {
|
||||
element_name: args.name,
|
||||
element_description: args.description,
|
||||
reference_type: args.ref_type,
|
||||
callback_url: '',
|
||||
};
|
||||
|
||||
if (args.ref_type === 'image_refer') {
|
||||
if (!args.frontal_image) {
|
||||
console.error('Error / 错误: image_refer requires --frontal_image'); process.exit(1);
|
||||
}
|
||||
const imageList = {
|
||||
frontal_image: await readMediaAsValue(args.frontal_image),
|
||||
};
|
||||
if (args.refer_images) {
|
||||
const imgs = args.refer_images.split(',');
|
||||
imageList.refer_images = [];
|
||||
for (const img of imgs) {
|
||||
imageList.refer_images.push({ image_url: await readMediaAsValue(img.trim()) });
|
||||
}
|
||||
}
|
||||
payload.element_image_list = imageList;
|
||||
} else if (args.ref_type === 'video_refer') {
|
||||
if (!args.video) {
|
||||
console.error('Error / 错误: video_refer requires --video'); process.exit(1);
|
||||
}
|
||||
payload.element_video_list = {
|
||||
refer_videos: [{ video_url: await readMediaAsValue(args.video) }],
|
||||
};
|
||||
} else {
|
||||
console.error('Error / 错误: --ref_type must be image_refer or video_refer');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.voice_id) {
|
||||
payload.element_voice_id = args.voice_id;
|
||||
}
|
||||
|
||||
if (args.tags) {
|
||||
payload.tag_list = args.tags.split(',').map(id => ({ tag_id: id.trim() }));
|
||||
}
|
||||
|
||||
const result = await submitTask(API_PATH, payload, token);
|
||||
console.log(`\nTask ID / 任务 ID: ${result.taskId}`);
|
||||
|
||||
if (args.wait !== false) {
|
||||
console.log();
|
||||
const data = await pollTask(API_PATH, result.taskId, { token });
|
||||
const elements = data?.task_result?.elements || [];
|
||||
if (elements.length > 0) {
|
||||
console.log('\n✓ Created / 已创建:');
|
||||
for (const el of elements) {
|
||||
console.log(` Element ID / 主体 ID: ${el.element_id}`);
|
||||
console.log(` Name / 名称: ${el.element_name}`);
|
||||
console.log(` Description / 描述: ${el.element_description}`);
|
||||
console.log(` Type / 类型: ${getElementType(el)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function actionQuery(args, token) {
|
||||
if (!args.task_id) { console.error('Error / 错误: --task_id required'); process.exit(1); }
|
||||
const data = await queryTask(API_PATH, args.task_id, token);
|
||||
console.log(`Task ID / 任务 ID: ${args.task_id}`);
|
||||
console.log(`Status / 状态: ${data?.task_status || 'unknown'}`);
|
||||
if (data?.task_status_msg) console.log(`Message / 消息: ${data.task_status_msg}`);
|
||||
const elements = data?.task_result?.elements || [];
|
||||
for (const el of elements) {
|
||||
console.log(`\nElement ID / 主体 ID: ${el.element_id}`);
|
||||
console.log(` Name / 名称: ${el.element_name}`);
|
||||
console.log(` Description / 描述: ${el.element_description}`);
|
||||
console.log(` Type / 类型: ${getElementType(el)}`);
|
||||
if (el.element_voice_info?.voice_id) {
|
||||
console.log(` Voice / 音色: ${el.element_voice_info.voice_name} (${el.element_voice_info.voice_id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function actionList(args, token, presets) {
|
||||
const path = presets ? API_PATH_PRESETS : API_PATH;
|
||||
const pageNum = args.page_num || '1';
|
||||
const pageSize = args.page_size || '30';
|
||||
const data = await klingGet(`${path}?pageNum=${pageNum}&pageSize=${pageSize}`, token);
|
||||
|
||||
const items = Array.isArray(data) ? data : [data];
|
||||
const label = presets ? 'Preset / 预设主体' : 'Custom / 自定义主体';
|
||||
console.log(`${label} (page ${pageNum}):\n`);
|
||||
|
||||
for (const item of items) {
|
||||
const elements = item?.task_result?.elements || [];
|
||||
if (elements.length === 0 && item?.task_id) {
|
||||
console.log(` Task / 任务 ${item.task_id}: ${item.task_status || 'unknown'}`);
|
||||
continue;
|
||||
}
|
||||
for (const el of elements) {
|
||||
console.log(` [${el.element_id}] ${el.element_name} — ${el.element_description} (${getElementType(el)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function actionDelete(args, token) {
|
||||
if (!args.element_id) { console.error('Error / 错误: --element_id required'); process.exit(1); }
|
||||
const data = await klingPost(API_PATH_DELETE, { element_id: String(args.element_id) }, token);
|
||||
console.log(`✓ Deleted / 已删除: ${args.element_id}`);
|
||||
if (data?.task_status) console.log(` Status / 状态: ${data.task_status}`);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const args = parseArgs(process.argv, ['no-wait']);
|
||||
if (args.help) { printHelp(); return; }
|
||||
|
||||
const token = await getTokenOrExit();
|
||||
const action = args.action;
|
||||
|
||||
if (!action) {
|
||||
console.error('Error / 错误: --action required (create / query / list / list-presets / delete)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'create': await actionCreate(args, token); break;
|
||||
case 'query': await actionQuery(args, token); break;
|
||||
case 'list': await actionList(args, token, false); break;
|
||||
case 'list-presets': await actionList(args, token, true); break;
|
||||
case 'delete': await actionDelete(args, token); break;
|
||||
default:
|
||||
console.error(`Error / 错误: unknown action "${action}". Use: create / query / list / list-presets / delete`);
|
||||
process.exit(1);
|
||||
}
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
327
.claude/skills/klingai-1.1.0/scripts/image.mjs
Normal file
327
.claude/skills/klingai-1.1.0/scripts/image.mjs
Normal file
@@ -0,0 +1,327 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
80
.claude/skills/klingai-1.1.0/scripts/kling.mjs
Normal file
80
.claude/skills/klingai-1.1.0/scripts/kling.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Kling AI — video generation, image generation, subject management
|
||||
* Usage: node kling.mjs <video|image|element|account> [options]
|
||||
* Node.js 18+, zero external deps
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function getVersionFromSkillMd() {
|
||||
try {
|
||||
const raw = readFileSync(join(__dirname, '..', 'SKILL.md'), 'utf-8');
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!m) return null;
|
||||
const v = m[1].match(/^version:\s*["']?([^"'\s\n]+)["']?/m);
|
||||
return v ? v[1].trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let argvRest = process.argv.slice(2);
|
||||
const vidx = argvRest.indexOf('--skill-version');
|
||||
if (vidx === -1 || argvRest[vidx + 1] == null || String(argvRest[vidx + 1]).startsWith('--')) {
|
||||
argvRest = [argvRest[0], '--skill-version', getVersionFromSkillMd() || '1.0', ...argvRest.slice(1)];
|
||||
}
|
||||
process.argv = [process.argv[0], process.argv[1], ...argvRest];
|
||||
|
||||
const SUBCOMMANDS = new Set(['video', 'image', 'element', 'account']);
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Kling AI
|
||||
|
||||
Usage:
|
||||
node kling.mjs <subcommand> [options]
|
||||
|
||||
Subcommands:
|
||||
video Video generation (text-to-video, image-to-video, Omni, multi-shot)
|
||||
image Image generation (text-to-image, image-to-image, 4K, series, subject)
|
||||
element Subject management (create, query, list, delete)
|
||||
account Quota, bind-url/import credentials, configure
|
||||
|
||||
Examples:
|
||||
node kling.mjs video --prompt "A cat running on the grass" --output_dir ./out
|
||||
node kling.mjs image --prompt "Sunset over mountains" --resolution 4k
|
||||
node kling.mjs element --action list
|
||||
node kling.mjs account
|
||||
node kling.mjs account --bind-url
|
||||
|
||||
node kling.mjs video --help
|
||||
node kling.mjs image --help
|
||||
node kling.mjs element --help
|
||||
|
||||
Env: credentials under ~/.config/kling/.credentials (or KLING_STORAGE_ROOT/.credentials), or session KLING_TOKEN; KLING_API_BASE
|
||||
--skill-version: version for skill (default from SKILL.md)`);
|
||||
}
|
||||
|
||||
const sub = argvRest[0];
|
||||
if (!sub || sub === '--help' || sub === '-h') {
|
||||
printHelp();
|
||||
process.exit(sub === '--help' || sub === '-h' ? 0 : 1);
|
||||
}
|
||||
|
||||
if (!SUBCOMMANDS.has(sub)) {
|
||||
console.error(`Error / 错误: unknown subcommand "${sub}". Use: video | image | element | account`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const mod = await import(`./${sub}.mjs`);
|
||||
await mod.main();
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(`Error / 错误: ${err?.message || err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
205
.claude/skills/klingai-1.1.0/scripts/shared/args.mjs
Normal file
205
.claude/skills/klingai-1.1.0/scripts/shared/args.mjs
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Kling AI CLI helpers (zero external deps)
|
||||
* Argument parsing, auth, media file reading
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, relative, sep } from 'node:path';
|
||||
import { platform } from 'node:process';
|
||||
import {
|
||||
getBearerToken,
|
||||
CredentialsMissingError,
|
||||
setSkillVersion,
|
||||
} from './auth.mjs';
|
||||
import { runDeviceBindFlow } from './client.mjs';
|
||||
|
||||
/** 是否允许读取/写入 cwd 与 KLING_MEDIA_ROOTS 以外的本地路径(默认关闭) */
|
||||
function allowAbsolutePaths() {
|
||||
const v = (process.env.KLING_ALLOW_ABSOLUTE_PATHS || '').trim().toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'yes';
|
||||
}
|
||||
|
||||
/** 额外允许的根目录(逗号分隔),用于下载目录、WSL 跨盘路径等 */
|
||||
function extraMediaRoots() {
|
||||
const raw = (process.env.KLING_MEDIA_ROOTS || '').trim();
|
||||
if (!raw) return [];
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean).map((p) => resolve(p));
|
||||
}
|
||||
|
||||
function allAllowedRoots() {
|
||||
const roots = [resolve(process.cwd()), ...extraMediaRoots()];
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Windows:仅在同盘内做 relative 校验 */
|
||||
function sameDriveRoot(a, b) {
|
||||
if (platform !== 'win32') return true;
|
||||
const ra = resolve(a);
|
||||
const rb = resolve(b);
|
||||
const da = ra.match(/^([A-Za-z]:)/);
|
||||
const db = rb.match(/^([A-Za-z]:)/);
|
||||
if (!da || !db) return true;
|
||||
return da[1].toLowerCase() === db[1].toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断绝对路径是否落在任一允许根下(用于本地文件读、输出目录写)
|
||||
* @param {string} absPath 已 resolve 的绝对路径
|
||||
*/
|
||||
export function isAllowedLocalPath(absPath) {
|
||||
if (allowAbsolutePaths()) return true;
|
||||
const normalized = resolve(absPath);
|
||||
for (const root of allAllowedRoots()) {
|
||||
if (!sameDriveRoot(root, normalized)) continue;
|
||||
const rel = relative(root, normalized);
|
||||
if (rel === '') return true;
|
||||
if (!rel.startsWith('..') && !rel.includes(`${sep}..`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并返回用于读文件的绝对路径(URL 不适用)
|
||||
* @param {string} userPath 用户传入的本地路径
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveAllowedReadPath(userPath) {
|
||||
const normalized = resolve(userPath.trim());
|
||||
if (!isAllowedLocalPath(normalized)) {
|
||||
const roots = allAllowedRoots().join(', ');
|
||||
throw new Error(
|
||||
`Local path outside allowed roots / 本地路径不在允许范围内: ${normalized}\n`
|
||||
+ `Allowed / 允许: cwd + KLING_MEDIA_ROOTS, or set KLING_ALLOW_ABSOLUTE_PATHS=1\n`
|
||||
+ `Roots / 当前根: ${roots}\n`
|
||||
+ `Example / 示例: export KLING_MEDIA_ROOTS="/mnt/c/Users/you/Downloads,/tmp/claw-downloads"`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验输出目录(相对路径相对于 cwd 解析)
|
||||
* @param {string} userPath 如 ./output 或绝对路径
|
||||
* @returns {string} 绝对路径
|
||||
*/
|
||||
export function resolveAllowedOutputDir(userPath) {
|
||||
const normalized = resolve(userPath.trim());
|
||||
if (!isAllowedLocalPath(normalized)) {
|
||||
const roots = allAllowedRoots().join(', ');
|
||||
throw new Error(
|
||||
`Output dir outside allowed roots / 输出目录不在允许范围内: ${normalized}\n`
|
||||
+ `Allowed / 允许: under cwd, KLING_MEDIA_ROOTS, or KLING_ALLOW_ABSOLUTE_PATHS=1\n`
|
||||
+ `Roots / 当前根: ${roots}`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** 消费 --skill-version */
|
||||
function consumeSkillVersionArgv(argv) {
|
||||
for (let i = 2; i < argv.length - 1; i++) {
|
||||
if (argv[i] === '--skill-version') {
|
||||
setSkillVersion(argv[i + 1]);
|
||||
argv.splice(i, 2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析命令行参数
|
||||
* @param {string[]} argv process.argv(会原地消费 --skill-version)
|
||||
* @param {string[]} [booleanFlags] 额外的布尔标志名(不需要跟值的 --flag)
|
||||
* @returns {object} 参数键值对
|
||||
*/
|
||||
export function parseArgs(argv, booleanFlags = []) {
|
||||
consumeSkillVersionArgv(argv);
|
||||
const boolSet = new Set(['no-wait', 'download', 'wait', 'help', ...booleanFlags]);
|
||||
const args = {};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
if (!key.startsWith('--')) continue;
|
||||
const name = key.slice(2);
|
||||
if (name === 'no-wait') { args.wait = false; continue; }
|
||||
if (boolSet.has(name)) { args[name] = true; continue; }
|
||||
const val = argv[i + 1];
|
||||
if (val !== undefined && !val.startsWith('--')) {
|
||||
args[name] = val; i++;
|
||||
} else {
|
||||
args[name] = true;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Bearer:优先进程内 KLING_TOKEN;否则 credentials 中 AK/SK → JWT。
|
||||
* 若皆无(首次或仅有空凭证),自动执行设备绑定(bind)后再取 token。
|
||||
*/
|
||||
export async function getTokenOrExit() {
|
||||
try {
|
||||
return getBearerToken();
|
||||
} catch (e) {
|
||||
const missing = e instanceof CredentialsMissingError || e?.name === 'CredentialsMissingError';
|
||||
if (!missing) {
|
||||
throw new Error(`Auth error / 鉴权错误: ${e?.message || e}`);
|
||||
}
|
||||
try {
|
||||
console.error('\n── No credentials / 无可用凭证,启动设备绑定 bind ────\n');
|
||||
await runDeviceBindFlow({});
|
||||
return getBearerToken();
|
||||
} catch (err) {
|
||||
const lines = [
|
||||
`Bind failed / 绑定失败: ${err?.message || err}`,
|
||||
];
|
||||
if (err?.bindAuthorizeUrl) {
|
||||
lines.push(`Bind URL / 手动绑定链接: ${err.bindAuthorizeUrl}`);
|
||||
}
|
||||
lines.push('Fallback / 备选:');
|
||||
lines.push(' node skills/klingai/scripts/kling.mjs account --bind-url');
|
||||
lines.push(' set KLING_ACCESS_KEY_ID + KLING_SECRET_ACCESS_KEY, then');
|
||||
lines.push(' node skills/klingai/scripts/kling.mjs account --import-env');
|
||||
lines.push(' or pass args: --import-credentials --access_key_id <AK> --secret_access_key <SK>');
|
||||
lines.push(' or set KLING_TOKEN for this session / 或设置 KLING_TOKEN');
|
||||
throw new Error(lines.join('\n'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取媒体文件:URL 直接返回,本地文件读为 base64(路径受 KLING_MEDIA_ROOTS / KLING_ALLOW_ABSOLUTE_PATHS 约束)
|
||||
* @param {string} pathOrUrl 文件路径或 URL
|
||||
* @returns {Promise<string>} URL 或 base64 字符串
|
||||
*/
|
||||
export async function readMediaAsValue(pathOrUrl) {
|
||||
if (!pathOrUrl) return undefined;
|
||||
const s = pathOrUrl.trim();
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.protocol === 'http:' || u.protocol === 'https:') return s;
|
||||
} catch {
|
||||
// Not a URL: treat as local path below.
|
||||
}
|
||||
const abs = resolveAllowedReadPath(s);
|
||||
const buf = await readFile(abs);
|
||||
return buf.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Omni-Video 参考视频字段 `video_list[].video_url`:仅接受公网 `http://` 或 `https://` 链接,不接受本地路径或 Base64。
|
||||
* @param {string} pathOrUrl
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function readOmniVideoRefUrl(pathOrUrl) {
|
||||
if (!pathOrUrl) return undefined;
|
||||
const s = pathOrUrl.trim();
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.protocol === 'http:' || u.protocol === 'https:') return s;
|
||||
} catch {
|
||||
// Fallthrough to unified error below.
|
||||
}
|
||||
throw new Error(
|
||||
'Omni --video must be a public http(s) URL / Omni --video 须为公网 http(s) 链接(不接受本地路径或 Base64)。\n'
|
||||
+ 'Upload the file and pass the URL / 请先上传视频再传入 URL。',
|
||||
);
|
||||
}
|
||||
457
.claude/skills/klingai-1.1.0/scripts/shared/auth.mjs
Normal file
457
.claude/skills/klingai-1.1.0/scripts/shared/auth.mjs
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Kling AI — 鉴权层(无网络)
|
||||
*
|
||||
* 凭证优先级:
|
||||
* 1. 当前进程 KLING_TOKEN(仅环境变量显式传入,不落盘)
|
||||
* 2. ~/.config/kling/.credentials(INI,[profile] access_key_id / secret_access_key)→ 请求时 makeJwt(30min exp)
|
||||
* bind / configure 写入 credentials,固定 default profile。
|
||||
* 存储根目录默认 ~/.config/kling;可选 KLING_STORAGE_ROOT 指向统一存储根。
|
||||
* 非凭证 env:仅读 <storageRoot>/kling.env,不覆盖启动前已在 process.env 中的键。
|
||||
* 探测得到的 API Base 由 client 调用 `persistProbedApiBase` 写回 ~/.config/kling/kling.env 中的 KLING_API_BASE;
|
||||
* **不会**从文件注入 KLING_TOKEN(凭证仅 credentials 文件 + 可选进程内 KLING_TOKEN)。
|
||||
*
|
||||
* 网络与 API Base 探测统一在 client.mjs。
|
||||
*/
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
readFileSync, writeFileSync, mkdirSync, chmodSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createInterface } from 'node:readline';
|
||||
import os from 'node:os';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const KLING_ENV_FILENAME = 'kling.env';
|
||||
const IDENTITY_FILENAME = 'identity.json';
|
||||
const CREDENTIALS_FILENAME = '.credentials';
|
||||
const STORAGE_ROOT_ENV = 'KLING_STORAGE_ROOT';
|
||||
|
||||
/** 写入 process.env 时跳过(凭证不走 dotenv 文件) */
|
||||
const CREDENTIAL_ENV_DENYLIST = new Set(['KLING_TOKEN']);
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @param {{ shellKeys: Set<string> }} opts
|
||||
*/
|
||||
function parseEnvContent(content, opts) {
|
||||
const { shellKeys } = opts;
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx <= 0) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
if (CREDENTIAL_ENV_DENYLIST.has(key)) continue;
|
||||
let val = trimmed.slice(eqIdx + 1).trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
// 已在启动前导出的环境变量优先,不被文件覆盖。
|
||||
if (!shellKeys.has(key) && !(key in process.env)) {
|
||||
process.env[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getKlingConfigDir() {
|
||||
const explicitRoot = (process.env[STORAGE_ROOT_ENV] || '').trim();
|
||||
if (explicitRoot) return resolve(explicitRoot);
|
||||
const home = process.env.HOME || process.env.USERPROFILE;
|
||||
if (home) return join(home, '.config', 'kling');
|
||||
return resolve(__dir, '..', '..', '..');
|
||||
}
|
||||
|
||||
function getDefaultKlingEnvPath() {
|
||||
return join(getKlingConfigDir(), KLING_ENV_FILENAME);
|
||||
}
|
||||
|
||||
/** 更新或追加 KLING_API_BASE=…,仅写入 ~/.config/kling/kling.env */
|
||||
function upsertEnvFileKey(content, key, value) {
|
||||
const line = `${key}=${value}`;
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`^${escaped}=.*$`, 'm');
|
||||
if (re.test(content)) return content.replace(re, line);
|
||||
const trimmed = content.replace(/\s+$/, '');
|
||||
if (!trimmed) return `${line}\n`;
|
||||
return `${trimmed}\n${line}\n`;
|
||||
}
|
||||
|
||||
(function loadEnvFiles() {
|
||||
const shellKeys = new Set(Object.keys(process.env));
|
||||
try {
|
||||
parseEnvContent(readFileSync(getDefaultKlingEnvPath(), 'utf-8'), { shellKeys });
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
export function getIdentityFilePath() {
|
||||
return join(getKlingConfigDir(), IDENTITY_FILENAME);
|
||||
}
|
||||
|
||||
/** 凭证 INI 路径:<storageRoot>/.credentials */
|
||||
export function getCredentialsFilePath() {
|
||||
return join(getKlingConfigDir(), CREDENTIALS_FILENAME);
|
||||
}
|
||||
|
||||
export function getActiveProfile() {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
export class CredentialsMissingError extends Error {
|
||||
constructor(msg = 'No credentials / 未配置凭证') {
|
||||
super(msg);
|
||||
this.name = 'CredentialsMissingError';
|
||||
}
|
||||
}
|
||||
|
||||
function logAuthSource(source) {
|
||||
const messageMap = {
|
||||
credentials: 'Auth source / 鉴权来源: credentials (AK/SK -> JWT)',
|
||||
env_token: 'Auth source / 鉴权来源: KLING_TOKEN (process env)',
|
||||
};
|
||||
const msg = messageMap[source];
|
||||
if (msg) console.error(msg);
|
||||
}
|
||||
|
||||
function parseCredentialsIni(content) {
|
||||
const profiles = {};
|
||||
let current = null;
|
||||
for (const line of content.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('#') || t.startsWith(';')) continue;
|
||||
const m = t.match(/^\[([^\]]+)\]\s*$/);
|
||||
if (m) {
|
||||
current = m[1].trim();
|
||||
if (!profiles[current]) profiles[current] = {};
|
||||
continue;
|
||||
}
|
||||
const eqIdx = t.indexOf('=');
|
||||
if (eqIdx <= 0 || !current) continue;
|
||||
const k = t.slice(0, eqIdx).trim();
|
||||
let v = t.slice(eqIdx + 1).trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
||||
v = v.slice(1, -1);
|
||||
}
|
||||
profiles[current][k] = v;
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/** @returns {{ access_key_id: string, secret_access_key: string }} */
|
||||
export function readCredentialsProfile(profile) {
|
||||
try {
|
||||
const raw = readFileSync(getCredentialsFilePath(), 'utf-8');
|
||||
const all = parseCredentialsIni(raw);
|
||||
const p = all[profile] || {};
|
||||
const ak = String(p.access_key_id || p.access_key || '').trim();
|
||||
const sk = String(p.secret_access_key || p.secret_key || '').trim();
|
||||
return { access_key_id: ak, secret_access_key: sk };
|
||||
} catch {
|
||||
return { access_key_id: '', secret_access_key: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export function hasStoredAccessKeys() {
|
||||
const { access_key_id, secret_access_key } = readCredentialsProfile(getActiveProfile());
|
||||
return Boolean(access_key_id && secret_access_key);
|
||||
}
|
||||
|
||||
export function hasSessionBearerOverride() {
|
||||
return Boolean((process.env.KLING_TOKEN || '').trim());
|
||||
}
|
||||
|
||||
export function hasUsableCredentialSource() {
|
||||
return hasStoredAccessKeys() || hasSessionBearerOverride();
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 [profile] 下 AK/SK,Unix 上 chmod 600
|
||||
* @param {string} profile
|
||||
* @param {string} accessKey
|
||||
* @param {string} secretKey
|
||||
* @param {Record<string,string>} [extra] 如 region
|
||||
*/
|
||||
export function writeCredentialsProfile(profile, accessKey, secretKey, extra = {}) {
|
||||
const path = getCredentialsFilePath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
let all = {};
|
||||
try {
|
||||
all = parseCredentialsIni(readFileSync(path, 'utf-8'));
|
||||
} catch {}
|
||||
all[profile] = {
|
||||
...all[profile],
|
||||
access_key_id: String(accessKey || '').trim(),
|
||||
secret_access_key: String(secretKey || '').trim(),
|
||||
...extra,
|
||||
};
|
||||
const lines = [];
|
||||
for (const prof of Object.keys(all)) {
|
||||
lines.push(`[${prof}]`);
|
||||
const o = all[prof];
|
||||
for (const [k, v] of Object.entries(o)) {
|
||||
if (v == null || String(v) === '') continue;
|
||||
lines.push(`${k} = ${String(v)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
writeFileSync(path, lines.join('\n').trimEnd() + '\n');
|
||||
try {
|
||||
if (process.platform !== 'win32') chmodSync(path, 0o600);
|
||||
} catch {}
|
||||
return path;
|
||||
}
|
||||
|
||||
// —— Skill 版本 / 请求头 ——
|
||||
const DEFAULT_SKILL_VERSION = '1.0.0';
|
||||
let skillVersion = DEFAULT_SKILL_VERSION;
|
||||
export function setSkillVersion(version) {
|
||||
skillVersion = String(version || DEFAULT_SKILL_VERSION);
|
||||
}
|
||||
export function getSkillVersion() {
|
||||
return skillVersion;
|
||||
}
|
||||
|
||||
export function makeKlingHeaders(token, contentType = 'application/json') {
|
||||
const h = { 'User-Agent': `Kling-Provider-Skill/${getSkillVersion()}` };
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
if (contentType) h['Content-Type'] = contentType;
|
||||
return h;
|
||||
}
|
||||
|
||||
function base64url(buf) {
|
||||
return Buffer.from(buf).toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
function makeJwt(accessKey, secretKey) {
|
||||
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = base64url(JSON.stringify({
|
||||
iss: accessKey,
|
||||
exp: now + 1800,
|
||||
nbf: now - 5,
|
||||
}));
|
||||
const signature = base64url(
|
||||
createHmac('sha256', secretKey).update(`${header}.${payload}`).digest()
|
||||
);
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) 进程环境变量 KLING_TOKEN(不落盘;kling.env 不会注入 KLING_TOKEN)
|
||||
* 2) 否则 credentials 文件 AK/SK → 每次调用重新签发 JWT(30min exp)
|
||||
*/
|
||||
export function getBearerToken() {
|
||||
let token = (process.env.KLING_TOKEN || '').trim();
|
||||
if (token) {
|
||||
logAuthSource('env_token');
|
||||
if (token.toLowerCase().startsWith('bearer ')) {
|
||||
token = token.slice(7).trim();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
const profile = getActiveProfile();
|
||||
const { access_key_id, secret_access_key } = readCredentialsProfile(profile);
|
||||
if (access_key_id && secret_access_key) {
|
||||
logAuthSource('credentials');
|
||||
return makeJwt(access_key_id, secret_access_key);
|
||||
}
|
||||
throw new CredentialsMissingError(
|
||||
'Configure credentials under KLING_STORAGE_ROOT (or ~/.config/kling), set KLING_TOKEN for this session, or run account bind/configure / '
|
||||
+ '请在 KLING_STORAGE_ROOT(或 ~/.config/kling)下配置 credentials、本次 shell 导出 KLING_TOKEN,或执行 account --bind|--configure',
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredApiBase() {
|
||||
const baseTest = (process.env.KLING_API_BASE_TEST || '').trim();
|
||||
if (baseTest) return baseTest;
|
||||
const base = (process.env.KLING_API_BASE || '').trim();
|
||||
return base || null;
|
||||
}
|
||||
|
||||
export function getConfiguredBindBase() {
|
||||
const baseTest = (process.env.KLING_BIND_BASE_TEST || '').trim();
|
||||
if (baseTest) return baseTest;
|
||||
const base = (process.env.KLING_BIND_BASE || '').trim();
|
||||
return base || null;
|
||||
}
|
||||
|
||||
/** 将探测到的业务 API 根写入 ~/.config/kling/kling.env(仅 KLING_API_BASE 一行) */
|
||||
export function persistProbedApiBase(baseUrl) {
|
||||
const b = String(baseUrl || '').trim();
|
||||
if (!b) return;
|
||||
const dir = getKlingConfigDir();
|
||||
const path = getDefaultKlingEnvPath();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
let raw = '';
|
||||
try {
|
||||
raw = readFileSync(path, 'utf-8');
|
||||
} catch {}
|
||||
writeFileSync(path, upsertEnvFileKey(raw, 'KLING_API_BASE', b));
|
||||
process.env.KLING_API_BASE = b;
|
||||
}
|
||||
|
||||
export function readIdentity() {
|
||||
try {
|
||||
const raw = readFileSync(getIdentityFilePath(), 'utf-8');
|
||||
const o = JSON.parse(raw);
|
||||
return o && typeof o === 'object' ? o : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeIdentity(obj) {
|
||||
const dir = getKlingConfigDir();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(getIdentityFilePath(), `${JSON.stringify(obj, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export function ensureIdentityForBind() {
|
||||
const existing = readIdentity() || {};
|
||||
const id = { ...existing };
|
||||
let dirty = Object.keys(existing).length === 0;
|
||||
if (!id.client_instance_id) {
|
||||
id.client_instance_id = randomUUID();
|
||||
dirty = true;
|
||||
}
|
||||
const localHostname = (() => {
|
||||
try {
|
||||
const h = String(os.hostname() || '').trim();
|
||||
return h || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
if (!id.hostname) {
|
||||
id.hostname = localHostname;
|
||||
dirty = true;
|
||||
}
|
||||
if (!id.device_name) {
|
||||
const n = String(process.env.COMPUTERNAME || process.env.HOSTNAME || id.hostname || '').trim();
|
||||
id.device_name = n || 'unknown';
|
||||
dirty = true;
|
||||
}
|
||||
if (!id.platform) {
|
||||
if (process.platform === 'darwin') id.platform = 'macOS';
|
||||
else if (process.platform === 'win32') id.platform = 'Windows';
|
||||
else if (process.platform === 'linux') id.platform = 'Linux';
|
||||
else id.platform = 'unknown';
|
||||
dirty = true;
|
||||
}
|
||||
id.version = id.version ?? 1;
|
||||
if (id.session_id === undefined) id.session_id = null;
|
||||
id.updated_at = Date.now();
|
||||
if (dirty) writeIdentity(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function patchKlingIdentity(patch) {
|
||||
const cur = readIdentity() || {};
|
||||
const next = { ...cur, ...patch, updated_at: Date.now() };
|
||||
writeIdentity(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 绑定 / configure 成功后写入 credentials;identity 中不保留 AK/SK(并清除历史字段) */
|
||||
export function persistBoundApiKeys(accessKey, secretKey, extraIdentity = {}, extraCredentials = {}) {
|
||||
const ak = String(accessKey || '').trim();
|
||||
const sk = String(secretKey || '').trim();
|
||||
if (!ak || !sk) throw new Error('Missing access_key or secret_key / 缺少 access_key 或 secret_key');
|
||||
const profile = getActiveProfile();
|
||||
const savePath = writeCredentialsProfile(profile, ak, sk, extraCredentials);
|
||||
const cur = readIdentity() || {};
|
||||
const next = { ...cur, ...extraIdentity, bound_at: Date.now(), updated_at: Date.now() };
|
||||
delete next.access_key;
|
||||
delete next.secret_key;
|
||||
delete next.credential_id;
|
||||
delete next.account_id;
|
||||
delete next.credentialId;
|
||||
delete next.accountId;
|
||||
writeIdentity(next);
|
||||
return { savePath, token: makeJwt(ak, sk) };
|
||||
}
|
||||
|
||||
export { makeJwt };
|
||||
|
||||
function readHiddenLine(prompt) {
|
||||
function sanitizeChunk(chunk) {
|
||||
// Strip bracketed-paste markers (\x1b[200~...\x1b[201~), keep printable chars only.
|
||||
return String(chunk || '')
|
||||
.replace(/\u001b\[200~/g, '')
|
||||
.replace(/\u001b\[201~/g, '')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '');
|
||||
}
|
||||
|
||||
const stdin = process.stdin;
|
||||
const stdout = process.stderr;
|
||||
if (!stdin.isTTY) {
|
||||
return new Promise((r) => {
|
||||
const rl = createInterface({ input: stdin, output: stdout });
|
||||
rl.question(prompt, (a) => {
|
||||
rl.close();
|
||||
r(a.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
stdout.write(prompt);
|
||||
return new Promise((resolveLine) => {
|
||||
stdin.setRawMode(true);
|
||||
stdin.resume();
|
||||
stdin.setEncoding('utf8');
|
||||
let s = '';
|
||||
const onData = (key) => {
|
||||
const k = String(key);
|
||||
if (k === '\u0003') {
|
||||
stdin.setRawMode(false);
|
||||
stdin.removeListener('data', onData);
|
||||
stdin.pause();
|
||||
process.exit(1);
|
||||
}
|
||||
if (k === '\r' || k === '\n') {
|
||||
stdin.setRawMode(false);
|
||||
stdin.removeListener('data', onData);
|
||||
stdin.pause();
|
||||
stdout.write('\n');
|
||||
resolveLine(s);
|
||||
return;
|
||||
}
|
||||
if (k === '\u007f' || k === '\b') {
|
||||
s = s.slice(0, -1);
|
||||
return;
|
||||
}
|
||||
s += sanitizeChunk(k);
|
||||
};
|
||||
stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** 交互式录入 AK/SK → credentials(SK 在 TTY 下隐藏输入,支持粘贴) */
|
||||
export async function promptInteractiveCredentialsFile() {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
throw new CredentialsMissingError(
|
||||
'TTY required / 需要交互式终端',
|
||||
);
|
||||
}
|
||||
|
||||
console.error('\n── Kling AI configure / 可灵凭证配置 ─────────────');
|
||||
console.error(`Profile / 配置名: ${getActiveProfile()}`);
|
||||
console.error(`File / 文件: ${getCredentialsFilePath()}`);
|
||||
console.error('────────────────────────────────────────────────\n');
|
||||
|
||||
const rl1 = createInterface({ input: process.stdin, output: process.stderr });
|
||||
const accessKey = await new Promise((r) => {
|
||||
rl1.question('Access Key ID / 访问密钥 ID: ', (a) => r(a.trim()));
|
||||
});
|
||||
rl1.close();
|
||||
if (!accessKey) throw new Error('Access Key required / 需要 Access Key');
|
||||
|
||||
const secretKey = await readHiddenLine('Secret Access Key / 秘密访问密钥(隐藏输入,可粘贴): ');
|
||||
if (!secretKey) throw new Error('Secret Key required / 需要 Secret Key');
|
||||
const savePath = writeCredentialsProfile(getActiveProfile(), accessKey, secretKey);
|
||||
console.error(`\n✓ Saved / 已保存(密钥未在日志中输出): ${savePath}\n`);
|
||||
return makeJwt(accessKey, secretKey);
|
||||
}
|
||||
680
.claude/skills/klingai-1.1.0/scripts/shared/client.mjs
Normal file
680
.claude/skills/klingai-1.1.0/scripts/shared/client.mjs
Normal file
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* Kling AI HTTP client (zero external deps, Node.js 18+ fetch)
|
||||
*
|
||||
* - **klingGet / klingPost**:Bearer 鉴权 + resolveApiBase(业务 API)
|
||||
* - **runAccountBindHttpSequence**:无 Bearer,固定 bind 端点(与鉴权流量区分在实现上,不混用 token)
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
getBearerToken,
|
||||
makeKlingHeaders,
|
||||
getConfiguredApiBase,
|
||||
getConfiguredBindBase,
|
||||
persistProbedApiBase,
|
||||
getSkillVersion,
|
||||
ensureIdentityForBind,
|
||||
patchKlingIdentity,
|
||||
persistBoundApiKeys,
|
||||
} from './auth.mjs';
|
||||
|
||||
const KLING_API_ENDPOINTS = Object.freeze([
|
||||
{
|
||||
key: 'cn',
|
||||
apiBase: 'https://api-beijing.klingai.com',
|
||||
bindBase: 'https://klingai.com',
|
||||
consoleUrl: 'https://klingai.com/dev/api-key',
|
||||
},
|
||||
{
|
||||
key: 'global',
|
||||
apiBase: 'https://api-singapore.klingai.com',
|
||||
bindBase: 'https://kling.ai',
|
||||
consoleUrl: 'https://kling.ai/dev/api-key',
|
||||
},
|
||||
]);
|
||||
|
||||
const ALL_KLING_CONSOLE_URLS = Object.freeze(
|
||||
Object.fromEntries(KLING_API_ENDPOINTS.map((item) => [item.key, item.consoleUrl])),
|
||||
);
|
||||
|
||||
const API_BASE = KLING_API_ENDPOINTS[0].apiBase;
|
||||
const CANDIDATE_BASES = KLING_API_ENDPOINTS.map((item) => item.apiBase);
|
||||
export let KLING_CONSOLE_URLS = ALL_KLING_CONSOLE_URLS;
|
||||
|
||||
function normalizeApiBase(base) {
|
||||
return String(base || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function findEndpointByBase(base) {
|
||||
const normalized = normalizeApiBase(base);
|
||||
if (!normalized) return null;
|
||||
const direct = KLING_API_ENDPOINTS.find((item) => normalizeApiBase(item.apiBase) === normalized);
|
||||
if (direct) return direct;
|
||||
const bindDirect = KLING_API_ENDPOINTS.find((item) => normalizeApiBase(item.bindBase) === normalized);
|
||||
if (bindDirect) return bindDirect;
|
||||
if (normalized.includes('api-beijing.klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
|
||||
if (normalized.includes('api-singapore.klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'global') || null;
|
||||
if (normalized.includes('klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
|
||||
if (normalized.includes('kling.ai')) return KLING_API_ENDPOINTS.find((item) => item.key === 'global') || null;
|
||||
if (normalized.includes('kuaishou.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function setConsoleUrlsForBase(base) {
|
||||
const endpoint = findEndpointByBase(base);
|
||||
if (!endpoint) {
|
||||
KLING_CONSOLE_URLS = ALL_KLING_CONSOLE_URLS;
|
||||
return;
|
||||
}
|
||||
KLING_CONSOLE_URLS = Object.freeze({ [endpoint.key]: endpoint.consoleUrl });
|
||||
}
|
||||
|
||||
const initialConfiguredApiBase = getConfiguredApiBase();
|
||||
if (initialConfiguredApiBase) {
|
||||
setConsoleUrlsForBase(initialConfiguredApiBase);
|
||||
}
|
||||
|
||||
function printConsoleUrlsHint(prefix = ' ') {
|
||||
for (const [region, url] of Object.entries(KLING_CONSOLE_URLS)) {
|
||||
const label = region === 'cn' ? 'China / 国内' : (region === 'global' ? 'Global / 国际' : region);
|
||||
console.error(`${prefix}${label}: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeBase(base, token) {
|
||||
try {
|
||||
const res = await fetch(`${base}/v1/videos/text2video?pageNum=1&pageSize=1`, {
|
||||
method: 'GET',
|
||||
headers: makeKlingHeaders(token, null),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json().catch(() => null);
|
||||
return json != null && (json.code === 0 || json.code === 200);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let _resolvedBase = null;
|
||||
|
||||
async function resolveApiBase(token) {
|
||||
if (_resolvedBase) return _resolvedBase;
|
||||
const configuredApiBase = getConfiguredApiBase();
|
||||
if (configuredApiBase) {
|
||||
_resolvedBase = normalizeApiBase(configuredApiBase);
|
||||
setConsoleUrlsForBase(_resolvedBase);
|
||||
return _resolvedBase;
|
||||
}
|
||||
|
||||
console.error('\n🔍 Probing API endpoints... / 正在检测 API 节点...');
|
||||
for (const endpoint of KLING_API_ENDPOINTS) {
|
||||
process.stderr.write(` [${endpoint.key}] ${endpoint.apiBase} ... `);
|
||||
if (await probeBase(endpoint.apiBase, token)) {
|
||||
process.stderr.write('✓ OK\n\n');
|
||||
_resolvedBase = endpoint.apiBase;
|
||||
setConsoleUrlsForBase(_resolvedBase);
|
||||
try {
|
||||
persistProbedApiBase(_resolvedBase);
|
||||
} catch {}
|
||||
return _resolvedBase;
|
||||
}
|
||||
process.stderr.write('✗\n');
|
||||
}
|
||||
|
||||
console.error('\n❌ Cannot connect to any Kling API endpoint / 无法连接任何可灵 API 节点');
|
||||
for (const base of CANDIDATE_BASES) console.error(` • ${base}`);
|
||||
console.error('\nPossible causes / 可能原因:');
|
||||
console.error(' 1. Token invalid or expired / Token 无效或已过期:');
|
||||
printConsoleUrlsHint();
|
||||
console.error(' 2. Network issue / 网络问题');
|
||||
console.error('\nCheck credentials file, KLING_TOKEN, or run account configure / 检查 credentials、KLING_TOKEN 或 account configure:\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保护 JSON 中的大整数字段(防止 Number 精度丢失)
|
||||
* 将 element_id, task_id 等大整数字段转为字符串
|
||||
*/
|
||||
function protectBigInts(text) {
|
||||
return text.replace(
|
||||
/"(element_id|task_id|elementId|taskId)":\s*(\d{15,})/g,
|
||||
'"$1":"$2"'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析可灵 API 响应,code 为 0 或 200 为成功
|
||||
*/
|
||||
function parseResponse(json) {
|
||||
if (json.code !== 0 && json.code !== 200) {
|
||||
throw new Error(`API error / API 错误 (code=${json.code}): ${json.message || 'Unknown error'}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
function parseJsonSafely(text) {
|
||||
try {
|
||||
return JSON.parse(protectBigInts(String(text || '')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHttpErrorMessage(status, text) {
|
||||
const body = parseJsonSafely(text);
|
||||
if (status === 401 && body && typeof body === 'object') {
|
||||
const code = Number(body.code);
|
||||
const requestId = body.request_id ? `, request_id=${body.request_id}` : '';
|
||||
if (code === 1000) {
|
||||
return `HTTP 401: code=1000,signature is invalid / 秘钥无效,请重新绑定${requestId}`;
|
||||
}
|
||||
if (code === 1002) {
|
||||
return `HTTP 401: code=1002,access key not exist / 账户不存在,请重新绑定${requestId}`;
|
||||
}
|
||||
}
|
||||
return `HTTP ${status}: ${text}`;
|
||||
}
|
||||
|
||||
function parseApiJsonOrThrow(text) {
|
||||
const parsed = parseJsonSafely(text);
|
||||
if (parsed != null) return parsed;
|
||||
const preview = String(text || '').trim().slice(0, 60);
|
||||
if (preview.startsWith('<')) {
|
||||
throw new Error(`API Service Error: Non-JSON content. check KLING_API_BASE and network/DNS/proxy: ${preview}`);
|
||||
}
|
||||
throw new Error(`API Service Error: Cannot parse JSON: ${preview}`);
|
||||
}
|
||||
|
||||
async function safeFetch(url, init, context) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (e) {
|
||||
const baseHint = getConfiguredApiBase() || '<auto>';
|
||||
const msg = e?.message || String(e);
|
||||
throw new Error(
|
||||
`Network error / 网络错误: ${msg}\n`
|
||||
+ `Request / 请求: ${context.method} ${url}\n`
|
||||
+ `KLING_API_BASE: ${baseHint}\n`
|
||||
+ 'Hint / 提示: check KLING_API_BASE and network/DNS/proxy, or remove KLING_API_BASE to auto-probe official endpoints / '
|
||||
+ '请检查 KLING_API_BASE 与网络(DNS/代理),或移除 KLING_API_BASE 让脚本自动探测官方节点。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求可灵 API
|
||||
* @param {string} path API 路径,如 /v1/videos/image2video
|
||||
* @param {object} body 请求体
|
||||
* @param {string} [token] 可选 token,不传则自动获取
|
||||
* @returns {Promise<object>} data 字段
|
||||
*/
|
||||
export async function klingPost(path, body, token) {
|
||||
if (!token) token = getBearerToken();
|
||||
const base = await resolveApiBase(token);
|
||||
const url = `${base}${path}`;
|
||||
const res = await safeFetch(url, {
|
||||
method: 'POST',
|
||||
headers: makeKlingHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
}, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(buildHttpErrorMessage(res.status, text));
|
||||
}
|
||||
const text = await res.text();
|
||||
return parseResponse(parseApiJsonOrThrow(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求可灵 API
|
||||
* @param {string} path API 路径,如 /v1/videos/image2video/{task_id}
|
||||
* @param {string} [token] 可选 token,不传则自动获取
|
||||
* @param {{ contentType?: string|null }} [options] 如部分接口要求 `Content-Type: application/json`(传 `'application/json'`);默认不传 Content-Type
|
||||
* @returns {Promise<object>} data 字段
|
||||
*/
|
||||
export async function klingGet(path, token, options = {}) {
|
||||
if (!token) token = getBearerToken();
|
||||
const base = await resolveApiBase(token);
|
||||
const ct = options.contentType !== undefined ? options.contentType : null;
|
||||
const url = `${base}${path}`;
|
||||
const res = await safeFetch(url, {
|
||||
method: 'GET',
|
||||
headers: makeKlingHeaders(token, ct),
|
||||
}, { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(buildHttpErrorMessage(res.status, text));
|
||||
}
|
||||
const text = await res.text();
|
||||
return parseResponse(parseApiJsonOrThrow(text));
|
||||
}
|
||||
|
||||
// —— 设备绑定 HTTP(无 Authorization;不经过 resolveApiBase) ——
|
||||
|
||||
const DEFAULT_BIND_INIT = '/console/api/auth/skill/init-sessions';
|
||||
const DEFAULT_BIND_EXCHANGE = '/console/api/auth/skill/exchange';
|
||||
const DEFAULT_BIND_SKILL_ID = 'Kling-Provider-Skill';
|
||||
const DEFAULT_BIND_SCOPE = 'kling.openapi.invoke';
|
||||
const DEFAULT_BIND_FETCH_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_BIND_TIMEOUT_MS = 180000;
|
||||
|
||||
function sleepBind(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function base64url(input) {
|
||||
return Buffer.from(input).toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
function createPkcePair() {
|
||||
const codeVerifier = base64url(randomBytes(48));
|
||||
const codeChallenge = base64url(createHash('sha256').update(codeVerifier, "utf8").digest());
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
function bindExtractData(json) {
|
||||
if (json == null || typeof json !== 'object') return json;
|
||||
const c = json.code;
|
||||
if (c !== undefined && c !== 0 && c !== 200) {
|
||||
const msg = json.message || json.msg || 'Unknown error';
|
||||
throw new Error(`Bind API error / 绑定接口错误 (code=${c}): ${msg}`);
|
||||
}
|
||||
return json.data !== undefined ? json.data : json;
|
||||
}
|
||||
|
||||
function normalizeBindBase(base) {
|
||||
const raw = String(base || '').trim();
|
||||
return raw.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function resolveBindBase(bindBaseOverride) {
|
||||
const override = normalizeBindBase(bindBaseOverride);
|
||||
if (override) {
|
||||
const overrideEndpoint = findEndpointByBase(override);
|
||||
if (overrideEndpoint?.bindBase) return normalizeBindBase(overrideEndpoint.bindBase);
|
||||
return override;
|
||||
}
|
||||
const configuredBindBase = getConfiguredBindBase();
|
||||
if (configuredBindBase) return normalizeBindBase(configuredBindBase);
|
||||
const candidate = getConfiguredApiBase() || _resolvedBase || API_BASE;
|
||||
const endpoint = findEndpointByBase(candidate);
|
||||
if (endpoint?.bindBase) return normalizeBindBase(endpoint.bindBase);
|
||||
return normalizeBindBase(candidate);
|
||||
}
|
||||
|
||||
async function skillBindHttpJson(userAgent, base, path, body, method = 'POST') {
|
||||
const b = String(base || '').replace(/\/$/, '');
|
||||
const p = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = method === 'GET' && body && typeof body === 'object'
|
||||
? `${b}${p}${p.includes('?') ? '&' : '?'}${new URLSearchParams(
|
||||
Object.entries(body).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]),
|
||||
).toString()}`
|
||||
: `${b}${p}`;
|
||||
const headers = { 'User-Agent': userAgent };
|
||||
if (method !== 'GET') headers['Content-Type'] = 'application/json';
|
||||
const init = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(DEFAULT_BIND_FETCH_TIMEOUT_MS),
|
||||
};
|
||||
if (method !== 'GET' && body != null) init.body = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, init);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Network error / 网络错误: ${e?.message || e}\n`
|
||||
+ 'Hint / 提示: check network/DNS/proxy and endpoint reachability / 请检查网络、DNS、代理与目标地址可达性。',
|
||||
);
|
||||
}
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${res.status}: ${text}\n`
|
||||
+ 'Hint / 提示: verify API base and network reachability / 请确认 API 基址与网络可达性。',
|
||||
);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON / 非 JSON 响应: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return bindExtractData(json);
|
||||
}
|
||||
|
||||
function pickBindSessionId(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return data.session_id || data.sessionId || data.bind_session_id || data.id || null;
|
||||
}
|
||||
|
||||
function pickBindAuthorizeHint(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return (
|
||||
data.verificationUriComplete
|
||||
|| data.verification_uri_complete
|
||||
|| data.verificationUri
|
||||
|| data.verification_uri
|
||||
|| data.authorize_url
|
||||
|| data.authorization_url
|
||||
|| data.qr_url
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
function pickBindAccessSecretKeys(data) {
|
||||
const src = data?.credential && typeof data.credential === 'object' ? data.credential : data;
|
||||
if (!src || typeof src !== 'object') {
|
||||
return {
|
||||
ak: null, sk: null, credentialId: null, accountId: null,
|
||||
};
|
||||
}
|
||||
const ak = src.accessKey || src.access_key || src.access_key_id || src.accessKeyId || src.ak;
|
||||
const sk = src.secretKey || src.secret_key || src.secret_access_key || src.secretAccessKey || src.sk;
|
||||
const credentialId = src.credentialId || src.credential_id || src.credentialID || src.credentialid;
|
||||
const accountId = src.accountId || src.account_id || src.accountID || src.accountid;
|
||||
return {
|
||||
ak: ak != null ? String(ak).trim() : null,
|
||||
sk: sk != null ? String(sk).trim() : null,
|
||||
credentialId: credentialId != null ? String(credentialId).trim() : null,
|
||||
accountId: accountId != null ? String(accountId).trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBindStatus(data) {
|
||||
if (!data || typeof data !== 'object') return 'pending';
|
||||
const s = data.status || data.state || data.bind_status || data.phase;
|
||||
if (s == null) return 'pending';
|
||||
return String(s).toUpperCase();
|
||||
}
|
||||
|
||||
function makeBindFlowError(message, meta = {}) {
|
||||
const err = new Error(message);
|
||||
err.name = 'BindFlowError';
|
||||
if (meta.code) err.bindCode = meta.code;
|
||||
if (meta.authorizeUrl) err.bindAuthorizeUrl = meta.authorizeUrl;
|
||||
if (meta.sessionId) err.bindSessionId = meta.sessionId;
|
||||
if (meta.status) err.bindStatus = meta.status;
|
||||
if (meta.responseData !== undefined) err.bindResponseData = meta.responseData;
|
||||
return err;
|
||||
}
|
||||
|
||||
function resolveAuthorizationUrl(bindBase, authorizePathOrUrl) {
|
||||
const raw = String(authorizePathOrUrl || '').trim();
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
|
||||
const baseUrl = new URL(`${normalizeBindBase(bindBase)}/`);
|
||||
if (raw.startsWith('/')) return `${baseUrl.origin}${raw}`;
|
||||
return new URL(raw, baseUrl).toString();
|
||||
}
|
||||
|
||||
function defaultBindOnLog(ev) {
|
||||
if (ev.url) {
|
||||
console.error(`${ev.message}\n ${ev.url}`);
|
||||
} else {
|
||||
console.error(ev.message);
|
||||
}
|
||||
}
|
||||
|
||||
function maskSecret(secret) {
|
||||
const s = String(secret || '');
|
||||
if (!s) return '';
|
||||
if (s.length <= 6) return '***';
|
||||
return `${s.slice(0, 3)}***${s.slice(-2)}`;
|
||||
}
|
||||
|
||||
function maskAccessKey(accessKey) {
|
||||
const s = String(accessKey || '');
|
||||
if (!s) return '';
|
||||
if (s.length <= 8) return `${s.slice(0, 2)}***`;
|
||||
return `${s.slice(0, 4)}***${s.slice(-3)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整设备绑定并写入 credentials(供 account 与 getTokenOrExit 自动调用)
|
||||
* @param {{ onLog?: function }} [options]
|
||||
*/
|
||||
export async function runDeviceBindFlow(options = {}) {
|
||||
const onLog = options.onLog || defaultBindOnLog;
|
||||
const identity = ensureIdentityForBind();
|
||||
const {
|
||||
client_instance_id, device_name, platform, hostname,
|
||||
} = identity;
|
||||
const userAgent = `Kling-Provider-Skill/${getSkillVersion()}`;
|
||||
|
||||
const result = await runAccountBindHttpSequence({
|
||||
userAgent,
|
||||
skillVersion: getSkillVersion(),
|
||||
identity: {
|
||||
clientInstanceId: client_instance_id,
|
||||
deviceName: device_name,
|
||||
platform,
|
||||
hostname,
|
||||
},
|
||||
onInitSession: (sessionId) => {
|
||||
patchKlingIdentity({ session_id: sessionId });
|
||||
},
|
||||
onLog,
|
||||
});
|
||||
const persisted = persistBoundApiKeys(
|
||||
result.accessKey,
|
||||
result.secretKey,
|
||||
{ session_id: result.sessionId },
|
||||
{
|
||||
credentialId: result.credentialId || null,
|
||||
accountId: result.accountId || null,
|
||||
},
|
||||
);
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
authorizeUrl: result.authorizeHint || null,
|
||||
savePath: persisted.savePath,
|
||||
accessKeyMasked: maskAccessKey(result.accessKey),
|
||||
secretKeyMasked: maskSecret(result.secretKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅执行绑定前置:init → verify,返回可手动打开的授权 URL。
|
||||
* @param {{ onLog?: function }} [options]
|
||||
*/
|
||||
export async function prepareDeviceBindUrl(options = {}) {
|
||||
const onLog = options.onLog || defaultBindOnLog;
|
||||
const identity = ensureIdentityForBind();
|
||||
const {
|
||||
client_instance_id, device_name, platform, hostname,
|
||||
} = identity;
|
||||
const userAgent = `Kling-Provider-Skill/${getSkillVersion()}`;
|
||||
const result = await runAccountBindInitVerify({
|
||||
userAgent,
|
||||
skillVersion: getSkillVersion(),
|
||||
identity: {
|
||||
clientInstanceId: client_instance_id,
|
||||
deviceName: device_name,
|
||||
platform,
|
||||
hostname,
|
||||
},
|
||||
onInitSession: (sessionId) => {
|
||||
patchKlingIdentity({ session_id: sessionId });
|
||||
},
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
authorizeUrl: result.authorizeHint || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号绑定前半段:init → verify,拿到可给用户手动打开的授权 URL。
|
||||
* @returns {Promise<{sessionId: string, authorizeHint: string|null}>}
|
||||
*/
|
||||
export async function runAccountBindInitVerify(options) {
|
||||
const bindBase = options.bindBase ? normalizeBindBase(options.bindBase) : resolveBindBase();
|
||||
const initPath = options.initPath || DEFAULT_BIND_INIT;
|
||||
const {
|
||||
clientInstanceId,
|
||||
deviceName,
|
||||
platform,
|
||||
hostname,
|
||||
} = options.identity || {};
|
||||
if (!clientInstanceId) {
|
||||
throw makeBindFlowError('identity.clientInstanceId is required / 缺少 identity.clientInstanceId', { code: 'MISSING_CLIENT_INSTANCE_ID' });
|
||||
}
|
||||
const userAgent = String(options.userAgent || 'Kling-Provider-Skill/unknown');
|
||||
const skillVersion = String(options.skillVersion || getSkillVersion());
|
||||
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
|
||||
const onInitSession = options.onInitSession;
|
||||
const { codeVerifier, codeChallenge } = createPkcePair();
|
||||
onLog({ step: 'base', message: 'Using bind base / 当前 bind 基址:', url: bindBase });
|
||||
|
||||
onLog({ step: 'init', message: 'Calling init-sessions / 调用 init-sessions …' });
|
||||
const initData = await skillBindHttpJson(userAgent, bindBase, initPath, {
|
||||
skillId: DEFAULT_BIND_SKILL_ID,
|
||||
skillVersion,
|
||||
clientInstanceId,
|
||||
deviceName: String(deviceName || '').trim() || 'unknown',
|
||||
platform: String(platform || '').trim() || 'unknown',
|
||||
hostname: String(hostname || '').trim() || 'unknown',
|
||||
requestedScopes: [DEFAULT_BIND_SCOPE],
|
||||
codeChallenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
const sessionId = pickBindSessionId(initData);
|
||||
if (!sessionId) {
|
||||
throw makeBindFlowError(
|
||||
'init-sessions response missing sessionId / init-sessions 响应缺少 sessionId',
|
||||
{ code: 'MISSING_SESSION_ID' },
|
||||
);
|
||||
}
|
||||
if (onInitSession) await onInitSession(sessionId);
|
||||
const deviceCode = String(initData.deviceCode || initData.device_code || '').trim();
|
||||
if (!deviceCode) {
|
||||
throw makeBindFlowError(
|
||||
'init-sessions response missing deviceCode / init-sessions 响应缺少 deviceCode',
|
||||
{ code: 'MISSING_DEVICE_CODE', sessionId },
|
||||
);
|
||||
}
|
||||
const authorizeHint = resolveAuthorizationUrl(bindBase, pickBindAuthorizeHint(initData));
|
||||
if (!authorizeHint) {
|
||||
throw makeBindFlowError(
|
||||
'init-sessions response missing authorize url / init-sessions 响应缺少授权链接',
|
||||
{ code: 'MISSING_AUTHORIZE_URL', sessionId },
|
||||
);
|
||||
}
|
||||
|
||||
onLog({ step: 'authorize', message: 'Open in browser / 请在浏览器完成授权:', url: authorizeHint });
|
||||
return {
|
||||
sessionId,
|
||||
deviceCode,
|
||||
codeVerifier,
|
||||
authorizeHint,
|
||||
interval: Number(initData.interval),
|
||||
expiresIn: Number(initData.expiresIn),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号设备绑定:init → verify → 轮询 check。无 Bearer;凭证落盘由调用方配合 auth 负责。
|
||||
*/
|
||||
export async function runAccountBindHttpSequence(options) {
|
||||
const bindBase = resolveBindBase(options.bindBase);
|
||||
const exchangePath = options.exchangePath || DEFAULT_BIND_EXCHANGE;
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs ?? DEFAULT_BIND_TIMEOUT_MS));
|
||||
const userAgent = String(options.userAgent || 'Kling-Provider-Skill/unknown');
|
||||
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
|
||||
const {
|
||||
sessionId,
|
||||
deviceCode,
|
||||
codeVerifier,
|
||||
authorizeHint,
|
||||
expiresIn,
|
||||
} = await runAccountBindInitVerify({
|
||||
...options,
|
||||
bindBase,
|
||||
userAgent,
|
||||
onLog,
|
||||
});
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
// 服务端已返回 ttl,优先取较小值避免本地等待过长。
|
||||
let remainingTtlSec = Number.isFinite(Number(expiresIn))
|
||||
? Number(expiresIn)
|
||||
: null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (remainingTtlSec != null && remainingTtlSec <= 0) {
|
||||
throw makeBindFlowError('Bind expired / 绑定已过期', {
|
||||
code: 'BIND_EXPIRED',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status: 'EXPIRED',
|
||||
});
|
||||
}
|
||||
onLog({ step: 'exchange', message: 'Polling exchange / 轮询 exchange …' });
|
||||
const exchangeData = await skillBindHttpJson(userAgent, bindBase, exchangePath, {
|
||||
sessionId,
|
||||
deviceCode,
|
||||
codeVerifier,
|
||||
}, 'POST');
|
||||
const status = normalizeBindStatus(exchangeData);
|
||||
if (status === 'ISSUED' || status === 'ALREADY_EXCHANGED') {
|
||||
const {
|
||||
ak, sk, credentialId, accountId,
|
||||
} = pickBindAccessSecretKeys(exchangeData);
|
||||
if (!ak || !sk) {
|
||||
throw makeBindFlowError(`${status} without credential / ${status} 但缺少 credential`, {
|
||||
code: 'MISSING_CREDENTIAL',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status,
|
||||
responseData: exchangeData,
|
||||
});
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
authorizeHint,
|
||||
accessKey: ak,
|
||||
secretKey: sk,
|
||||
credentialId,
|
||||
accountId,
|
||||
status,
|
||||
};
|
||||
}
|
||||
if (status !== 'PENDING') {
|
||||
throw makeBindFlowError(`Bind status: ${status}`, {
|
||||
code: 'BIND_STATUS',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status,
|
||||
responseData: exchangeData,
|
||||
});
|
||||
}
|
||||
const waitSec = Number(exchangeData?.pollAfterSeconds);
|
||||
const nextExpiresSec = Number(exchangeData?.expiresIn);
|
||||
if (Number.isFinite(nextExpiresSec)) remainingTtlSec = nextExpiresSec;
|
||||
if (!Number.isFinite(waitSec) || waitSec <= 0) {
|
||||
throw makeBindFlowError('Missing pollAfterSeconds, treat as timeout / 缺少 pollAfterSeconds,按超时处理', {
|
||||
code: 'BIND_TIMEOUT',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
await sleepBind(waitSec * 1000);
|
||||
}
|
||||
|
||||
throw makeBindFlowError(`Bind timeout / 绑定超时(>${timeoutMs}ms)`, {
|
||||
code: 'BIND_TIMEOUT',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status: 'TIMEOUT',
|
||||
});
|
||||
}
|
||||
|
||||
export { getBearerToken, makeKlingHeaders, setSkillVersion, getSkillVersion } from './auth.mjs';
|
||||
export { API_BASE, CANDIDATE_BASES, resolveApiBase };
|
||||
103
.claude/skills/klingai-1.1.0/scripts/shared/task.mjs
Normal file
103
.claude/skills/klingai-1.1.0/scripts/shared/task.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Kling AI task helpers (zero external deps)
|
||||
* Submit → poll status → download result
|
||||
*/
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { klingPost, klingGet, makeKlingHeaders } from './client.mjs';
|
||||
|
||||
/**
|
||||
* 提交任务
|
||||
* @param {string} apiPath 如 /v1/videos/image2video
|
||||
* @param {object} payload 请求体
|
||||
* @param {string} [token]
|
||||
* @returns {Promise<{taskId: string, status: string, data: object}>}
|
||||
*/
|
||||
export async function submitTask(apiPath, payload, token) {
|
||||
const data = await klingPost(apiPath, payload, token);
|
||||
const taskId = data?.task_id;
|
||||
if (!taskId) throw new Error('API did not return task_id / API 未返回 task_id');
|
||||
console.log(`Task submitted / 任务已提交: ${taskId}`);
|
||||
console.log(`Status / 状态: ${data.task_status || 'submitted'}`);
|
||||
return { taskId, status: data.task_status || 'submitted', data };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务状态
|
||||
* @param {string} apiPath 如 /v1/videos/image2video
|
||||
* @param {string} taskId
|
||||
* @param {string} [token]
|
||||
* @returns {Promise<object>} task data
|
||||
*/
|
||||
export async function queryTask(apiPath, taskId, token) {
|
||||
return klingGet(`${apiPath}/${taskId}`, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询任务直到完成
|
||||
* @param {string} apiPath
|
||||
* @param {string} taskId
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.interval=10000] 轮询间隔(ms)
|
||||
* @param {string} [opts.token]
|
||||
* @returns {Promise<object>} 成功的 task data
|
||||
*/
|
||||
export async function pollTask(apiPath, taskId, opts = {}) {
|
||||
const interval = opts.interval || 10000;
|
||||
const token = opts.token;
|
||||
console.log('Waiting for task... / 等待任务完成...');
|
||||
while (true) {
|
||||
const data = await queryTask(apiPath, taskId, token);
|
||||
const status = data?.task_status;
|
||||
console.log(`Status / 状态: ${status}`);
|
||||
if (status === 'succeed') return data;
|
||||
if (status === 'failed') {
|
||||
throw new Error(`Task failed / 任务失败: ${data?.task_status_msg || 'Unknown error'}`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件到本地
|
||||
* @param {string} url 下载 URL
|
||||
* @param {string} outPath 输出文件路径
|
||||
*/
|
||||
export async function downloadFile(url, outPath) {
|
||||
console.log('Downloading... / 正在下载...');
|
||||
const res = await fetch(url, { headers: makeKlingHeaders(null, null) });
|
||||
if (!res.ok) throw new Error(`Download failed / 下载失败: HTTP ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await mkdir(join(outPath, '..'), { recursive: true });
|
||||
await writeFile(outPath, buf);
|
||||
console.log(`Saved / 已保存: ${outPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询并下载结果
|
||||
* @param {string} apiPath
|
||||
* @param {string} taskId
|
||||
* @param {string} outputDir
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.urlField='url'] output 中的 URL 字段名
|
||||
* @param {string} [opts.ext='.mp4'] 文件扩展名
|
||||
* @param {number} [opts.interval]
|
||||
* @param {string} [opts.token]
|
||||
* @returns {Promise<string>} 输出文件路径
|
||||
*/
|
||||
export async function pollAndDownload(apiPath, taskId, outputDir, opts = {}) {
|
||||
const data = await pollTask(apiPath, taskId, opts);
|
||||
const urlField = opts.urlField || 'url';
|
||||
const ext = opts.ext || '.mp4';
|
||||
const output = data?.task_result || {};
|
||||
// 支持多种输出结构
|
||||
const url = output[urlField]
|
||||
|| output?.videos?.[0]?.[urlField]
|
||||
|| output?.images?.[0]?.url
|
||||
|| (typeof output === 'string' ? output : null);
|
||||
if (!url) throw new Error(`Task succeeded but missing ${urlField} / 任务成功但未返回 ${urlField}`);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const outPath = join(outputDir, `${taskId}${ext}`);
|
||||
await downloadFile(url, outPath);
|
||||
return outPath;
|
||||
}
|
||||
646
.claude/skills/klingai-1.1.0/scripts/video.mjs
Normal file
646
.claude/skills/klingai-1.1.0/scripts/video.mjs
Normal file
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Kling AI video generation — text-to-video, image-to-video, Omni, multi-shot
|
||||
* Node.js 18+, zero external deps
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { submitTask, queryTask, pollAndDownload, downloadFile } from './shared/task.mjs';
|
||||
import { parseArgs, getTokenOrExit, readMediaAsValue, readOmniVideoRefUrl, resolveAllowedOutputDir } from './shared/args.mjs';
|
||||
|
||||
const API_T2V = '/v1/videos/text2video';
|
||||
const API_I2V = '/v1/videos/image2video';
|
||||
const API_OMNI = '/v1/videos/omni-video';
|
||||
|
||||
function normalizeModelName(v) {
|
||||
return String(v || '').trim();
|
||||
}
|
||||
|
||||
/** Lowercase trim for route checks and API `model_name` enum matching. */
|
||||
function normalizeModelKey(v) {
|
||||
return normalizeModelName(v).toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeAliasKey(v) {
|
||||
return String(v || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
|
||||
}
|
||||
|
||||
function getVideoModelAliasTarget(v) {
|
||||
const key = normalizeAliasKey(v);
|
||||
const aliasMap = new Map([
|
||||
['omni3', 'kling-v3-omni'],
|
||||
['omni-3', 'kling-v3-omni'],
|
||||
['omni-v3', 'kling-v3-omni'],
|
||||
['kling-video-o3', 'kling-v3-omni'],
|
||||
['v3-omni', 'kling-v3-omni'],
|
||||
['o3', 'kling-v3-omni'],
|
||||
['O3', 'kling-v3-omni'],
|
||||
['kling-o3', 'kling-v3-omni'],
|
||||
['omni1', 'kling-video-o1'],
|
||||
['omni-1', 'kling-video-o1'],
|
||||
['o1', 'kling-video-o1'],
|
||||
['kling-o1', 'kling-video-o1'],
|
||||
]);
|
||||
return aliasMap.get(key) || '';
|
||||
}
|
||||
|
||||
function validateModelAliasInput(rawModel) {
|
||||
if (!rawModel) return;
|
||||
const model = normalizeModelKey(rawModel);
|
||||
const target = getVideoModelAliasTarget(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; o1 | omni1 -> kling-video-o1',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSound(v) {
|
||||
const s = String(v || '').trim().toLowerCase();
|
||||
if (!s) return '';
|
||||
if (s === 'on' || s === 'off') return s;
|
||||
return s;
|
||||
}
|
||||
|
||||
function normalizeReferType(v) {
|
||||
const s = String(v || '').trim().toLowerCase();
|
||||
if (!s) return 'base';
|
||||
return s;
|
||||
}
|
||||
|
||||
function normalizeKeepOriginalSound(v) {
|
||||
const s = String(v || '').trim().toLowerCase();
|
||||
if (!s) return '';
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Multi-shot `shot_type`: `customize` | `intelligence` (empty → default customize when --multi_shot) */
|
||||
function normalizeShotType(v) {
|
||||
const s = String(v || '').trim().toLowerCase();
|
||||
if (!s) return '';
|
||||
if (s === 'customize' || s === 'intelligence') return s;
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets `multi_shot`, `shot_type`, and `prompt` / `multi_prompt` on payload (text2video / image2video / omni-video share rules).
|
||||
* Exits the process on validation error.
|
||||
* @param {Record<string, unknown>} payload
|
||||
* @param {Record<string, unknown>} args
|
||||
*/
|
||||
function mergeMultiShotIntoPayload(payload, args) {
|
||||
const rawShot = normalizeShotType(args.shot_type);
|
||||
const shotType = rawShot || 'customize';
|
||||
if (shotType !== 'customize' && shotType !== 'intelligence') {
|
||||
console.error(
|
||||
'Error / 错误: --shot_type must be customize or intelligence / 须为 customize 或 intelligence',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
payload.multi_shot = true;
|
||||
payload.shot_type = shotType;
|
||||
|
||||
if (shotType === 'customize') {
|
||||
if (!args.multi_prompt || !String(args.multi_prompt).trim()) {
|
||||
console.error(
|
||||
'Error / 错误: customize multi-shot requires --multi_prompt / 自定义分镜须提供 --multi_prompt',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
payload.multi_prompt = JSON.parse(args.multi_prompt);
|
||||
} catch {
|
||||
console.error('Error / 错误: --multi_prompt must be valid JSON / 必须是合法 JSON');
|
||||
process.exit(1);
|
||||
}
|
||||
payload.prompt = '';
|
||||
} else {
|
||||
const p = String(args.prompt || '').trim();
|
||||
if (!p) {
|
||||
console.error(
|
||||
'Error / 错误: intelligence multi-shot requires non-empty --prompt / 智能分镜须提供非空 --prompt',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (args.multi_prompt && String(args.multi_prompt).trim()) {
|
||||
console.error(
|
||||
'Error / 错误: intelligence multi-shot does not use --multi_prompt / 智能分镜请勿传 --multi_prompt',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
payload.prompt = p;
|
||||
}
|
||||
}
|
||||
|
||||
function validateModelForRoute(apiPath, args) {
|
||||
validateModelAliasInput(args.model);
|
||||
const model = normalizeModelKey(args.model);
|
||||
if (!model) return;
|
||||
|
||||
// We only validate what we can be sure about from public enums.
|
||||
// - omni-video: only kling-v3-omni / kling-video-o1
|
||||
// - non-omni video: must not use omni-only models
|
||||
if (apiPath === API_OMNI) {
|
||||
const allowed = new Set(['kling-v3-omni', 'kling-video-o1']);
|
||||
if (!allowed.has(model)) {
|
||||
throw new Error(
|
||||
`Invalid --model for omni-video / omni-video 不支持该模型: ${model}\n`
|
||||
+ `Allowed / 允许: kling-v3-omni, kling-video-o1`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const forbidden = new Set(['kling-v3-omni', 'kling-video-o1', 'kling-image-o1']);
|
||||
if (forbidden.has(model)) {
|
||||
throw new Error(
|
||||
`Invalid --model for text2video/image2video / 文生/图生不支持该模型: ${model}\n`
|
||||
+ `Hint / 提示: remove --model or use a basic video model (e.g. kling-v3, kling-v2-6)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSoundConstraints(apiPath, args) {
|
||||
const sound = normalizeSound(args.sound || 'off') || 'off';
|
||||
const model = normalizeModelKey(args.model);
|
||||
|
||||
if (apiPath === API_OMNI && args.video && sound === 'on') {
|
||||
throw new Error(
|
||||
'Invalid --sound with Omni --video / Omni 参考视频时 sound 仅支持 off。\n'
|
||||
+ 'Fix / 修复: remove --sound or set --sound off',
|
||||
);
|
||||
}
|
||||
if (model === 'kling-video-o1' && sound === 'on') {
|
||||
throw new Error(
|
||||
'Invalid --sound for kling-video-o1 / kling-video-o1 不支持 sound。\n'
|
||||
+ 'Fix / 修复: set --sound off or omit it',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOmniVideoListRules(args) {
|
||||
if (!args.video) {
|
||||
if (args.video_refer_type) {
|
||||
throw new Error(
|
||||
'Invalid --video_refer_type without --video / 仅在传入 --video 时才能设置 --video_refer_type。',
|
||||
);
|
||||
}
|
||||
if (args.keep_original_sound) {
|
||||
throw new Error(
|
||||
'Invalid --keep_original_sound without --video / 仅在传入 --video 时才能设置 --keep_original_sound。',
|
||||
);
|
||||
}
|
||||
return { referType: '', keepOriginalSound: '' };
|
||||
}
|
||||
|
||||
const rawVideo = String(args.video).trim();
|
||||
if (!rawVideo) {
|
||||
throw new Error('Invalid --video / --video 不能为空,video_url 必须为非空公网 http(s) URL。');
|
||||
}
|
||||
if (rawVideo.includes(',')) {
|
||||
throw new Error('Invalid --video / 当前仅支持 1 段参考视频,请只传一个 video_url。');
|
||||
}
|
||||
|
||||
const referType = normalizeReferType(args.video_refer_type);
|
||||
if (referType !== 'feature' && referType !== 'base') {
|
||||
throw new Error(
|
||||
`Invalid --video_refer_type / 无效 refer_type: ${referType}. Allowed / 允许: feature, base`,
|
||||
);
|
||||
}
|
||||
|
||||
const keepOriginalSound = normalizeKeepOriginalSound(args.keep_original_sound);
|
||||
if (keepOriginalSound && keepOriginalSound !== 'yes' && keepOriginalSound !== 'no') {
|
||||
throw new Error(
|
||||
`Invalid --keep_original_sound / 无效 keep_original_sound: ${keepOriginalSound}. Allowed / 允许: yes, no`,
|
||||
);
|
||||
}
|
||||
|
||||
return { referType, keepOriginalSound };
|
||||
}
|
||||
|
||||
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_url 非空。',
|
||||
);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseImageTypes(rawImageTypesArg, imageCount) {
|
||||
if (!rawImageTypesArg) return new Array(imageCount).fill('');
|
||||
const parts = String(rawImageTypesArg).split(',').map(s => s.trim().toLowerCase());
|
||||
if (parts.length !== imageCount) {
|
||||
throw new Error(
|
||||
`Invalid --image_types / --image_types 数量需与 --image 一致: expected ${imageCount}, got ${parts.length}`,
|
||||
);
|
||||
}
|
||||
for (const t of parts) {
|
||||
if (!t) continue;
|
||||
if (t !== 'first_frame' && t !== 'end_frame') {
|
||||
throw new Error(
|
||||
`Invalid image type / 无效图片 type: ${t}. Allowed / 允许: first_frame, end_frame, empty`,
|
||||
);
|
||||
}
|
||||
}
|
||||
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 validateOmniImageListRules(args, imageInputs, imageTypes, hasTailArg) {
|
||||
// API limit: with reference video max 4 images, otherwise max 7.
|
||||
const maxImages = args.video ? 4 : 7;
|
||||
const totalImages = imageInputs.length + (hasTailArg ? 1 : 0);
|
||||
if (totalImages > maxImages) {
|
||||
throw new Error(
|
||||
`Too many images for omni-video / omni-video 图片数量超限: max ${maxImages} (current ${totalImages})`,
|
||||
);
|
||||
}
|
||||
|
||||
const hasFirstFrame = imageTypes.includes('first_frame');
|
||||
const hasEndFrame = imageTypes.includes('end_frame') || hasTailArg;
|
||||
|
||||
if (hasEndFrame && !hasFirstFrame) {
|
||||
throw new Error(
|
||||
'Invalid image_list: end_frame needs first_frame / 不支持仅尾帧,配置 end_frame 时必须同时有 first_frame。',
|
||||
);
|
||||
}
|
||||
|
||||
// O1 + >2 images does not support end_frame.
|
||||
const model = normalizeModelKey(args.model);
|
||||
if (model === 'kling-video-o1' && hasEndFrame && totalImages > 2) {
|
||||
throw new Error(
|
||||
'Invalid image_list for kling-video-o1 / kling-video-o1 在图片数超过 2 时不支持任何 end_frame。',
|
||||
);
|
||||
}
|
||||
|
||||
// Frame generation cannot be used with video editing (base).
|
||||
const hasFrame = hasFirstFrame || hasEndFrame;
|
||||
if (hasFrame && args.video && normalizeReferType(args.video_refer_type) === 'base') {
|
||||
throw new Error(
|
||||
'Invalid combo: frame images with video edit / 首帧或尾帧生视频不能与视频编辑(--video_refer_type base)同时使用。',
|
||||
);
|
||||
}
|
||||
return { totalImages, hasFirstFrame, hasEndFrame };
|
||||
}
|
||||
|
||||
function validateOmniElementListRules(args, elementIds, imageState) {
|
||||
if (!elementIds.length) return;
|
||||
const model = normalizeModelKey(args.model);
|
||||
const hasFirstAndEnd = imageState.hasFirstFrame && imageState.hasEndFrame;
|
||||
|
||||
// Frame-generation with subjects supports up to 3 subjects.
|
||||
if ((imageState.hasFirstFrame || imageState.hasEndFrame) && elementIds.length > 3) {
|
||||
throw new Error(
|
||||
`Too many subjects with frame generation / 首帧或尾帧生视频时主体最多 3 个: current ${elementIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// First+last frame with O1 does not support subjects.
|
||||
if (hasFirstAndEnd && model === 'kling-video-o1') {
|
||||
throw new Error(
|
||||
'Invalid element_list for kling-video-o1 / kling-video-o1 在首尾帧生视频场景不支持主体。',
|
||||
);
|
||||
}
|
||||
|
||||
// Combined reference count limit: images + elements.
|
||||
const totalRefs = imageState.totalImages + elementIds.length;
|
||||
const maxRefs = args.video ? 4 : 7;
|
||||
if (totalRefs > maxRefs) {
|
||||
throw new Error(
|
||||
`Too many refs for omni-video / omni-video 参考图与主体总数超限: max ${maxRefs} (current ${totalRefs})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Kling AI video generation
|
||||
|
||||
Usage:
|
||||
node kling.mjs video --prompt <text> [options] # Text-to-video
|
||||
node kling.mjs video --image <path|url> [--prompt ...] # Image-to-video
|
||||
node kling.mjs video --prompt "..." [--image ...] [--element_ids ...] # Omni
|
||||
node kling.mjs video --multi_shot --shot_type customize --multi_prompt <json> # Multi-shot (customize)
|
||||
node kling.mjs video --multi_shot --shot_type intelligence --prompt "..." # Multi-shot (intelligence)
|
||||
node kling.mjs video --task_id <id> [--download] # Query/download
|
||||
|
||||
Submit (common):
|
||||
--prompt Video description (Omni: <<<element_1>>> <<<image_1>>> <<<video_1>>>)
|
||||
--duration Duration 3-15 s (default: 5)
|
||||
--model T2V/I2V: kling-v3 / kling-v2-6 / …; explicit kling-v3-omni or kling-video-o1 → omni-video (simple t2v/i2v too). Omni default: kling-v3-omni or kling-video-o1
|
||||
--mode pro / std (default: pro)
|
||||
--aspect_ratio 16:9 / 9:16 / 1:1 (default: 16:9). With --image, this routes to omni-video
|
||||
--sound on / off (default: off). v3/omni support; with --video only off; o1 no sound
|
||||
--negative_prompt Negative prompt
|
||||
--output_dir Output dir (default: ./output)
|
||||
--no-wait Submit only, do not wait
|
||||
--wait Wait for completion (default)
|
||||
|
||||
Image-to-video / Omni:
|
||||
--image Image list path or URL (comma-separated for Omni)
|
||||
--image_types Optional type list aligned with --image (comma-separated): first_frame/end_frame/empty
|
||||
--image_tail Last-frame image
|
||||
--element_ids Subject IDs, comma-separated (Omni; combined limits with images)
|
||||
--video Omni reference video: public http(s) URL only (video_list[].video_url)
|
||||
--video_refer_type feature / base (default: base)
|
||||
--keep_original_sound yes / no (optional; works for feature/base)
|
||||
|
||||
Multi-shot (text2video / image2video / omni-video; same rules; see SKILL.md):
|
||||
--multi_shot Enable multi-shot (with customize, top-level --prompt unused; not with --image_tail)
|
||||
--shot_type customize | intelligence (required when multi_shot; default: customize)
|
||||
--multi_prompt customize only: JSON array, max 6 shots, durations sum to --duration
|
||||
--prompt intelligence: required (model splits shots); customize: ignored if set
|
||||
|
||||
Query/download:
|
||||
--task_id Task ID
|
||||
--download Download if task succeeded
|
||||
|
||||
Watermark:
|
||||
--watermark Generate with watermark (adds watermark_info: {enabled: true})
|
||||
|
||||
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 local media / --output_dir (default: cwd only)
|
||||
KLING_ALLOW_ABSOLUTE_PATHS=1 Allow any local path (e.g. WSL downloads outside project)`);
|
||||
}
|
||||
|
||||
function chooseApiPath(args) {
|
||||
if (args.element_ids || args.video) return API_OMNI;
|
||||
const m = normalizeModelKey(args.model);
|
||||
const explicitOmniModel = m === 'kling-v3-omni' || m === 'kling-video-o1';
|
||||
if (args.image) {
|
||||
const images = args.image.split(',').map(s => s.trim()).filter(Boolean);
|
||||
// image2video does not support aspect_ratio; route to omni-video when explicitly provided.
|
||||
if (args.aspect_ratio) return API_OMNI;
|
||||
if (images.length > 1) return API_OMNI;
|
||||
if (explicitOmniModel) return API_OMNI;
|
||||
return API_I2V;
|
||||
}
|
||||
if (explicitOmniModel) return API_OMNI;
|
||||
return API_T2V;
|
||||
}
|
||||
|
||||
async function queryTaskAnyPath(taskId, token) {
|
||||
const paths = [API_OMNI, API_I2V, API_T2V];
|
||||
for (const apiPath of paths) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const args = parseArgs(process.argv, ['multi_shot']);
|
||||
if (args.help) { printHelp(); return; }
|
||||
validateModelAliasInput(args.model);
|
||||
|
||||
const token = await getTokenOrExit();
|
||||
const outputDir = resolveAllowedOutputDir(args.output_dir || './output');
|
||||
|
||||
if (args.task_id && !args.prompt && !args.image && !args.multi_shot) {
|
||||
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'}`);
|
||||
if (data?.task_status_msg) console.log(`Message / 消息: ${data.task_status_msg}`);
|
||||
const videos = data?.task_result?.videos || [];
|
||||
if (videos.length > 0 && videos[0].url) {
|
||||
console.log(`Video URL / 视频链接: ${videos[0].url}`);
|
||||
if (videos[0].watermark_url) {
|
||||
console.log(`Watermark URL / 水印视频: ${videos[0].watermark_url}`);
|
||||
}
|
||||
if (args.download) {
|
||||
const { mkdir } = await import('node:fs/promises');
|
||||
const { join } = await import('node:path');
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await downloadFile(videos[0].url, join(outputDir, `${args.task_id}.mp4`));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error / 错误: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const imageInputs = parseImageInputs(args.image);
|
||||
const imageTypes = parseImageTypes(args.image_types, imageInputs.length);
|
||||
const elementIds = parseElementIds(args.element_ids);
|
||||
const aspectForcesOmni = Boolean(args.image && args.aspect_ratio && imageInputs.length > 0);
|
||||
const videoState = validateOmniVideoListRules(args);
|
||||
const hasImage = imageInputs.length > 0;
|
||||
if (!args.prompt && !hasImage && !args.multi_shot) {
|
||||
console.error('Error / 错误: --prompt, --image, or --multi_shot required');
|
||||
console.error('Use --help / 使用 --help 查看帮助');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.image_tail && !hasImage) {
|
||||
console.error('Error / 错误: --image_tail requires --image (first frame) / 首尾帧需要首帧 --image');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.multi_shot && args.image_tail) {
|
||||
console.error(
|
||||
'Error / 错误: multi-shot does not support first+last frame (--image_tail) / 多镜头不支持首尾帧生视频,请去掉 --image_tail',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (hasImage) {
|
||||
const firstInput = imageInputs[0];
|
||||
const isUrl = firstInput.startsWith('http://') || firstInput.startsWith('https://');
|
||||
if (!isUrl && !existsSync(resolve(firstInput))) {
|
||||
console.error(`Error / 错误: image not found / 图片不存在: ${firstInput}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const apiPath = chooseApiPath(args);
|
||||
const queryHint = `node kling.mjs video --task_id`;
|
||||
if (apiPath === API_OMNI && aspectForcesOmni && args.model) {
|
||||
const model = normalizeModelKey(args.model);
|
||||
const isOmniModel = model === 'kling-v3-omni' || model === 'kling-video-o1';
|
||||
if (!isOmniModel) {
|
||||
console.error(
|
||||
`Error / 错误: --model ${model} does not support --aspect_ratio with --image.\n`
|
||||
+ 'Use omni model / 请使用 Omni 模型: kling-v3-omni or kling-video-o1',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (apiPath === API_OMNI && aspectForcesOmni && args.negative_prompt) {
|
||||
console.error(
|
||||
'Info / 提示: omni-video does not support --negative_prompt; this parameter will be ignored',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
validateModelForRoute(apiPath, args);
|
||||
validateSoundConstraints(apiPath, args);
|
||||
|
||||
if (apiPath === API_T2V) {
|
||||
const payload = {
|
||||
model_name: args.model ? normalizeModelKey(args.model) : 'kling-v3',
|
||||
negative_prompt: args.negative_prompt || '',
|
||||
duration: String(args.duration || '5'),
|
||||
mode: args.mode || 'pro',
|
||||
aspect_ratio: args.aspect_ratio || '16:9',
|
||||
sound: args.sound || 'off',
|
||||
callback_url: '',
|
||||
external_task_id: '',
|
||||
};
|
||||
if (args.watermark) payload.watermark_info = { enabled: true };
|
||||
if (args.multi_shot) {
|
||||
mergeMultiShotIntoPayload(payload, args);
|
||||
} else {
|
||||
const p = String(args.prompt || '').trim();
|
||||
if (!p) {
|
||||
console.error(
|
||||
'Error / 错误: text-to-video requires --prompt when not using --multi_shot / 文生视频非多镜头须提供 --prompt',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
payload.prompt = args.prompt;
|
||||
}
|
||||
const result = await submitTask(API_T2V, payload, token);
|
||||
console.log(`\nTask ID / 任务 ID: ${result.taskId}`);
|
||||
console.log(`Query / 查询: ${queryHint} ${result.taskId} [--download]`);
|
||||
if (args.wait !== false) {
|
||||
console.log();
|
||||
const outPath = await pollAndDownload(API_T2V, result.taskId, outputDir, { token });
|
||||
console.log(`\n✓ Done / 完成: ${outPath}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiPath === API_I2V) {
|
||||
const payload = {
|
||||
model_name: args.model ? normalizeModelKey(args.model) : 'kling-v3',
|
||||
image: await readMediaAsValue(args.image),
|
||||
image_tail: args.image_tail ? await readMediaAsValue(args.image_tail) : '',
|
||||
negative_prompt: args.negative_prompt || '',
|
||||
duration: String(args.duration || '5'),
|
||||
mode: args.mode || 'pro',
|
||||
sound: args.sound || 'off',
|
||||
callback_url: '',
|
||||
external_task_id: '',
|
||||
};
|
||||
if (args.watermark) payload.watermark_info = { enabled: true };
|
||||
if (args.multi_shot) {
|
||||
mergeMultiShotIntoPayload(payload, args);
|
||||
} else {
|
||||
payload.prompt = args.prompt || '';
|
||||
}
|
||||
const result = await submitTask(API_I2V, payload, token);
|
||||
console.log(`\nTask ID / 任务 ID: ${result.taskId}`);
|
||||
console.log(`Query / 查询: ${queryHint} ${result.taskId} [--download]`);
|
||||
if (args.wait !== false) {
|
||||
console.log();
|
||||
const outPath = await pollAndDownload(API_I2V, result.taskId, outputDir, { token });
|
||||
console.log(`\n✓ Done / 完成: ${outPath}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
model_name: args.model ? normalizeModelKey(args.model) : 'kling-v3-omni',
|
||||
duration: String(args.duration || '5'),
|
||||
mode: args.mode || 'pro',
|
||||
sound: args.sound || 'off',
|
||||
callback_url: '',
|
||||
};
|
||||
const hasFirstFrameRef = imageTypes.includes('first_frame');
|
||||
const usesVideoEdit = Boolean(args.video && normalizeReferType(args.video_refer_type) === 'base');
|
||||
const requireAspectRatio = !hasFirstFrameRef && !usesVideoEdit;
|
||||
if (args.aspect_ratio) {
|
||||
payload.aspect_ratio = args.aspect_ratio;
|
||||
} else if (requireAspectRatio) {
|
||||
payload.aspect_ratio = '16:9';
|
||||
}
|
||||
if (args.watermark) payload.watermark_info = { enabled: true };
|
||||
|
||||
if (args.multi_shot) {
|
||||
mergeMultiShotIntoPayload(payload, args);
|
||||
} else {
|
||||
const p = String(args.prompt || '').trim();
|
||||
if (!p) {
|
||||
console.error(
|
||||
'Error / 错误: Omni (non-multi-shot) requires non-empty --prompt / 非多镜头 Omni 须提供非空 --prompt',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
payload.multi_shot = false;
|
||||
payload.prompt = args.prompt;
|
||||
}
|
||||
|
||||
const imageList = [];
|
||||
let imageState = { totalImages: 0, hasFirstFrame: false, hasEndFrame: false };
|
||||
if (imageInputs.length > 0 || args.image_tail) {
|
||||
imageState = validateOmniImageListRules(args, imageInputs, imageTypes, Boolean(args.image_tail));
|
||||
}
|
||||
validateOmniElementListRules(args, elementIds, imageState);
|
||||
if (imageInputs.length > 0) {
|
||||
for (let i = 0; i < imageInputs.length; i++) {
|
||||
const item = { image_url: await readMediaAsValue(imageInputs[i]) };
|
||||
if (imageTypes[i]) item.type = imageTypes[i];
|
||||
imageList.push(item);
|
||||
}
|
||||
}
|
||||
if (args.image_tail) {
|
||||
imageList.push({ image_url: await readMediaAsValue(args.image_tail), type: 'end_frame' });
|
||||
}
|
||||
if (imageList.length > 0) payload.image_list = imageList;
|
||||
|
||||
if (elementIds.length > 0) {
|
||||
payload.element_list = elementIds.map(id => {
|
||||
return { element_id: String(id.trim()) };
|
||||
});
|
||||
}
|
||||
|
||||
if (args.video) {
|
||||
const videoUrl = readOmniVideoRefUrl(args.video);
|
||||
const videoItem = { video_url: videoUrl, refer_type: videoState.referType };
|
||||
if (videoState.keepOriginalSound) videoItem.keep_original_sound = videoState.keepOriginalSound;
|
||||
payload.video_list = [videoItem];
|
||||
}
|
||||
|
||||
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 outPath = await pollAndDownload(API_OMNI, result.taskId, outputDir, { token });
|
||||
console.log(`\n✓ Done / 完成: ${outPath}`);
|
||||
}
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user