sparki-cli 1.0.0__py3-none-any.whl
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.
- sparki/__init__.py +5 -0
- sparki/cli.py +720 -0
- sparki/client.py +135 -0
- sparki/config.py +55 -0
- sparki/constants.py +144 -0
- sparki/models.py +59 -0
- sparki/output.py +42 -0
- sparki_cli-1.0.0.dist-info/METADATA +73 -0
- sparki_cli-1.0.0.dist-info/RECORD +12 -0
- sparki_cli-1.0.0.dist-info/WHEEL +4 -0
- sparki_cli-1.0.0.dist-info/entry_points.txt +2 -0
- sparki_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
sparki/__init__.py
ADDED
sparki/cli.py
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
"""CLI entry point — all sparki subcommands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Any, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from sparki.client import SparkiClient
|
|
12
|
+
from sparki.config import Config, DEFAULT_CONFIG_DIR
|
|
13
|
+
from sparki.constants import (
|
|
14
|
+
ALLOWED_EXTENSIONS,
|
|
15
|
+
MAX_FILES_PER_UPLOAD,
|
|
16
|
+
MAX_UPLOAD_SIZE,
|
|
17
|
+
EditMode,
|
|
18
|
+
VALID_DURATION_RANGES,
|
|
19
|
+
TELEGRAM_FILE_SIZE_LIMIT,
|
|
20
|
+
validate_style,
|
|
21
|
+
style_to_payload,
|
|
22
|
+
DEFAULT_ASSET_POLL_INTERVAL,
|
|
23
|
+
DEFAULT_ASSET_POLL_TIMEOUT,
|
|
24
|
+
DEFAULT_PROJECT_POLL_INTERVAL,
|
|
25
|
+
DEFAULT_PROJECT_POLL_TIMEOUT,
|
|
26
|
+
)
|
|
27
|
+
from sparki.output import log, print_error, print_success
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(add_completion=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_config_dir() -> Path:
|
|
33
|
+
"""Return the config directory path."""
|
|
34
|
+
return DEFAULT_CONFIG_DIR
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_config() -> Config:
|
|
38
|
+
"""Load configuration from the config directory."""
|
|
39
|
+
return Config(config_dir=get_config_dir())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_auth() -> tuple[Config, SparkiClient] | None:
|
|
43
|
+
"""Load config and return (Config, SparkiClient), or print AUTH_FAILED and return None."""
|
|
44
|
+
cfg = _load_config()
|
|
45
|
+
if not cfg.api_key:
|
|
46
|
+
print_error("AUTH_FAILED")
|
|
47
|
+
return None
|
|
48
|
+
client = SparkiClient(base_url=cfg.base_url, api_key=cfg.api_key)
|
|
49
|
+
return cfg, client
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_files(files: list[Path]) -> list[Path] | None:
|
|
53
|
+
"""Validate file existence, format, and size. Returns list or None on error."""
|
|
54
|
+
if len(files) > MAX_FILES_PER_UPLOAD:
|
|
55
|
+
print_error("UPLOAD_FAILED", f"Too many files: max {MAX_FILES_PER_UPLOAD} allowed, got {len(files)}")
|
|
56
|
+
return None
|
|
57
|
+
for f in files:
|
|
58
|
+
if not f.exists():
|
|
59
|
+
print_error("UPLOAD_FAILED", f"File not found: {f}")
|
|
60
|
+
return None
|
|
61
|
+
ext = f.suffix.lstrip(".").lower()
|
|
62
|
+
if ext not in ALLOWED_EXTENSIONS:
|
|
63
|
+
print_error("INVALID_FILE_FORMAT")
|
|
64
|
+
return None
|
|
65
|
+
if f.stat().st_size > MAX_UPLOAD_SIZE:
|
|
66
|
+
print_error("FILE_TOO_LARGE")
|
|
67
|
+
return None
|
|
68
|
+
return files
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _history_file() -> Path:
|
|
72
|
+
"""Return the path to the local project history file."""
|
|
73
|
+
return get_config_dir() / "sparki_history.json"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_history() -> list[dict]:
|
|
77
|
+
"""Load locally tracked project IDs."""
|
|
78
|
+
hf = _history_file()
|
|
79
|
+
if hf.exists():
|
|
80
|
+
return json.loads(hf.read_text())
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _lookup_mode(task_id: str) -> str | None:
|
|
85
|
+
"""Look up the edit mode for a task_id from local history."""
|
|
86
|
+
for entry in _load_history():
|
|
87
|
+
if entry.get("task_id") == task_id:
|
|
88
|
+
return entry.get("mode")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _get_status_by_mode(client, task_id: str) -> dict:
|
|
93
|
+
"""Call the correct status endpoint based on the project's mode."""
|
|
94
|
+
mode = _lookup_mode(task_id)
|
|
95
|
+
if mode == EditMode.STYLE_CLONE:
|
|
96
|
+
return await client.get_emulate_project_status(task_id)
|
|
97
|
+
return await client.get_project_status(task_id)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _save_project(task_id: str, mode: str = "", style: str = "") -> None:
|
|
101
|
+
"""Track a project ID locally for history lookups."""
|
|
102
|
+
from datetime import datetime, timezone
|
|
103
|
+
history = _load_history()
|
|
104
|
+
history.insert(0, {
|
|
105
|
+
"task_id": task_id,
|
|
106
|
+
"mode": mode,
|
|
107
|
+
"style": style,
|
|
108
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
109
|
+
})
|
|
110
|
+
history = history[:100]
|
|
111
|
+
hf = _history_file()
|
|
112
|
+
hf.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
hf.write_text(json.dumps(history, indent=2))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _extract_result_url(data: dict[str, Any]) -> str | None:
|
|
117
|
+
"""Extract result URL from project status response data."""
|
|
118
|
+
materials = data.get("materials", data.get("outputResultAssets",
|
|
119
|
+
data.get("output_result_assets", [])))
|
|
120
|
+
if not materials:
|
|
121
|
+
return None
|
|
122
|
+
item = materials[0]
|
|
123
|
+
if isinstance(item, dict):
|
|
124
|
+
return (item.get("url") or item.get("download_url")
|
|
125
|
+
or item.get("downloadUrl"))
|
|
126
|
+
if isinstance(item, str):
|
|
127
|
+
return item
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _run_async(coro_fn):
|
|
132
|
+
"""Run an async function, catching httpx errors as NETWORK_ERROR."""
|
|
133
|
+
try:
|
|
134
|
+
asyncio.run(coro_fn())
|
|
135
|
+
except (httpx.HTTPError, httpx.StreamError) as e:
|
|
136
|
+
print_error("NETWORK_ERROR", str(e))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _upload_and_poll_asset(
|
|
140
|
+
client: SparkiClient,
|
|
141
|
+
file_path: Path,
|
|
142
|
+
label: str = "",
|
|
143
|
+
) -> str | None:
|
|
144
|
+
"""Upload a file and poll until processed. Returns object_key or None on failure."""
|
|
145
|
+
import time
|
|
146
|
+
|
|
147
|
+
display = label or file_path.name
|
|
148
|
+
log(f"Uploading {display}...")
|
|
149
|
+
resp = await client.upload_asset(file_path)
|
|
150
|
+
if resp.get("code") != 200:
|
|
151
|
+
print_error("UPLOAD_FAILED", resp.get("message"))
|
|
152
|
+
return None
|
|
153
|
+
object_key = resp["data"]["object_key"]
|
|
154
|
+
|
|
155
|
+
log(f"Waiting for processing: {object_key}")
|
|
156
|
+
start = time.monotonic()
|
|
157
|
+
while True:
|
|
158
|
+
if time.monotonic() - start >= DEFAULT_ASSET_POLL_TIMEOUT:
|
|
159
|
+
print_error("RENDER_TIMEOUT", "Asset processing timed out")
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
status_resp = await client.get_asset_status(object_key)
|
|
163
|
+
except httpx.HTTPError as exc:
|
|
164
|
+
log(f"Asset status check failed ({exc}), retrying...")
|
|
165
|
+
await asyncio.sleep(DEFAULT_ASSET_POLL_INTERVAL)
|
|
166
|
+
continue
|
|
167
|
+
asset_status = (status_resp.get("data", {}).get("status")
|
|
168
|
+
if status_resp.get("data") else None)
|
|
169
|
+
if asset_status == 1:
|
|
170
|
+
return object_key
|
|
171
|
+
if asset_status == -2:
|
|
172
|
+
print_error("UPLOAD_FAILED", f"Asset processing failed: {object_key}")
|
|
173
|
+
return None
|
|
174
|
+
await asyncio.sleep(DEFAULT_ASSET_POLL_INTERVAL)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command()
|
|
178
|
+
def setup(
|
|
179
|
+
api_key: Annotated[str, typer.Option("--api-key", help="Your Sparki API key")],
|
|
180
|
+
base_url: Annotated[Optional[str], typer.Option("--base-url", help="Override the Sparki API base URL")] = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Save API key and validate it against the Sparki backend."""
|
|
183
|
+
|
|
184
|
+
async def _run() -> None:
|
|
185
|
+
cfg = _load_config()
|
|
186
|
+
effective_base_url = base_url or cfg.base_url
|
|
187
|
+
client = SparkiClient(base_url=effective_base_url, api_key=api_key)
|
|
188
|
+
valid = await client.validate_key()
|
|
189
|
+
if not valid:
|
|
190
|
+
print_error("AUTH_FAILED")
|
|
191
|
+
return
|
|
192
|
+
cfg.save(api_key=api_key, base_url=base_url)
|
|
193
|
+
log("Welcome to Sparki! Configuration saved.")
|
|
194
|
+
print_success({"message": "API key saved successfully",
|
|
195
|
+
"config_dir": str(get_config_dir())})
|
|
196
|
+
|
|
197
|
+
_run_async(_run)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command()
|
|
201
|
+
def upload(
|
|
202
|
+
file: Annotated[list[Path], typer.Option("--file", help="Video file(s) to upload")],
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Upload one or more video files to Sparki."""
|
|
205
|
+
|
|
206
|
+
auth = _require_auth()
|
|
207
|
+
if auth is None:
|
|
208
|
+
return
|
|
209
|
+
_, client = auth
|
|
210
|
+
|
|
211
|
+
validated = _validate_files(file)
|
|
212
|
+
if validated is None:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
async def _run() -> None:
|
|
216
|
+
assets = []
|
|
217
|
+
for f in validated:
|
|
218
|
+
log(f"Uploading {f.name}...")
|
|
219
|
+
resp = await client.upload_asset(f)
|
|
220
|
+
if resp.get("code") != 200:
|
|
221
|
+
print_error("UPLOAD_FAILED", resp.get("message"))
|
|
222
|
+
return
|
|
223
|
+
assets.append(resp["data"])
|
|
224
|
+
print_success({"assets": assets})
|
|
225
|
+
|
|
226
|
+
_run_async(_run)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.command(name="upload-tg")
|
|
230
|
+
def upload_tg() -> None:
|
|
231
|
+
"""Return the configured Telegram upload link."""
|
|
232
|
+
auth = _require_auth()
|
|
233
|
+
if auth is None:
|
|
234
|
+
return
|
|
235
|
+
cfg, _ = auth
|
|
236
|
+
print_success({"upload_tg": cfg.upload_tg})
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@app.command()
|
|
240
|
+
def assets(
|
|
241
|
+
limit: Annotated[int, typer.Option("--limit", help="Max number of assets to return")] = 20,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""List uploaded assets."""
|
|
244
|
+
|
|
245
|
+
auth = _require_auth()
|
|
246
|
+
if auth is None:
|
|
247
|
+
return
|
|
248
|
+
_, client = auth
|
|
249
|
+
|
|
250
|
+
async def _run() -> None:
|
|
251
|
+
resp = await client.list_assets(page_size=limit)
|
|
252
|
+
if resp.get("code") != 200:
|
|
253
|
+
print_error("NETWORK_ERROR", resp.get("message"))
|
|
254
|
+
return
|
|
255
|
+
print_success(resp["data"])
|
|
256
|
+
|
|
257
|
+
_run_async(_run)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@app.command()
|
|
261
|
+
def edit(
|
|
262
|
+
object_key: Annotated[list[str], typer.Option("--object-key", help="Asset object key(s) to edit")],
|
|
263
|
+
mode: Annotated[str, typer.Option("--mode", help="Edit mode: style-guided, prompt-driven, style-clone")],
|
|
264
|
+
style: Annotated[Optional[str], typer.Option("--style", help="Style for style-guided mode (e.g. vlog/daily)")] = None,
|
|
265
|
+
prompt: Annotated[Optional[str], typer.Option("--prompt", help="Text prompt for prompt-driven or style-clone mode")] = None,
|
|
266
|
+
aspect_ratio: Annotated[str, typer.Option("--aspect-ratio", help="Output aspect ratio")] = "9:16",
|
|
267
|
+
duration_range: Annotated[Optional[str], typer.Option("--duration-range", help="Duration range (e.g. 30s~60s)")] = None,
|
|
268
|
+
reference_url: Annotated[Optional[str], typer.Option("--reference-url", help="Reference video URL (TikTok, Instagram, X, Facebook)")] = None,
|
|
269
|
+
reference_file: Annotated[Optional[Path], typer.Option("--reference-file", help="Local reference video file")] = None,
|
|
270
|
+
reference_tg: Annotated[bool, typer.Option("--reference-tg", help="Get Telegram upload link for reference video")] = False,
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Create a new edit project for uploaded assets."""
|
|
273
|
+
|
|
274
|
+
auth = _require_auth()
|
|
275
|
+
if auth is None:
|
|
276
|
+
return
|
|
277
|
+
cfg, client = auth
|
|
278
|
+
|
|
279
|
+
if duration_range and duration_range not in VALID_DURATION_RANGES:
|
|
280
|
+
print_error("INVALID_MODE", f"Invalid duration range: {duration_range}")
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# Cross-mode validation
|
|
284
|
+
ref_count = sum([bool(reference_url), bool(reference_file), reference_tg])
|
|
285
|
+
if mode == EditMode.STYLE_CLONE:
|
|
286
|
+
if ref_count != 1:
|
|
287
|
+
print_error("INVALID_REFERENCE")
|
|
288
|
+
return
|
|
289
|
+
if style:
|
|
290
|
+
print_error("INVALID_MODE", "--style is not used with style-clone mode")
|
|
291
|
+
return
|
|
292
|
+
if duration_range:
|
|
293
|
+
print_error("INVALID_MODE", "--duration-range is not supported for style-clone mode")
|
|
294
|
+
return
|
|
295
|
+
else:
|
|
296
|
+
if ref_count > 0:
|
|
297
|
+
print_error("INVALID_MODE", "--reference-* options are only for style-clone mode")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
if mode == EditMode.STYLE_GUIDED:
|
|
301
|
+
if not style or not validate_style(style):
|
|
302
|
+
print_error("INVALID_STYLE")
|
|
303
|
+
return
|
|
304
|
+
payload = style_to_payload(style)
|
|
305
|
+
tags = payload["tags"]
|
|
306
|
+
agent_type = payload["agent_type"]
|
|
307
|
+
user_input = prompt or payload.get("default_prompt", "")
|
|
308
|
+
elif mode == EditMode.PROMPT_DRIVEN:
|
|
309
|
+
if not prompt:
|
|
310
|
+
print_error("INVALID_MODE")
|
|
311
|
+
return
|
|
312
|
+
tags = []
|
|
313
|
+
agent_type = None
|
|
314
|
+
user_input = prompt
|
|
315
|
+
elif mode == EditMode.STYLE_CLONE:
|
|
316
|
+
user_input = prompt or ""
|
|
317
|
+
|
|
318
|
+
# --reference-tg: return upload link, no project created
|
|
319
|
+
if reference_tg:
|
|
320
|
+
print_success({
|
|
321
|
+
"action": "upload_reference_via_telegram",
|
|
322
|
+
"upload_tg": cfg.upload_tg,
|
|
323
|
+
"message": "Please ask the user to upload their reference video via the Telegram Mini App.",
|
|
324
|
+
})
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
async def _run_clone() -> None:
|
|
328
|
+
ref_key = None
|
|
329
|
+
ref_url = None
|
|
330
|
+
|
|
331
|
+
if reference_file:
|
|
332
|
+
validated = _validate_files([reference_file])
|
|
333
|
+
if validated is None:
|
|
334
|
+
return
|
|
335
|
+
ref_key = await _upload_and_poll_asset(
|
|
336
|
+
client, reference_file,
|
|
337
|
+
label=f"reference video: {reference_file.name}",
|
|
338
|
+
)
|
|
339
|
+
if ref_key is None:
|
|
340
|
+
return
|
|
341
|
+
elif reference_url:
|
|
342
|
+
ref_url = reference_url
|
|
343
|
+
|
|
344
|
+
resp = await client.create_emulate_project(
|
|
345
|
+
source_object_keys=object_key,
|
|
346
|
+
reference_object_key=ref_key,
|
|
347
|
+
reference_url=ref_url,
|
|
348
|
+
user_input=user_input,
|
|
349
|
+
aspect_ratio=aspect_ratio,
|
|
350
|
+
)
|
|
351
|
+
if resp.get("code") != 200:
|
|
352
|
+
print_error("NETWORK_ERROR", resp.get("message"))
|
|
353
|
+
return
|
|
354
|
+
data = resp["data"]
|
|
355
|
+
task_id = data.get("task_id", data.get("taskId", ""))
|
|
356
|
+
_save_project(task_id, mode=mode, style="")
|
|
357
|
+
print_success(data)
|
|
358
|
+
|
|
359
|
+
_run_async(_run_clone)
|
|
360
|
+
return
|
|
361
|
+
else:
|
|
362
|
+
print_error("INVALID_MODE")
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
# Existing style-guided / prompt-driven flow (unchanged)
|
|
366
|
+
async def _run() -> None:
|
|
367
|
+
resp = await client.create_project(
|
|
368
|
+
object_keys=object_key,
|
|
369
|
+
tags=tags,
|
|
370
|
+
user_input=user_input,
|
|
371
|
+
aspect_ratio=aspect_ratio,
|
|
372
|
+
agent_type=agent_type,
|
|
373
|
+
duration_range=duration_range,
|
|
374
|
+
)
|
|
375
|
+
if resp.get("code") != 200:
|
|
376
|
+
print_error("NETWORK_ERROR", resp.get("message"))
|
|
377
|
+
return
|
|
378
|
+
data = resp["data"]
|
|
379
|
+
task_id = data.get("task_id", data.get("taskId", ""))
|
|
380
|
+
_save_project(task_id, mode=mode, style=style or "")
|
|
381
|
+
print_success(data)
|
|
382
|
+
|
|
383
|
+
_run_async(_run)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@app.command()
|
|
387
|
+
def status(
|
|
388
|
+
task_id: Annotated[str, typer.Option("--task-id", help="Project ID to check")],
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Check the status of an edit project."""
|
|
391
|
+
|
|
392
|
+
auth = _require_auth()
|
|
393
|
+
if auth is None:
|
|
394
|
+
return
|
|
395
|
+
_, client = auth
|
|
396
|
+
|
|
397
|
+
async def _run() -> None:
|
|
398
|
+
resp = await _get_status_by_mode(client, task_id)
|
|
399
|
+
if resp.get("code") != 200:
|
|
400
|
+
print_error("TASK_NOT_FOUND", resp.get("message"))
|
|
401
|
+
return
|
|
402
|
+
print_success(resp["data"])
|
|
403
|
+
|
|
404
|
+
_run_async(_run)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@app.command()
|
|
408
|
+
def download(
|
|
409
|
+
task_id: Annotated[str, typer.Option("--task-id", help="Project ID to download")],
|
|
410
|
+
output: Annotated[Optional[Path], typer.Option("--output", help="Output file path")] = None,
|
|
411
|
+
) -> None:
|
|
412
|
+
"""Download the result of a completed edit project."""
|
|
413
|
+
|
|
414
|
+
auth = _require_auth()
|
|
415
|
+
if auth is None:
|
|
416
|
+
return
|
|
417
|
+
cfg, client = auth
|
|
418
|
+
|
|
419
|
+
async def _run() -> None:
|
|
420
|
+
resp = await _get_status_by_mode(client, task_id)
|
|
421
|
+
if resp.get("code") != 200:
|
|
422
|
+
print_error("TASK_NOT_FOUND", resp.get("message"))
|
|
423
|
+
return
|
|
424
|
+
data = resp["data"]
|
|
425
|
+
task_status = data.get("status", "").upper()
|
|
426
|
+
if task_status != "COMPLETED":
|
|
427
|
+
print_error("TASK_NOT_FOUND",
|
|
428
|
+
f"Project {task_id} is not completed (status: {data.get('status')})")
|
|
429
|
+
return
|
|
430
|
+
result_url = _extract_result_url(data)
|
|
431
|
+
if not result_url:
|
|
432
|
+
print_error("NETWORK_ERROR", "No result URL available")
|
|
433
|
+
return
|
|
434
|
+
out_path = output or cfg.default_output_dir / f"{task_id}.mp4"
|
|
435
|
+
file_size = await client.download_result(result_url, out_path)
|
|
436
|
+
delivery_hint = ("telegram_direct" if file_size <= TELEGRAM_FILE_SIZE_LIMIT
|
|
437
|
+
else "link_only")
|
|
438
|
+
print_success({
|
|
439
|
+
"task_id": task_id,
|
|
440
|
+
"file_path": str(out_path),
|
|
441
|
+
"file_size": file_size,
|
|
442
|
+
"result_url": result_url,
|
|
443
|
+
"delivery_hint": delivery_hint,
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
_run_async(_run)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@app.command()
|
|
450
|
+
def history(
|
|
451
|
+
limit: Annotated[int, typer.Option("--limit", help="Number of projects to return")] = 20,
|
|
452
|
+
status: Annotated[Optional[str], typer.Option("--status", help="Filter by status (or 'all')")] = "all",
|
|
453
|
+
) -> None:
|
|
454
|
+
"""List recent edit projects."""
|
|
455
|
+
|
|
456
|
+
auth = _require_auth()
|
|
457
|
+
if auth is None:
|
|
458
|
+
return
|
|
459
|
+
_, client = auth
|
|
460
|
+
|
|
461
|
+
async def _run() -> None:
|
|
462
|
+
local_history = _load_history()
|
|
463
|
+
if not local_history:
|
|
464
|
+
print_success({"projects": [], "total": 0})
|
|
465
|
+
return
|
|
466
|
+
entries = local_history[:limit]
|
|
467
|
+
projects = []
|
|
468
|
+
for entry in entries:
|
|
469
|
+
tid = entry["task_id"]
|
|
470
|
+
resp = await _get_status_by_mode(client, tid)
|
|
471
|
+
if resp.get("code") == 200:
|
|
472
|
+
projects.append(resp["data"])
|
|
473
|
+
|
|
474
|
+
if status and status != "all":
|
|
475
|
+
projects = [p for p in projects
|
|
476
|
+
if p.get("status", "").upper() == status.upper()]
|
|
477
|
+
|
|
478
|
+
print_success({"projects": projects, "total": len(projects)})
|
|
479
|
+
|
|
480
|
+
_run_async(_run)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@app.command()
|
|
484
|
+
def run(
|
|
485
|
+
file: Annotated[list[Path], typer.Option("--file", help="Video file(s) to upload and edit")],
|
|
486
|
+
mode: Annotated[str, typer.Option("--mode", help="Edit mode: style-guided, prompt-driven, style-clone")],
|
|
487
|
+
style: Annotated[Optional[str], typer.Option("--style", help="Style for style-guided mode")] = None,
|
|
488
|
+
prompt: Annotated[Optional[str], typer.Option("--prompt", help="Text prompt for prompt-driven mode")] = None,
|
|
489
|
+
aspect_ratio: Annotated[str, typer.Option("--aspect-ratio", help="Output aspect ratio")] = "9:16",
|
|
490
|
+
duration_range: Annotated[Optional[str], typer.Option("--duration-range", help="Duration range")] = None,
|
|
491
|
+
output: Annotated[Optional[Path], typer.Option("--output", help="Output file path")] = None,
|
|
492
|
+
poll_interval: Annotated[int, typer.Option("--poll-interval", help="Seconds between project status polls")] = DEFAULT_PROJECT_POLL_INTERVAL,
|
|
493
|
+
timeout: Annotated[int, typer.Option("--timeout", help="Max seconds to wait for completion")] = DEFAULT_PROJECT_POLL_TIMEOUT,
|
|
494
|
+
reference_url: Annotated[Optional[str], typer.Option("--reference-url", help="Reference video URL (TikTok, Instagram, X, Facebook)")] = None,
|
|
495
|
+
reference_file: Annotated[Optional[Path], typer.Option("--reference-file", help="Local reference video file")] = None,
|
|
496
|
+
reference_tg: Annotated[bool, typer.Option("--reference-tg", help="Not available in run")] = False,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""End-to-end workflow: upload -> edit -> poll -> download."""
|
|
499
|
+
|
|
500
|
+
auth = _require_auth()
|
|
501
|
+
if auth is None:
|
|
502
|
+
return
|
|
503
|
+
cfg, client = auth
|
|
504
|
+
|
|
505
|
+
# Cross-mode validation
|
|
506
|
+
ref_count = sum([bool(reference_url), bool(reference_file), reference_tg])
|
|
507
|
+
if mode == EditMode.STYLE_CLONE:
|
|
508
|
+
if reference_tg:
|
|
509
|
+
print_error("INVALID_MODE", "--reference-tg is not available in run command")
|
|
510
|
+
return
|
|
511
|
+
if ref_count != 1:
|
|
512
|
+
print_error("INVALID_REFERENCE")
|
|
513
|
+
return
|
|
514
|
+
if style:
|
|
515
|
+
print_error("INVALID_MODE", "--style is not used with style-clone mode")
|
|
516
|
+
return
|
|
517
|
+
if duration_range:
|
|
518
|
+
print_error("INVALID_MODE", "--duration-range is not supported for style-clone mode")
|
|
519
|
+
return
|
|
520
|
+
else:
|
|
521
|
+
if ref_count > 0:
|
|
522
|
+
print_error("INVALID_MODE", "--reference-* options are only for style-clone mode")
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
# Validate duration range
|
|
526
|
+
if duration_range and duration_range not in VALID_DURATION_RANGES:
|
|
527
|
+
print_error("INVALID_MODE", f"Invalid duration range: {duration_range}")
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
# Validate mode/style
|
|
531
|
+
if mode == EditMode.STYLE_GUIDED:
|
|
532
|
+
if not style or not validate_style(style):
|
|
533
|
+
print_error("INVALID_STYLE")
|
|
534
|
+
return
|
|
535
|
+
payload = style_to_payload(style)
|
|
536
|
+
tags = payload["tags"]
|
|
537
|
+
agent_type = payload["agent_type"]
|
|
538
|
+
user_input = prompt or payload.get("default_prompt", "")
|
|
539
|
+
elif mode == EditMode.PROMPT_DRIVEN:
|
|
540
|
+
if not prompt:
|
|
541
|
+
print_error("INVALID_MODE")
|
|
542
|
+
return
|
|
543
|
+
tags = []
|
|
544
|
+
agent_type = None
|
|
545
|
+
user_input = prompt
|
|
546
|
+
elif mode == EditMode.STYLE_CLONE:
|
|
547
|
+
user_input = prompt or ""
|
|
548
|
+
else:
|
|
549
|
+
print_error("INVALID_MODE")
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
validated = _validate_files(file)
|
|
553
|
+
if validated is None:
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
if reference_file:
|
|
557
|
+
ref_validated = _validate_files([reference_file])
|
|
558
|
+
if ref_validated is None:
|
|
559
|
+
return
|
|
560
|
+
|
|
561
|
+
if mode == EditMode.STYLE_CLONE:
|
|
562
|
+
async def _run_clone() -> None:
|
|
563
|
+
import time
|
|
564
|
+
|
|
565
|
+
# Step 1: Upload source files
|
|
566
|
+
object_keys = []
|
|
567
|
+
for f in validated:
|
|
568
|
+
key = await _upload_and_poll_asset(client, f, label=f"source video: {f.name}")
|
|
569
|
+
if key is None:
|
|
570
|
+
return
|
|
571
|
+
object_keys.append(key)
|
|
572
|
+
|
|
573
|
+
# Step 2: Handle reference video
|
|
574
|
+
ref_key = None
|
|
575
|
+
ref_url = None
|
|
576
|
+
if reference_file:
|
|
577
|
+
ref_key = await _upload_and_poll_asset(
|
|
578
|
+
client, reference_file, label=f"reference video: {reference_file.name}")
|
|
579
|
+
if ref_key is None:
|
|
580
|
+
return
|
|
581
|
+
elif reference_url:
|
|
582
|
+
ref_url = reference_url
|
|
583
|
+
|
|
584
|
+
# Step 3: Create emulate project
|
|
585
|
+
log("Creating style-clone project...")
|
|
586
|
+
proj_resp = await client.create_emulate_project(
|
|
587
|
+
source_object_keys=object_keys,
|
|
588
|
+
reference_object_key=ref_key,
|
|
589
|
+
reference_url=ref_url,
|
|
590
|
+
user_input=user_input,
|
|
591
|
+
aspect_ratio=aspect_ratio,
|
|
592
|
+
)
|
|
593
|
+
if proj_resp.get("code") != 200:
|
|
594
|
+
print_error("NETWORK_ERROR", proj_resp.get("message"))
|
|
595
|
+
return
|
|
596
|
+
data = proj_resp["data"]
|
|
597
|
+
task_id = data.get("task_id", data.get("taskId", ""))
|
|
598
|
+
_save_project(task_id, mode=mode, style="")
|
|
599
|
+
|
|
600
|
+
# Step 4: Poll for completion
|
|
601
|
+
proj_start = time.monotonic()
|
|
602
|
+
while True:
|
|
603
|
+
elapsed = time.monotonic() - proj_start
|
|
604
|
+
if elapsed >= timeout:
|
|
605
|
+
print_error("RENDER_TIMEOUT")
|
|
606
|
+
return
|
|
607
|
+
try:
|
|
608
|
+
status_resp = await client.get_emulate_project_status(task_id)
|
|
609
|
+
except httpx.HTTPError as exc:
|
|
610
|
+
log(f"Project status check failed ({exc}), retrying...")
|
|
611
|
+
await asyncio.sleep(poll_interval)
|
|
612
|
+
continue
|
|
613
|
+
if status_resp.get("code") != 200:
|
|
614
|
+
print_error("TASK_NOT_FOUND", status_resp.get("message"))
|
|
615
|
+
return
|
|
616
|
+
proj_data = status_resp["data"]
|
|
617
|
+
task_status = proj_data.get("status", "")
|
|
618
|
+
log(f"Project {task_id} status: {task_status}")
|
|
619
|
+
if task_status.upper() == "COMPLETED":
|
|
620
|
+
break
|
|
621
|
+
if task_status.upper() in ("FAILED", "CANCEL"):
|
|
622
|
+
print_error("NETWORK_ERROR", f"Project failed: {task_id}")
|
|
623
|
+
return
|
|
624
|
+
await asyncio.sleep(poll_interval)
|
|
625
|
+
|
|
626
|
+
# Step 5: Download result
|
|
627
|
+
result_url = _extract_result_url(proj_data)
|
|
628
|
+
if not result_url:
|
|
629
|
+
print_error("NETWORK_ERROR", "No result URL available")
|
|
630
|
+
return
|
|
631
|
+
out_path = output or cfg.default_output_dir / f"{task_id}.mp4"
|
|
632
|
+
file_size = await client.download_result(result_url, out_path)
|
|
633
|
+
delivery_hint = ("telegram_direct" if file_size <= TELEGRAM_FILE_SIZE_LIMIT
|
|
634
|
+
else "link_only")
|
|
635
|
+
print_success({
|
|
636
|
+
"task_id": task_id,
|
|
637
|
+
"status": task_status,
|
|
638
|
+
"file_path": str(out_path),
|
|
639
|
+
"file_size": file_size,
|
|
640
|
+
"result_url": result_url,
|
|
641
|
+
"delivery_hint": delivery_hint,
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
_run_async(_run_clone)
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
async def _run() -> None:
|
|
648
|
+
import time
|
|
649
|
+
|
|
650
|
+
# Step 1: Upload all files
|
|
651
|
+
object_keys = []
|
|
652
|
+
for f in validated:
|
|
653
|
+
key = await _upload_and_poll_asset(client, f)
|
|
654
|
+
if key is None:
|
|
655
|
+
return
|
|
656
|
+
object_keys.append(key)
|
|
657
|
+
|
|
658
|
+
# Step 2: Create project
|
|
659
|
+
log("Creating edit project...")
|
|
660
|
+
proj_resp = await client.create_project(
|
|
661
|
+
object_keys=object_keys,
|
|
662
|
+
tags=tags,
|
|
663
|
+
user_input=user_input,
|
|
664
|
+
aspect_ratio=aspect_ratio,
|
|
665
|
+
agent_type=agent_type,
|
|
666
|
+
duration_range=duration_range,
|
|
667
|
+
)
|
|
668
|
+
if proj_resp.get("code") != 200:
|
|
669
|
+
print_error("NETWORK_ERROR", proj_resp.get("message"))
|
|
670
|
+
return
|
|
671
|
+
data = proj_resp["data"]
|
|
672
|
+
task_id = data.get("task_id", data.get("taskId", ""))
|
|
673
|
+
_save_project(task_id, mode=mode, style=style or "")
|
|
674
|
+
|
|
675
|
+
# Step 3: Poll for project completion
|
|
676
|
+
proj_start = time.monotonic()
|
|
677
|
+
while True:
|
|
678
|
+
elapsed = time.monotonic() - proj_start
|
|
679
|
+
if elapsed >= timeout:
|
|
680
|
+
print_error("RENDER_TIMEOUT")
|
|
681
|
+
return
|
|
682
|
+
try:
|
|
683
|
+
status_resp = await client.get_project_status(task_id)
|
|
684
|
+
except httpx.HTTPError as exc:
|
|
685
|
+
log(f"Project status check failed ({exc}), retrying...")
|
|
686
|
+
await asyncio.sleep(poll_interval)
|
|
687
|
+
continue
|
|
688
|
+
if status_resp.get("code") != 200:
|
|
689
|
+
print_error("TASK_NOT_FOUND", status_resp.get("message"))
|
|
690
|
+
return
|
|
691
|
+
proj_data = status_resp["data"]
|
|
692
|
+
task_status = proj_data.get("status", "")
|
|
693
|
+
log(f"Project {task_id} status: {task_status}")
|
|
694
|
+
if task_status.upper() in ("COMPLETED",):
|
|
695
|
+
break
|
|
696
|
+
if task_status.upper() in ("FAILED", "CANCEL"):
|
|
697
|
+
print_error("NETWORK_ERROR", f"Project failed: {task_id}")
|
|
698
|
+
return
|
|
699
|
+
await asyncio.sleep(poll_interval)
|
|
700
|
+
|
|
701
|
+
# Step 4: Download result
|
|
702
|
+
result_url = _extract_result_url(proj_data)
|
|
703
|
+
if not result_url:
|
|
704
|
+
print_error("NETWORK_ERROR", "No result URL available")
|
|
705
|
+
return
|
|
706
|
+
out_path = output or cfg.default_output_dir / f"{task_id}.mp4"
|
|
707
|
+
file_size = await client.download_result(result_url, out_path)
|
|
708
|
+
delivery_hint = ("telegram_direct" if file_size <= TELEGRAM_FILE_SIZE_LIMIT
|
|
709
|
+
else "link_only")
|
|
710
|
+
|
|
711
|
+
print_success({
|
|
712
|
+
"task_id": task_id,
|
|
713
|
+
"status": task_status,
|
|
714
|
+
"file_path": str(out_path),
|
|
715
|
+
"file_size": file_size,
|
|
716
|
+
"result_url": result_url,
|
|
717
|
+
"delivery_hint": delivery_hint,
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
_run_async(_run)
|
sparki/client.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""HTTP API client wrapping all Sparki C-end API endpoints."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SparkiClient:
|
|
10
|
+
def __init__(self, base_url: str, api_key: str):
|
|
11
|
+
self.base_url = base_url.rstrip("/")
|
|
12
|
+
self.api_key = api_key
|
|
13
|
+
self._headers = {"X-API-Key": api_key}
|
|
14
|
+
|
|
15
|
+
def _url(self, path: str) -> str:
|
|
16
|
+
return f"{self.base_url}{path}"
|
|
17
|
+
|
|
18
|
+
async def validate_key(self) -> bool:
|
|
19
|
+
async with httpx.AsyncClient() as c:
|
|
20
|
+
resp = await c.get(self._url("/api/v1/account/info"),
|
|
21
|
+
headers=self._headers)
|
|
22
|
+
return resp.status_code == 200
|
|
23
|
+
|
|
24
|
+
async def upload_asset(self, file_path: Path) -> dict[str, Any]:
|
|
25
|
+
async with httpx.AsyncClient(timeout=300) as c:
|
|
26
|
+
with open(file_path, "rb") as f:
|
|
27
|
+
files = {"file": (file_path.name, f,
|
|
28
|
+
f"video/{file_path.suffix.lstrip('.').lower()}")}
|
|
29
|
+
resp = await c.post(self._url("/api/v1/assets/upload"),
|
|
30
|
+
headers=self._headers, files=files)
|
|
31
|
+
return resp.json()
|
|
32
|
+
|
|
33
|
+
async def list_assets(self, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
|
34
|
+
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
|
35
|
+
async with httpx.AsyncClient() as c:
|
|
36
|
+
resp = await c.get(self._url("/api/v1/assets/user_assets"),
|
|
37
|
+
headers=self._headers, params=params)
|
|
38
|
+
return resp.json()
|
|
39
|
+
|
|
40
|
+
async def get_asset_status(self, object_key: str) -> dict[str, Any]:
|
|
41
|
+
page = 1
|
|
42
|
+
page_size = 50
|
|
43
|
+
while True:
|
|
44
|
+
result = await self.list_assets(page=page, page_size=page_size)
|
|
45
|
+
data = result.get("data", {})
|
|
46
|
+
items = data.get("assets", data.get("items", []))
|
|
47
|
+
for item in items:
|
|
48
|
+
if item.get("object_key") == object_key or item.get("objectKey") == object_key:
|
|
49
|
+
return {"code": result.get("code"), "data": item}
|
|
50
|
+
if len(items) < page_size:
|
|
51
|
+
break
|
|
52
|
+
page += 1
|
|
53
|
+
return {"code": result.get("code"), "data": None}
|
|
54
|
+
|
|
55
|
+
async def create_project(
|
|
56
|
+
self,
|
|
57
|
+
object_keys: list[str],
|
|
58
|
+
tags: list[str],
|
|
59
|
+
user_input: str = "",
|
|
60
|
+
aspect_ratio: str = "9:16",
|
|
61
|
+
agent_type: str | None = None,
|
|
62
|
+
duration_range: str | None = None,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
resources = [{"idx": i, "s3_object_key": key}
|
|
65
|
+
for i, key in enumerate(object_keys)]
|
|
66
|
+
body: dict[str, Any] = {
|
|
67
|
+
"resources": resources,
|
|
68
|
+
"user_input": user_input,
|
|
69
|
+
"generation_preferences": {"aspect_ratio": aspect_ratio},
|
|
70
|
+
"send_after_create": True,
|
|
71
|
+
}
|
|
72
|
+
if tags:
|
|
73
|
+
body["tags"] = tags
|
|
74
|
+
if agent_type:
|
|
75
|
+
body["agent_type"] = agent_type
|
|
76
|
+
if duration_range:
|
|
77
|
+
body["generation_preferences"]["duration_range"] = duration_range
|
|
78
|
+
async with httpx.AsyncClient() as c:
|
|
79
|
+
resp = await c.post(self._url("/api/v1/projects/"),
|
|
80
|
+
headers=self._headers, json=body)
|
|
81
|
+
return resp.json()
|
|
82
|
+
|
|
83
|
+
async def get_project_status(self, task_id: str) -> dict[str, Any]:
|
|
84
|
+
async with httpx.AsyncClient() as c:
|
|
85
|
+
resp = await c.get(self._url(f"/api/v1/projects/task/{task_id}"),
|
|
86
|
+
headers=self._headers)
|
|
87
|
+
return resp.json()
|
|
88
|
+
|
|
89
|
+
async def create_emulate_project(
|
|
90
|
+
self,
|
|
91
|
+
source_object_keys: list[str],
|
|
92
|
+
reference_object_key: str | None = None,
|
|
93
|
+
reference_url: str | None = None,
|
|
94
|
+
user_input: str = "",
|
|
95
|
+
aspect_ratio: str = "9:16",
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
ref: dict[str, Any] = {"idx": 0, "media_type": "video"}
|
|
98
|
+
if reference_object_key:
|
|
99
|
+
ref["s3_object_key"] = reference_object_key
|
|
100
|
+
elif reference_url:
|
|
101
|
+
ref["video_url"] = reference_url
|
|
102
|
+
|
|
103
|
+
sources = [{"idx": i, "s3_object_key": key, "media_type": "video"}
|
|
104
|
+
for i, key in enumerate(source_object_keys)]
|
|
105
|
+
|
|
106
|
+
body: dict[str, Any] = {
|
|
107
|
+
"reference_resource": [ref],
|
|
108
|
+
"resources": sources,
|
|
109
|
+
"user_input": user_input,
|
|
110
|
+
"send_after_create": True,
|
|
111
|
+
"generation_preferences": {"aspect_ratio": aspect_ratio},
|
|
112
|
+
}
|
|
113
|
+
async with httpx.AsyncClient() as c:
|
|
114
|
+
resp = await c.post(self._url("/api/v1/emulate/projects/"),
|
|
115
|
+
headers=self._headers, json=body)
|
|
116
|
+
return resp.json()
|
|
117
|
+
|
|
118
|
+
async def get_emulate_project_status(self, task_id: str) -> dict[str, Any]:
|
|
119
|
+
async with httpx.AsyncClient() as c:
|
|
120
|
+
resp = await c.get(
|
|
121
|
+
self._url(f"/api/v1/emulate/projects/task/{task_id}"),
|
|
122
|
+
headers=self._headers)
|
|
123
|
+
return resp.json()
|
|
124
|
+
|
|
125
|
+
async def download_result(self, url: str, output_path: Path) -> int:
|
|
126
|
+
async with httpx.AsyncClient(timeout=600, follow_redirects=True) as c:
|
|
127
|
+
async with c.stream("GET", url) as resp:
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
total = 0
|
|
131
|
+
with open(output_path, "wb") as f:
|
|
132
|
+
async for chunk in resp.aiter_bytes(chunk_size=1024 * 1024):
|
|
133
|
+
f.write(chunk)
|
|
134
|
+
total += len(chunk)
|
|
135
|
+
return total
|
sparki/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Configuration management — API key, base URL, output directory."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from sparki.constants import DEFAULT_BASE_URL, DEFAULT_UPLOAD_TG_LINK
|
|
8
|
+
|
|
9
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".openclaw" / "config"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Config:
|
|
13
|
+
def __init__(self, config_dir: Path | None = None):
|
|
14
|
+
self.config_dir = config_dir or DEFAULT_CONFIG_DIR
|
|
15
|
+
self.config_file = self.config_dir / "sparki.json"
|
|
16
|
+
self._data: dict = {}
|
|
17
|
+
if self.config_file.exists():
|
|
18
|
+
self._data = json.loads(self.config_file.read_text())
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def api_key(self) -> str | None:
|
|
22
|
+
env_key = os.environ.get("SPARKI_API_KEY")
|
|
23
|
+
if env_key:
|
|
24
|
+
return env_key
|
|
25
|
+
return self._data.get("api_key")
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def base_url(self) -> str:
|
|
29
|
+
return self._data.get("base_url", DEFAULT_BASE_URL)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def default_output_dir(self) -> Path:
|
|
33
|
+
configured = self._data.get("default_output_dir")
|
|
34
|
+
if configured:
|
|
35
|
+
return Path(configured).expanduser()
|
|
36
|
+
return Path.home() / ".openclaw" / "workspace" / "sparki" / "videos"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def upload_tg(self) -> str:
|
|
40
|
+
env = os.environ.get("SPARKI_UPLOAD_TG_LINK")
|
|
41
|
+
if env:
|
|
42
|
+
return env
|
|
43
|
+
return self._data.get("upload_tg", DEFAULT_UPLOAD_TG_LINK)
|
|
44
|
+
|
|
45
|
+
def save(self, api_key: str | None = None, base_url: str | None = None, default_output_dir: str | None = None) -> None:
|
|
46
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
if api_key is not None:
|
|
48
|
+
self._data["api_key"] = api_key
|
|
49
|
+
if base_url is not None:
|
|
50
|
+
self._data["base_url"] = base_url
|
|
51
|
+
elif "base_url" not in self._data:
|
|
52
|
+
self._data["base_url"] = DEFAULT_BASE_URL
|
|
53
|
+
if default_output_dir is not None:
|
|
54
|
+
self._data["default_output_dir"] = default_output_dir
|
|
55
|
+
self.config_file.write_text(json.dumps(self._data, indent=2))
|
sparki/constants.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Edit modes, style catalog, error codes, and limit constants."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EditMode(StrEnum):
|
|
7
|
+
STYLE_GUIDED = "style-guided"
|
|
8
|
+
PROMPT_DRIVEN = "prompt-driven"
|
|
9
|
+
STYLE_CLONE = "style-clone"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
STYLE_CATALOG: dict[str, dict[str | None, dict]] = {
|
|
13
|
+
"vlog": {
|
|
14
|
+
"energetic-sports": {"tags": ["19"], "agent_type": None},
|
|
15
|
+
"funny-commentary": {"tags": ["20"], "agent_type": None},
|
|
16
|
+
"daily": {"tags": ["21"], "agent_type": "vlog"},
|
|
17
|
+
"upbeat-energy": {"tags": ["22"], "agent_type": None},
|
|
18
|
+
"chill-vibe": {"tags": ["23"], "agent_type": "vlog"},
|
|
19
|
+
},
|
|
20
|
+
"montage": {
|
|
21
|
+
"highlight-reel": {"tags": ["28"], "agent_type": None},
|
|
22
|
+
"hype-beatsync": {"tags": ["29"], "agent_type": None},
|
|
23
|
+
"creative-splitscreen": {"tags": ["30"], "agent_type": None},
|
|
24
|
+
"meme-moments": {"tags": ["31"], "agent_type": None},
|
|
25
|
+
},
|
|
26
|
+
"commentary": {
|
|
27
|
+
"tiktok-trending-recap": {"tags": ["24"], "agent_type": None},
|
|
28
|
+
"funny-commentary": {"tags": ["25"], "agent_type": None},
|
|
29
|
+
"master-storyteller": {"tags": ["26"], "agent_type": None},
|
|
30
|
+
"first-person-narration": {"tags": ["27"], "agent_type": None},
|
|
31
|
+
},
|
|
32
|
+
"talking-head": {
|
|
33
|
+
"tutorial": {"tags": ["32"], "agent_type": None},
|
|
34
|
+
"podcast-interview": {"tags": ["33"], "agent_type": None},
|
|
35
|
+
"product-review": {"tags": ["34"], "agent_type": None},
|
|
36
|
+
"reaction-commentary": {"tags": ["35"], "agent_type": None},
|
|
37
|
+
},
|
|
38
|
+
"long-to-short": {
|
|
39
|
+
None: {"tags": [], "agent_type": None,
|
|
40
|
+
"default_prompt": "Find hooks, highlights, and turn the video into viral shorts"},
|
|
41
|
+
},
|
|
42
|
+
"ai-caption": {
|
|
43
|
+
None: {"tags": [], "agent_type": None,
|
|
44
|
+
"default_prompt": "Add captions to the video"},
|
|
45
|
+
},
|
|
46
|
+
"video-resizer": {
|
|
47
|
+
None: {"tags": [], "agent_type": None,
|
|
48
|
+
"default_prompt": "Reframe the video for the target aspect ratio"},
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
VALID_DURATION_RANGES = {"<30s", "30s~60s", "60s~90s", ">90s", "custom"}
|
|
53
|
+
|
|
54
|
+
TELEGRAM_FILE_SIZE_LIMIT = 100 * 1024 * 1024
|
|
55
|
+
MAX_UPLOAD_SIZE = 3 * 1024 * 1024 * 1024
|
|
56
|
+
ALLOWED_EXTENSIONS = {"mp4", "mov"}
|
|
57
|
+
MAX_FILES_PER_UPLOAD = 10
|
|
58
|
+
DEFAULT_ASSET_POLL_INTERVAL = 10
|
|
59
|
+
DEFAULT_ASSET_POLL_TIMEOUT = 1200
|
|
60
|
+
DEFAULT_PROJECT_POLL_INTERVAL = 30
|
|
61
|
+
DEFAULT_PROJECT_POLL_TIMEOUT = 3600
|
|
62
|
+
DEFAULT_BASE_URL = "https://agent-api.sparki.io"
|
|
63
|
+
DEFAULT_UPLOAD_TG_LINK = "https://t.me/Sparki_AI_bot/upload"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_style(style: str) -> bool:
|
|
67
|
+
if not style:
|
|
68
|
+
return False
|
|
69
|
+
if "/" in style:
|
|
70
|
+
category, sub = style.split("/", 1)
|
|
71
|
+
return category in STYLE_CATALOG and sub in STYLE_CATALOG[category]
|
|
72
|
+
else:
|
|
73
|
+
return style in STYLE_CATALOG and None in STYLE_CATALOG[style]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def style_to_payload(style: str) -> dict:
|
|
77
|
+
if "/" in style:
|
|
78
|
+
category, sub = style.split("/", 1)
|
|
79
|
+
return STYLE_CATALOG[category][sub]
|
|
80
|
+
else:
|
|
81
|
+
return STYLE_CATALOG[style][None]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def all_styles_list() -> list[str]:
|
|
85
|
+
styles = []
|
|
86
|
+
for category, subs in STYLE_CATALOG.items():
|
|
87
|
+
for sub in subs:
|
|
88
|
+
if sub is None:
|
|
89
|
+
styles.append(category)
|
|
90
|
+
else:
|
|
91
|
+
styles.append(f"{category}/{sub}")
|
|
92
|
+
return styles
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
ERROR_CODES: dict[str, dict[str, str]] = {
|
|
96
|
+
"AUTH_FAILED": {
|
|
97
|
+
"message": "Invalid or missing API key",
|
|
98
|
+
"action": "Run `sparki setup --api-key <key>` or get a key from @sparki_bot on Telegram",
|
|
99
|
+
},
|
|
100
|
+
"QUOTA_EXCEEDED": {
|
|
101
|
+
"message": "API quota exhausted",
|
|
102
|
+
"action": "Visit https://sparki.io/pricing to upgrade",
|
|
103
|
+
},
|
|
104
|
+
"FILE_TOO_LARGE": {
|
|
105
|
+
"message": "File exceeds 3GB limit",
|
|
106
|
+
"action": "Compress or trim the video before uploading",
|
|
107
|
+
},
|
|
108
|
+
"CONCURRENT_LIMIT": {
|
|
109
|
+
"message": "Too many projects running",
|
|
110
|
+
"action": "Wait for a running project to complete",
|
|
111
|
+
},
|
|
112
|
+
"INVALID_FILE_FORMAT": {
|
|
113
|
+
"message": "Unsupported file format",
|
|
114
|
+
"action": "Only mp4 and mov files are supported",
|
|
115
|
+
},
|
|
116
|
+
"INVALID_STYLE": {
|
|
117
|
+
"message": "Unknown style",
|
|
118
|
+
"action": "See available styles with `sparki edit --help`",
|
|
119
|
+
},
|
|
120
|
+
"INVALID_MODE": {
|
|
121
|
+
"message": "Unknown edit mode",
|
|
122
|
+
"action": "Choose from: style-guided, prompt-driven, style-clone",
|
|
123
|
+
},
|
|
124
|
+
"INVALID_REFERENCE": {
|
|
125
|
+
"message": "Reference video required for style-clone mode",
|
|
126
|
+
"action": "Provide --reference-url, --reference-file, or --reference-tg",
|
|
127
|
+
},
|
|
128
|
+
"UPLOAD_FAILED": {
|
|
129
|
+
"message": "Upload failed",
|
|
130
|
+
"action": "Check your network connection and try again",
|
|
131
|
+
},
|
|
132
|
+
"RENDER_TIMEOUT": {
|
|
133
|
+
"message": "Video processing timed out",
|
|
134
|
+
"action": "Try a shorter clip or increase --timeout",
|
|
135
|
+
},
|
|
136
|
+
"TASK_NOT_FOUND": {
|
|
137
|
+
"message": "Task not found",
|
|
138
|
+
"action": "Run `sparki history` to see your recent tasks",
|
|
139
|
+
},
|
|
140
|
+
"NETWORK_ERROR": {
|
|
141
|
+
"message": "Cannot reach Sparki servers",
|
|
142
|
+
"action": "Check your internet connection and try again",
|
|
143
|
+
},
|
|
144
|
+
}
|
sparki/models.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Pydantic models for API responses and CLI output."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, computed_field
|
|
4
|
+
|
|
5
|
+
from sparki.constants import TELEGRAM_FILE_SIZE_LIMIT
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AssetInfo(BaseModel):
|
|
9
|
+
object_key: str
|
|
10
|
+
file_name: str
|
|
11
|
+
status: int
|
|
12
|
+
file_size: int
|
|
13
|
+
is_duplicate: bool = False
|
|
14
|
+
duration: float | None = None
|
|
15
|
+
resolution: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UploadResponse(BaseModel):
|
|
19
|
+
object_key: str
|
|
20
|
+
file_name: str
|
|
21
|
+
file_size: int
|
|
22
|
+
status: int
|
|
23
|
+
is_duplicate: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProjectInfo(BaseModel):
|
|
27
|
+
task_id: str
|
|
28
|
+
status: str
|
|
29
|
+
created_at: str | None = None
|
|
30
|
+
result_url: str | None = None
|
|
31
|
+
thumbnail_url: str | None = None
|
|
32
|
+
duration: float | None = None
|
|
33
|
+
resolution: str | None = None
|
|
34
|
+
filesize: int | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DownloadResult(BaseModel):
|
|
38
|
+
task_id: str
|
|
39
|
+
file_path: str
|
|
40
|
+
file_size: int
|
|
41
|
+
result_url: str
|
|
42
|
+
|
|
43
|
+
@computed_field
|
|
44
|
+
@property
|
|
45
|
+
def delivery_hint(self) -> str:
|
|
46
|
+
return "telegram_direct" if self.file_size <= TELEGRAM_FILE_SIZE_LIMIT else "link_only"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RunResult(BaseModel):
|
|
50
|
+
task_id: str
|
|
51
|
+
status: str
|
|
52
|
+
file_path: str
|
|
53
|
+
file_size: int
|
|
54
|
+
result_url: str
|
|
55
|
+
|
|
56
|
+
@computed_field
|
|
57
|
+
@property
|
|
58
|
+
def delivery_hint(self) -> str:
|
|
59
|
+
return "telegram_direct" if self.file_size <= TELEGRAM_FILE_SIZE_LIMIT else "link_only"
|
sparki/output.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Unified JSON output formatter for all CLI commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sparki.constants import ERROR_CODES
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def success(data: dict[str, Any]) -> str:
|
|
11
|
+
return json.dumps({"ok": True, "data": data, "error": None}, ensure_ascii=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_error(code: str, message: str | None = None) -> dict[str, str]:
|
|
15
|
+
defaults = ERROR_CODES.get(code, {
|
|
16
|
+
"message": "An unexpected error occurred",
|
|
17
|
+
"action": "Please try again or contact support",
|
|
18
|
+
})
|
|
19
|
+
return {
|
|
20
|
+
"code": code,
|
|
21
|
+
"message": message or defaults["message"],
|
|
22
|
+
"action": defaults["action"],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def error(code: str, message: str | None = None) -> str:
|
|
27
|
+
return json.dumps(
|
|
28
|
+
{"ok": False, "data": None, "error": format_error(code, message)},
|
|
29
|
+
ensure_ascii=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def print_success(data: dict[str, Any]) -> None:
|
|
34
|
+
print(success(data))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def print_error(code: str, message: str | None = None) -> None:
|
|
38
|
+
print(error(code, message))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def log(msg: str) -> None:
|
|
42
|
+
print(msg, file=sys.stderr)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sparki-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Sparki video editor CLI
|
|
5
|
+
Author: Sparki AI
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: httpx>=0.27.0
|
|
15
|
+
Requires-Dist: pydantic>=2.0.0
|
|
16
|
+
Requires-Dist: typer>=0.9.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Sparki
|
|
20
|
+
|
|
21
|
+
AI-powered video editing from the command line.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pip install sparki-cli
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
uv tool install sparki-cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Save your API key
|
|
39
|
+
sparki setup --api-key YOUR_KEY
|
|
40
|
+
|
|
41
|
+
# Upload, edit, and download in one step
|
|
42
|
+
sparki run --file video.mp4 --mode style-guided --style vlog/daily
|
|
43
|
+
|
|
44
|
+
# Or step by step
|
|
45
|
+
sparki upload --file video.mp4
|
|
46
|
+
sparki edit --object-key KEY --mode prompt-driven --prompt "Make a highlight reel"
|
|
47
|
+
sparki status --task-id TASK_ID
|
|
48
|
+
sparki download --task-id TASK_ID
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Commands
|
|
52
|
+
|
|
53
|
+
| Command | Description |
|
|
54
|
+
|---------|-------------|
|
|
55
|
+
| `setup` | Save and validate your API key |
|
|
56
|
+
| `upload` | Upload video files |
|
|
57
|
+
| `assets` | List uploaded assets |
|
|
58
|
+
| `edit` | Create an edit project |
|
|
59
|
+
| `status` | Check project status |
|
|
60
|
+
| `download` | Download completed result |
|
|
61
|
+
| `run` | End-to-end: upload → edit → download |
|
|
62
|
+
| `history` | List recent projects |
|
|
63
|
+
|
|
64
|
+
## Edit Modes
|
|
65
|
+
|
|
66
|
+
- **style-guided** — Choose from preset styles (e.g. `vlog/daily`, `montage/highlight-reel`)
|
|
67
|
+
- **prompt-driven** — Describe what you want in natural language
|
|
68
|
+
- **style-clone** — Clone the style of a reference video
|
|
69
|
+
|
|
70
|
+
## Links
|
|
71
|
+
|
|
72
|
+
- Website: https://sparki.io
|
|
73
|
+
- Telegram: https://t.me/Sparki_AI_bot
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
sparki/__init__.py,sha256=ma_MWG7nIP75DiC8DoydG1CQPoQdRofU7uRTDyLAzMA,112
|
|
2
|
+
sparki/cli.py,sha256=mq3-6xwwbTEq4sK9D2rZlEHzacw22LfaQqnHvQHWO_0,25968
|
|
3
|
+
sparki/client.py,sha256=nZnrFd16ZWNxsRIc9p9k7kEREdUfmS7vt-RRmDdwCAk,5493
|
|
4
|
+
sparki/config.py,sha256=-uDZUFA3W9uCijBsRGcVybZt2-suaFIS4BdoU1TQu88,1957
|
|
5
|
+
sparki/constants.py,sha256=kNViOaP4VqwSEoFWh7QFBosjVuqyiVPqM7XUdKl2LRU,5119
|
|
6
|
+
sparki/models.py,sha256=zY3yak-Gl3y25-80Ah1LFWcJRgruu_6IiUo93qdT1LE,1337
|
|
7
|
+
sparki/output.py,sha256=lGTom-RhDr2PV-UAE_q0anf9nCYv4p5B-FiAIF-QUTQ,1063
|
|
8
|
+
sparki_cli-1.0.0.dist-info/METADATA,sha256=KMyRYLRSqiUN0R-5228SCR_TzZZrBNA3xbx3Q9BBK4M,1820
|
|
9
|
+
sparki_cli-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
sparki_cli-1.0.0.dist-info/entry_points.txt,sha256=cB61gHZ_-lbfAbOvk_g9Xx_DA4ikReQmDD_n4kX8sEI,42
|
|
11
|
+
sparki_cli-1.0.0.dist-info/licenses/LICENSE,sha256=VBTuXUpmn-mx-nb_NI2o2SsxjA27gDC90UdPIvKWYvM,1066
|
|
12
|
+
sparki_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sparki AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|