init: video-create project with skills and accounts

This commit is contained in:
2026-04-29 21:04:43 +08:00
commit dadddc7aec
64 changed files with 14715 additions and 0 deletions

View 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。',
);
}

View File

@@ -0,0 +1,457 @@
/**
* Kling AI — 鉴权层(无网络)
*
* 凭证优先级:
* 1. 当前进程 KLING_TOKEN仅环境变量显式传入不落盘
* 2. ~/.config/kling/.credentialsINI[profile] access_key_id / secret_access_key→ 请求时 makeJwt30min 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/SKUnix 上 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 → 每次调用重新签发 JWT30min 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 成功后写入 credentialsidentity 中不保留 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 → credentialsSK 在 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);
}

View 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=1000signature is invalid / 秘钥无效,请重新绑定${requestId}`;
}
if (code === 1002) {
return `HTTP 401: code=1002access 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 };

View 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;
}