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