@optima-chat/optima-agent 0.8.95 → 0.8.97

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 (41) hide show
  1. package/.claude/skills/gen/SKILL.md +1 -1
  2. package/.claude/skills/video-gen/SKILL.md +449 -0
  3. package/.claude/skills/video-gen/templates/lifestyle-scene.md +18 -0
  4. package/.claude/skills/video-gen/templates/pdp-360-showcase.md +17 -0
  5. package/.claude/skills/video-gen/templates/pdp-feature-highlight.md +18 -0
  6. package/.claude/skills/video-gen/templates/tiktok-before-after.md +17 -0
  7. package/.claude/skills/video-gen/templates/tiktok-product-reveal.md +17 -0
  8. package/.claude/skills/video-gen/templates/tiktok-unboxing.md +18 -0
  9. package/package.json +1 -1
  10. package/.claude/skills/video-clone/SKILL.md +0 -199
  11. package/.claude/skills/video-clone/assets/phase-state-template.json +0 -11
  12. package/.claude/skills/video-clone/references/ffmpeg-commands.md +0 -42
  13. package/.claude/skills/video-clone/references/gate-enforcement.md +0 -144
  14. package/.claude/skills/video-clone/references/kling-api.md +0 -85
  15. package/.claude/skills/video-clone/references/prompt-template.md +0 -71
  16. package/.claude/skills/video-clone/references/url-parsing.md +0 -32
  17. package/.claude/skills/video-clone/references/workflow-system.md +0 -92
  18. package/.claude/skills/video-clone/scripts/_confirm.py +0 -96
  19. package/.claude/skills/video-clone/scripts/_confirm_test.py +0 -125
  20. package/.claude/skills/video-clone/scripts/_gate.py +0 -162
  21. package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +0 -226
  22. package/.claude/skills/video-clone/scripts/_gate_test.py +0 -148
  23. package/.claude/skills/video-clone/scripts/_project.py +0 -56
  24. package/.claude/skills/video-clone/scripts/analyze_source.py +0 -113
  25. package/.claude/skills/video-clone/scripts/analyze_source_test.py +0 -52
  26. package/.claude/skills/video-clone/scripts/assemble.py +0 -106
  27. package/.claude/skills/video-clone/scripts/confirm.py +0 -12
  28. package/.claude/skills/video-clone/scripts/edit_first_frame.py +0 -66
  29. package/.claude/skills/video-clone/scripts/extract_frames.py +0 -108
  30. package/.claude/skills/video-clone/scripts/gen_video.py +0 -59
  31. package/.claude/skills/video-clone/scripts/init_project.py +0 -103
  32. package/.claude/skills/video-clone/scripts/init_project_test.py +0 -106
  33. package/.claude/skills/video-clone/scripts/kling_generate.py +0 -262
  34. package/.claude/skills/video-clone/scripts/kling_generate_test.py +0 -191
  35. package/.claude/skills/video-clone/scripts/preflight.py +0 -102
  36. package/.claude/skills/video-clone/scripts/preview.py +0 -208
  37. package/.claude/skills/video-clone/scripts/preview_test.py +0 -169
  38. package/.claude/skills/video-clone/scripts/save_workflow.py +0 -129
  39. package/.claude/skills/video-clone/scripts/save_workflow_test.py +0 -106
  40. package/.claude/skills/video-clone/scripts/status.py +0 -202
  41. package/.claude/skills/video-clone/scripts/status_test.py +0 -174
@@ -1,191 +0,0 @@
1
- """Tests for the pure helper functions in kling_generate.py.
2
-
3
- No network. We do not exercise _submit / _poll / _download — those are
4
- I/O-bound and covered by end-to-end staging runs. Unit tests here keep the
5
- helper contracts honest so refactors don't silently break the auth-header
6
- normalization or the token / API-URL discovery order.
7
-
8
- Run: python scripts/kling_generate_test.py
9
- """
10
- from __future__ import annotations
11
-
12
- import json
13
- import os
14
- import subprocess
15
- import sys
16
- import tempfile
17
- from pathlib import Path
18
- from unittest import mock
19
-
20
- SCRIPTS_DIR = Path(__file__).parent
21
- sys.path.insert(0, str(SCRIPTS_DIR))
22
-
23
- import kling_generate # noqa: E402
24
-
25
-
26
- # ---------- _auth_header ----------
27
-
28
- def test_auth_header_adds_bearer_prefix():
29
- assert kling_generate._auth_header("xyz123") == "Bearer xyz123"
30
-
31
-
32
- def test_auth_header_preserves_existing_bearer():
33
- assert kling_generate._auth_header("Bearer xyz123") == "Bearer xyz123"
34
-
35
-
36
- def test_auth_header_is_case_insensitive_on_existing_prefix():
37
- assert kling_generate._auth_header("bearer xyz123") == "bearer xyz123"
38
- assert kling_generate._auth_header("BEARER xyz123") == "BEARER xyz123"
39
-
40
-
41
- # ---------- _get_token ----------
42
-
43
- def test_get_token_prefers_env_var(tmp_token_file):
44
- tmp_token_file.write_text(
45
- json.dumps({"access_token": "from-file", "env": "prod"}), encoding="utf-8"
46
- )
47
- os.environ["OPTIMA_TOKEN"] = "from-env"
48
- try:
49
- assert kling_generate._get_token() == "from-env"
50
- finally:
51
- os.environ.pop("OPTIMA_TOKEN", None)
52
-
53
-
54
- def test_get_token_falls_back_to_file(tmp_token_file):
55
- os.environ.pop("OPTIMA_TOKEN", None)
56
- tmp_token_file.write_text(
57
- json.dumps({"access_token": "file-xyz", "env": "stage"}), encoding="utf-8"
58
- )
59
- assert kling_generate._get_token() == "file-xyz"
60
-
61
-
62
- def test_get_token_exits_when_neither_source_has_token(tmp_token_file):
63
- os.environ.pop("OPTIMA_TOKEN", None)
64
- # tmp_token_file does not exist (fixture creates parent, caller decides)
65
- # Here: don't create the file — _load_token_file should return None
66
- code = (
67
- "import sys; sys.path.insert(0, r'{d}'); import kling_generate; "
68
- "kling_generate._get_token()"
69
- ).format(d=str(SCRIPTS_DIR))
70
- env = {**os.environ}
71
- env.pop("OPTIMA_TOKEN", None)
72
- env["HOME"] = str(tmp_token_file.parent.parent) # .../.optima/token.json → HOME = .../
73
- env["USERPROFILE"] = env["HOME"]
74
- result = subprocess.run(
75
- [sys.executable, "-c", code], capture_output=True, text=True, env=env,
76
- )
77
- assert result.returncode == 1
78
- assert "optima login" in result.stderr or "OPTIMA_TOKEN" in result.stderr
79
-
80
-
81
- # ---------- _get_gen_api_url ----------
82
-
83
- def test_get_gen_api_url_env_override_wins(tmp_token_file):
84
- tmp_token_file.write_text(
85
- json.dumps({"access_token": "t", "env": "stage"}), encoding="utf-8"
86
- )
87
- os.environ["GENERATION_API_URL"] = "https://custom.example/api"
88
- try:
89
- assert kling_generate._get_gen_api_url() == "https://custom.example/api"
90
- finally:
91
- os.environ.pop("GENERATION_API_URL", None)
92
-
93
-
94
- def test_get_gen_api_url_trims_trailing_slash():
95
- os.environ["GENERATION_API_URL"] = "https://custom.example/"
96
- try:
97
- assert kling_generate._get_gen_api_url() == "https://custom.example"
98
- finally:
99
- os.environ.pop("GENERATION_API_URL", None)
100
-
101
-
102
- def test_get_gen_api_url_reads_env_from_token_file(tmp_token_file):
103
- os.environ.pop("GENERATION_API_URL", None)
104
- tmp_token_file.write_text(
105
- json.dumps({"access_token": "t", "env": "stage"}), encoding="utf-8"
106
- )
107
- assert kling_generate._get_gen_api_url() == "https://gen-api.stage.optima.onl"
108
-
109
-
110
- def test_get_gen_api_url_defaults_to_prod_when_no_file(tmp_token_file):
111
- os.environ.pop("GENERATION_API_URL", None)
112
- # tmp_token_file not created → should default to prod
113
- assert kling_generate._get_gen_api_url() == "https://gen-api.optima.onl"
114
-
115
-
116
- # ---------- fixture ----------
117
-
118
- class _TmpToken:
119
- """Manage a throwaway ~/.optima/token.json by monkey-patching the module constant."""
120
- def __init__(self):
121
- self.tmp = tempfile.TemporaryDirectory()
122
- self.path = Path(self.tmp.name) / ".optima" / "token.json"
123
- self.path.parent.mkdir(parents=True)
124
- self._orig = kling_generate.TOKEN_FILE
125
- kling_generate.TOKEN_FILE = self.path
126
-
127
- def write_text(self, *a, **kw):
128
- self.path.write_text(*a, **kw)
129
-
130
- @property
131
- def parent(self):
132
- return self.path.parent
133
-
134
- def cleanup(self):
135
- kling_generate.TOKEN_FILE = self._orig
136
- self.tmp.cleanup()
137
-
138
-
139
- def _with_tmp_token_file(fn):
140
- """Decorator-ish: pass a fresh _TmpToken to the test, then clean up."""
141
- def wrapped():
142
- h = _TmpToken()
143
- try:
144
- return fn(h)
145
- finally:
146
- h.cleanup()
147
- return wrapped
148
-
149
-
150
- # Rebind each test to wrap with the fixture
151
- test_get_token_prefers_env_var = _with_tmp_token_file(test_get_token_prefers_env_var)
152
- test_get_token_falls_back_to_file = _with_tmp_token_file(test_get_token_falls_back_to_file)
153
- test_get_token_exits_when_neither_source_has_token = _with_tmp_token_file(test_get_token_exits_when_neither_source_has_token)
154
- test_get_gen_api_url_env_override_wins = _with_tmp_token_file(test_get_gen_api_url_env_override_wins)
155
- test_get_gen_api_url_reads_env_from_token_file = _with_tmp_token_file(test_get_gen_api_url_reads_env_from_token_file)
156
- test_get_gen_api_url_defaults_to_prod_when_no_file = _with_tmp_token_file(test_get_gen_api_url_defaults_to_prod_when_no_file)
157
-
158
-
159
- TESTS = [
160
- ("auth_header adds Bearer prefix", test_auth_header_adds_bearer_prefix),
161
- ("auth_header preserves Bearer", test_auth_header_preserves_existing_bearer),
162
- ("auth_header case-insensitive on prefix", test_auth_header_is_case_insensitive_on_existing_prefix),
163
- ("get_token: env wins over file", test_get_token_prefers_env_var),
164
- ("get_token: falls back to token.json", test_get_token_falls_back_to_file),
165
- ("get_token: exits 1 when neither source", test_get_token_exits_when_neither_source_has_token),
166
- ("get_gen_api_url: env override wins", test_get_gen_api_url_env_override_wins),
167
- ("get_gen_api_url: trims trailing slash", test_get_gen_api_url_trims_trailing_slash),
168
- ("get_gen_api_url: reads env from token.json", test_get_gen_api_url_reads_env_from_token_file),
169
- ("get_gen_api_url: defaults to prod", test_get_gen_api_url_defaults_to_prod_when_no_file),
170
- ]
171
-
172
-
173
- def main() -> int:
174
- failed = 0
175
- for name, fn in TESTS:
176
- try:
177
- fn()
178
- print(f"PASS {name}")
179
- except AssertionError as e:
180
- failed += 1
181
- print(f"FAIL {name}: {e}")
182
- except Exception as e:
183
- failed += 1
184
- print(f"ERROR {name}: {type(e).__name__}: {e}")
185
- print()
186
- print(f"{len(TESTS) - failed}/{len(TESTS)} passed")
187
- return 0 if failed == 0 else 1
188
-
189
-
190
- if __name__ == "__main__":
191
- sys.exit(main())
@@ -1,102 +0,0 @@
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 (routed through generation backend)
5
- - `ffmpeg` / `ffprobe`
6
- - Optima auth — either OPTIMA_TOKEN env var or ~/.optima/token.json
7
- (matches @optima-chat/gen-cli convention)
8
- - Python >= 3.10
9
-
10
- Exits 0 if all checks pass, 1 otherwise. On failure, prints a human-readable
11
- list of missing items and how to fix each one.
12
-
13
- This script is safe to run any time — it only reads env and probes binaries.
14
- No state, no side effects.
15
- """
16
- from __future__ import annotations
17
-
18
- import json
19
- import os
20
- import shutil
21
- import subprocess
22
- import sys
23
- from pathlib import Path
24
-
25
- TOKEN_FILE = Path.home() / ".optima" / "token.json"
26
-
27
-
28
- def _check_binary(name: str, fix_hint: str) -> tuple[bool, str]:
29
- path = shutil.which(name)
30
- if path is None:
31
- return False, f"{name}: NOT FOUND. {fix_hint}"
32
- # probe --version for extra confidence (non-blocking if version flag missing)
33
- try:
34
- r = subprocess.run(
35
- [name, "--version"], capture_output=True, text=True, timeout=5
36
- )
37
- head = (r.stdout or r.stderr).splitlines()[0] if (r.stdout or r.stderr) else ""
38
- except Exception:
39
- head = ""
40
- return True, f"{name}: OK ({path}) {head}".strip()
41
-
42
-
43
- def _check_optima_auth() -> tuple[bool, str]:
44
- """Token comes from OPTIMA_TOKEN env var OR ~/.optima/token.json (gen-cli convention)."""
45
- if os.environ.get("OPTIMA_TOKEN"):
46
- return True, "Optima token: OK (source=env OPTIMA_TOKEN)"
47
- if TOKEN_FILE.is_file():
48
- try:
49
- data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
50
- if data.get("access_token"):
51
- env = data.get("env", "prod")
52
- return True, f"Optima token: OK (source={TOKEN_FILE}, env={env})"
53
- except (OSError, json.JSONDecodeError):
54
- pass
55
- return (
56
- False,
57
- f"Optima token: NOT FOUND. Run `optima login` or set OPTIMA_TOKEN env var.",
58
- )
59
-
60
-
61
- def _check_python() -> tuple[bool, str]:
62
- v = sys.version_info
63
- ok = (v.major, v.minor) >= (3, 10)
64
- msg = f"python: {v.major}.{v.minor}.{v.micro}"
65
- if not ok:
66
- msg += " — NEED >= 3.10"
67
- return ok, msg + (" OK" if ok else "")
68
-
69
-
70
- CHECKS = [
71
- lambda: _check_python(),
72
- lambda: _check_binary(
73
- "gen",
74
- "Install the gen CLI and ensure it is on PATH. (gen image / gen video)",
75
- ),
76
- lambda: _check_binary("ffmpeg", "Install ffmpeg and ensure it is on PATH."),
77
- lambda: _check_binary("ffprobe", "Install ffmpeg (bundles ffprobe)."),
78
- lambda: _check_optima_auth(),
79
- ]
80
-
81
-
82
- def main() -> int:
83
- results = [check() for check in CHECKS]
84
- missing = [msg for ok, msg in results if not ok]
85
- passing = [msg for ok, msg in results if ok]
86
-
87
- print("== video-clone preflight ==")
88
- for msg in passing:
89
- print(f" [OK] {msg}")
90
- for msg in missing:
91
- print(f" [FAIL] {msg}")
92
- print()
93
-
94
- if missing:
95
- print(f"{len(missing)} check(s) failed. Fix the above and re-run.")
96
- return 1
97
- print("All checks passed.")
98
- return 0
99
-
100
-
101
- if __name__ == "__main__":
102
- sys.exit(main())
@@ -1,208 +0,0 @@
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())
@@ -1,169 +0,0 @@
1
- """Tests for preview.py — Phase 4 artifact collection."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import os
6
- import subprocess
7
- import sys
8
- import tempfile
9
- from pathlib import Path
10
-
11
- SCRIPTS_DIR = Path(__file__).parent
12
- SKILL_ROOT = SCRIPTS_DIR.parent
13
-
14
-
15
- def _make_project(tmp: Path, task_type: str = "video_clone") -> Path:
16
- proj = tmp / "video-clone" / "test-proj"
17
- for sub in ("source", "frames", "videos", ".state"):
18
- (proj / sub).mkdir(parents=True)
19
- state = {
20
- "schema_version": 1,
21
- "project": "test-proj",
22
- "task_type": task_type,
23
- "created_at": "2025-01-01T00:00:00Z",
24
- "current_phase": 0,
25
- "gates": {
26
- "preview_confirmed": {"status": False, "confirmed_at": None, "user_quote": None}
27
- },
28
- "history": [],
29
- }
30
- (proj / ".state" / "phase.json").write_text(
31
- json.dumps(state, indent=2), encoding="utf-8"
32
- )
33
- return proj
34
-
35
-
36
- def _run(tmp: Path, extra_args: list[str] | None = None) -> subprocess.CompletedProcess:
37
- env = os.environ.copy()
38
- env["GEN_OUTPUT_ROOT"] = str(tmp)
39
- cmd = [sys.executable, str(SCRIPTS_DIR / "preview.py"), "--project", "test-proj"]
40
- if extra_args:
41
- cmd += extra_args
42
- return subprocess.run(cmd, capture_output=True, text=True, env=env)
43
-
44
-
45
- def _add_analysis(proj: Path) -> None:
46
- analysis = {
47
- "duration_s": 10,
48
- "width": 1280,
49
- "height": 720,
50
- "fps": 30.0,
51
- "has_audio": False,
52
- "segments": 1,
53
- "classification": "single",
54
- "scene_cuts": [],
55
- }
56
- (proj / "source" / "analysis_v1.json").write_text(
57
- json.dumps(analysis), encoding="utf-8"
58
- )
59
-
60
-
61
- def _add_grid(proj: Path) -> None:
62
- extract = proj / "frames" / "extract_v1"
63
- extract.mkdir(parents=True, exist_ok=True)
64
- (extract / "grid.jpg").write_bytes(b"\xff\xd8\xff\xe0dummy")
65
-
66
-
67
- def _add_prompt(proj: Path) -> None:
68
- (proj / "prompt.md").write_text("A person walking.", encoding="utf-8")
69
-
70
-
71
- def _add_frame(proj: Path) -> None:
72
- (proj / "frames" / "frame_v1.png").write_bytes(b"\x89PNG\r\ndummy")
73
-
74
-
75
- def test_preview_full_artifacts_writes_v1():
76
- with tempfile.TemporaryDirectory() as td:
77
- tmp = Path(td)
78
- proj = _make_project(tmp)
79
- _add_analysis(proj)
80
- _add_grid(proj)
81
- _add_prompt(proj)
82
- _add_frame(proj)
83
-
84
- result = _run(tmp)
85
- assert result.returncode == 0, f"Expected 0 but got {result.returncode}\nstderr: {result.stderr}"
86
- out_file = proj / "preview_v1.md"
87
- assert out_file.is_file(), "preview_v1.md should be written"
88
- content = out_file.read_text(encoding="utf-8")
89
- assert "# Preview: test-proj" in content
90
- assert "## 1. 技术方案" in content
91
- assert "## 4. Motion Prompt" in content
92
- assert "A person walking." in content
93
- print("PASS test_preview_full_artifacts_writes_v1")
94
-
95
-
96
- def test_preview_missing_prompt_does_not_write_file_and_exits_one():
97
- with tempfile.TemporaryDirectory() as td:
98
- tmp = Path(td)
99
- proj = _make_project(tmp)
100
- _add_analysis(proj)
101
- _add_grid(proj)
102
- # NO prompt
103
- _add_frame(proj)
104
-
105
- result = _run(tmp)
106
- assert result.returncode == 1, f"Expected 1 but got {result.returncode}"
107
- assert "prompt.md" in result.stderr
108
- out_file = proj / "preview_v1.md"
109
- assert not out_file.exists(), "preview file must NOT be written on failure"
110
- print("PASS test_preview_missing_prompt_does_not_write_file_and_exits_one")
111
-
112
-
113
- def test_preview_missing_cost_json_uses_tbd():
114
- with tempfile.TemporaryDirectory() as td:
115
- tmp = Path(td)
116
- proj = _make_project(tmp)
117
- _add_analysis(proj)
118
- _add_grid(proj)
119
- _add_prompt(proj)
120
- _add_frame(proj)
121
- # No cost.json
122
-
123
- result = _run(tmp)
124
- assert result.returncode == 0, f"stderr: {result.stderr}"
125
- content = (proj / "preview_v1.md").read_text(encoding="utf-8")
126
- assert "TBD" in content
127
- print("PASS test_preview_missing_cost_json_uses_tbd")
128
-
129
-
130
- def test_preview_rerun_creates_v2():
131
- with tempfile.TemporaryDirectory() as td:
132
- tmp = Path(td)
133
- proj = _make_project(tmp)
134
- _add_analysis(proj)
135
- _add_grid(proj)
136
- _add_prompt(proj)
137
- _add_frame(proj)
138
-
139
- r1 = _run(tmp)
140
- assert r1.returncode == 0
141
- assert (proj / "preview_v1.md").is_file()
142
-
143
- r2 = _run(tmp)
144
- assert r2.returncode == 0
145
- assert (proj / "preview_v2.md").is_file()
146
- print("PASS test_preview_rerun_creates_v2")
147
-
148
-
149
- def test_preview_video_gen_only_needs_prompt():
150
- with tempfile.TemporaryDirectory() as td:
151
- tmp = Path(td)
152
- proj = _make_project(tmp, task_type="video_gen")
153
- _add_prompt(proj)
154
- # No analysis, no grid, no frame
155
-
156
- result = _run(tmp)
157
- assert result.returncode == 0, f"Expected 0 but got {result.returncode}\nstderr: {result.stderr}"
158
- content = (proj / "preview_v1.md").read_text(encoding="utf-8")
159
- assert "纯生成任务" in content
160
- print("PASS test_preview_video_gen_only_needs_prompt")
161
-
162
-
163
- if __name__ == "__main__":
164
- test_preview_full_artifacts_writes_v1()
165
- test_preview_missing_prompt_does_not_write_file_and_exits_one()
166
- test_preview_missing_cost_json_uses_tbd()
167
- test_preview_rerun_creates_v2()
168
- test_preview_video_gen_only_needs_prompt()
169
- print("\nAll 5 preview tests passed.")