@optima-chat/optima-agent 0.8.95 → 0.8.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/gen/SKILL.md +1 -1
- package/.claude/skills/video-gen/SKILL.md +443 -0
- package/.claude/skills/video-gen/templates/lifestyle-scene.md +18 -0
- package/.claude/skills/video-gen/templates/pdp-360-showcase.md +17 -0
- package/.claude/skills/video-gen/templates/pdp-feature-highlight.md +18 -0
- package/.claude/skills/video-gen/templates/tiktok-before-after.md +17 -0
- package/.claude/skills/video-gen/templates/tiktok-product-reveal.md +17 -0
- package/.claude/skills/video-gen/templates/tiktok-unboxing.md +18 -0
- package/package.json +1 -1
- package/.claude/skills/video-clone/SKILL.md +0 -199
- package/.claude/skills/video-clone/assets/phase-state-template.json +0 -11
- package/.claude/skills/video-clone/references/ffmpeg-commands.md +0 -42
- package/.claude/skills/video-clone/references/gate-enforcement.md +0 -144
- package/.claude/skills/video-clone/references/kling-api.md +0 -85
- package/.claude/skills/video-clone/references/prompt-template.md +0 -71
- package/.claude/skills/video-clone/references/url-parsing.md +0 -32
- package/.claude/skills/video-clone/references/workflow-system.md +0 -92
- package/.claude/skills/video-clone/scripts/_confirm.py +0 -96
- package/.claude/skills/video-clone/scripts/_confirm_test.py +0 -125
- package/.claude/skills/video-clone/scripts/_gate.py +0 -162
- package/.claude/skills/video-clone/scripts/_gate_e2e_test.py +0 -226
- package/.claude/skills/video-clone/scripts/_gate_test.py +0 -148
- package/.claude/skills/video-clone/scripts/_project.py +0 -56
- package/.claude/skills/video-clone/scripts/analyze_source.py +0 -113
- package/.claude/skills/video-clone/scripts/analyze_source_test.py +0 -52
- package/.claude/skills/video-clone/scripts/assemble.py +0 -106
- package/.claude/skills/video-clone/scripts/confirm.py +0 -12
- package/.claude/skills/video-clone/scripts/edit_first_frame.py +0 -66
- package/.claude/skills/video-clone/scripts/extract_frames.py +0 -108
- package/.claude/skills/video-clone/scripts/gen_video.py +0 -59
- package/.claude/skills/video-clone/scripts/init_project.py +0 -103
- package/.claude/skills/video-clone/scripts/init_project_test.py +0 -106
- package/.claude/skills/video-clone/scripts/kling_generate.py +0 -262
- package/.claude/skills/video-clone/scripts/kling_generate_test.py +0 -191
- package/.claude/skills/video-clone/scripts/preflight.py +0 -102
- package/.claude/skills/video-clone/scripts/preview.py +0 -208
- package/.claude/skills/video-clone/scripts/preview_test.py +0 -169
- package/.claude/skills/video-clone/scripts/save_workflow.py +0 -129
- package/.claude/skills/video-clone/scripts/save_workflow_test.py +0 -106
- package/.claude/skills/video-clone/scripts/status.py +0 -202
- package/.claude/skills/video-clone/scripts/status_test.py +0 -174
|
@@ -1,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.")
|