@optima-chat/optima-agent 0.8.95 → 0.8.96
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/gen/SKILL.md +1 -1
- package/.claude/skills/video-gen/SKILL.md +443 -0
- package/.claude/skills/video-gen/templates/lifestyle-scene.md +18 -0
- package/.claude/skills/video-gen/templates/pdp-360-showcase.md +17 -0
- package/.claude/skills/video-gen/templates/pdp-feature-highlight.md +18 -0
- package/.claude/skills/video-gen/templates/tiktok-before-after.md +17 -0
- package/.claude/skills/video-gen/templates/tiktok-product-reveal.md +17 -0
- package/.claude/skills/video-gen/templates/tiktok-unboxing.md +18 -0
- package/package.json +1 -1
- package/.claude/skills/video-clone/SKILL.md +0 -199
- package/.claude/skills/video-clone/assets/phase-state-template.json +0 -11
- package/.claude/skills/video-clone/references/ffmpeg-commands.md +0 -42
- package/.claude/skills/video-clone/references/gate-enforcement.md +0 -144
- package/.claude/skills/video-clone/references/kling-api.md +0 -85
- package/.claude/skills/video-clone/references/prompt-template.md +0 -71
- package/.claude/skills/video-clone/references/url-parsing.md +0 -32
- package/.claude/skills/video-clone/references/workflow-system.md +0 -92
- package/.claude/skills/video-clone/scripts/_confirm.py +0 -96
- package/.claude/skills/video-clone/scripts/_confirm_test.py +0 -125
- package/.claude/skills/video-clone/scripts/_gate.py +0 -162
- package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +0 -226
- package/.claude/skills/video-clone/scripts/_gate_test.py +0 -148
- package/.claude/skills/video-clone/scripts/_project.py +0 -56
- package/.claude/skills/video-clone/scripts/analyze_source.py +0 -113
- package/.claude/skills/video-clone/scripts/analyze_source_test.py +0 -52
- package/.claude/skills/video-clone/scripts/assemble.py +0 -106
- package/.claude/skills/video-clone/scripts/confirm.py +0 -12
- package/.claude/skills/video-clone/scripts/edit_first_frame.py +0 -66
- package/.claude/skills/video-clone/scripts/extract_frames.py +0 -108
- package/.claude/skills/video-clone/scripts/gen_video.py +0 -59
- package/.claude/skills/video-clone/scripts/init_project.py +0 -103
- package/.claude/skills/video-clone/scripts/init_project_test.py +0 -106
- package/.claude/skills/video-clone/scripts/kling_generate.py +0 -262
- package/.claude/skills/video-clone/scripts/kling_generate_test.py +0 -191
- package/.claude/skills/video-clone/scripts/preflight.py +0 -102
- package/.claude/skills/video-clone/scripts/preview.py +0 -208
- package/.claude/skills/video-clone/scripts/preview_test.py +0 -169
- package/.claude/skills/video-clone/scripts/save_workflow.py +0 -129
- package/.claude/skills/video-clone/scripts/save_workflow_test.py +0 -106
- package/.claude/skills/video-clone/scripts/status.py +0 -202
- package/.claude/skills/video-clone/scripts/status_test.py +0 -174
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
"""Phase 3 (no-audio path): generate video via `gen video` (Wan 2.6 I2V).
|
|
2
|
-
|
|
3
|
-
Usage:
|
|
4
|
-
python scripts/gen_video.py --project <name> --frame <path> \
|
|
5
|
-
[--duration 10]
|
|
6
|
-
|
|
7
|
-
Use this instead of kling_generate.py when the video does NOT need audio /
|
|
8
|
-
lip sync (cost ~$0.02 vs ~$1). Prompt is read from <project>/prompt.md.
|
|
9
|
-
|
|
10
|
-
Gate: requires preview_confirmed.
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import argparse
|
|
15
|
-
import subprocess
|
|
16
|
-
import sys
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
from _gate import require_gate
|
|
20
|
-
from _project import resolve_project, next_version, append_log
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def main() -> int:
|
|
24
|
-
ap = argparse.ArgumentParser()
|
|
25
|
-
ap.add_argument("--project", required=True)
|
|
26
|
-
ap.add_argument("--frame", required=True)
|
|
27
|
-
ap.add_argument("--duration", type=int, default=10, choices=[5, 10])
|
|
28
|
-
args = ap.parse_args()
|
|
29
|
-
|
|
30
|
-
project_dir = resolve_project(args.project)
|
|
31
|
-
require_gate(project_dir, "preview_confirmed")
|
|
32
|
-
|
|
33
|
-
frame = Path(args.frame)
|
|
34
|
-
if not frame.is_file():
|
|
35
|
-
print(f"ERROR: frame not found at {frame}", file=sys.stderr)
|
|
36
|
-
return 1
|
|
37
|
-
|
|
38
|
-
prompt_path = project_dir / "prompt.md"
|
|
39
|
-
if not prompt_path.is_file():
|
|
40
|
-
print(f"ERROR: {prompt_path} missing. Write prompt.md first.", file=sys.stderr)
|
|
41
|
-
return 1
|
|
42
|
-
prompt = prompt_path.read_text(encoding="utf-8").strip()
|
|
43
|
-
|
|
44
|
-
out = next_version(project_dir / "videos", f"video_{args.duration}s_", ".mp4")
|
|
45
|
-
cmd = ["gen", "video", "-i", str(frame), "-o", str(out),
|
|
46
|
-
"-p", prompt, "--duration", str(args.duration)]
|
|
47
|
-
print(f"Running: {' '.join(cmd[:4])} … (prompt trimmed)")
|
|
48
|
-
r = subprocess.run(cmd)
|
|
49
|
-
if r.returncode != 0:
|
|
50
|
-
print(f"ERROR: gen video failed with exit {r.returncode}", file=sys.stderr)
|
|
51
|
-
return r.returncode
|
|
52
|
-
|
|
53
|
-
print(f"\nDone: {out}")
|
|
54
|
-
append_log(project_dir, f"video_generated_silent → {out.name}")
|
|
55
|
-
return 0
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if __name__ == "__main__":
|
|
59
|
-
sys.exit(main())
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
"""Initialize a new video-clone project directory + phase state.
|
|
2
|
-
|
|
3
|
-
Usage:
|
|
4
|
-
python scripts/init_project.py --name <project-name> --task-type video_clone
|
|
5
|
-
python scripts/init_project.py --name <project-name> --task-type video_gen
|
|
6
|
-
|
|
7
|
-
Creates:
|
|
8
|
-
<gen-output>/video-clone/<name>/
|
|
9
|
-
source/
|
|
10
|
-
frames/
|
|
11
|
-
videos/
|
|
12
|
-
.state/phase.json (copied from assets/phase-state-template.json)
|
|
13
|
-
|
|
14
|
-
By default <gen-output> is `./gen-output`. Override with GEN_OUTPUT_ROOT env.
|
|
15
|
-
|
|
16
|
-
Refuses to overwrite an existing project directory — pick a new name or
|
|
17
|
-
bump a version suffix (e.g. handheld-phone-swap-v2).
|
|
18
|
-
"""
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import argparse
|
|
22
|
-
import json
|
|
23
|
-
import os
|
|
24
|
-
import sys
|
|
25
|
-
from datetime import datetime, timezone
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
|
|
28
|
-
VALID_TASK_TYPES = ("video_clone", "video_gen")
|
|
29
|
-
|
|
30
|
-
SKILL_ROOT = Path(__file__).parent.parent
|
|
31
|
-
TEMPLATE = SKILL_ROOT / "assets" / "phase-state-template.json"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _gen_output_root() -> Path:
|
|
35
|
-
override = os.environ.get("GEN_OUTPUT_ROOT")
|
|
36
|
-
if override:
|
|
37
|
-
return Path(override)
|
|
38
|
-
return Path.cwd() / "gen-output"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _now_iso() -> str:
|
|
42
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def main() -> int:
|
|
46
|
-
ap = argparse.ArgumentParser(description="Initialize a video-clone project.")
|
|
47
|
-
ap.add_argument("--name", required=True, help="Project name (slug).")
|
|
48
|
-
ap.add_argument(
|
|
49
|
-
"--task-type",
|
|
50
|
-
required=True,
|
|
51
|
-
choices=VALID_TASK_TYPES,
|
|
52
|
-
help="video_clone or video_gen",
|
|
53
|
-
)
|
|
54
|
-
args = ap.parse_args()
|
|
55
|
-
|
|
56
|
-
if not TEMPLATE.is_file():
|
|
57
|
-
print(f"ERROR: state template missing at {TEMPLATE}", file=sys.stderr)
|
|
58
|
-
return 1
|
|
59
|
-
|
|
60
|
-
base = _gen_output_root() / "video-clone" / args.name
|
|
61
|
-
if base.exists():
|
|
62
|
-
print(
|
|
63
|
-
f"ERROR: project already exists at {base}\n"
|
|
64
|
-
f"Pick a new name or bump a version suffix (e.g. {args.name}-v2).",
|
|
65
|
-
file=sys.stderr,
|
|
66
|
-
)
|
|
67
|
-
return 1
|
|
68
|
-
|
|
69
|
-
for sub in ("source", "frames", "videos", ".state"):
|
|
70
|
-
(base / sub).mkdir(parents=True)
|
|
71
|
-
|
|
72
|
-
state = json.loads(TEMPLATE.read_text(encoding="utf-8"))
|
|
73
|
-
state["project"] = args.name
|
|
74
|
-
state["task_type"] = args.task_type
|
|
75
|
-
state["created_at"] = _now_iso()
|
|
76
|
-
state["history"].append({"event": "project_created", "at": state["created_at"]})
|
|
77
|
-
|
|
78
|
-
(base / ".state" / "phase.json").write_text(
|
|
79
|
-
json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
print(f"Created project: {base}")
|
|
83
|
-
print(f" phase state: {base / '.state' / 'phase.json'}")
|
|
84
|
-
print()
|
|
85
|
-
print("Next steps:")
|
|
86
|
-
print(" 1. (Phase 0 sanity check) Tell the user one sentence describing your")
|
|
87
|
-
print(" understanding of the task and reference workflow. Wait for 'yes'.")
|
|
88
|
-
print(" 2. Run prep autonomously: analyze_source → extract_frames → write")
|
|
89
|
-
print(" prompt.md → edit_first_frame → write cost.json → preview.py")
|
|
90
|
-
print(f" 3. Show preview to user, then run:")
|
|
91
|
-
print(
|
|
92
|
-
f" python scripts/confirm.py --project {args.name} "
|
|
93
|
-
f"--quote \"<user's actual confirmation>\""
|
|
94
|
-
)
|
|
95
|
-
print(f" 4. Run kling_generate.py / gen_video.py → assemble.py")
|
|
96
|
-
print()
|
|
97
|
-
print(f"At any time, check status:")
|
|
98
|
-
print(f" python scripts/status.py --project {args.name}")
|
|
99
|
-
return 0
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if __name__ == "__main__":
|
|
103
|
-
sys.exit(main())
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
"""Tests for init_project.py.
|
|
2
|
-
|
|
3
|
-
Uses GEN_OUTPUT_ROOT env override to isolate the tests from the real
|
|
4
|
-
gen-output/ directory.
|
|
5
|
-
|
|
6
|
-
Run: python scripts/init_project_test.py
|
|
7
|
-
"""
|
|
8
|
-
import json
|
|
9
|
-
import os
|
|
10
|
-
import shutil
|
|
11
|
-
import subprocess
|
|
12
|
-
import sys
|
|
13
|
-
import tempfile
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
SCRIPTS = Path(__file__).parent
|
|
17
|
-
INIT = SCRIPTS / "init_project.py"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _run(root: Path, *args: str) -> subprocess.CompletedProcess:
|
|
21
|
-
env = os.environ.copy()
|
|
22
|
-
env["GEN_OUTPUT_ROOT"] = str(root)
|
|
23
|
-
return subprocess.run(
|
|
24
|
-
[sys.executable, str(INIT), *args],
|
|
25
|
-
capture_output=True, text=True, env=env,
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_creates_full_structure():
|
|
30
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
31
|
-
root = Path(tmp)
|
|
32
|
-
r = _run(root, "--name", "myproj", "--task-type", "video_clone")
|
|
33
|
-
assert r.returncode == 0, f"init failed: {r.stderr}"
|
|
34
|
-
base = root / "video-clone" / "myproj"
|
|
35
|
-
assert (base / "source").is_dir()
|
|
36
|
-
assert (base / "frames").is_dir()
|
|
37
|
-
assert (base / "videos").is_dir()
|
|
38
|
-
state_file = base / ".state" / "phase.json"
|
|
39
|
-
assert state_file.is_file()
|
|
40
|
-
state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
41
|
-
assert state["project"] == "myproj"
|
|
42
|
-
assert state["task_type"] == "video_clone"
|
|
43
|
-
assert state["created_at"] is not None
|
|
44
|
-
# Single-gate model: only preview_confirmed, no plan/prompt/frame gates
|
|
45
|
-
assert "preview_confirmed" in state["gates"], (
|
|
46
|
-
f"Expected single-gate schema with 'preview_confirmed', got: {list(state['gates'])}"
|
|
47
|
-
)
|
|
48
|
-
assert state["gates"]["preview_confirmed"]["status"] is False
|
|
49
|
-
# Old 3-gate keys must NOT exist
|
|
50
|
-
for old_gate in ("plan_confirmed", "prompt_confirmed", "frame_confirmed"):
|
|
51
|
-
assert old_gate not in state["gates"], (
|
|
52
|
-
f"Old gate '{old_gate}' still present in template"
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_creates_video_gen_project():
|
|
57
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
58
|
-
root = Path(tmp)
|
|
59
|
-
r = _run(root, "--name", "genproj", "--task-type", "video_gen")
|
|
60
|
-
assert r.returncode == 0, f"init failed: {r.stderr}"
|
|
61
|
-
state_file = root / "video-clone" / "genproj" / ".state" / "phase.json"
|
|
62
|
-
state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
63
|
-
assert state["task_type"] == "video_gen"
|
|
64
|
-
assert "preview_confirmed" in state["gates"]
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_rejects_duplicate():
|
|
68
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
69
|
-
root = Path(tmp)
|
|
70
|
-
_run(root, "--name", "dup", "--task-type", "video_clone")
|
|
71
|
-
r = _run(root, "--name", "dup", "--task-type", "video_clone")
|
|
72
|
-
assert r.returncode == 1, (
|
|
73
|
-
f"expected exit 1 on duplicate, got {r.returncode}\n"
|
|
74
|
-
f"stdout: {r.stdout}\nstderr: {r.stderr}"
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def test_rejects_unknown_task_type():
|
|
79
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
80
|
-
r = _run(Path(tmp), "--name", "x", "--task-type", "nonsense")
|
|
81
|
-
assert r.returncode != 0
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
TESTS = [
|
|
85
|
-
("creates full structure", test_creates_full_structure),
|
|
86
|
-
("creates video_gen project", test_creates_video_gen_project),
|
|
87
|
-
("rejects duplicate", test_rejects_duplicate),
|
|
88
|
-
("rejects unknown task type", test_rejects_unknown_task_type),
|
|
89
|
-
]
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def main() -> int:
|
|
93
|
-
failed = 0
|
|
94
|
-
for name, fn in TESTS:
|
|
95
|
-
try:
|
|
96
|
-
fn()
|
|
97
|
-
print(f"PASS {name}")
|
|
98
|
-
except Exception as e:
|
|
99
|
-
failed += 1
|
|
100
|
-
print(f"FAIL {name}: {type(e).__name__}: {e}")
|
|
101
|
-
print(f"\n{len(TESTS) - failed}/{len(TESTS)} passed")
|
|
102
|
-
return 0 if failed == 0 else 1
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if __name__ == "__main__":
|
|
106
|
-
sys.exit(main())
|
|
@@ -1,262 +0,0 @@
|
|
|
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.
|
|
6
|
-
|
|
7
|
-
Usage:
|
|
8
|
-
python scripts/kling_generate.py --project <name> --frame <path> \
|
|
9
|
-
[--duration 10] [--aspect-ratio 9:16] [--mode std] \
|
|
10
|
-
[--cfg-scale 0.5] [--no-audio]
|
|
11
|
-
|
|
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.
|
|
23
|
-
|
|
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).
|
|
30
|
-
|
|
31
|
-
Reads prompt from <project>/prompt.md. Writes output with a versioned filename.
|
|
32
|
-
"""
|
|
33
|
-
from __future__ import annotations
|
|
34
|
-
|
|
35
|
-
import argparse
|
|
36
|
-
import base64
|
|
37
|
-
import json
|
|
38
|
-
import os
|
|
39
|
-
import sys
|
|
40
|
-
import time
|
|
41
|
-
from pathlib import Path
|
|
42
|
-
|
|
43
|
-
from _gate import require_gate
|
|
44
|
-
from _project import resolve_project, next_version, append_log
|
|
45
|
-
|
|
46
|
-
try:
|
|
47
|
-
import requests # type: ignore
|
|
48
|
-
except ImportError: # pragma: no cover
|
|
49
|
-
requests = None
|
|
50
|
-
|
|
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
|
|
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
|
-
}
|
|
61
|
-
|
|
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,
|
|
91
|
-
)
|
|
92
|
-
sys.exit(1)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _get_gen_api_url() -> str:
|
|
96
|
-
"""Resolve the generation service base URL.
|
|
97
|
-
|
|
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,
|
|
115
|
-
duration: int, aspect: str, mode: str,
|
|
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
|
-
"""
|
|
122
|
-
payload = {
|
|
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,
|
|
131
|
-
}
|
|
132
|
-
r = requests.post(
|
|
133
|
-
f"{gen_url}/api/video/generate",
|
|
134
|
-
headers={"Authorization": auth, "Content-Type": "application/json"},
|
|
135
|
-
json=payload, timeout=60,
|
|
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')}")
|
|
141
|
-
r.raise_for_status()
|
|
142
|
-
return r.json()["task_id"]
|
|
143
|
-
|
|
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).
|
|
147
|
-
|
|
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()
|
|
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
|
-
)
|
|
160
|
-
r = requests.get(
|
|
161
|
-
f"{gen_url}/api/task/{task_id}",
|
|
162
|
-
headers={"Authorization": auth}, timeout=60,
|
|
163
|
-
)
|
|
164
|
-
r.raise_for_status()
|
|
165
|
-
d = r.json()
|
|
166
|
-
status = d.get("status", "")
|
|
167
|
-
if status == "completed":
|
|
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 {}
|
|
174
|
-
if status == "failed":
|
|
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)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _download(url: str, out: Path) -> None:
|
|
181
|
-
last_err = None
|
|
182
|
-
for attempt in range(DOWNLOAD_RETRIES):
|
|
183
|
-
try:
|
|
184
|
-
r = requests.get(url, timeout=300)
|
|
185
|
-
r.raise_for_status()
|
|
186
|
-
out.write_bytes(r.content)
|
|
187
|
-
return
|
|
188
|
-
except Exception as e:
|
|
189
|
-
last_err = e
|
|
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}")
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def main() -> int:
|
|
196
|
-
ap = argparse.ArgumentParser()
|
|
197
|
-
ap.add_argument("--project", required=True)
|
|
198
|
-
ap.add_argument("--frame", required=True, help="confirmed first frame")
|
|
199
|
-
ap.add_argument("--duration", type=int, default=10, choices=[5, 10])
|
|
200
|
-
ap.add_argument("--aspect-ratio", default="9:16")
|
|
201
|
-
ap.add_argument("--mode", default="std", choices=["std", "pro"])
|
|
202
|
-
ap.add_argument("--cfg-scale", type=float, default=0.5)
|
|
203
|
-
ap.add_argument("--no-audio", action="store_true")
|
|
204
|
-
args = ap.parse_args()
|
|
205
|
-
|
|
206
|
-
project_dir = resolve_project(args.project)
|
|
207
|
-
require_gate(project_dir, "preview_confirmed")
|
|
208
|
-
|
|
209
|
-
if requests is None:
|
|
210
|
-
print("ERROR: the 'requests' package is required. Install: pip install requests",
|
|
211
|
-
file=sys.stderr)
|
|
212
|
-
return 1
|
|
213
|
-
|
|
214
|
-
gen_url = _get_gen_api_url()
|
|
215
|
-
auth = _auth_header(_get_token())
|
|
216
|
-
|
|
217
|
-
frame = Path(args.frame)
|
|
218
|
-
if not frame.is_file():
|
|
219
|
-
print(f"ERROR: frame not found at {frame}", file=sys.stderr)
|
|
220
|
-
return 1
|
|
221
|
-
|
|
222
|
-
prompt_path = project_dir / "prompt.md"
|
|
223
|
-
if not prompt_path.is_file():
|
|
224
|
-
print(f"ERROR: {prompt_path} missing. Write prompt.md first.", file=sys.stderr)
|
|
225
|
-
return 1
|
|
226
|
-
prompt = prompt_path.read_text(encoding="utf-8").strip()
|
|
227
|
-
|
|
228
|
-
frame_b64 = base64.b64encode(frame.read_bytes()).decode()
|
|
229
|
-
|
|
230
|
-
print("Submitting video generation task …")
|
|
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
|
|
240
|
-
print(f" task_id: {task_id}")
|
|
241
|
-
|
|
242
|
-
print("Polling for completion …")
|
|
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}")
|
|
251
|
-
|
|
252
|
-
out = next_version(project_dir / "videos", f"video_{args.duration}s_", ".mp4")
|
|
253
|
-
print(f"Downloading → {out}")
|
|
254
|
-
_download(result_url, out)
|
|
255
|
-
|
|
256
|
-
print(f"\nDone: {out}")
|
|
257
|
-
append_log(project_dir, f"video_generated_with_audio → {out.name} (task {task_id})")
|
|
258
|
-
return 0
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if __name__ == "__main__":
|
|
262
|
-
sys.exit(main())
|