@optima-chat/optima-agent 0.8.91 → 0.8.93
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 +371 -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 +75 -75
- 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 +262 -0
- package/.claude/skills/video-clone/scripts/kling_generate_test.py +191 -0
- package/.claude/skills/video-clone/scripts/preflight.py +102 -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,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())
|
|
@@ -0,0 +1,169 @@
|
|
|
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.")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Phase 5: capture a successful project as a reusable workflow.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python scripts/save_workflow.py --name handheld-phone-swap \
|
|
5
|
+
--project <source-project> \
|
|
6
|
+
--scene "手持vlog + 单物品替换" \
|
|
7
|
+
--rating 5 \
|
|
8
|
+
--strategy "双图首帧, t=15s选帧, 简单手部动作"
|
|
9
|
+
|
|
10
|
+
Creates <gen-output>/video-clone/workflows/<name>.md (must not exist) and
|
|
11
|
+
appends a row to workflows/README.md index.
|
|
12
|
+
|
|
13
|
+
This is where experience becomes durable. Without Phase 5, the workflow
|
|
14
|
+
library stays stuck at 2 entries forever.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from _gate import require_gate
|
|
24
|
+
from _project import gen_output_root, resolve_project
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
README_HEADER = """# Video Clone Workflows
|
|
28
|
+
|
|
29
|
+
| Workflow | 适用场景 | 效果 | 关键策略 |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_or_init_readme(readme: Path) -> str:
|
|
35
|
+
if readme.exists():
|
|
36
|
+
return readme.read_text(encoding="utf-8")
|
|
37
|
+
return README_HEADER
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _append_index_row(readme_text: str, name: str, scene: str,
|
|
41
|
+
rating: int, strategy: str) -> str:
|
|
42
|
+
stars = "⭐" * max(1, min(5, rating))
|
|
43
|
+
row = f"| [{name}]({name}.md) | {scene} | {stars} | {strategy} |\n"
|
|
44
|
+
if name in readme_text:
|
|
45
|
+
# already indexed — don't duplicate. Caller should edit manually.
|
|
46
|
+
return readme_text
|
|
47
|
+
return readme_text.rstrip() + "\n" + row
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _workflow_body(name: str, scene: str, strategy: str, project: str) -> str:
|
|
51
|
+
return f"""# {name}
|
|
52
|
+
|
|
53
|
+
## 适用场景
|
|
54
|
+
{scene}
|
|
55
|
+
|
|
56
|
+
## 策略
|
|
57
|
+
|
|
58
|
+
### 首帧
|
|
59
|
+
{strategy}
|
|
60
|
+
|
|
61
|
+
### Prompt
|
|
62
|
+
(从 {project}/prompt.md 提取可复用片段)
|
|
63
|
+
|
|
64
|
+
### 视频生成
|
|
65
|
+
(从 {project}/log.md 提取成功的参数组合)
|
|
66
|
+
|
|
67
|
+
### 后处理
|
|
68
|
+
(特殊 ffmpeg 参数,如有)
|
|
69
|
+
|
|
70
|
+
## 成功案例
|
|
71
|
+
- {project}
|
|
72
|
+
|
|
73
|
+
## 踩坑记录
|
|
74
|
+
- (试过但失败的方法)
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> int:
|
|
79
|
+
ap = argparse.ArgumentParser()
|
|
80
|
+
ap.add_argument("--name", required=True, help="workflow slug")
|
|
81
|
+
ap.add_argument("--project", required=True, help="source project name")
|
|
82
|
+
ap.add_argument("--scene", required=True, help="适用场景 one-liner")
|
|
83
|
+
ap.add_argument("--rating", type=int, choices=range(1, 6), required=True)
|
|
84
|
+
ap.add_argument("--strategy", required=True, help="key strategy one-liner")
|
|
85
|
+
args = ap.parse_args()
|
|
86
|
+
|
|
87
|
+
project_dir = resolve_project(args.project)
|
|
88
|
+
require_gate(project_dir, "preview_confirmed")
|
|
89
|
+
|
|
90
|
+
videos = list((project_dir / "videos").glob("*.mp4"))
|
|
91
|
+
if not videos:
|
|
92
|
+
print(
|
|
93
|
+
f"ERROR: cannot save workflow — no videos in {project_dir / 'videos'}.\n"
|
|
94
|
+
f"Run kling_generate.py or gen_video.py first.",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
return 1
|
|
98
|
+
|
|
99
|
+
workflows = gen_output_root() / "video-clone" / "workflows"
|
|
100
|
+
workflows.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
wf_file = workflows / f"{args.name}.md"
|
|
103
|
+
if wf_file.exists():
|
|
104
|
+
print(
|
|
105
|
+
f"ERROR: workflow {wf_file} already exists. Edit it by hand or "
|
|
106
|
+
f"pick a different --name.",
|
|
107
|
+
file=sys.stderr,
|
|
108
|
+
)
|
|
109
|
+
return 1
|
|
110
|
+
|
|
111
|
+
wf_file.write_text(
|
|
112
|
+
_workflow_body(args.name, args.scene, args.strategy, args.project),
|
|
113
|
+
encoding="utf-8",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
readme = workflows / "README.md"
|
|
117
|
+
readme_text = _load_or_init_readme(readme)
|
|
118
|
+
readme_text = _append_index_row(
|
|
119
|
+
readme_text, args.name, args.scene, args.rating, args.strategy
|
|
120
|
+
)
|
|
121
|
+
readme.write_text(readme_text, encoding="utf-8")
|
|
122
|
+
|
|
123
|
+
print(f"Saved workflow: {wf_file}")
|
|
124
|
+
print(f"Updated index: {readme}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
sys.exit(main())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Tests for save_workflow.py — Phase 5 workflow capture."""
|
|
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
|
+
|
|
13
|
+
|
|
14
|
+
def _make_project(tmp: Path, gate: bool = True) -> Path:
|
|
15
|
+
proj = tmp / "video-clone" / "test-proj"
|
|
16
|
+
for sub in ("source", "frames", "videos", ".state"):
|
|
17
|
+
(proj / sub).mkdir(parents=True)
|
|
18
|
+
state = {
|
|
19
|
+
"schema_version": 1,
|
|
20
|
+
"project": "test-proj",
|
|
21
|
+
"task_type": "video_clone",
|
|
22
|
+
"created_at": "2025-01-01T00:00:00Z",
|
|
23
|
+
"current_phase": 0,
|
|
24
|
+
"gates": {
|
|
25
|
+
"preview_confirmed": {
|
|
26
|
+
"status": gate,
|
|
27
|
+
"confirmed_at": "2025-01-01T00:01:00Z" if gate else None,
|
|
28
|
+
"user_quote": "go" if gate else None,
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"history": [],
|
|
32
|
+
}
|
|
33
|
+
(proj / ".state" / "phase.json").write_text(
|
|
34
|
+
json.dumps(state, indent=2), encoding="utf-8"
|
|
35
|
+
)
|
|
36
|
+
if gate:
|
|
37
|
+
# Add a video so save_workflow doesn't fail the "no videos" check
|
|
38
|
+
(proj / "videos" / "final_v1.mp4").write_bytes(b"dummy")
|
|
39
|
+
return proj
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run(tmp: Path, extra_args: list[str] | None = None) -> subprocess.CompletedProcess:
|
|
43
|
+
env = os.environ.copy()
|
|
44
|
+
env["GEN_OUTPUT_ROOT"] = str(tmp)
|
|
45
|
+
cmd = [
|
|
46
|
+
sys.executable, str(SCRIPTS_DIR / "save_workflow.py"),
|
|
47
|
+
"--project", "test-proj",
|
|
48
|
+
"--name", "test-workflow",
|
|
49
|
+
"--scene", "手持 vlog",
|
|
50
|
+
"--rating", "4",
|
|
51
|
+
"--strategy", "双图首帧",
|
|
52
|
+
]
|
|
53
|
+
if extra_args:
|
|
54
|
+
cmd += extra_args
|
|
55
|
+
return subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_save_workflow_blocked_without_gate():
|
|
59
|
+
with tempfile.TemporaryDirectory() as td:
|
|
60
|
+
tmp = Path(td)
|
|
61
|
+
_make_project(tmp, gate=False)
|
|
62
|
+
result = _run(tmp)
|
|
63
|
+
assert result.returncode == 1, (
|
|
64
|
+
f"Expected exit 1 without gate\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
|
65
|
+
)
|
|
66
|
+
assert "HARD-GATE BLOCKED" in result.stderr or "preview_confirmed" in result.stderr
|
|
67
|
+
print("PASS test_save_workflow_blocked_without_gate")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_save_workflow_creates_file_with_gate():
|
|
71
|
+
with tempfile.TemporaryDirectory() as td:
|
|
72
|
+
tmp = Path(td)
|
|
73
|
+
_make_project(tmp, gate=True)
|
|
74
|
+
result = _run(tmp)
|
|
75
|
+
assert result.returncode == 0, (
|
|
76
|
+
f"Expected exit 0\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
|
77
|
+
)
|
|
78
|
+
wf_file = tmp / "video-clone" / "workflows" / "test-workflow.md"
|
|
79
|
+
assert wf_file.is_file(), "workflow file should be created"
|
|
80
|
+
content = wf_file.read_text(encoding="utf-8")
|
|
81
|
+
assert "手持 vlog" in content
|
|
82
|
+
assert "双图首帧" in content
|
|
83
|
+
# README index should be updated
|
|
84
|
+
readme = tmp / "video-clone" / "workflows" / "README.md"
|
|
85
|
+
assert readme.is_file()
|
|
86
|
+
assert "test-workflow" in readme.read_text(encoding="utf-8")
|
|
87
|
+
print("PASS test_save_workflow_creates_file_with_gate")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_save_workflow_rejects_duplicate_name():
|
|
91
|
+
with tempfile.TemporaryDirectory() as td:
|
|
92
|
+
tmp = Path(td)
|
|
93
|
+
_make_project(tmp, gate=True)
|
|
94
|
+
r1 = _run(tmp)
|
|
95
|
+
assert r1.returncode == 0
|
|
96
|
+
r2 = _run(tmp)
|
|
97
|
+
assert r2.returncode == 1, "second run with same name should exit 1"
|
|
98
|
+
assert "already exists" in r2.stderr
|
|
99
|
+
print("PASS test_save_workflow_rejects_duplicate_name")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
test_save_workflow_blocked_without_gate()
|
|
104
|
+
test_save_workflow_creates_file_with_gate()
|
|
105
|
+
test_save_workflow_rejects_duplicate_name()
|
|
106
|
+
print("\nAll 3 save_workflow tests passed.")
|