@optima-chat/optima-agent 0.8.92 → 0.8.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/kol-outreach/SKILL.md +18 -7
- package/.claude/skills/video-clone/SKILL.md +1 -1
- package/.claude/skills/video-clone/references/kling-api.md +65 -32
- package/.claude/skills/video-clone/scripts/kling_generate.py +154 -74
- package/.claude/skills/video-clone/scripts/kling_generate_test.py +191 -0
- package/.claude/skills/video-clone/scripts/preflight.py +24 -17
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kol-outreach
|
|
3
|
-
description: "KOL 建联全自动化:从 TikTok 发现潜在 KOL、提取 bio email、发送个性化 outreach、自动谈判价格与条款。当用户说'帮我找 KOL'、'{产品} KOL 建联'、'KOL 进展如何',或收到系统触发的 [KOL_INBOUND:...] 标记时,使用此 skill。"
|
|
3
|
+
description: "KOL 建联全自动化:从 TikTok 发现潜在 KOL、提取 bio email、发送个性化 outreach、自动谈判价格与条款。当用户说'帮我找 KOL'、'{产品} KOL 建联'、'KOL 进展如何',或收到系统触发的 [KOL_INBOUND:...] 或 [KOL_INBOUND] 标记时,使用此 skill。"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# KOL Outreach Skill
|
|
@@ -31,7 +31,7 @@ description: "KOL 建联全自动化:从 TikTok 发现潜在 KOL、提取 bio
|
|
|
31
31
|
|
|
32
32
|
按顺序执行这些检查,决定本次 session 的动作:
|
|
33
33
|
|
|
34
|
-
1. **检测系统触发**:用户消息是否包含 `[KOL_INBOUND:{campaignId}]`
|
|
34
|
+
1. **检测系统触发**:用户消息是否包含 `[KOL_INBOUND:{campaignId}]` 或 `[KOL_INBOUND]`(无 campaignId)标记?
|
|
35
35
|
- 是 → 走「KOL 回信处理」流程(见下)
|
|
36
36
|
- 否 → 继续步骤 2
|
|
37
37
|
2. **检测首次使用**:`ls ~/kol-outreach/BRAND.md` 是否存在?
|
|
@@ -217,16 +217,27 @@ description: "KOL 建联全自动化:从 TikTok 发现潜在 KOL、提取 bio
|
|
|
217
217
|
|
|
218
218
|
---
|
|
219
219
|
|
|
220
|
-
## 流程 D:KOL 回信处理(由 `[KOL_INBOUND:{campaignId}]` 触发)
|
|
220
|
+
## 流程 D:KOL 回信处理(由 `[KOL_INBOUND:{campaignId}]` 或 `[KOL_INBOUND]` 触发)
|
|
221
221
|
|
|
222
|
-
1. 解析 prompt
|
|
222
|
+
1. 解析 prompt:
|
|
223
|
+
- 如果包含 `[KOL_INBOUND:{campaignId}]` → 直接知道 campaignId,同时抠 email metadata(From / Subject / Message-ID / In-Reply-To / References / Body)
|
|
224
|
+
- 如果包含 `[KOL_INBOUND]` + `merchant_id: {X}` → campaignId 未知,抠 merchant_id 和 email metadata;campaignId 在步骤 3 中搜索确定
|
|
225
|
+
(`merchant_id` 仅用于日志和审计。campaign 查找基于 `~/kol-outreach/campaigns/*/KOLS.md` 遍历,因为工作目录已隐含了 merchant 身份。)
|
|
223
226
|
|
|
224
|
-
2. 校验 campaign
|
|
227
|
+
2. 校验 campaign 存在(仅 campaignId 已知时):
|
|
225
228
|
- `ls ~/kol-outreach/campaigns/{campaignId}/CONFIG.md`
|
|
226
229
|
- 不存在 → 写 merchant PROGRESS.md 异常记录 + `sentinel report --condition-met false --summary "unknown campaign"` + 退出
|
|
227
230
|
|
|
228
|
-
3.
|
|
229
|
-
-
|
|
231
|
+
3. 按 from_email 查找 KOL:
|
|
232
|
+
- **campaignId 已知**(from step 1):只读该 campaign 的 KOLS.md
|
|
233
|
+
- 找到 → 继续步骤 4
|
|
234
|
+
- 找不到 → orphan_inbound:写 campaign PROGRESS.md + sentinel report false + 退出
|
|
235
|
+
- **campaignId 未知**(fallback):遍历 `~/kol-outreach/campaigns/*/KOLS.md`
|
|
236
|
+
- 唯一匹配 → 确定 campaignId,继续步骤 4
|
|
237
|
+
- 多个匹配 → 选 **KOLS.md 中该 email 对应条目的 `last_update` 字段**最新的那个 campaign
|
|
238
|
+
- 无匹配 → orphan_inbound:写 merchant PROGRESS.md + sentinel report false + 退出
|
|
239
|
+
|
|
240
|
+
※ 同一 campaign 内如果同一 email 出现多条记录(例如 KOL 换 username 后被重新添加),取 status 非终态(非 rejected / expired / no_contact)的活跃记录。
|
|
230
241
|
|
|
231
242
|
4. 读本 campaign 的 `CONFIG.md`、`BRAND.md`、`CONVERSATIONS/{username}.md`
|
|
232
243
|
|
|
@@ -90,7 +90,7 @@ save_workflow.py (Phase 5 — 可选,效果好时沉淀)
|
|
|
90
90
|
|
|
91
91
|
**仅做轻量元数据识别,不执行工具调用**。不下载视频、不调 ffprobe、不抽帧。
|
|
92
92
|
|
|
93
|
-
按需提问(不一次全问):产品是什么、替换目标、音频需求(有音频 ~$1,无音频 ~$0.02
|
|
93
|
+
按需提问(不一次全问):产品是什么、替换目标、音频需求(有音频 ~$1.50/10s,无音频 ~$0.02/10s,扣 Optima credits 由服务端中间件完成)、时长期望。
|
|
94
94
|
|
|
95
95
|
先查 `gen-output/video-clone/workflows/README.md` — 完全匹配 → 复用;部分匹配 → 调整;
|
|
96
96
|
无匹配 → 走通用 pipeline。详见 [workflow-system.md](references/workflow-system.md)。
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Video generation with audio — what the script handles + what you need to know
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
3
|
+
The `kling_generate.py` script calls the **Optima generation backend**, which
|
|
4
|
+
routes to Kling 3.0 internally. The script itself knows nothing about the
|
|
5
|
+
upstream provider — only the backend does.
|
|
5
6
|
|
|
6
7
|
```bash
|
|
7
8
|
python scripts/kling_generate.py --project <name> --frame <confirmed-frame.png>
|
|
@@ -9,44 +10,76 @@ python scripts/kling_generate.py --project <name> --frame <confirmed-frame.png>
|
|
|
9
10
|
# --cfg-scale 0.5 --no-audio
|
|
10
11
|
```
|
|
11
12
|
|
|
12
|
-
## When to use
|
|
13
|
+
## When to use `kling_generate.py` vs `gen_video.py`
|
|
13
14
|
|
|
14
15
|
| Need audio / lip sync? | Use |
|
|
15
16
|
|---|---|
|
|
16
|
-
| Yes | `kling_generate.py` (
|
|
17
|
-
| No | `gen_video.py` (
|
|
17
|
+
| Yes | `kling_generate.py` ($0.15/s ≈ $1.50 per 10s equivalent) |
|
|
18
|
+
| No | `gen_video.py` (~$0.02 per 10s equivalent) |
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
Both scripts go through the same generation backend and the same billing
|
|
21
|
+
middleware — the difference is server-side provider selection.
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
`500` which produces nonsense. The script uses `float(cfg)`.
|
|
23
|
-
- Status field is lowercase `"completed"` (not `"Completed"`).
|
|
24
|
-
- Output field differs by version: 3.0 returns `output.video`, 2.6 returns
|
|
25
|
-
`output.video_url`. Script hardcodes 3.0's `output.video`.
|
|
26
|
-
- `enable_audio` is 3.0-only — no-op on 2.6.
|
|
27
|
-
- PiAPI CDN drops large downloads; the script retries 3× with 5s backoff.
|
|
28
|
-
- Kling rejects base64 in `image_url`. The script uploads to
|
|
29
|
-
freeimage.host first to get a public URL. Do not switch to catbox.moe
|
|
30
|
-
(PiAPI's servers can't reach it).
|
|
23
|
+
## Auth + API URL discovery
|
|
31
24
|
|
|
32
|
-
|
|
25
|
+
`kling_generate.py` mirrors the `@optima-chat/gen-cli` auth convention, so
|
|
26
|
+
any user who has run `optima login` is automatically ready — no extra env
|
|
27
|
+
setup needed.
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
use a different prompt for a test run, edit `prompt.md` (it's versioned
|
|
36
|
-
via `log.md` in the project dir, so edits are recoverable).
|
|
29
|
+
Token resolution (first match wins):
|
|
37
30
|
|
|
38
|
-
|
|
31
|
+
1. `OPTIMA_TOKEN` env var
|
|
32
|
+
2. `~/.optima/token.json` (`access_token` field)
|
|
39
33
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
Generation API URL resolution (first match wins):
|
|
35
|
+
|
|
36
|
+
1. `GENERATION_API_URL` env var
|
|
37
|
+
2. `~/.optima/token.json` `env` field → `ci` / `stage` / `prod` mapping
|
|
38
|
+
3. default: `https://gen-api.optima.onl` (prod)
|
|
39
|
+
|
|
40
|
+
If neither token source resolves, the script exits 1 with a clear hint
|
|
41
|
+
(`Run `optima login` or set OPTIMA_TOKEN`). **No fallback to direct upstream
|
|
42
|
+
calls** — that would bypass billing.
|
|
43
|
+
|
|
44
|
+
Run `scripts/preflight.py` to verify the auth state and other deps.
|
|
45
|
+
|
|
46
|
+
The `requests` Python package is also required.
|
|
47
|
+
|
|
48
|
+
## Billing
|
|
49
|
+
|
|
50
|
+
Billing is entirely server-side. When `kling_generate.py` calls
|
|
51
|
+
`POST /api/video/generate`, the backend's billing middleware pre-deducts
|
|
52
|
+
credits from the user's Optima wallet based on `metadata.duration`. If the
|
|
53
|
+
upstream task later fails, the server refunds the credits automatically
|
|
54
|
+
(see `billingClient.refund()` in the generation service worker).
|
|
55
|
+
|
|
56
|
+
This means the skill never sees raw USD pricing — it just sends a request
|
|
57
|
+
and waits. The user's wallet is the source of truth for how much was spent.
|
|
58
|
+
|
|
59
|
+
## Error handling
|
|
60
|
+
|
|
61
|
+
`_submit()` translates the common billing errors into readable messages:
|
|
62
|
+
|
|
63
|
+
- HTTP 402 `INSUFFICIENT_CREDITS` — user does not have enough credits
|
|
64
|
+
- HTTP 403 `PLAN_RESTRICTED` — user's plan does not include video generation
|
|
65
|
+
|
|
66
|
+
Other HTTP errors propagate via `raise_for_status()`. Polling uses the
|
|
67
|
+
backend's unified `GET /api/task/{id}` endpoint.
|
|
68
|
+
|
|
69
|
+
## Non-obvious traps (now handled server-side)
|
|
70
|
+
|
|
71
|
+
The `cfg_scale`-must-be-float / lowercase-status / 3.0-vs-2.6 response
|
|
72
|
+
differences / freeimage-vs-catbox quirks are all handled by the server's
|
|
73
|
+
PiAPI adapter (`optima-gen: packages/generation/src/adapters/piapi-video.ts`).
|
|
74
|
+
The skill no longer has to know about them.
|
|
44
75
|
|
|
45
|
-
|
|
46
|
-
deliberately not a CLI flag because the same negatives apply to every run.
|
|
76
|
+
## What changed vs the old direct-PiAPI version
|
|
47
77
|
|
|
48
|
-
|
|
78
|
+
Before: script uploaded to freeimage.host, submitted to PiAPI, polled PiAPI,
|
|
79
|
+
downloaded from Kling CDN. No billing. Shared PiAPI key hardcoded in the
|
|
80
|
+
skill.
|
|
49
81
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
82
|
+
After: script sends one POST + polls one endpoint on the Optima backend.
|
|
83
|
+
Billing is automatic. No upstream API keys in the skill. Provider switches
|
|
84
|
+
(if the Kling API ever changes) happen server-side without touching any
|
|
85
|
+
client code.
|
|
@@ -1,21 +1,34 @@
|
|
|
1
|
-
"""Phase 3: generate video via
|
|
1
|
+
"""Phase 3: generate video with audio via the Optima generation backend.
|
|
2
|
+
|
|
3
|
+
The generation backend routes to Kling 3.0 (PiAPI) when provider="piapi".
|
|
4
|
+
Billing is automatic — the server's billing middleware deducts credits from
|
|
5
|
+
the user's Optima wallet at submit time and refunds on task failure.
|
|
2
6
|
|
|
3
7
|
Usage:
|
|
4
8
|
python scripts/kling_generate.py --project <name> --frame <path> \
|
|
5
9
|
[--duration 10] [--aspect-ratio 9:16] [--mode std] \
|
|
6
10
|
[--cfg-scale 0.5] [--no-audio]
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
Auth & API URL discovery (matches @optima-chat/gen-cli convention):
|
|
13
|
+
Token:
|
|
14
|
+
1. OPTIMA_TOKEN env var
|
|
15
|
+
2. ~/.optima/token.json ("access_token" field)
|
|
16
|
+
Generation API URL:
|
|
17
|
+
1. GENERATION_API_URL env var
|
|
18
|
+
2. ~/.optima/token.json ("env" field) → ci/stage/prod mapping
|
|
19
|
+
3. default: prod (https://gen-api.optima.onl)
|
|
20
|
+
|
|
21
|
+
A logged-in user (`optima login`) has both automatically; no server-side
|
|
22
|
+
env injection is required.
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
Pipeline:
|
|
25
|
+
1. Gate: requires preview_confirmed.
|
|
26
|
+
2. POST <GEN_URL>/api/video/generate with provider="piapi" + base64 frame.
|
|
27
|
+
Server-side billing middleware pre-deducts credits here.
|
|
28
|
+
3. Poll GET <GEN_URL>/api/task/{id} every 10s until completed/failed.
|
|
29
|
+
4. Download result_url to <project>/videos/ (3-attempt retry).
|
|
17
30
|
|
|
18
|
-
|
|
31
|
+
Reads prompt from <project>/prompt.md. Writes output with a versioned filename.
|
|
19
32
|
"""
|
|
20
33
|
from __future__ import annotations
|
|
21
34
|
|
|
@@ -30,82 +43,143 @@ from pathlib import Path
|
|
|
30
43
|
from _gate import require_gate
|
|
31
44
|
from _project import resolve_project, next_version, append_log
|
|
32
45
|
|
|
33
|
-
# Lazy import of requests so py_compile / gate checks work without the dep.
|
|
34
46
|
try:
|
|
35
47
|
import requests # type: ignore
|
|
36
48
|
except ImportError: # pragma: no cover
|
|
37
49
|
requests = None
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"slow motion, dreamy, ethereal, cinematic, blurry, "
|
|
44
|
-
"distorted, deformed hands, extra fingers"
|
|
45
|
-
)
|
|
51
|
+
POLL_INTERVAL_S = 10
|
|
52
|
+
POLL_TIMEOUT_S = 15 * 60 # 15 min (server side uses 10 min, leave 5 min headroom)
|
|
53
|
+
DOWNLOAD_RETRIES = 3
|
|
54
|
+
DOWNLOAD_BACKOFF_S = 5
|
|
46
55
|
|
|
56
|
+
TOKEN_FILE = Path.home() / ".optima" / "token.json"
|
|
57
|
+
API_URLS = {
|
|
58
|
+
"prod": "https://gen-api.optima.onl",
|
|
59
|
+
"stage": "https://gen-api.stage.optima.onl",
|
|
60
|
+
}
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
|
|
63
|
+
def _load_token_file() -> dict | None:
|
|
64
|
+
"""Read ~/.optima/token.json if present. Returns parsed dict or None."""
|
|
65
|
+
if not TOKEN_FILE.is_file():
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
|
|
69
|
+
except (OSError, json.JSONDecodeError):
|
|
70
|
+
return None
|
|
71
|
+
if not data.get("access_token"):
|
|
72
|
+
return None
|
|
73
|
+
return data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_token() -> str:
|
|
77
|
+
"""Resolve the Optima auth token.
|
|
78
|
+
|
|
79
|
+
Order matches @optima-chat/gen-cli: env var > token.json. Fail fast with
|
|
80
|
+
a login hint if neither is available.
|
|
81
|
+
"""
|
|
82
|
+
env_token = os.environ.get("OPTIMA_TOKEN", "").strip()
|
|
83
|
+
if env_token:
|
|
84
|
+
return env_token
|
|
85
|
+
data = _load_token_file()
|
|
86
|
+
if data:
|
|
87
|
+
return data["access_token"]
|
|
88
|
+
print(
|
|
89
|
+
"ERROR: Optima token not found. Set OPTIMA_TOKEN or run `optima login`.",
|
|
90
|
+
file=sys.stderr,
|
|
55
91
|
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return data["image"]["url"]
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
59
94
|
|
|
95
|
+
def _get_gen_api_url() -> str:
|
|
96
|
+
"""Resolve the generation service base URL.
|
|
60
97
|
|
|
61
|
-
|
|
98
|
+
Order matches @optima-chat/gen-cli: GENERATION_API_URL > token.json env
|
|
99
|
+
field > prod default.
|
|
100
|
+
"""
|
|
101
|
+
override = os.environ.get("GENERATION_API_URL", "").strip()
|
|
102
|
+
if override:
|
|
103
|
+
return override.rstrip("/")
|
|
104
|
+
data = _load_token_file()
|
|
105
|
+
env = (data or {}).get("env") or "prod"
|
|
106
|
+
return API_URLS.get(env, API_URLS["prod"])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _auth_header(token: str) -> str:
|
|
110
|
+
"""Accept either 'Bearer xxx' or a raw token; normalize to Bearer form."""
|
|
111
|
+
return token if token.lower().startswith("bearer ") else f"Bearer {token}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _submit(gen_url: str, auth: str, prompt: str, frame_b64: str,
|
|
62
115
|
duration: int, aspect: str, mode: str,
|
|
63
116
|
cfg: float, audio: bool) -> str:
|
|
117
|
+
"""POST /api/video/generate with provider='piapi'. Returns backend task_id.
|
|
118
|
+
|
|
119
|
+
On billing failure the server returns 402 (INSUFFICIENT_CREDITS) or
|
|
120
|
+
403 (PLAN_RESTRICTED); both are surfaced as actionable errors.
|
|
121
|
+
"""
|
|
64
122
|
payload = {
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"mode": mode,
|
|
74
|
-
"version": "3.0",
|
|
75
|
-
"cfg_scale": float(cfg), # MUST be float
|
|
76
|
-
"enable_audio": audio,
|
|
77
|
-
},
|
|
78
|
-
"config": {"service_mode": "public"},
|
|
123
|
+
"provider": "piapi",
|
|
124
|
+
"image": frame_b64,
|
|
125
|
+
"prompt": prompt,
|
|
126
|
+
"duration": duration,
|
|
127
|
+
"aspect_ratio": aspect,
|
|
128
|
+
"mode": mode,
|
|
129
|
+
"cfg_scale": float(cfg),
|
|
130
|
+
"enable_audio": audio,
|
|
79
131
|
}
|
|
80
132
|
r = requests.post(
|
|
81
|
-
f"{
|
|
82
|
-
headers={"
|
|
133
|
+
f"{gen_url}/api/video/generate",
|
|
134
|
+
headers={"Authorization": auth, "Content-Type": "application/json"},
|
|
83
135
|
json=payload, timeout=60,
|
|
84
136
|
)
|
|
137
|
+
if r.status_code == 402:
|
|
138
|
+
raise RuntimeError(f"INSUFFICIENT_CREDITS: {r.json().get('error', {}).get('message', 'not enough credits')}")
|
|
139
|
+
if r.status_code == 403:
|
|
140
|
+
raise RuntimeError(f"PLAN_RESTRICTED: {r.json().get('error', {}).get('message', 'plan does not include video generation')}")
|
|
85
141
|
r.raise_for_status()
|
|
86
|
-
return r.json()["
|
|
142
|
+
return r.json()["task_id"]
|
|
143
|
+
|
|
87
144
|
|
|
145
|
+
def _poll(gen_url: str, auth: str, task_id: str) -> tuple[str, dict]:
|
|
146
|
+
"""Poll /api/task/{id} until completed or failed. Returns (result_url, result_meta).
|
|
88
147
|
|
|
89
|
-
|
|
148
|
+
On client-side timeout the task may still complete server-side (credits
|
|
149
|
+
already deducted). The task_id is included in the timeout message so the
|
|
150
|
+
caller can re-check later via GET /api/task/<id>.
|
|
151
|
+
"""
|
|
152
|
+
start = time.monotonic()
|
|
90
153
|
while True:
|
|
154
|
+
if time.monotonic() - start > POLL_TIMEOUT_S:
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
f"polling timed out after {POLL_TIMEOUT_S}s (task_id={task_id}). "
|
|
157
|
+
f"Task may still complete server-side — query "
|
|
158
|
+
f"GET /api/task/{task_id} to check status and retrieve the result."
|
|
159
|
+
)
|
|
91
160
|
r = requests.get(
|
|
92
|
-
f"{
|
|
93
|
-
headers={"
|
|
161
|
+
f"{gen_url}/api/task/{task_id}",
|
|
162
|
+
headers={"Authorization": auth}, timeout=60,
|
|
94
163
|
)
|
|
95
164
|
r.raise_for_status()
|
|
96
|
-
d = r.json()
|
|
165
|
+
d = r.json()
|
|
97
166
|
status = d.get("status", "")
|
|
98
167
|
if status == "completed":
|
|
99
|
-
|
|
168
|
+
url = d.get("result_url")
|
|
169
|
+
if not url:
|
|
170
|
+
raise RuntimeError(
|
|
171
|
+
f"task {task_id} completed but result_url missing from response"
|
|
172
|
+
)
|
|
173
|
+
return url, d.get("result_meta") or {}
|
|
100
174
|
if status == "failed":
|
|
101
|
-
raise RuntimeError(d.get("error"
|
|
102
|
-
print(f" status: {status} — polling again in
|
|
103
|
-
time.sleep(
|
|
175
|
+
raise RuntimeError(d.get("error_message") or f"task {task_id} failed without error message")
|
|
176
|
+
print(f" status: {status} — polling again in {POLL_INTERVAL_S}s")
|
|
177
|
+
time.sleep(POLL_INTERVAL_S)
|
|
104
178
|
|
|
105
179
|
|
|
106
180
|
def _download(url: str, out: Path) -> None:
|
|
107
181
|
last_err = None
|
|
108
|
-
for attempt in range(
|
|
182
|
+
for attempt in range(DOWNLOAD_RETRIES):
|
|
109
183
|
try:
|
|
110
184
|
r = requests.get(url, timeout=300)
|
|
111
185
|
r.raise_for_status()
|
|
@@ -113,9 +187,9 @@ def _download(url: str, out: Path) -> None:
|
|
|
113
187
|
return
|
|
114
188
|
except Exception as e:
|
|
115
189
|
last_err = e
|
|
116
|
-
print(f" download attempt {attempt + 1}/
|
|
117
|
-
time.sleep(
|
|
118
|
-
raise RuntimeError(f"download failed after
|
|
190
|
+
print(f" download attempt {attempt + 1}/{DOWNLOAD_RETRIES} failed: {e}")
|
|
191
|
+
time.sleep(DOWNLOAD_BACKOFF_S)
|
|
192
|
+
raise RuntimeError(f"download failed after {DOWNLOAD_RETRIES} attempts: {last_err}")
|
|
119
193
|
|
|
120
194
|
|
|
121
195
|
def main() -> int:
|
|
@@ -137,10 +211,8 @@ def main() -> int:
|
|
|
137
211
|
file=sys.stderr)
|
|
138
212
|
return 1
|
|
139
213
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
print("ERROR: PIAPI_KEY env var not set. See preflight.py.", file=sys.stderr)
|
|
143
|
-
return 1
|
|
214
|
+
gen_url = _get_gen_api_url()
|
|
215
|
+
auth = _auth_header(_get_token())
|
|
144
216
|
|
|
145
217
|
frame = Path(args.frame)
|
|
146
218
|
if not frame.is_file():
|
|
@@ -153,28 +225,36 @@ def main() -> int:
|
|
|
153
225
|
return 1
|
|
154
226
|
prompt = prompt_path.read_text(encoding="utf-8").strip()
|
|
155
227
|
|
|
156
|
-
|
|
157
|
-
image_url = _upload_frame(frame)
|
|
158
|
-
print(f" image_url: {image_url}")
|
|
228
|
+
frame_b64 = base64.b64encode(frame.read_bytes()).decode()
|
|
159
229
|
|
|
160
230
|
print("Submitting video generation task …")
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
231
|
+
try:
|
|
232
|
+
task_id = _submit(
|
|
233
|
+
gen_url, auth, prompt, frame_b64,
|
|
234
|
+
duration=args.duration, aspect=args.aspect_ratio, mode=args.mode,
|
|
235
|
+
cfg=args.cfg_scale, audio=not args.no_audio,
|
|
236
|
+
)
|
|
237
|
+
except RuntimeError as e:
|
|
238
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
239
|
+
return 1
|
|
166
240
|
print(f" task_id: {task_id}")
|
|
167
241
|
|
|
168
242
|
print("Polling for completion …")
|
|
169
|
-
|
|
170
|
-
|
|
243
|
+
try:
|
|
244
|
+
result_url, result_meta = _poll(gen_url, auth, task_id)
|
|
245
|
+
except RuntimeError as e:
|
|
246
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
247
|
+
return 1
|
|
248
|
+
print(f" result_url: {result_url}")
|
|
249
|
+
if result_meta:
|
|
250
|
+
print(f" result_meta: {result_meta}")
|
|
171
251
|
|
|
172
252
|
out = next_version(project_dir / "videos", f"video_{args.duration}s_", ".mp4")
|
|
173
253
|
print(f"Downloading → {out}")
|
|
174
|
-
_download(
|
|
254
|
+
_download(result_url, out)
|
|
175
255
|
|
|
176
256
|
print(f"\nDone: {out}")
|
|
177
|
-
append_log(project_dir, f"video_generated_with_audio → {out.name}")
|
|
257
|
+
append_log(project_dir, f"video_generated_with_audio → {out.name} (task {task_id})")
|
|
178
258
|
return 0
|
|
179
259
|
|
|
180
260
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Tests for the pure helper functions in kling_generate.py.
|
|
2
|
+
|
|
3
|
+
No network. We do not exercise _submit / _poll / _download — those are
|
|
4
|
+
I/O-bound and covered by end-to-end staging runs. Unit tests here keep the
|
|
5
|
+
helper contracts honest so refactors don't silently break the auth-header
|
|
6
|
+
normalization or the token / API-URL discovery order.
|
|
7
|
+
|
|
8
|
+
Run: python scripts/kling_generate_test.py
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from unittest import mock
|
|
19
|
+
|
|
20
|
+
SCRIPTS_DIR = Path(__file__).parent
|
|
21
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
22
|
+
|
|
23
|
+
import kling_generate # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------- _auth_header ----------
|
|
27
|
+
|
|
28
|
+
def test_auth_header_adds_bearer_prefix():
|
|
29
|
+
assert kling_generate._auth_header("xyz123") == "Bearer xyz123"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_auth_header_preserves_existing_bearer():
|
|
33
|
+
assert kling_generate._auth_header("Bearer xyz123") == "Bearer xyz123"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_auth_header_is_case_insensitive_on_existing_prefix():
|
|
37
|
+
assert kling_generate._auth_header("bearer xyz123") == "bearer xyz123"
|
|
38
|
+
assert kling_generate._auth_header("BEARER xyz123") == "BEARER xyz123"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------- _get_token ----------
|
|
42
|
+
|
|
43
|
+
def test_get_token_prefers_env_var(tmp_token_file):
|
|
44
|
+
tmp_token_file.write_text(
|
|
45
|
+
json.dumps({"access_token": "from-file", "env": "prod"}), encoding="utf-8"
|
|
46
|
+
)
|
|
47
|
+
os.environ["OPTIMA_TOKEN"] = "from-env"
|
|
48
|
+
try:
|
|
49
|
+
assert kling_generate._get_token() == "from-env"
|
|
50
|
+
finally:
|
|
51
|
+
os.environ.pop("OPTIMA_TOKEN", None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_get_token_falls_back_to_file(tmp_token_file):
|
|
55
|
+
os.environ.pop("OPTIMA_TOKEN", None)
|
|
56
|
+
tmp_token_file.write_text(
|
|
57
|
+
json.dumps({"access_token": "file-xyz", "env": "stage"}), encoding="utf-8"
|
|
58
|
+
)
|
|
59
|
+
assert kling_generate._get_token() == "file-xyz"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_get_token_exits_when_neither_source_has_token(tmp_token_file):
|
|
63
|
+
os.environ.pop("OPTIMA_TOKEN", None)
|
|
64
|
+
# tmp_token_file does not exist (fixture creates parent, caller decides)
|
|
65
|
+
# Here: don't create the file — _load_token_file should return None
|
|
66
|
+
code = (
|
|
67
|
+
"import sys; sys.path.insert(0, r'{d}'); import kling_generate; "
|
|
68
|
+
"kling_generate._get_token()"
|
|
69
|
+
).format(d=str(SCRIPTS_DIR))
|
|
70
|
+
env = {**os.environ}
|
|
71
|
+
env.pop("OPTIMA_TOKEN", None)
|
|
72
|
+
env["HOME"] = str(tmp_token_file.parent.parent) # .../.optima/token.json → HOME = .../
|
|
73
|
+
env["USERPROFILE"] = env["HOME"]
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
[sys.executable, "-c", code], capture_output=True, text=True, env=env,
|
|
76
|
+
)
|
|
77
|
+
assert result.returncode == 1
|
|
78
|
+
assert "optima login" in result.stderr or "OPTIMA_TOKEN" in result.stderr
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------- _get_gen_api_url ----------
|
|
82
|
+
|
|
83
|
+
def test_get_gen_api_url_env_override_wins(tmp_token_file):
|
|
84
|
+
tmp_token_file.write_text(
|
|
85
|
+
json.dumps({"access_token": "t", "env": "stage"}), encoding="utf-8"
|
|
86
|
+
)
|
|
87
|
+
os.environ["GENERATION_API_URL"] = "https://custom.example/api"
|
|
88
|
+
try:
|
|
89
|
+
assert kling_generate._get_gen_api_url() == "https://custom.example/api"
|
|
90
|
+
finally:
|
|
91
|
+
os.environ.pop("GENERATION_API_URL", None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_get_gen_api_url_trims_trailing_slash():
|
|
95
|
+
os.environ["GENERATION_API_URL"] = "https://custom.example/"
|
|
96
|
+
try:
|
|
97
|
+
assert kling_generate._get_gen_api_url() == "https://custom.example"
|
|
98
|
+
finally:
|
|
99
|
+
os.environ.pop("GENERATION_API_URL", None)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_get_gen_api_url_reads_env_from_token_file(tmp_token_file):
|
|
103
|
+
os.environ.pop("GENERATION_API_URL", None)
|
|
104
|
+
tmp_token_file.write_text(
|
|
105
|
+
json.dumps({"access_token": "t", "env": "stage"}), encoding="utf-8"
|
|
106
|
+
)
|
|
107
|
+
assert kling_generate._get_gen_api_url() == "https://gen-api.stage.optima.onl"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_get_gen_api_url_defaults_to_prod_when_no_file(tmp_token_file):
|
|
111
|
+
os.environ.pop("GENERATION_API_URL", None)
|
|
112
|
+
# tmp_token_file not created → should default to prod
|
|
113
|
+
assert kling_generate._get_gen_api_url() == "https://gen-api.optima.onl"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------- fixture ----------
|
|
117
|
+
|
|
118
|
+
class _TmpToken:
|
|
119
|
+
"""Manage a throwaway ~/.optima/token.json by monkey-patching the module constant."""
|
|
120
|
+
def __init__(self):
|
|
121
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
122
|
+
self.path = Path(self.tmp.name) / ".optima" / "token.json"
|
|
123
|
+
self.path.parent.mkdir(parents=True)
|
|
124
|
+
self._orig = kling_generate.TOKEN_FILE
|
|
125
|
+
kling_generate.TOKEN_FILE = self.path
|
|
126
|
+
|
|
127
|
+
def write_text(self, *a, **kw):
|
|
128
|
+
self.path.write_text(*a, **kw)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def parent(self):
|
|
132
|
+
return self.path.parent
|
|
133
|
+
|
|
134
|
+
def cleanup(self):
|
|
135
|
+
kling_generate.TOKEN_FILE = self._orig
|
|
136
|
+
self.tmp.cleanup()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _with_tmp_token_file(fn):
|
|
140
|
+
"""Decorator-ish: pass a fresh _TmpToken to the test, then clean up."""
|
|
141
|
+
def wrapped():
|
|
142
|
+
h = _TmpToken()
|
|
143
|
+
try:
|
|
144
|
+
return fn(h)
|
|
145
|
+
finally:
|
|
146
|
+
h.cleanup()
|
|
147
|
+
return wrapped
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Rebind each test to wrap with the fixture
|
|
151
|
+
test_get_token_prefers_env_var = _with_tmp_token_file(test_get_token_prefers_env_var)
|
|
152
|
+
test_get_token_falls_back_to_file = _with_tmp_token_file(test_get_token_falls_back_to_file)
|
|
153
|
+
test_get_token_exits_when_neither_source_has_token = _with_tmp_token_file(test_get_token_exits_when_neither_source_has_token)
|
|
154
|
+
test_get_gen_api_url_env_override_wins = _with_tmp_token_file(test_get_gen_api_url_env_override_wins)
|
|
155
|
+
test_get_gen_api_url_reads_env_from_token_file = _with_tmp_token_file(test_get_gen_api_url_reads_env_from_token_file)
|
|
156
|
+
test_get_gen_api_url_defaults_to_prod_when_no_file = _with_tmp_token_file(test_get_gen_api_url_defaults_to_prod_when_no_file)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
TESTS = [
|
|
160
|
+
("auth_header adds Bearer prefix", test_auth_header_adds_bearer_prefix),
|
|
161
|
+
("auth_header preserves Bearer", test_auth_header_preserves_existing_bearer),
|
|
162
|
+
("auth_header case-insensitive on prefix", test_auth_header_is_case_insensitive_on_existing_prefix),
|
|
163
|
+
("get_token: env wins over file", test_get_token_prefers_env_var),
|
|
164
|
+
("get_token: falls back to token.json", test_get_token_falls_back_to_file),
|
|
165
|
+
("get_token: exits 1 when neither source", test_get_token_exits_when_neither_source_has_token),
|
|
166
|
+
("get_gen_api_url: env override wins", test_get_gen_api_url_env_override_wins),
|
|
167
|
+
("get_gen_api_url: trims trailing slash", test_get_gen_api_url_trims_trailing_slash),
|
|
168
|
+
("get_gen_api_url: reads env from token.json", test_get_gen_api_url_reads_env_from_token_file),
|
|
169
|
+
("get_gen_api_url: defaults to prod", test_get_gen_api_url_defaults_to_prod_when_no_file),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main() -> int:
|
|
174
|
+
failed = 0
|
|
175
|
+
for name, fn in TESTS:
|
|
176
|
+
try:
|
|
177
|
+
fn()
|
|
178
|
+
print(f"PASS {name}")
|
|
179
|
+
except AssertionError as e:
|
|
180
|
+
failed += 1
|
|
181
|
+
print(f"FAIL {name}: {e}")
|
|
182
|
+
except Exception as e:
|
|
183
|
+
failed += 1
|
|
184
|
+
print(f"ERROR {name}: {type(e).__name__}: {e}")
|
|
185
|
+
print()
|
|
186
|
+
print(f"{len(TESTS) - failed}/{len(TESTS)} passed")
|
|
187
|
+
return 0 if failed == 0 else 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
sys.exit(main())
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Pre-flight environment check for the video-clone skill.
|
|
2
2
|
|
|
3
3
|
Verifies the external dependencies the executor scripts will need:
|
|
4
|
-
- `gen` CLI
|
|
4
|
+
- `gen` CLI — gen image / gen video (routed through generation backend)
|
|
5
5
|
- `ffmpeg` / `ffprobe`
|
|
6
|
-
-
|
|
6
|
+
- Optima auth — either OPTIMA_TOKEN env var or ~/.optima/token.json
|
|
7
|
+
(matches @optima-chat/gen-cli convention)
|
|
7
8
|
- Python >= 3.10
|
|
8
9
|
|
|
9
10
|
Exits 0 if all checks pass, 1 otherwise. On failure, prints a human-readable
|
|
@@ -14,10 +15,14 @@ No state, no side effects.
|
|
|
14
15
|
"""
|
|
15
16
|
from __future__ import annotations
|
|
16
17
|
|
|
18
|
+
import json
|
|
17
19
|
import os
|
|
18
20
|
import shutil
|
|
19
21
|
import subprocess
|
|
20
22
|
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
TOKEN_FILE = Path.home() / ".optima" / "token.json"
|
|
21
26
|
|
|
22
27
|
|
|
23
28
|
def _check_binary(name: str, fix_hint: str) -> tuple[bool, str]:
|
|
@@ -35,17 +40,22 @@ def _check_binary(name: str, fix_hint: str) -> tuple[bool, str]:
|
|
|
35
40
|
return True, f"{name}: OK ({path}) {head}".strip()
|
|
36
41
|
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
def _check_optima_auth() -> tuple[bool, str]:
|
|
44
|
+
"""Token comes from OPTIMA_TOKEN env var OR ~/.optima/token.json (gen-cli convention)."""
|
|
45
|
+
if os.environ.get("OPTIMA_TOKEN"):
|
|
46
|
+
return True, "Optima token: OK (source=env OPTIMA_TOKEN)"
|
|
47
|
+
if TOKEN_FILE.is_file():
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
|
|
50
|
+
if data.get("access_token"):
|
|
51
|
+
env = data.get("env", "prod")
|
|
52
|
+
return True, f"Optima token: OK (source={TOKEN_FILE}, env={env})"
|
|
53
|
+
except (OSError, json.JSONDecodeError):
|
|
54
|
+
pass
|
|
55
|
+
return (
|
|
56
|
+
False,
|
|
57
|
+
f"Optima token: NOT FOUND. Run `optima login` or set OPTIMA_TOKEN env var.",
|
|
58
|
+
)
|
|
49
59
|
|
|
50
60
|
|
|
51
61
|
def _check_python() -> tuple[bool, str]:
|
|
@@ -65,10 +75,7 @@ CHECKS = [
|
|
|
65
75
|
),
|
|
66
76
|
lambda: _check_binary("ffmpeg", "Install ffmpeg and ensure it is on PATH."),
|
|
67
77
|
lambda: _check_binary("ffprobe", "Install ffmpeg (bundles ffprobe)."),
|
|
68
|
-
lambda:
|
|
69
|
-
"PIAPI_KEY",
|
|
70
|
-
"Required for Kling 3.0 via PiAPI. Set with: export PIAPI_KEY=sk-...",
|
|
71
|
-
),
|
|
78
|
+
lambda: _check_optima_auth(),
|
|
72
79
|
]
|
|
73
80
|
|
|
74
81
|
|