@optima-chat/optima-agent 0.8.91 → 0.8.92
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/browser/SKILL.md +8 -0
- package/.claude/skills/homepage/SKILL.md +4 -3
- package/.claude/skills/kol-outreach/SKILL.md +360 -0
- package/.claude/skills/kol-outreach/template/campaign/CONFIG.md +60 -0
- package/.claude/skills/kol-outreach/template/campaign/CONVERSATIONS/.gitkeep +0 -0
- package/.claude/skills/kol-outreach/template/campaign/KOLS.md +6 -0
- package/.claude/skills/kol-outreach/template/campaign/PROGRESS.md +3 -0
- package/.claude/skills/kol-outreach/template/campaign/TEMPLATES.md +88 -0
- package/.claude/skills/kol-outreach/template/campaign/assets/.gitkeep +0 -0
- package/.claude/skills/kol-outreach/template/merchant/BRAND.md +36 -0
- package/.claude/skills/kol-outreach/template/merchant/CAMPAIGNS.md +6 -0
- package/.claude/skills/kol-outreach/template/merchant/MERCHANT_LIMITS.md +16 -0
- package/.claude/skills/kol-outreach/template/merchant/PROGRESS.md +4 -0
- package/.claude/skills/kol-outreach/template/merchant/README.md +20 -0
- package/.claude/skills/video-clone/SKILL.md +125 -217
- package/.claude/skills/video-clone/assets/phase-state-template.json +11 -0
- package/.claude/skills/video-clone/references/ffmpeg-commands.md +31 -34
- package/.claude/skills/video-clone/references/gate-enforcement.md +144 -0
- package/.claude/skills/video-clone/references/kling-api.md +39 -72
- package/.claude/skills/video-clone/references/url-parsing.md +32 -13
- package/.claude/skills/video-clone/scripts/_confirm.py +96 -0
- package/.claude/skills/video-clone/scripts/_confirm_test.py +125 -0
- package/.claude/skills/video-clone/scripts/_gate.py +162 -0
- package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +226 -0
- package/.claude/skills/video-clone/scripts/_gate_test.py +148 -0
- package/.claude/skills/video-clone/scripts/_project.py +56 -0
- package/.claude/skills/video-clone/scripts/analyze_source.py +113 -0
- package/.claude/skills/video-clone/scripts/analyze_source_test.py +52 -0
- package/.claude/skills/video-clone/scripts/assemble.py +106 -0
- package/.claude/skills/video-clone/scripts/confirm.py +12 -0
- package/.claude/skills/video-clone/scripts/edit_first_frame.py +66 -0
- package/.claude/skills/video-clone/scripts/extract_frames.py +108 -0
- package/.claude/skills/video-clone/scripts/gen_video.py +59 -0
- package/.claude/skills/video-clone/scripts/init_project.py +103 -0
- package/.claude/skills/video-clone/scripts/init_project_test.py +106 -0
- package/.claude/skills/video-clone/scripts/kling_generate.py +182 -0
- package/.claude/skills/video-clone/scripts/preflight.py +95 -0
- package/.claude/skills/video-clone/scripts/preview.py +208 -0
- package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
- package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
- package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
- package/.claude/skills/video-clone/scripts/status.py +202 -0
- package/.claude/skills/video-clone/scripts/status_test.py +174 -0
- package/package.json +2 -1
|
@@ -0,0 +1,106 @@
|
|
|
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())
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Phase 3: generate video via Kling 3.0 over PiAPI.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python scripts/kling_generate.py --project <name> --frame <path> \
|
|
5
|
+
[--duration 10] [--aspect-ratio 9:16] [--mode std] \
|
|
6
|
+
[--cfg-scale 0.5] [--no-audio]
|
|
7
|
+
|
|
8
|
+
Pipeline (ported verbatim from references/kling-api.md):
|
|
9
|
+
1. Gate: requires preview_confirmed.
|
|
10
|
+
2. Upload first frame to freeimage.host → get public URL.
|
|
11
|
+
3. POST /api/v1/task to PiAPI with kling model + 3.0 params.
|
|
12
|
+
4. Poll /api/v1/task/{id} every 15s until status=='completed'.
|
|
13
|
+
5. Download mp4 with 3-attempt retry (5s backoff).
|
|
14
|
+
|
|
15
|
+
Reads prompt from <project>/prompt.md. Writes output to <project>/videos/
|
|
16
|
+
with a versioned filename.
|
|
17
|
+
|
|
18
|
+
Requires: PIAPI_KEY env var. Run `python scripts/preflight.py` first if unsure.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from _gate import require_gate
|
|
31
|
+
from _project import resolve_project, next_version, append_log
|
|
32
|
+
|
|
33
|
+
# Lazy import of requests so py_compile / gate checks work without the dep.
|
|
34
|
+
try:
|
|
35
|
+
import requests # type: ignore
|
|
36
|
+
except ImportError: # pragma: no cover
|
|
37
|
+
requests = None
|
|
38
|
+
|
|
39
|
+
FREEIMAGE_KEY = "6d207e02198a847aa98d0a2a901485a5"
|
|
40
|
+
FREEIMAGE_URL = "https://freeimage.host/api/1/upload"
|
|
41
|
+
PIAPI_BASE = "https://api.piapi.ai/api/v1"
|
|
42
|
+
DEFAULT_NEGATIVE = (
|
|
43
|
+
"slow motion, dreamy, ethereal, cinematic, blurry, "
|
|
44
|
+
"distorted, deformed hands, extra fingers"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _upload_frame(frame: Path) -> str:
|
|
49
|
+
img_b64 = base64.b64encode(frame.read_bytes()).decode()
|
|
50
|
+
r = requests.post(
|
|
51
|
+
FREEIMAGE_URL,
|
|
52
|
+
data={"key": FREEIMAGE_KEY, "action": "upload",
|
|
53
|
+
"source": img_b64, "format": "json"},
|
|
54
|
+
timeout=60,
|
|
55
|
+
)
|
|
56
|
+
r.raise_for_status()
|
|
57
|
+
data = r.json()
|
|
58
|
+
return data["image"]["url"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _submit(api_key: str, prompt: str, image_url: str,
|
|
62
|
+
duration: int, aspect: str, mode: str,
|
|
63
|
+
cfg: float, audio: bool) -> str:
|
|
64
|
+
payload = {
|
|
65
|
+
"model": "kling",
|
|
66
|
+
"task_type": "video_generation",
|
|
67
|
+
"input": {
|
|
68
|
+
"prompt": prompt,
|
|
69
|
+
"negative_prompt": DEFAULT_NEGATIVE,
|
|
70
|
+
"image_url": image_url,
|
|
71
|
+
"duration": duration,
|
|
72
|
+
"aspect_ratio": aspect,
|
|
73
|
+
"mode": mode,
|
|
74
|
+
"version": "3.0",
|
|
75
|
+
"cfg_scale": float(cfg), # MUST be float
|
|
76
|
+
"enable_audio": audio,
|
|
77
|
+
},
|
|
78
|
+
"config": {"service_mode": "public"},
|
|
79
|
+
}
|
|
80
|
+
r = requests.post(
|
|
81
|
+
f"{PIAPI_BASE}/task",
|
|
82
|
+
headers={"x-api-key": api_key, "Content-Type": "application/json"},
|
|
83
|
+
json=payload, timeout=60,
|
|
84
|
+
)
|
|
85
|
+
r.raise_for_status()
|
|
86
|
+
return r.json()["data"]["task_id"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _poll(api_key: str, task_id: str) -> str:
|
|
90
|
+
while True:
|
|
91
|
+
r = requests.get(
|
|
92
|
+
f"{PIAPI_BASE}/task/{task_id}",
|
|
93
|
+
headers={"x-api-key": api_key}, timeout=60,
|
|
94
|
+
)
|
|
95
|
+
r.raise_for_status()
|
|
96
|
+
d = r.json().get("data", {})
|
|
97
|
+
status = d.get("status", "")
|
|
98
|
+
if status == "completed":
|
|
99
|
+
return d["output"]["video"] # 3.0 uses output.video
|
|
100
|
+
if status == "failed":
|
|
101
|
+
raise RuntimeError(d.get("error", d))
|
|
102
|
+
print(f" status: {status} — polling again in 15s")
|
|
103
|
+
time.sleep(15)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _download(url: str, out: Path) -> None:
|
|
107
|
+
last_err = None
|
|
108
|
+
for attempt in range(3):
|
|
109
|
+
try:
|
|
110
|
+
r = requests.get(url, timeout=300)
|
|
111
|
+
r.raise_for_status()
|
|
112
|
+
out.write_bytes(r.content)
|
|
113
|
+
return
|
|
114
|
+
except Exception as e:
|
|
115
|
+
last_err = e
|
|
116
|
+
print(f" download attempt {attempt + 1}/3 failed: {e}")
|
|
117
|
+
time.sleep(5)
|
|
118
|
+
raise RuntimeError(f"download failed after 3 attempts: {last_err}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main() -> int:
|
|
122
|
+
ap = argparse.ArgumentParser()
|
|
123
|
+
ap.add_argument("--project", required=True)
|
|
124
|
+
ap.add_argument("--frame", required=True, help="confirmed first frame")
|
|
125
|
+
ap.add_argument("--duration", type=int, default=10, choices=[5, 10])
|
|
126
|
+
ap.add_argument("--aspect-ratio", default="9:16")
|
|
127
|
+
ap.add_argument("--mode", default="std", choices=["std", "pro"])
|
|
128
|
+
ap.add_argument("--cfg-scale", type=float, default=0.5)
|
|
129
|
+
ap.add_argument("--no-audio", action="store_true")
|
|
130
|
+
args = ap.parse_args()
|
|
131
|
+
|
|
132
|
+
project_dir = resolve_project(args.project)
|
|
133
|
+
require_gate(project_dir, "preview_confirmed")
|
|
134
|
+
|
|
135
|
+
if requests is None:
|
|
136
|
+
print("ERROR: the 'requests' package is required. Install: pip install requests",
|
|
137
|
+
file=sys.stderr)
|
|
138
|
+
return 1
|
|
139
|
+
|
|
140
|
+
api_key = os.environ.get("PIAPI_KEY", "2fccd94d5825b15840a27b8110e077b29ee3adb38c62fedd95d9e922ad440954")
|
|
141
|
+
if not api_key:
|
|
142
|
+
print("ERROR: PIAPI_KEY env var not set. See preflight.py.", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
frame = Path(args.frame)
|
|
146
|
+
if not frame.is_file():
|
|
147
|
+
print(f"ERROR: frame not found at {frame}", file=sys.stderr)
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
prompt_path = project_dir / "prompt.md"
|
|
151
|
+
if not prompt_path.is_file():
|
|
152
|
+
print(f"ERROR: {prompt_path} missing. Write prompt.md first.", file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
prompt = prompt_path.read_text(encoding="utf-8").strip()
|
|
155
|
+
|
|
156
|
+
print("Uploading frame to freeimage.host …")
|
|
157
|
+
image_url = _upload_frame(frame)
|
|
158
|
+
print(f" image_url: {image_url}")
|
|
159
|
+
|
|
160
|
+
print("Submitting video generation task …")
|
|
161
|
+
task_id = _submit(
|
|
162
|
+
api_key, prompt, image_url,
|
|
163
|
+
duration=args.duration, aspect=args.aspect_ratio, mode=args.mode,
|
|
164
|
+
cfg=args.cfg_scale, audio=not args.no_audio,
|
|
165
|
+
)
|
|
166
|
+
print(f" task_id: {task_id}")
|
|
167
|
+
|
|
168
|
+
print("Polling for completion …")
|
|
169
|
+
video_url = _poll(api_key, task_id)
|
|
170
|
+
print(f" video_url: {video_url}")
|
|
171
|
+
|
|
172
|
+
out = next_version(project_dir / "videos", f"video_{args.duration}s_", ".mp4")
|
|
173
|
+
print(f"Downloading → {out}")
|
|
174
|
+
_download(video_url, out)
|
|
175
|
+
|
|
176
|
+
print(f"\nDone: {out}")
|
|
177
|
+
append_log(project_dir, f"video_generated_with_audio → {out.name}")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
sys.exit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Pre-flight environment check for the video-clone skill.
|
|
2
|
+
|
|
3
|
+
Verifies the external dependencies the executor scripts will need:
|
|
4
|
+
- `gen` CLI — gen image / gen video
|
|
5
|
+
- `ffmpeg` / `ffprobe`
|
|
6
|
+
- PIAPI_KEY env var — for Kling 3.0
|
|
7
|
+
- Python >= 3.10
|
|
8
|
+
|
|
9
|
+
Exits 0 if all checks pass, 1 otherwise. On failure, prints a human-readable
|
|
10
|
+
list of missing items and how to fix each one.
|
|
11
|
+
|
|
12
|
+
This script is safe to run any time — it only reads env and probes binaries.
|
|
13
|
+
No state, no side effects.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _check_binary(name: str, fix_hint: str) -> tuple[bool, str]:
|
|
24
|
+
path = shutil.which(name)
|
|
25
|
+
if path is None:
|
|
26
|
+
return False, f"{name}: NOT FOUND. {fix_hint}"
|
|
27
|
+
# probe --version for extra confidence (non-blocking if version flag missing)
|
|
28
|
+
try:
|
|
29
|
+
r = subprocess.run(
|
|
30
|
+
[name, "--version"], capture_output=True, text=True, timeout=5
|
|
31
|
+
)
|
|
32
|
+
head = (r.stdout or r.stderr).splitlines()[0] if (r.stdout or r.stderr) else ""
|
|
33
|
+
except Exception:
|
|
34
|
+
head = ""
|
|
35
|
+
return True, f"{name}: OK ({path}) {head}".strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_BUILTIN_KEYS = {
|
|
39
|
+
"PIAPI_KEY": "2fccd94d5825b15840a27b8110e077b29ee3adb38c62fedd95d9e922ad440954",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _check_env(name: str, fix_hint: str) -> tuple[bool, str]:
|
|
44
|
+
val = os.environ.get(name) or _BUILTIN_KEYS.get(name)
|
|
45
|
+
if not val:
|
|
46
|
+
return False, f"${name}: NOT SET. {fix_hint}"
|
|
47
|
+
src = "env" if os.environ.get(name) else "builtin"
|
|
48
|
+
return True, f"${name}: OK ({src}, len={len(val)})"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_python() -> tuple[bool, str]:
|
|
52
|
+
v = sys.version_info
|
|
53
|
+
ok = (v.major, v.minor) >= (3, 10)
|
|
54
|
+
msg = f"python: {v.major}.{v.minor}.{v.micro}"
|
|
55
|
+
if not ok:
|
|
56
|
+
msg += " — NEED >= 3.10"
|
|
57
|
+
return ok, msg + (" OK" if ok else "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
CHECKS = [
|
|
61
|
+
lambda: _check_python(),
|
|
62
|
+
lambda: _check_binary(
|
|
63
|
+
"gen",
|
|
64
|
+
"Install the gen CLI and ensure it is on PATH. (gen image / gen video)",
|
|
65
|
+
),
|
|
66
|
+
lambda: _check_binary("ffmpeg", "Install ffmpeg and ensure it is on PATH."),
|
|
67
|
+
lambda: _check_binary("ffprobe", "Install ffmpeg (bundles ffprobe)."),
|
|
68
|
+
lambda: _check_env(
|
|
69
|
+
"PIAPI_KEY",
|
|
70
|
+
"Required for Kling 3.0 via PiAPI. Set with: export PIAPI_KEY=sk-...",
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> int:
|
|
76
|
+
results = [check() for check in CHECKS]
|
|
77
|
+
missing = [msg for ok, msg in results if not ok]
|
|
78
|
+
passing = [msg for ok, msg in results if ok]
|
|
79
|
+
|
|
80
|
+
print("== video-clone preflight ==")
|
|
81
|
+
for msg in passing:
|
|
82
|
+
print(f" [OK] {msg}")
|
|
83
|
+
for msg in missing:
|
|
84
|
+
print(f" [FAIL] {msg}")
|
|
85
|
+
print()
|
|
86
|
+
|
|
87
|
+
if missing:
|
|
88
|
+
print(f"{len(missing)} check(s) failed. Fix the above and re-run.")
|
|
89
|
+
return 1
|
|
90
|
+
print("All checks passed.")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
sys.exit(main())
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Phase 4: collect all prep artifacts into one preview_v{N}.md.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python scripts/preview.py --project <name>
|
|
5
|
+
|
|
6
|
+
Behavior:
|
|
7
|
+
- Reads phase.json (must exist).
|
|
8
|
+
- For video_clone tasks, requires:
|
|
9
|
+
source/analysis_v*.json
|
|
10
|
+
frames/extract_v*/grid.jpg
|
|
11
|
+
prompt.md (non-empty)
|
|
12
|
+
frames/frame_v*.png (edited first frame)
|
|
13
|
+
- For video_gen tasks, only requires prompt.md.
|
|
14
|
+
- cost.json is optional; missing → "TBD" in cost section, no error.
|
|
15
|
+
- Renders a six-section markdown to preview_v{N}.md and stdout.
|
|
16
|
+
- If any required artifact is missing, exits 1 with a stderr listing
|
|
17
|
+
of missing items WITHOUT writing preview_v{N}.md. The file's
|
|
18
|
+
existence is the atomic signal that preview succeeded.
|
|
19
|
+
- Does NOT depend on _gate (preview neither reads nor writes gates).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from _project import resolve_project, next_version, append_log
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _latest(dir_path: Path, prefix: str, suffix: str) -> Path | None:
|
|
33
|
+
"""Return the highest-version file matching <prefix>v<N><suffix>, or None."""
|
|
34
|
+
if not dir_path.is_dir():
|
|
35
|
+
return None
|
|
36
|
+
candidates = sorted(
|
|
37
|
+
dir_path.glob(f"{prefix}v*{suffix}"),
|
|
38
|
+
key=lambda p: int(p.stem.removeprefix(prefix).lstrip("v") or "0"),
|
|
39
|
+
)
|
|
40
|
+
return candidates[-1] if candidates else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _latest_extract_dir(frames_dir: Path) -> Path | None:
|
|
44
|
+
if not frames_dir.is_dir():
|
|
45
|
+
return None
|
|
46
|
+
extracts = sorted(
|
|
47
|
+
[p for p in frames_dir.iterdir() if p.is_dir() and p.name.startswith("extract_v")],
|
|
48
|
+
key=lambda p: int(p.name.removeprefix("extract_v") or "0"),
|
|
49
|
+
)
|
|
50
|
+
return extracts[-1] if extracts else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _section_plan(state: dict, analysis: dict | None, cost: dict | None) -> str:
|
|
54
|
+
has_audio = analysis.get("has_audio") if analysis else None
|
|
55
|
+
audio_mode = "含音频" if has_audio else "静音" if has_audio is False else "TBD"
|
|
56
|
+
if analysis:
|
|
57
|
+
meta = (
|
|
58
|
+
f"<{state.get('project','?')}> "
|
|
59
|
+
f"({analysis.get('duration_s','?')}s, "
|
|
60
|
+
f"{analysis.get('width','?')}x{analysis.get('height','?')}, "
|
|
61
|
+
f"{analysis.get('fps','?')}fps, "
|
|
62
|
+
f"has_audio={analysis.get('has_audio','?')})"
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
meta = "(纯生成任务,无源视频)"
|
|
66
|
+
cost_line = "TBD"
|
|
67
|
+
duration_line = "TBD"
|
|
68
|
+
if cost:
|
|
69
|
+
cost_line = f"~${cost.get('estimate_usd', '?')}"
|
|
70
|
+
if cost.get("based_on"):
|
|
71
|
+
duration_line = f"({cost['based_on']})"
|
|
72
|
+
return (
|
|
73
|
+
f"## 1. 技术方案\n"
|
|
74
|
+
f"**任务类型**: {state.get('task_type','?')}\n"
|
|
75
|
+
f"**源视频**: {meta}\n"
|
|
76
|
+
f"**生成模式**: {audio_mode}\n"
|
|
77
|
+
f"**预估成本**: {cost_line}\n"
|
|
78
|
+
f"**预估说明**: {duration_line}\n"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _section_analysis(analysis: dict | None) -> str:
|
|
83
|
+
if not analysis:
|
|
84
|
+
return "## 2. 源视频分析\n(纯生成任务,无源视频)\n"
|
|
85
|
+
return (
|
|
86
|
+
f"## 2. 源视频分析\n"
|
|
87
|
+
f"- 时长: {analysis.get('duration_s','?')}s\n"
|
|
88
|
+
f"- 分辨率: {analysis.get('width','?')}x{analysis.get('height','?')}\n"
|
|
89
|
+
f"- 片段数: {analysis.get('segments','?')} ({analysis.get('classification','?')})\n"
|
|
90
|
+
f"- 场景切点: {analysis.get('scene_cuts',[])}\n"
|
|
91
|
+
f"- 含音频: {analysis.get('has_audio','?')}\n"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _section_grid(grid_path: Path | None) -> str:
|
|
96
|
+
if grid_path is None:
|
|
97
|
+
return "## 3. 帧网格\n**MISSING: 没有 extract_v*/grid.jpg**\n"
|
|
98
|
+
return f"## 3. 帧网格\n路径: {grid_path.relative_to(grid_path.parents[2])}\n"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _section_prompt(prompt_text: str | None) -> str:
|
|
102
|
+
if prompt_text is None:
|
|
103
|
+
return "## 4. Motion Prompt\n**MISSING: prompt.md 不存在或为空**\n"
|
|
104
|
+
return f"## 4. Motion Prompt\n```\n{prompt_text}\n```\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _section_frame(frame_path: Path | None, task_type: str) -> str:
|
|
108
|
+
if task_type == "video_gen":
|
|
109
|
+
return "## 5. 编辑后首帧\n(纯生成任务,跳过)\n"
|
|
110
|
+
if frame_path is None:
|
|
111
|
+
return "## 5. 编辑后首帧\n**MISSING: 没有 frame_v*.png**\n"
|
|
112
|
+
return f"## 5. 编辑后首帧\n路径: {frame_path.relative_to(frame_path.parents[2])}\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _section_next_step(project: str, frame_path: Path | None) -> str:
|
|
116
|
+
frame_arg = (
|
|
117
|
+
str(frame_path.relative_to(frame_path.parents[2]))
|
|
118
|
+
if frame_path else "frames/frame_v*.png"
|
|
119
|
+
)
|
|
120
|
+
return (
|
|
121
|
+
f"## 6. 下一步\n"
|
|
122
|
+
f"确认无误后运行:\n"
|
|
123
|
+
f" python scripts/confirm.py --project {project} --quote \"<你的原话>\"\n"
|
|
124
|
+
f"然后跑:\n"
|
|
125
|
+
f" python scripts/kling_generate.py --project {project} --frame {frame_arg}\n"
|
|
126
|
+
f" (或 gen_video.py,如果不需要音频)\n"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main() -> int:
|
|
131
|
+
ap = argparse.ArgumentParser()
|
|
132
|
+
ap.add_argument("--project", required=True)
|
|
133
|
+
args = ap.parse_args()
|
|
134
|
+
|
|
135
|
+
project_dir = resolve_project(args.project)
|
|
136
|
+
state_path = project_dir / ".state" / "phase.json"
|
|
137
|
+
if not state_path.is_file():
|
|
138
|
+
print(f"ERROR: state file not found at {state_path}", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
141
|
+
task_type = state.get("task_type", "video_clone")
|
|
142
|
+
|
|
143
|
+
# Collect artifacts (None when missing)
|
|
144
|
+
analysis_path = _latest(project_dir / "source", "analysis_", ".json")
|
|
145
|
+
analysis = json.loads(analysis_path.read_text(encoding="utf-8")) if analysis_path else None
|
|
146
|
+
|
|
147
|
+
extract_dir = _latest_extract_dir(project_dir / "frames")
|
|
148
|
+
grid_path = (extract_dir / "grid.jpg") if extract_dir and (extract_dir / "grid.jpg").exists() else None
|
|
149
|
+
|
|
150
|
+
prompt_path = project_dir / "prompt.md"
|
|
151
|
+
prompt_text = prompt_path.read_text(encoding="utf-8").strip() if prompt_path.is_file() else None
|
|
152
|
+
|
|
153
|
+
frame_path = _latest(project_dir / "frames", "frame_", ".png")
|
|
154
|
+
|
|
155
|
+
cost_path = project_dir / "cost.json"
|
|
156
|
+
cost = json.loads(cost_path.read_text(encoding="utf-8")) if cost_path.is_file() else None
|
|
157
|
+
|
|
158
|
+
# Determine missing required artifacts. Atomic behavior: if anything
|
|
159
|
+
# is missing, we exit 1 WITHOUT writing a preview file.
|
|
160
|
+
missing: list[str] = []
|
|
161
|
+
if task_type == "video_clone":
|
|
162
|
+
if analysis is None:
|
|
163
|
+
missing.append("source/analysis_v*.json")
|
|
164
|
+
if grid_path is None:
|
|
165
|
+
missing.append("frames/extract_v*/grid.jpg")
|
|
166
|
+
if frame_path is None:
|
|
167
|
+
missing.append("frames/frame_v*.png")
|
|
168
|
+
if not prompt_text:
|
|
169
|
+
missing.append("prompt.md (non-empty)")
|
|
170
|
+
|
|
171
|
+
if missing:
|
|
172
|
+
bullets = "\n".join(f" - {m}" for m in missing)
|
|
173
|
+
print(
|
|
174
|
+
f"ERROR: preview is incomplete. Missing artifacts:\n"
|
|
175
|
+
f"{bullets}\n"
|
|
176
|
+
f"Run the missing prep steps before retrying preview.py.",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
# All artifacts present — render and write atomically.
|
|
182
|
+
md = (
|
|
183
|
+
f"# Preview: {args.project}\n"
|
|
184
|
+
f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}\n\n"
|
|
185
|
+
+ _section_plan(state, analysis, cost)
|
|
186
|
+
+ "\n"
|
|
187
|
+
+ _section_analysis(analysis)
|
|
188
|
+
+ "\n"
|
|
189
|
+
+ _section_grid(grid_path)
|
|
190
|
+
+ "\n"
|
|
191
|
+
+ _section_prompt(prompt_text)
|
|
192
|
+
+ "\n"
|
|
193
|
+
+ _section_frame(frame_path, task_type)
|
|
194
|
+
+ "\n"
|
|
195
|
+
+ _section_next_step(args.project, frame_path)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
out = next_version(project_dir, "preview_", ".md")
|
|
199
|
+
out.write_text(md, encoding="utf-8")
|
|
200
|
+
print(md)
|
|
201
|
+
print(f"\nWrote: {out}")
|
|
202
|
+
append_log(project_dir, f"preview_generated → {out.name}")
|
|
203
|
+
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
sys.exit(main())
|