@mmmbuto/nexuscrew 0.8.23 → 0.8.25
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/CHANGELOG.md +744 -0
- package/README.md +18 -6
- package/frontend/dist/assets/index-Dov6E_p3.js +91 -0
- package/frontend/dist/assets/index-DqG-mDnV.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +16 -2
- package/lib/fleet/catalogs/alibaba-token-plan-qwen3.8.json +51 -0
- package/lib/fleet/managed.js +106 -10
- package/lib/fleet/routes.js +4 -1
- package/lib/nodes/store.js +40 -1
- package/lib/settings/pairing-coordinator.js +22 -1
- package/lib/settings/public-peering-routes.js +11 -2
- package/lib/settings/routes.js +14 -5
- package/package.json +3 -2
- package/skills/alibaba-token-media/SKILL.md +133 -0
- package/skills/alibaba-token-media/agents/openai.yaml +4 -0
- package/skills/alibaba-token-media/references/api-contract.md +97 -0
- package/skills/alibaba-token-media/scripts/alibaba_token_media.py +550 -0
- package/frontend/dist/assets/index-77r8nzQf.css +0 -32
- package/frontend/dist/assets/index-C0QL3gEp.js +0 -91
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Safe multi-client CLI for Alibaba Token Plan media generation.
|
|
3
|
+
|
|
4
|
+
The script is dependency-free and can be invoked by Claude Code, Codex,
|
|
5
|
+
Codex-VL, or Pi. Credentials are read only from ALIBABA_CODE_API_KEY and are
|
|
6
|
+
never printed or persisted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import base64
|
|
13
|
+
import contextlib
|
|
14
|
+
import datetime as dt
|
|
15
|
+
import fcntl
|
|
16
|
+
import ipaddress
|
|
17
|
+
import json
|
|
18
|
+
import mimetypes
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import re
|
|
22
|
+
import secrets
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
from typing import Any, Iterator
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.parse
|
|
28
|
+
import urllib.request
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
HOST = "https://token-plan.ap-southeast-1.maas.aliyuncs.com"
|
|
32
|
+
IMAGE_URL = HOST + "/api/v1/services/aigc/multimodal-generation/generation"
|
|
33
|
+
VIDEO_URL = HOST + "/api/v1/services/aigc/video-generation/video-synthesis"
|
|
34
|
+
TASK_URL = HOST + "/api/v1/tasks/{task_id}"
|
|
35
|
+
KEY_ENV = "ALIBABA_CODE_API_KEY"
|
|
36
|
+
|
|
37
|
+
IMAGE_MODELS = ("wan2.7-image", "wan2.7-image-pro")
|
|
38
|
+
VIDEO_MODELS = ("happyhorse-1.1-t2v", "happyhorse-1.1-i2v")
|
|
39
|
+
VIDEO_RATIOS = ("16:9", "9:16", "1:1", "4:3", "3:4", "4:5", "5:4", "9:21", "21:9")
|
|
40
|
+
IMAGE_MIME = {
|
|
41
|
+
".jpg": "image/jpeg",
|
|
42
|
+
".jpeg": "image/jpeg",
|
|
43
|
+
".png": "image/png",
|
|
44
|
+
".bmp": "image/bmp",
|
|
45
|
+
".webp": "image/webp",
|
|
46
|
+
}
|
|
47
|
+
I2V_MIME = {key: value for key, value in IMAGE_MIME.items() if key != ".bmp"}
|
|
48
|
+
MAX_INPUT_BYTES = 20 * 1024 * 1024
|
|
49
|
+
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
|
50
|
+
MAX_IMAGE_DOWNLOAD = 64 * 1024 * 1024
|
|
51
|
+
MAX_VIDEO_DOWNLOAD = 1024 * 1024 * 1024
|
|
52
|
+
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UsageError(RuntimeError):
|
|
56
|
+
"""A safe, user-actionable validation error."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _emit(value: Any) -> None:
|
|
60
|
+
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _require_key() -> str:
|
|
64
|
+
key = os.environ.get(KEY_ENV, "")
|
|
65
|
+
if not key:
|
|
66
|
+
raise UsageError(f"{KEY_ENV} is not set in this process environment")
|
|
67
|
+
if any(char.isspace() for char in key):
|
|
68
|
+
raise UsageError(f"{KEY_ENV} contains whitespace and was rejected")
|
|
69
|
+
return key
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _weighted_prompt_length(prompt: str) -> int:
|
|
73
|
+
# HappyHorse documents 5,000 non-Chinese or 2,500 Chinese characters.
|
|
74
|
+
return sum(2 if "\u3400" <= char <= "\u9fff" else 1 for char in prompt)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_prompt(prompt: str, *, video: bool = False, optional: bool = False) -> str:
|
|
78
|
+
prompt = prompt.strip()
|
|
79
|
+
if not prompt and not optional:
|
|
80
|
+
raise UsageError("prompt must not be empty")
|
|
81
|
+
if video:
|
|
82
|
+
if _weighted_prompt_length(prompt) > 5000:
|
|
83
|
+
raise UsageError("video prompt exceeds the documented weighted limit")
|
|
84
|
+
elif len(prompt) > 5000:
|
|
85
|
+
raise UsageError("image prompt exceeds 5,000 characters")
|
|
86
|
+
return prompt
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _inside_home(path: Path) -> bool:
|
|
90
|
+
home = Path.home().resolve()
|
|
91
|
+
try:
|
|
92
|
+
path.relative_to(home)
|
|
93
|
+
return True
|
|
94
|
+
except ValueError:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _reject_symlink_components(path: Path) -> None:
|
|
99
|
+
absolute = path.expanduser().absolute()
|
|
100
|
+
current = Path(absolute.anchor)
|
|
101
|
+
for part in absolute.parts[1:]:
|
|
102
|
+
current = current / part
|
|
103
|
+
if current.is_symlink():
|
|
104
|
+
raise UsageError(f"symlink input path rejected: {path}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@contextlib.contextmanager
|
|
108
|
+
def _submit_lock() -> Iterator[None]:
|
|
109
|
+
"""Permit one generation POST per local user across CLI processes."""
|
|
110
|
+
directory = Path.home() / ".cache" / "alibaba-token-media"
|
|
111
|
+
_reject_symlink_components(directory)
|
|
112
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
113
|
+
directory = directory.resolve(strict=True)
|
|
114
|
+
if not _inside_home(directory):
|
|
115
|
+
raise UsageError("submit lock directory escaped the current user's home")
|
|
116
|
+
os.chmod(directory, 0o700)
|
|
117
|
+
lock_path = directory / "submit.lock"
|
|
118
|
+
flags = os.O_RDWR | os.O_CREAT
|
|
119
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
120
|
+
flags |= os.O_NOFOLLOW
|
|
121
|
+
try:
|
|
122
|
+
fd = os.open(lock_path, flags, 0o600)
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
raise UsageError(f"cannot open private submit lock: {exc}") from None
|
|
125
|
+
locked = False
|
|
126
|
+
try:
|
|
127
|
+
os.fchmod(fd, 0o600)
|
|
128
|
+
try:
|
|
129
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
130
|
+
locked = True
|
|
131
|
+
except BlockingIOError:
|
|
132
|
+
raise UsageError("another media generation submit is already in progress") from None
|
|
133
|
+
yield
|
|
134
|
+
finally:
|
|
135
|
+
if locked:
|
|
136
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
137
|
+
os.close(fd)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _detect_image_mime(path: Path, allowed: dict[str, str]) -> str:
|
|
141
|
+
suffix = path.suffix.lower()
|
|
142
|
+
mime = allowed.get(suffix)
|
|
143
|
+
if not mime:
|
|
144
|
+
raise UsageError(f"unsupported image extension: {suffix or '[none]'}")
|
|
145
|
+
with path.open("rb") as handle:
|
|
146
|
+
head = handle.read(16)
|
|
147
|
+
valid = {
|
|
148
|
+
"image/jpeg": head.startswith(b"\xff\xd8\xff"),
|
|
149
|
+
"image/png": head.startswith(b"\x89PNG\r\n\x1a\n"),
|
|
150
|
+
"image/bmp": head.startswith(b"BM"),
|
|
151
|
+
"image/webp": head.startswith(b"RIFF") and head[8:12] == b"WEBP",
|
|
152
|
+
}[mime]
|
|
153
|
+
if not valid:
|
|
154
|
+
raise UsageError(f"file signature does not match {suffix}: {path}")
|
|
155
|
+
return mime
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _local_image_data(raw: str, *, i2v: bool = False) -> str:
|
|
159
|
+
supplied = Path(raw).expanduser()
|
|
160
|
+
_reject_symlink_components(supplied)
|
|
161
|
+
path = supplied.resolve(strict=True)
|
|
162
|
+
if not _inside_home(path):
|
|
163
|
+
raise UsageError("local image must be under the current user's home")
|
|
164
|
+
if not path.is_file():
|
|
165
|
+
raise UsageError(f"local image is not a regular file: {path}")
|
|
166
|
+
size = path.stat().st_size
|
|
167
|
+
if size <= 0 or size > MAX_INPUT_BYTES:
|
|
168
|
+
raise UsageError("local image must be between 1 byte and 20 MiB")
|
|
169
|
+
mime = _detect_image_mime(path, I2V_MIME if i2v else IMAGE_MIME)
|
|
170
|
+
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
171
|
+
return f"data:{mime};base64,{encoded}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _public_https_url(raw: str) -> str:
|
|
175
|
+
parsed = urllib.parse.urlsplit(raw)
|
|
176
|
+
if parsed.scheme != "https" or not parsed.hostname:
|
|
177
|
+
raise UsageError("remote image must use a public HTTPS URL")
|
|
178
|
+
if parsed.username or parsed.password or parsed.port not in (None, 443):
|
|
179
|
+
raise UsageError("remote image URL contains forbidden authority fields")
|
|
180
|
+
host = parsed.hostname.rstrip(".").lower()
|
|
181
|
+
if host == "localhost" or host.endswith((".localhost", ".local", ".internal")):
|
|
182
|
+
raise UsageError("local or internal remote-image host rejected")
|
|
183
|
+
try:
|
|
184
|
+
address = ipaddress.ip_address(host)
|
|
185
|
+
except ValueError:
|
|
186
|
+
address = None
|
|
187
|
+
if address and not address.is_global:
|
|
188
|
+
raise UsageError("non-public remote-image IP rejected")
|
|
189
|
+
return raw
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _image_value(raw: str, *, i2v: bool = False) -> str:
|
|
193
|
+
if raw.startswith(("http://", "https://")):
|
|
194
|
+
return _public_https_url(raw)
|
|
195
|
+
return _local_image_data(raw, i2v=i2v)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _safe_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
199
|
+
clone = json.loads(json.dumps(payload))
|
|
200
|
+
content = clone.get("input", {}).get("messages", [{}])[0].get("content", [])
|
|
201
|
+
for item in content:
|
|
202
|
+
if "image" in item:
|
|
203
|
+
item["image"] = "[image input omitted]"
|
|
204
|
+
for item in clone.get("input", {}).get("media", []):
|
|
205
|
+
if "url" in item:
|
|
206
|
+
item["url"] = "[first-frame input omitted]"
|
|
207
|
+
return clone
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _request_json(
|
|
211
|
+
url: str,
|
|
212
|
+
*,
|
|
213
|
+
key: str,
|
|
214
|
+
payload: dict[str, Any] | None = None,
|
|
215
|
+
asynchronous: bool = False,
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
headers = {
|
|
218
|
+
"Authorization": f"Bearer {key}",
|
|
219
|
+
"Accept": "application/json",
|
|
220
|
+
"User-Agent": "nexuscrew-alibaba-token-media-skill/1",
|
|
221
|
+
}
|
|
222
|
+
data = None
|
|
223
|
+
method = "GET"
|
|
224
|
+
if payload is not None:
|
|
225
|
+
method = "POST"
|
|
226
|
+
headers["Content-Type"] = "application/json"
|
|
227
|
+
data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
228
|
+
if asynchronous:
|
|
229
|
+
headers["X-DashScope-Async"] = "enable"
|
|
230
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
231
|
+
try:
|
|
232
|
+
with urllib.request.urlopen(request, timeout=300) as response:
|
|
233
|
+
final = urllib.parse.urlsplit(response.geturl())
|
|
234
|
+
expected = urllib.parse.urlsplit(url)
|
|
235
|
+
if final.scheme != "https" or final.hostname != expected.hostname:
|
|
236
|
+
raise UsageError("provider endpoint redirected outside its fixed HTTPS host")
|
|
237
|
+
body = response.read(MAX_RESPONSE_BYTES + 1)
|
|
238
|
+
except urllib.error.HTTPError as exc:
|
|
239
|
+
# Provider bodies can echo request fields or signed URLs. Keep errors
|
|
240
|
+
# intentionally content-free so secrets and ephemeral URLs never leak.
|
|
241
|
+
exc.read(MAX_RESPONSE_BYTES + 1)
|
|
242
|
+
raise UsageError(f"provider HTTP {exc.code}; response body omitted") from None
|
|
243
|
+
except urllib.error.URLError as exc:
|
|
244
|
+
raise UsageError(f"provider connection failed: {exc.reason}") from None
|
|
245
|
+
if len(body) > MAX_RESPONSE_BYTES:
|
|
246
|
+
raise UsageError("provider response exceeded 2 MiB")
|
|
247
|
+
try:
|
|
248
|
+
result = json.loads(body.decode("utf-8"))
|
|
249
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
250
|
+
raise UsageError("provider returned an invalid JSON response") from None
|
|
251
|
+
if not isinstance(result, dict):
|
|
252
|
+
raise UsageError("provider returned a non-object JSON response")
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _output_directory() -> Path:
|
|
257
|
+
date = dt.datetime.now().astimezone().date().isoformat()
|
|
258
|
+
return Path.home() / "Downloads" / "alibaba-token-plan" / date
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _validate_output(raw: str | None) -> Path | None:
|
|
262
|
+
if raw is None:
|
|
263
|
+
return None
|
|
264
|
+
supplied = Path(raw).expanduser().absolute()
|
|
265
|
+
_reject_symlink_components(supplied)
|
|
266
|
+
if not _inside_home(supplied.resolve(strict=False)):
|
|
267
|
+
raise UsageError("output path must remain under the current user's home")
|
|
268
|
+
if supplied.exists():
|
|
269
|
+
raise UsageError("output path already exists; overwrite is forbidden")
|
|
270
|
+
parent = supplied.parent.resolve(strict=True)
|
|
271
|
+
if not _inside_home(parent):
|
|
272
|
+
raise UsageError("output parent must remain under the current user's home")
|
|
273
|
+
return supplied
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _result_url(raw: str) -> str:
|
|
277
|
+
parsed = urllib.parse.urlsplit(raw)
|
|
278
|
+
host = (parsed.hostname or "").rstrip(".").lower()
|
|
279
|
+
if parsed.scheme != "https" or not host.endswith(".aliyuncs.com"):
|
|
280
|
+
raise UsageError("provider result URL is outside the approved Aliyun HTTPS domain")
|
|
281
|
+
if parsed.username or parsed.password or parsed.port not in (None, 443):
|
|
282
|
+
raise UsageError("provider result URL contains forbidden authority fields")
|
|
283
|
+
return raw
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _download(raw_url: str, *, kind: str, output: str | None) -> Path:
|
|
287
|
+
url = _result_url(raw_url)
|
|
288
|
+
explicit = _validate_output(output)
|
|
289
|
+
directory = explicit.parent if explicit else _output_directory()
|
|
290
|
+
_reject_symlink_components(directory)
|
|
291
|
+
if not explicit:
|
|
292
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
293
|
+
directory = directory.resolve(strict=True)
|
|
294
|
+
if not _inside_home(directory):
|
|
295
|
+
raise UsageError("download directory escaped the current user's home")
|
|
296
|
+
if not explicit:
|
|
297
|
+
os.chmod(directory, 0o700)
|
|
298
|
+
maximum = MAX_IMAGE_DOWNLOAD if kind == "image" else MAX_VIDEO_DOWNLOAD
|
|
299
|
+
request = urllib.request.Request(
|
|
300
|
+
url,
|
|
301
|
+
headers={"User-Agent": "nexuscrew-alibaba-token-media-skill/1"},
|
|
302
|
+
)
|
|
303
|
+
try:
|
|
304
|
+
with urllib.request.urlopen(request, timeout=300) as response:
|
|
305
|
+
_result_url(response.geturl())
|
|
306
|
+
content_type = response.headers.get_content_type()
|
|
307
|
+
length = response.headers.get("Content-Length")
|
|
308
|
+
if length and int(length) > maximum:
|
|
309
|
+
raise UsageError(f"{kind} download exceeds the configured safety limit")
|
|
310
|
+
if kind == "image" and not content_type.startswith("image/"):
|
|
311
|
+
raise UsageError(f"unexpected image content type: {content_type}")
|
|
312
|
+
if kind == "video" and content_type not in ("video/mp4", "application/octet-stream"):
|
|
313
|
+
raise UsageError(f"unexpected video content type: {content_type}")
|
|
314
|
+
extension = ".mp4" if kind == "video" else mimetypes.guess_extension(content_type) or ".img"
|
|
315
|
+
if explicit:
|
|
316
|
+
target = explicit
|
|
317
|
+
else:
|
|
318
|
+
stamp = dt.datetime.now().astimezone().strftime("%Y%m%d_%H%M%S")
|
|
319
|
+
target = directory / f"{kind}_{stamp}_{secrets.token_hex(3)}{extension}"
|
|
320
|
+
if target.exists():
|
|
321
|
+
raise UsageError("output path already exists; overwrite is forbidden")
|
|
322
|
+
fd, temp_name = tempfile.mkstemp(prefix=".download-", dir=directory)
|
|
323
|
+
total = 0
|
|
324
|
+
try:
|
|
325
|
+
with os.fdopen(fd, "wb") as handle:
|
|
326
|
+
while True:
|
|
327
|
+
chunk = response.read(1024 * 1024)
|
|
328
|
+
if not chunk:
|
|
329
|
+
break
|
|
330
|
+
total += len(chunk)
|
|
331
|
+
if total > maximum:
|
|
332
|
+
raise UsageError(f"{kind} download exceeded the configured safety limit")
|
|
333
|
+
handle.write(chunk)
|
|
334
|
+
if total == 0:
|
|
335
|
+
raise UsageError("provider result download was empty")
|
|
336
|
+
os.link(temp_name, target)
|
|
337
|
+
os.chmod(target, 0o600)
|
|
338
|
+
finally:
|
|
339
|
+
Path(temp_name).unlink(missing_ok=True)
|
|
340
|
+
except urllib.error.URLError as exc:
|
|
341
|
+
raise UsageError(f"result download failed: {exc.reason}") from None
|
|
342
|
+
return target
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _status(_: argparse.Namespace) -> None:
|
|
346
|
+
_emit({
|
|
347
|
+
"configured": bool(os.environ.get(KEY_ENV)),
|
|
348
|
+
"credential_env": KEY_ENV,
|
|
349
|
+
"host": HOST,
|
|
350
|
+
"supported_clients": ["claude-code", "codex", "codex-vl", "pi"],
|
|
351
|
+
"image_models": list(IMAGE_MODELS),
|
|
352
|
+
"video_models": list(VIDEO_MODELS),
|
|
353
|
+
"lite_defaults": {"image": "wan2.7-image/1K/n=1", "video": "720P/3s/n=1"},
|
|
354
|
+
"local_limits": {"batch": False, "concurrent_submits": 1, "automatic_post_retry": False},
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _image(args: argparse.Namespace) -> None:
|
|
359
|
+
prompt = _validate_prompt(args.prompt)
|
|
360
|
+
images = [_image_value(item) for item in args.image]
|
|
361
|
+
if len(images) > 9:
|
|
362
|
+
raise UsageError("Wan accepts at most nine input images")
|
|
363
|
+
if args.size == "4K" and (args.model != "wan2.7-image-pro" or images):
|
|
364
|
+
raise UsageError("4K is only valid for wan2.7-image-pro text-to-image")
|
|
365
|
+
if args.model == "wan2.7-image" and args.size == "4K":
|
|
366
|
+
raise UsageError("wan2.7-image does not support 4K")
|
|
367
|
+
high_cost = args.model == "wan2.7-image-pro" or args.size != "1K"
|
|
368
|
+
if high_cost and not args.confirm_high_cost:
|
|
369
|
+
raise UsageError("Wan Pro, 2K, and 4K require --confirm-high-cost")
|
|
370
|
+
content: list[dict[str, str]] = [{"text": prompt}]
|
|
371
|
+
content.extend({"image": item} for item in images)
|
|
372
|
+
parameters: dict[str, Any] = {
|
|
373
|
+
"n": 1,
|
|
374
|
+
"size": args.size,
|
|
375
|
+
"enable_sequential": False,
|
|
376
|
+
"watermark": args.watermark,
|
|
377
|
+
"thinking_mode": not args.no_thinking,
|
|
378
|
+
}
|
|
379
|
+
if args.seed is not None:
|
|
380
|
+
parameters["seed"] = args.seed
|
|
381
|
+
payload = {
|
|
382
|
+
"model": args.model,
|
|
383
|
+
"input": {"messages": [{"role": "user", "content": content}]},
|
|
384
|
+
"parameters": parameters,
|
|
385
|
+
}
|
|
386
|
+
if args.dry_run:
|
|
387
|
+
_emit({"dry_run": True, "method": "POST", "url": IMAGE_URL, "payload": _safe_payload(payload)})
|
|
388
|
+
return
|
|
389
|
+
if not args.confirm_credit_use:
|
|
390
|
+
raise UsageError("real image generation requires --confirm-credit-use")
|
|
391
|
+
with _submit_lock():
|
|
392
|
+
result = _request_json(IMAGE_URL, key=_require_key(), payload=payload)
|
|
393
|
+
urls: list[str] = []
|
|
394
|
+
for choice in result.get("output", {}).get("choices", []):
|
|
395
|
+
for item in choice.get("message", {}).get("content", []):
|
|
396
|
+
if isinstance(item, dict) and isinstance(item.get("image"), str):
|
|
397
|
+
urls.append(item["image"])
|
|
398
|
+
if len(urls) != 1:
|
|
399
|
+
raise UsageError(f"expected one generated image URL, received {len(urls)}")
|
|
400
|
+
target = _download(urls[0], kind="image", output=args.output)
|
|
401
|
+
_emit({
|
|
402
|
+
"status": "SUCCEEDED",
|
|
403
|
+
"file": str(target),
|
|
404
|
+
"request_id": result.get("request_id"),
|
|
405
|
+
"usage": result.get("usage"),
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _video_submit(args: argparse.Namespace) -> None:
|
|
410
|
+
i2v = args.model == "happyhorse-1.1-i2v"
|
|
411
|
+
prompt = _validate_prompt(args.prompt or "", video=True, optional=i2v)
|
|
412
|
+
if i2v and not args.image:
|
|
413
|
+
raise UsageError("happyhorse-1.1-i2v requires exactly one --image")
|
|
414
|
+
if not i2v and args.image:
|
|
415
|
+
raise UsageError("happyhorse-1.1-t2v does not accept --image")
|
|
416
|
+
if i2v and args.ratio != "16:9":
|
|
417
|
+
raise UsageError("image-to-video follows the input image; do not set --ratio")
|
|
418
|
+
high_cost = args.resolution == "1080P" or args.duration > 5
|
|
419
|
+
if high_cost and not args.confirm_high_cost:
|
|
420
|
+
raise UsageError("1080P or duration above five seconds requires --confirm-high-cost")
|
|
421
|
+
input_data: dict[str, Any] = {}
|
|
422
|
+
if prompt:
|
|
423
|
+
input_data["prompt"] = prompt
|
|
424
|
+
parameters: dict[str, Any] = {
|
|
425
|
+
"resolution": args.resolution,
|
|
426
|
+
"duration": args.duration,
|
|
427
|
+
"watermark": args.watermark,
|
|
428
|
+
}
|
|
429
|
+
if i2v:
|
|
430
|
+
input_data["media"] = [{"type": "first_frame", "url": _image_value(args.image, i2v=True)}]
|
|
431
|
+
else:
|
|
432
|
+
parameters["ratio"] = args.ratio
|
|
433
|
+
if args.seed is not None:
|
|
434
|
+
parameters["seed"] = args.seed
|
|
435
|
+
payload = {"model": args.model, "input": input_data, "parameters": parameters}
|
|
436
|
+
if args.dry_run:
|
|
437
|
+
_emit({"dry_run": True, "method": "POST", "url": VIDEO_URL, "payload": _safe_payload(payload)})
|
|
438
|
+
return
|
|
439
|
+
if not args.confirm_credit_use or not args.confirm_expensive:
|
|
440
|
+
raise UsageError("real video generation requires --confirm-credit-use and --confirm-expensive")
|
|
441
|
+
with _submit_lock():
|
|
442
|
+
result = _request_json(
|
|
443
|
+
VIDEO_URL,
|
|
444
|
+
key=_require_key(),
|
|
445
|
+
payload=payload,
|
|
446
|
+
asynchronous=True,
|
|
447
|
+
)
|
|
448
|
+
output = result.get("output", {})
|
|
449
|
+
task_id = output.get("task_id")
|
|
450
|
+
if not isinstance(task_id, str) or not TASK_ID_RE.fullmatch(task_id):
|
|
451
|
+
raise UsageError("provider did not return a valid task ID")
|
|
452
|
+
_emit({
|
|
453
|
+
"task_id": task_id,
|
|
454
|
+
"task_status": output.get("task_status"),
|
|
455
|
+
"request_id": result.get("request_id"),
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _video_status(args: argparse.Namespace) -> None:
|
|
460
|
+
if not TASK_ID_RE.fullmatch(args.task_id):
|
|
461
|
+
raise UsageError("invalid task ID")
|
|
462
|
+
url = TASK_URL.format(task_id=urllib.parse.quote(args.task_id, safe=""))
|
|
463
|
+
result = _request_json(url, key=_require_key())
|
|
464
|
+
output = result.get("output", {})
|
|
465
|
+
status = output.get("task_status")
|
|
466
|
+
response: dict[str, Any] = {
|
|
467
|
+
"task_id": output.get("task_id", args.task_id),
|
|
468
|
+
"task_status": status,
|
|
469
|
+
"request_id": result.get("request_id"),
|
|
470
|
+
}
|
|
471
|
+
if status == "FAILED":
|
|
472
|
+
response["error"] = "provider reported task failure; response details omitted"
|
|
473
|
+
if status == "SUCCEEDED":
|
|
474
|
+
response["usage"] = result.get("usage")
|
|
475
|
+
if args.download:
|
|
476
|
+
raw_url = output.get("video_url")
|
|
477
|
+
if not isinstance(raw_url, str):
|
|
478
|
+
raise UsageError("successful task response omitted video_url")
|
|
479
|
+
response["file"] = str(_download(raw_url, kind="video", output=args.output))
|
|
480
|
+
elif args.download:
|
|
481
|
+
raise UsageError(f"task is {status!r}; download is available only after SUCCEEDED")
|
|
482
|
+
_emit(response)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _seed(value: str) -> int:
|
|
486
|
+
number = int(value)
|
|
487
|
+
if not 0 <= number <= 2_147_483_647:
|
|
488
|
+
raise argparse.ArgumentTypeError("seed must be from 0 through 2147483647")
|
|
489
|
+
return number
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _parser() -> argparse.ArgumentParser:
|
|
493
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
494
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
495
|
+
|
|
496
|
+
status = sub.add_parser("status", help="show allowlist and whether the key is configured")
|
|
497
|
+
status.set_defaults(run=_status)
|
|
498
|
+
|
|
499
|
+
image = sub.add_parser("image", help="generate or edit exactly one Wan image")
|
|
500
|
+
image.add_argument("--prompt", required=True)
|
|
501
|
+
image.add_argument("--model", choices=IMAGE_MODELS, default="wan2.7-image")
|
|
502
|
+
image.add_argument("--size", choices=("1K", "2K", "4K"), default="1K")
|
|
503
|
+
image.add_argument("--image", action="append", default=[], metavar="PATH_OR_HTTPS_URL")
|
|
504
|
+
image.add_argument("--watermark", action="store_true")
|
|
505
|
+
image.add_argument("--no-thinking", action="store_true")
|
|
506
|
+
image.add_argument("--seed", type=_seed)
|
|
507
|
+
image.add_argument("--output")
|
|
508
|
+
image.add_argument("--dry-run", action="store_true")
|
|
509
|
+
image.add_argument("--confirm-credit-use", action="store_true")
|
|
510
|
+
image.add_argument("--confirm-high-cost", action="store_true")
|
|
511
|
+
image.set_defaults(run=_image)
|
|
512
|
+
|
|
513
|
+
video = sub.add_parser("video-submit", help="submit one HappyHorse video task")
|
|
514
|
+
video.add_argument("--prompt")
|
|
515
|
+
video.add_argument("--model", choices=VIDEO_MODELS, default="happyhorse-1.1-t2v")
|
|
516
|
+
video.add_argument("--image", metavar="PATH_OR_HTTPS_URL")
|
|
517
|
+
video.add_argument("--resolution", choices=("720P", "1080P"), default="720P")
|
|
518
|
+
video.add_argument("--duration", type=int, choices=range(3, 16), default=3)
|
|
519
|
+
video.add_argument("--ratio", choices=VIDEO_RATIOS, default="16:9")
|
|
520
|
+
video.add_argument("--watermark", action=argparse.BooleanOptionalAction, default=True)
|
|
521
|
+
video.add_argument("--seed", type=_seed)
|
|
522
|
+
video.add_argument("--dry-run", action="store_true")
|
|
523
|
+
video.add_argument("--confirm-credit-use", action="store_true")
|
|
524
|
+
video.add_argument("--confirm-expensive", action="store_true")
|
|
525
|
+
video.add_argument("--confirm-high-cost", action="store_true")
|
|
526
|
+
video.set_defaults(run=_video_submit)
|
|
527
|
+
|
|
528
|
+
task = sub.add_parser("video-status", help="query one HappyHorse task and optionally download it")
|
|
529
|
+
task.add_argument("task_id")
|
|
530
|
+
task.add_argument("--download", action="store_true")
|
|
531
|
+
task.add_argument("--output")
|
|
532
|
+
task.set_defaults(run=_video_status)
|
|
533
|
+
return parser
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def main() -> int:
|
|
537
|
+
try:
|
|
538
|
+
args = _parser().parse_args()
|
|
539
|
+
args.run(args)
|
|
540
|
+
return 0
|
|
541
|
+
except UsageError as exc:
|
|
542
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
543
|
+
return 2
|
|
544
|
+
except (OSError, ValueError) as exc:
|
|
545
|
+
print(f"error: local validation failed: {exc}", file=sys.stderr)
|
|
546
|
+
return 2
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
if __name__ == "__main__":
|
|
550
|
+
raise SystemExit(main())
|