@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.
Files changed (44) hide show
  1. package/.claude/skills/browser/SKILL.md +8 -0
  2. package/.claude/skills/homepage/SKILL.md +4 -3
  3. package/.claude/skills/kol-outreach/SKILL.md +360 -0
  4. package/.claude/skills/kol-outreach/template/campaign/CONFIG.md +60 -0
  5. package/.claude/skills/kol-outreach/template/campaign/CONVERSATIONS/.gitkeep +0 -0
  6. package/.claude/skills/kol-outreach/template/campaign/KOLS.md +6 -0
  7. package/.claude/skills/kol-outreach/template/campaign/PROGRESS.md +3 -0
  8. package/.claude/skills/kol-outreach/template/campaign/TEMPLATES.md +88 -0
  9. package/.claude/skills/kol-outreach/template/campaign/assets/.gitkeep +0 -0
  10. package/.claude/skills/kol-outreach/template/merchant/BRAND.md +36 -0
  11. package/.claude/skills/kol-outreach/template/merchant/CAMPAIGNS.md +6 -0
  12. package/.claude/skills/kol-outreach/template/merchant/MERCHANT_LIMITS.md +16 -0
  13. package/.claude/skills/kol-outreach/template/merchant/PROGRESS.md +4 -0
  14. package/.claude/skills/kol-outreach/template/merchant/README.md +20 -0
  15. package/.claude/skills/video-clone/SKILL.md +125 -217
  16. package/.claude/skills/video-clone/assets/phase-state-template.json +11 -0
  17. package/.claude/skills/video-clone/references/ffmpeg-commands.md +31 -34
  18. package/.claude/skills/video-clone/references/gate-enforcement.md +144 -0
  19. package/.claude/skills/video-clone/references/kling-api.md +39 -72
  20. package/.claude/skills/video-clone/references/url-parsing.md +32 -13
  21. package/.claude/skills/video-clone/scripts/_confirm.py +96 -0
  22. package/.claude/skills/video-clone/scripts/_confirm_test.py +125 -0
  23. package/.claude/skills/video-clone/scripts/_gate.py +162 -0
  24. package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +226 -0
  25. package/.claude/skills/video-clone/scripts/_gate_test.py +148 -0
  26. package/.claude/skills/video-clone/scripts/_project.py +56 -0
  27. package/.claude/skills/video-clone/scripts/analyze_source.py +113 -0
  28. package/.claude/skills/video-clone/scripts/analyze_source_test.py +52 -0
  29. package/.claude/skills/video-clone/scripts/assemble.py +106 -0
  30. package/.claude/skills/video-clone/scripts/confirm.py +12 -0
  31. package/.claude/skills/video-clone/scripts/edit_first_frame.py +66 -0
  32. package/.claude/skills/video-clone/scripts/extract_frames.py +108 -0
  33. package/.claude/skills/video-clone/scripts/gen_video.py +59 -0
  34. package/.claude/skills/video-clone/scripts/init_project.py +103 -0
  35. package/.claude/skills/video-clone/scripts/init_project_test.py +106 -0
  36. package/.claude/skills/video-clone/scripts/kling_generate.py +182 -0
  37. package/.claude/skills/video-clone/scripts/preflight.py +95 -0
  38. package/.claude/skills/video-clone/scripts/preview.py +208 -0
  39. package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
  40. package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
  41. package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
  42. package/.claude/skills/video-clone/scripts/status.py +202 -0
  43. package/.claude/skills/video-clone/scripts/status_test.py +174 -0
  44. 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())