fix: 问题

This commit is contained in:
2026-04-29 21:58:28 +08:00
parent 5619d753cc
commit 803da39b85
13 changed files with 0 additions and 3486 deletions

View File

@@ -1,14 +0,0 @@
# Kling AI
Video generation, image generation, and subject management.
- **Command**: `node scripts/kling.mjs <video|image|element|account> [options]`
- **Subcommands**:
- `video`: video generation (text-to-video, image-to-video, omni-video, multi-shot)
- `image`: image generation (text-to-image, image-to-image, omni-image, 4K/series)
- `element`: subject CRUD
- `account`: quota query and credential bind/import
- Choose by user intent; if ambiguous, ask the user first.
See [SKILL.md](SKILL.md) for full routing/parameters and [reference.md](reference.md) for endpoint mapping.
Official docs: [CN](https://app.klingai.com/cn/dev/document-api) / [Global](https://kling.ai/document-api/quickStart/productIntroduction/overview).

View File

@@ -1,407 +0,0 @@
---
name: klingai
version: "1.1.0"
description: Official Kling AI Skill. Call Kling AI for video generation, image generation, subject management, and account quota inquiry. Use subcommand video / image / element / account by user intent. Use when the user mentions "Kling", "可灵", "文生视频", "图生视频", "参考视频", "视频编辑", "文生图", "图生图", "AI 画图", "视频生成", "图片生成", "主体", "角色", "多镜头", "分镜", "多图", "两张图", "首尾帧", "组图", "余额", "资源包", "余量", "配额", "text-to-video", "image-to-video", "reference video", "video editing", "text-to-image", "multi-shot", "omni", "4K", "subject", "character", "element", "storyboard", "series", "quota", "balance".
metadata: {"openclaw":{"emoji":"🎬","requires":{"bins":["node"]},"primaryEnv":"KLING_TOKEN","homepage":"https://app.klingai.com/cn/dev/document-api"}}
---
> **Language**: Respond in the user's language (detect from their message). Use it for explanations, confirmations, errors, and follow-ups. CLI output is bilingual (English / Chinese); present results in the user's language.
# Kling AI
Video generation, image generation, subject management, and (read-only) account resource/quota inquiry.
Invoke with subcommand `video` | `image` | `element` | `account` by user intent.
Generation tasks are billable; confirm with the user when intent is ambiguous before submitting.
## Invocation
From repository root:
```bash
node skills/klingai/scripts/kling.mjs <video|image|element|account> [options]
```
In examples below, `{baseDir}` means the skill directory (for example `skills/klingai`).
## Routing priority (OpenClaw)
- For Kling/可灵 requests with complex generation requirements, default to this skill (`node {baseDir}/scripts/kling.mjs ...`).
- Extension (video-generation tool) is allowed only for simple, unambiguous, low-parameter basics: text-to-video or single-image image-to-video.
- Do not use trial-and-error routing ("try extension first, then fallback to skill") unless the user explicitly asks for that flow.
## Intent routing (required)
Choose subcommand from user intent first. HTTP API path and default `model_name` are determined by **Route & model**.
| User intent | Subcommand |
| --- | --- |
| Video (t2v, i2v, multi-shot, Omni ref/edit clip via `feature`/`base`, subject-in-video, animation) | `video` |
| Image (text-to-image, image-to-image, 4K, series, AI drawing) | `image` |
| Subject / element (create, manage, list, presets, delete) | `element` |
| Account resources, packs, remaining quota / balance (read-only) | `account` (`--costs`, default) |
| Credential setup (bind/import) | `account` with `--bind-url` / `--import-env` / `--import-credentials` |
Selection rules:
- Video-related -> `video`
- For simple, unambiguous basic t2v or single-image i2v, extension may be used; for other video cases, prefer this skill.
- Image-only -> `image`
- Subject CRUD -> `element`
- Quota/balance/resource packs -> `account` (default `--costs`)
- Use existing subject in generation -> `video`/`image` + `--element_ids`
- Create subject first -> `element`
Force skill conditions (any hit -> use this skill):
- multi-image input (>=2 images)
- Omni/frame control (`first_frame`/`end_frame`/`image_types`)
- reference video (`--video` + `feature`/`base`) or video editing
- subject/element reuse (`--element_ids`)
- storyboard/multi-shot (`--multi_shot`, 分镜)
- image series (`--result_type series`/`--series_amount`, 组图)
- extension parameter gaps or ambiguous/unclear parameter intent
Model name strict rule:
- `--model` must be canonical lowercase/hyphen names only: `kling-v3`, `kling-v3-omni`, `kling-video-o1`, `kling-image-o1`.
- Do not pass aliases as CLI values.
- Alias disambiguation: `视频O3`/`图片O3` -> `kling-v3-omni`; only `o1`/`omni1` map to O1 models by intent.
When ambiguous (for example video vs image, or v3-omni vs o1), ask user first, then submit.
## Preflight checklist (mandatory before submit)
Before any billable submit, pass all checks below. If any check fails, stop and ask user or run `--help`.
1. Subcommand is confirmed: `video` / `image` / `element` / `account`.
2. Route is confirmed by flags: basic vs Omni (from **Route & model**).
3. `--model` is canonical (no alias values like `o3`, `omni3`).
4. All params come from this SKILL.md or subcommand `--help`; no undocumented flags.
5. No conflicting combinations (for example `--multi_shot` + `--image_tail`, `--video` + `--sound on`).
6. Query mode and submit mode are not mixed.
## Anti-fabrication policy (no guessing)
- Do not invent model names, enums, ranges, defaults, request fields, or hidden flags.
- Do not infer unsupported values from older/other skills.
- If value is uncertain, verify with `node {baseDir}/scripts/kling.mjs <subcommand> --help`.
- If user intent is uncertain, ask first; do not submit trial jobs.
- If user uses alias words, map to canonical names and pass canonical only.
## Cost and submission rules
- Every submit is charged; do not submit speculatively.
- Confirm intent first when unclear.
- On timeout/failure/unexpected result, ask user whether to wait or retry.
- Do not auto-resubmit or silently change intent/parameters.
## Agent loop & results
- Entry: only `node {baseDir}/scripts/kling.mjs` with `video`/`image`/`element`/`account`.
- Default flow: submit -> poll (~10s interval) -> download to `--output_dir`.
- Keep user updated on long runs (`submitted -> processing -> succeed/failed`).
- `--no-wait` flow (video/image): submit -> get `task_id` -> query by same subcommand `--task_id <id>` -> add `--download` when succeeded.
- Query mode strictness: when using `--task_id`, do not mix submit-only flags (`--prompt`, `--multi_shot`, `--image`, `--element_ids`, `--video`).
- Never print secrets (`KLING_TOKEN`, `access_key_id`, `secret_access_key`).
Presenting results:
- Always return task id + local path(s).
- If stdout includes an URL, include markdown link as fallback.
## Prerequisites
- Runtime: Node.js 18+, no extra packages.
- Credential priority: `KLING_TOKEN` (session only) -> stored AK/SK in `.credentials` (JWT per request).
- `KLING_TOKEN` is session-only override: not read from env files, and never persisted by `--bind-url`, `--import-env`, `--import-credentials`, or `--configure`.
- Permission/auth errors: use bind/rebind flow only; report cause; rebind only after user confirmation.
- Storage root: default `~/.config/kling`, optional `KLING_STORAGE_ROOT`.
- No token and no AK/SK: CLI auto-starts bind flow.
- `account --bind-url`: init -> verify -> print URL (manual open) -> poll.
- Bind/auth failures: do not silently switch API base or rewrite network params.
- Forced rebind (requires user confirmation):
- `node {baseDir}/scripts/kling.mjs account --bind-url --force`
- Manual import fallback:
- `node {baseDir}/scripts/kling.mjs account --import-env`
- `node {baseDir}/scripts/kling.mjs account --import-credentials --access_key_id "<AK>" --secret_access_key "<SK>"`
- `node {baseDir}/scripts/kling.mjs account --configure`
- Mask secret values in user-facing text.
- Optional behavior (API base, media roots): check subcommand `--help`.
## Quick start
```bash
# Show help
node {baseDir}/scripts/kling.mjs --help
# Video
node {baseDir}/scripts/kling.mjs video --prompt "A cat running on the grass" --output_dir ./output
node {baseDir}/scripts/kling.mjs video --image ./photo.jpg --prompt "Wind blowing hair"
node {baseDir}/scripts/kling.mjs video --prompt "Match motion of <<<video_1>>>" --video "https://..." --video_refer_type feature
node {baseDir}/scripts/kling.mjs video --prompt "Change background to ..." --video "https://..." --video_refer_type base
node {baseDir}/scripts/kling.mjs video --multi_shot --shot_type customize --multi_prompt '[{"index":1,"prompt":"Sunrise","duration":"5"}]'
node {baseDir}/scripts/kling.mjs video --multi_shot --shot_type intelligence --prompt "A story in three beats: arrival, conflict, resolution"
# Image
node {baseDir}/scripts/kling.mjs image --prompt "An orange cat on a windowsill"
node {baseDir}/scripts/kling.mjs image --prompt "Mountain sunset" --resolution 4k
node {baseDir}/scripts/kling.mjs image --prompt "<<<element_1>>> on the beach" --element_ids 123456
# Subject / element
node {baseDir}/scripts/kling.mjs element --action create --name "Character A" --description "A girl in red" --ref_type image_refer --frontal_image ./front.jpg
node {baseDir}/scripts/kling.mjs element --action list
node {baseDir}/scripts/kling.mjs element --action query --task_id <id>
# Account
node {baseDir}/scripts/kling.mjs account --help
node {baseDir}/scripts/kling.mjs account
node {baseDir}/scripts/kling.mjs account --days 90
node {baseDir}/scripts/kling.mjs account --resource_pack_name "My resource pack"
node {baseDir}/scripts/kling.mjs account --bind-url
node {baseDir}/scripts/kling.mjs account --bind-url --force
node {baseDir}/scripts/kling.mjs account --import-env
node {baseDir}/scripts/kling.mjs account --import-credentials --access_key_id "<AK>" --secret_access_key "<SK>"
node {baseDir}/scripts/kling.mjs account --configure
# Query existing task
node {baseDir}/scripts/kling.mjs video --task_id <id> --download
node {baseDir}/scripts/kling.mjs image --task_id <id> --download
```
## Core parameters by subcommand
Do not invent values/ranges/enums/defaults. If unsure, check:
`node {baseDir}/scripts/kling.mjs <subcommand> --help`
### video (video generation)
| Parameter | Description | Default |
| --- | --- | --- |
| `--prompt` | Non-multi-shot text2video/Omni requires non-empty prompt. With `--multi_shot`, follow `--shot_type` rules. | — |
| `--image` | Basic i2v: single image. Omni: image list (comma-separated). With `--aspect_ratio`, route to Omni video. | — |
| `--image_types` | Omni only. Per-image type list aligned with `--image`: `first_frame` / `end_frame` / empty. | — |
| `--duration` | 315 seconds. | 5 |
| `--model` | `model_name`; see **Route & model** and **Model catalog**. | route default |
| `--mode` | `pro` (1080P) / `std` (720P). | pro |
| `--aspect_ratio` | `16:9` / `9:16` / `1:1`. With `--image`, routes to Omni. | 16:9 |
| `--sound` | `on` / `off`. `kling-v3` and `kling-v3-omni` support sound; `kling-video-o1` does not. With `--video`, must be `off`. | off |
| `--image_tail` | Last-frame image. | — |
| `--element_ids` | Subject IDs (comma-separated, Omni). | — |
| `--video` | Omni reference clip: public http(s) URL only. | — |
| `--video_refer_type` | `feature` (reference) / `base` (edit clip). | base |
| `--keep_original_sound` | Omni-only, with `--video`: `yes` / `no`. | — |
| `--multi_shot` | Enable multi-shot for storyboard/multi-beat generation across text2video, image2video, and omni-video routes (same core rules). | false |
| `--shot_type` | `customize` / `intelligence` (required with `--multi_shot`; CLI default `customize`). | — |
| `--multi_prompt` | For `shot_type=customize` only. | — |
| `--output_dir` | Output directory. | `./output` |
| `--task_id` | Query task id; pair with `--download` for download. | — |
Model alias reminder:
- `omni3`/`omni v3`/`o3`/`video o3`/`image o3`/`视频O3`/`图片O3` -> `kling-v3-omni`
- `o1`/`omni1` -> `kling-video-o1` or `kling-image-o1` by intent
Multi-shot (`--multi_shot`) rules (text2video / image2video / omni-video share the same request semantics):
- `multi_shot=false`: `shot_type` and `multi_prompt` ignored.
- `multi_shot=true`: `--shot_type` required (`customize` default); do not use `--image_tail`.
- `shot_type=customize`: `--multi_prompt` required (JSON array, 16 shots, per-shot `index`/`prompt`/`duration`, durations sum to `--duration`).
- `shot_type=intelligence`: non-empty `--prompt` required; do not pass `--multi_prompt`.
Omni `image_list` rules (video):
- `image_url` cannot be empty (URL or Base64).
- `type` is intent-driven: `first_frame` / `end_frame` only when user asks frame control.
- `--image_tail` requires `--image`.
- With `--video`: max 4 images. Without `--video`: max 7.
- `kling-video-o1`: when image count > 2, no `end_frame`.
- Frame generation cannot combine with `--video_refer_type base`.
Omni `element_list` rules (video):
- `element_id` cannot be empty.
- Frame generation supports up to 3 subjects.
- First+last frame with `kling-video-o1`: subjects unsupported.
- With `--video`: `image_count + element_count <= 4`; otherwise `<= 7`.
- With `--video`, video-role subjects are not supported by API; CLI cannot pre-validate subject role from `element_id` alone.
Omni `video_list` rules (video):
- Max one video URL.
- `--video_refer_type`: `feature` / `base` (default `base`).
- `--keep_original_sound`: `yes` / `no`.
- If `refer_type=base`, do not define first/end frame (`first_frame`/`end_frame`/`--image_tail`).
- When `--video` is used, `--sound` must be `off`.
Compact examples:
```bash
# explicit frame marking by intent
node {baseDir}/scripts/kling.mjs video --model kling-v3-omni --image a.jpg,b.jpg,c.jpg --image_types first_frame,,end_frame --prompt "..."
# with reference video: image count <= 4
node {baseDir}/scripts/kling.mjs video --video "https://..." --video_refer_type feature --image a.jpg,b.jpg --prompt "..."
```
### image (image generation)
| Parameter | Description | Default |
| --- | --- | --- |
| `--model` | `model_name`; see **Route & model** and **Model catalog**. | route default |
| `--prompt` | Image prompt (required). | — |
| `--image` | Basic: single image. Omni: image list (comma-separated). | — |
| `--resolution` | `1k` / `2k` / `4k`; `4k` routes to Omni. | 1k |
| `--aspect_ratio` | `16:9` / `9:16` / `1:1` / `auto` (`auto` Omni only). | basic: `16:9`; Omni: `auto` |
| `--n` | Result count 19 (`result_type=single`). | 1 |
| `--negative_prompt` | Basic API only. | — |
| `--result_type` | `single` / `series` (`series` is Omni and i2i-only). | single |
| `--series_amount` | 29 for `result_type=series`. | 4 |
| `--element_ids` | Subject IDs (comma-separated, Omni). | — |
| `--output_dir` | Output directory. | `./output` |
| `--task_id` | Query task id; pair with `--download`. | — |
Notes:
- `n` and `series_amount` apply to different modes.
- `series` is i2i-only, so `--result_type series` requires `--image`.
Omni refs rules (image):
- `image` cannot be empty (URL or Base64).
- `element_id` cannot be empty.
- `image_count + element_count <= 10`.
### element (subject management)
Manage custom subjects: create from image/video, query task, list custom/preset, delete.
Use `element_id` in `video`/`image` with `--element_ids` for reusable subject consistency.
| Parameter | Description |
| --- | --- |
| `--action create` | Create subject; requires `--name` (<=20), `--description` (<=100), `--ref_type` |
| `--ref_type` | `image_refer` (requires `--frontal_image`) / `video_refer` (requires `--video`) |
| `--frontal_image` | Front reference image (`image_refer`) |
| `--refer_images` | Other reference images (comma-separated, 13) |
| `--video` | Reference video (`video_refer`) |
| `--action query --task_id <id>` | Query creation task |
| `--action list` | List custom subjects |
| `--action list-presets` | List preset subjects |
| `--action delete --element_id <id>` | Delete subject |
### account (resource & quota inquiry, optional credential setup)
| Flag | Purpose |
| --- | --- |
| `--costs` (default) | Read-only quota/resource packs via `GET /account/costs`. |
| `--bind-url` | Device bind with polling; prints URL for manual open; optional `--force`. |
| `--import-env` | Read `KLING_ACCESS_KEY_ID` + `KLING_SECRET_ACCESS_KEY` and persist. |
| `--import-credentials` | Persist keys from `--access_key_id` + `--secret_access_key`. |
| `--configure` | Interactive key input and save credentials. |
All bind/account files persist under storage root (`~/.config/kling` by default, or `KLING_STORAGE_ROOT`).
`--costs` query params:
| Query param (API) | CLI | Default |
| --- | --- | --- |
| `start_time` (required, Unix ms) | `--start_time` | if omitted: `end_time - days` |
| `end_time` (required, Unix ms) | `--end_time` | if omitted: now |
| — | `--days` | 30 (only when `--start_time` omitted) |
| `resource_pack_name` (optional) | `--resource_pack_name` | — |
Run `node {baseDir}/scripts/kling.mjs account --help` for details.
Run `node {baseDir}/scripts/kling.mjs video --help`, `image --help`, or `element --help` for full params.
## Route & model (CLI: `kling.mjs` + flags -> default `model_name`)
Agents call `node {baseDir}/scripts/kling.mjs <video|image|element|account>` with flags.
`--model` sets `model_name` for selected route and must be exact canonical spelling.
If `--model` is omitted, route defaults apply.
CLI guardrails reject incompatible model/route and invalid `sound` combinations before submit.
### Routing decision tree (must follow)
1. Choose subcommand from intent: `video` / `image` / `element` / `account`.
2. Determine route triggers:
- any Omni trigger -> Omni route
- otherwise -> basic route
3. Validate model-route compatibility:
- Omni route accepts only Omni-capable canonical models
- basic route rejects Omni-only models
4. Validate strict parameter combos (`sound`, `multi_shot`, frame rules, ref limits).
5. If uncertain, run `--help` or ask user; never guess-submit.
### Video (`video` subcommand)
Omni routing triggers (any of these -> omni-video API route):
- `--element_ids`
- `--video`
- comma in `--image`
- `--image` + `--aspect_ratio`
- explicit `--model kling-v3-omni` or `--model kling-video-o1`
Otherwise:
- basic text2video (T2V): no `--image`
- basic image2video (I2V): single `--image` (optional `--image_tail`)
`--multi_shot` does not force Omni; storyboard mode still follows the same routing triggers above.
| Video routing (CLI) | Default if `--model` omitted | Allowed `--model` (examples) |
| --- | --- | --- |
| Basic T2V | `kling-v3` | `kling-v2-6`, `kling-v3` |
| Basic I2V | `kling-v3` | `kling-v2-6`, `kling-v3` |
| Omni | `kling-v3-omni` | `kling-v3-omni` (default), `kling-video-o1` (explicit) |
### Image (`image` subcommand)
Omni routing triggers (any of these -> omni-image API route):
- explicit `--model kling-v3-omni` or `--model kling-image-o1`
- `--element_ids`
- `--result_type series`
- `--resolution 4k`
- `--aspect_ratio auto`
- comma in `--image`
Else -> basic generations route (text-to-image / image-to-image).
| Image routing (CLI) | Default if `--model` omitted | Allowed `--model` (examples) |
| --- | --- | --- |
| Basic | `kling-v3` | `kling-v3` by default; use canonical basic-route models supported by current CLI (`image --help`) |
| Omni | `kling-v3-omni` | `kling-v3-omni` (default), `kling-image-o1` (explicit) |
### Model catalog (by name)
Common aliases (understanding only; do not pass aliases to `--model`):
- `omni3`, `omni v3`, `视频O3`, `O3`, `o3`, `图片O3` -> `kling-v3-omni`
- `o1`, `omni1` -> `kling-video-o1` or `kling-image-o1` by intent
`--model` input rule: pass only canonical names from this table.
| Model | Valid on | Notes |
| --- | --- | --- |
| `kling-v2-6` | Basic T2V / I2V only | Not Omni video. |
| `kling-v3` | Basic video / basic image | Default for basic routes. |
| `kling-v3-omni` | Omni video / Omni image | Default for Omni routes. With `--video`, `sound` must be `off`. |
| `kling-video-o1` | Omni video only | No `sound`. |
| `kling-image-o1` | Omni image only | Optional explicit Omni-image model. |
Principle:
- Set task flags first (`--image`, `--element_ids`, `--video`, `--multi_shot`, ...).
- Omit `--model` to use route defaults.
- If `--model` is explicit, it must match route implied by flags.
## When to use Omni; element vs image reference
Which route: follow **Route & model** triggers.
Prefer Omni when you need multi-image composition, images + elements, 4K/series modes, or edit-style instructions.
Use prompt placeholders `<<<...>>>` for Omni media/subject references.
Prefer plain image reference for simple tasks.
Create element first only when user explicitly wants reusable subject consistency across outputs.
## Prompt template syntax (video / image Omni)
In Omni, pass media/subjects by flags; reference in `--prompt` with placeholders:
- `<<<image_1>>>` -> first `--image` (`<<<image_2>>>`, ...)
- `<<<element_1>>>` -> first `--element_ids` (`<<<element_2>>>`, ...)
- `<<<video_1>>>` -> `--video` clip (`video` subcommand only)
## Notes
- Timing: video ~15+ min; image ~2060 s; subject creation ~30 s2 min.
- Retention: platform may remove assets after ~30 days; save outputs locally.
## Reference
- Official developer docs (CN): https://app.klingai.com/cn/dev/document-api
- Official developer docs (Global): https://kling.ai/document-api/quickStart/productIntroduction/overview
- API endpoint quick map in this package: `reference.md`

View File

@@ -1,6 +0,0 @@
{
"ownerId": "kn7bczybw3dwrwf452ghdtzty582nxj0",
"slug": "klingai",
"version": "1.1.0",
"publishedAt": 1775744696609
}

View File

@@ -1,31 +0,0 @@
# Kling AI — API reference
| Subcommand | Endpoints |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `video` | `POST/GET /v1/videos/text2video`, `POST/GET /v1/videos/image2video`, `POST/GET /v1/videos/omni-video` |
| `image` | `POST/GET /v1/images/generations`, `POST/GET /v1/images/omni-image` |
| `element` | `POST/GET /v1/general/advanced-custom-elements`, `GET /v1/general/advanced-presets-elements`, `POST /v1/general/delete-elements` |
| `account` | `GET /account/costs` (quota/resource packs); bind flow (no Bearer, bind base): `POST /console/api/auth/skill/init-sessions`, `POST /console/api/auth/skill/exchange` |
Auth and polling notes:
- Business APIs (`/v1/...` + `/account/costs`) use Bearer token (JWT from `~/.config/kling/.credentials`, or session `KLING_TOKEN`).
- Bind APIs (`/console/api/auth/skill/...`) are device-bind endpoints and do not use Bearer.
- Submit APIs return `task_id`; polling uses `GET {submit_path}/{task_id}` until `succeed`/`failed`, then read result URLs from `task_result`/`output`.
Account mode mapping:
- `account --costs`: remote call to `GET /account/costs`
- `account --bind` / `account --bind-url`: remote bind calls (`init-sessions` + `exchange`)
- `account --import-env` / `--import-credentials` / `--configure`: local credential operations only (no business API submit)
## Model docs
Official docs (use as primary source; paths may vary by locale):
- [Developer docs home (CN)](https://app.klingai.com/cn/dev/document-api)
- [Developer docs home (Global)](https://kling.ai/document-api/quickStart/productIntroduction/overview)
- Use the navigation from the Global/CN docs home to open model pages for video/image/omni in the current site structure.

View File

@@ -1,319 +0,0 @@
#!/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);
});
}

View File

@@ -1,211 +0,0 @@
#!/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);
});
}

View File

@@ -1,327 +0,0 @@
#!/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请提供 --imaget2i 不支持 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);
});
}

View File

@@ -1,80 +0,0 @@
#!/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);
});

View File

@@ -1,205 +0,0 @@
/**
* 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

@@ -1,457 +0,0 @@
/**
* 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

@@ -1,680 +0,0 @@
/**
* 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

@@ -1,103 +0,0 @@
/**
* 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;
}

View File

@@ -1,646 +0,0 @@
#!/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);
});
}