@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.
Files changed (45) 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 +371 -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 +75 -75
  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 +262 -0
  37. package/.claude/skills/video-clone/scripts/kling_generate_test.py +191 -0
  38. package/.claude/skills/video-clone/scripts/preflight.py +102 -0
  39. package/.claude/skills/video-clone/scripts/preview.py +208 -0
  40. package/.claude/skills/video-clone/scripts/preview_test.py +169 -0
  41. package/.claude/skills/video-clone/scripts/save_workflow.py +129 -0
  42. package/.claude/skills/video-clone/scripts/save_workflow_test.py +106 -0
  43. package/.claude/skills/video-clone/scripts/status.py +202 -0
  44. package/.claude/skills/video-clone/scripts/status_test.py +174 -0
  45. package/package.json +2 -1
@@ -0,0 +1,113 @@
1
+ """Phase 1: analyze a source video — ffprobe metadata + scene detection.
2
+
3
+ Usage:
4
+ python scripts/analyze_source.py --project <name> --video <path-or-url>
5
+
6
+ Behavior:
7
+ 1. No gate required. Runs autonomously as part of prep.
8
+ 2. If --video looks like a URL, the caller must download it first; this
9
+ script operates on a local file path only (downloads are caller's job,
10
+ because platform parsing is listed in references/url-parsing.md).
11
+ 3. Runs ffprobe to extract duration, resolution, fps, codec info.
12
+ 4. Runs ffmpeg scene detection with select='gt(scene,0.3)' and reports
13
+ cut count → single or multi-segment classification.
14
+ 5. Writes analysis to <project>/source/analysis.json (versioned).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ from fractions import Fraction
24
+ from pathlib import Path
25
+
26
+ from _project import resolve_project, next_version, append_log
27
+
28
+
29
+ def _ffprobe(video: Path) -> dict:
30
+ r = subprocess.run(
31
+ ["ffprobe", "-v", "quiet", "-print_format", "json",
32
+ "-show_format", "-show_streams", str(video)],
33
+ capture_output=True, text=True, check=True,
34
+ )
35
+ return json.loads(r.stdout)
36
+
37
+
38
+ def _scene_detect(video: Path) -> list[float]:
39
+ """Return list of scene-change timestamps (seconds)."""
40
+ r = subprocess.run(
41
+ ["ffmpeg", "-hide_banner", "-i", str(video),
42
+ "-vf", "select='gt(scene,0.3)',showinfo",
43
+ "-vsync", "vfr", "-f", "null", "-"],
44
+ capture_output=True, text=True,
45
+ )
46
+ # scene-change info shows up on stderr
47
+ return [float(m.group(1)) for m in re.finditer(r"pts_time:(\d+\.?\d*)", r.stderr)]
48
+
49
+
50
+ def _parse_fps(rate: str | None) -> float | None:
51
+ """Parse ffprobe avg_frame_rate ('30000/1001') into a float, safely.
52
+
53
+ Replaces the old eval()-based parsing. Returns None for malformed input
54
+ instead of raising.
55
+ """
56
+ if not rate:
57
+ return None
58
+ try:
59
+ return float(Fraction(rate))
60
+ except (ValueError, ZeroDivisionError):
61
+ return None
62
+
63
+
64
+ def _summarize(meta: dict) -> dict:
65
+ fmt = meta.get("format", {})
66
+ streams = meta.get("streams", [])
67
+ vstream = next((s for s in streams if s.get("codec_type") == "video"), {})
68
+ astream = next((s for s in streams if s.get("codec_type") == "audio"), {})
69
+ return {
70
+ "duration_s": float(fmt.get("duration", 0)),
71
+ "width": vstream.get("width"),
72
+ "height": vstream.get("height"),
73
+ "fps": _parse_fps(vstream.get("avg_frame_rate")) if vstream else None,
74
+ "vcodec": vstream.get("codec_name"),
75
+ "has_audio": bool(astream),
76
+ "acodec": astream.get("codec_name"),
77
+ }
78
+
79
+
80
+ def main() -> int:
81
+ ap = argparse.ArgumentParser()
82
+ ap.add_argument("--project", required=True)
83
+ ap.add_argument("--video", required=True, help="local path to source video")
84
+ args = ap.parse_args()
85
+
86
+ project_dir = resolve_project(args.project)
87
+
88
+ video = Path(args.video)
89
+ if not video.is_file():
90
+ print(f"ERROR: video not found at {video}", file=sys.stderr)
91
+ return 1
92
+
93
+ print(f"Analyzing {video} …")
94
+ meta = _ffprobe(video)
95
+ summary = _summarize(meta)
96
+ cuts = _scene_detect(video)
97
+ cuts_significant = [t for t in cuts if t > 0.5]
98
+ segment_count = 1 if len(cuts_significant) <= 1 else len(cuts_significant) + 1
99
+
100
+ summary["scene_cuts"] = cuts_significant
101
+ summary["segments"] = segment_count
102
+ summary["classification"] = "multi" if segment_count >= 2 else "single"
103
+
104
+ out = next_version(project_dir / "source", "analysis_", ".json")
105
+ out.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
106
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
107
+ print(f"\nWrote: {out}")
108
+ append_log(project_dir, f"analysis_completed → {out.name}")
109
+ return 0
110
+
111
+
112
+ if __name__ == "__main__":
113
+ sys.exit(main())
@@ -0,0 +1,52 @@
1
+ """Unit tests for analyze_source._parse_fps — verifies the eval()→Fraction migration (R3)."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ SCRIPTS_DIR = Path(__file__).parent
8
+ sys.path.insert(0, str(SCRIPTS_DIR))
9
+
10
+ from analyze_source import _parse_fps # noqa: E402
11
+
12
+
13
+ def test_parse_fps_30000_over_1001():
14
+ result = _parse_fps("30000/1001")
15
+ assert result is not None
16
+ assert abs(result - 29.97002997) < 1e-6
17
+ print("PASS test_parse_fps_30000_over_1001")
18
+
19
+
20
+ def test_parse_fps_30_over_1():
21
+ assert _parse_fps("30/1") == 30.0
22
+ print("PASS test_parse_fps_30_over_1")
23
+
24
+
25
+ def test_parse_fps_zero_over_zero():
26
+ assert _parse_fps("0/0") is None
27
+ print("PASS test_parse_fps_zero_over_zero")
28
+
29
+
30
+ def test_parse_fps_garbage():
31
+ assert _parse_fps("abc") is None
32
+ print("PASS test_parse_fps_garbage")
33
+
34
+
35
+ def test_parse_fps_none():
36
+ assert _parse_fps(None) is None
37
+ print("PASS test_parse_fps_none")
38
+
39
+
40
+ def test_parse_fps_empty():
41
+ assert _parse_fps("") is None
42
+ print("PASS test_parse_fps_empty")
43
+
44
+
45
+ if __name__ == "__main__":
46
+ test_parse_fps_30000_over_1001()
47
+ test_parse_fps_30_over_1()
48
+ test_parse_fps_zero_over_zero()
49
+ test_parse_fps_garbage()
50
+ test_parse_fps_none()
51
+ test_parse_fps_empty()
52
+ print("\nAll 6 _parse_fps tests passed.")
@@ -0,0 +1,106 @@
1
+ """Phase 4: post-process and assemble the final video.
2
+
3
+ Usage:
4
+ # single-segment — normalize only
5
+ python scripts/assemble.py --project <name> --single videos/video_v1_10s.mp4
6
+
7
+ # multi-segment — normalize each + concat
8
+ python scripts/assemble.py --project <name> \
9
+ --multi videos/scene1_v1_5s.mp4 videos/scene2_v1_5s.mp4
10
+
11
+ Writes to <project>/videos/final_v{N}.mp4 (versioned).
12
+
13
+ No gate: post-processing always runs on already-generated videos, which by
14
+ definition must have passed the preview_confirmed gate.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from _project import resolve_project, next_version, append_log
24
+
25
+
26
+ def _normalize_single(video: Path, out: Path) -> None:
27
+ subprocess.run(
28
+ ["ffmpeg", "-y", "-i", str(video),
29
+ "-r", "30", "-c:v", "libx264", "-crf", "18",
30
+ "-c:a", "aac", "-b:a", "192k",
31
+ str(out)],
32
+ check=True,
33
+ )
34
+
35
+
36
+ def _normalize_clip(video: Path, out: Path) -> None:
37
+ subprocess.run(
38
+ ["ffmpeg", "-y", "-i", str(video),
39
+ "-vf", "scale=720:1280", "-r", "30",
40
+ "-c:v", "libx264", "-crf", "18",
41
+ "-c:a", "aac", "-b:a", "128k",
42
+ str(out)],
43
+ check=True,
44
+ )
45
+
46
+
47
+ def _concat(norm_clips: list[Path], out: Path, workdir: Path) -> None:
48
+ concat_list = workdir / "concat_list.txt"
49
+ concat_list.write_text(
50
+ "\n".join(f"file '{p.name}'" for p in norm_clips) + "\n",
51
+ encoding="utf-8",
52
+ )
53
+ subprocess.run(
54
+ ["ffmpeg", "-y", "-f", "concat", "-safe", "0",
55
+ "-i", str(concat_list),
56
+ "-c:v", "libx264", "-crf", "18",
57
+ "-c:a", "aac", "-b:a", "192k",
58
+ str(out)],
59
+ check=True, cwd=workdir,
60
+ )
61
+ concat_list.unlink(missing_ok=True)
62
+
63
+
64
+ def main() -> int:
65
+ ap = argparse.ArgumentParser()
66
+ ap.add_argument("--project", required=True)
67
+ group = ap.add_mutually_exclusive_group(required=True)
68
+ group.add_argument("--single", help="single video to normalize")
69
+ group.add_argument("--multi", nargs="+", help="2+ videos to normalize and concat")
70
+ args = ap.parse_args()
71
+
72
+ project_dir = resolve_project(args.project)
73
+ videos_dir = project_dir / "videos"
74
+ out = next_version(videos_dir, "final_", ".mp4")
75
+
76
+ if args.single:
77
+ src = Path(args.single)
78
+ if not src.is_absolute():
79
+ src = project_dir / args.single
80
+ if not src.is_file():
81
+ print(f"ERROR: {src} not found", file=sys.stderr)
82
+ return 1
83
+ _normalize_single(src, out)
84
+ else:
85
+ norm_clips = []
86
+ for i, clip in enumerate(args.multi, 1):
87
+ src = Path(clip)
88
+ if not src.is_absolute():
89
+ src = project_dir / clip
90
+ if not src.is_file():
91
+ print(f"ERROR: {src} not found", file=sys.stderr)
92
+ return 1
93
+ norm = videos_dir / f"_norm_{i}.mp4"
94
+ _normalize_clip(src, norm)
95
+ norm_clips.append(norm)
96
+ _concat(norm_clips, out, videos_dir)
97
+ for n in norm_clips:
98
+ n.unlink(missing_ok=True)
99
+
100
+ print(f"\nFinal video: {out}")
101
+ append_log(project_dir, f"assembled → {out.name}")
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())
@@ -0,0 +1,12 @@
1
+ """Entry point: record user confirmation for preview_confirmed.
2
+
3
+ Usage:
4
+ python scripts/confirm.py --project <name> --quote "<verbatim user words>"
5
+
6
+ The actual logic lives in _confirm.run_confirm().
7
+ """
8
+ import sys
9
+ from _confirm import run_confirm
10
+
11
+ if __name__ == "__main__":
12
+ sys.exit(run_confirm())
@@ -0,0 +1,66 @@
1
+ """Phase 2: edit the first frame — swap product via gen image.
2
+
3
+ Usage:
4
+ python scripts/edit_first_frame.py --project <name> --frame <path> \
5
+ --product <path> --prompt "<instruction>" [--seed <int>]
6
+
7
+ Behavior:
8
+ - No gate required. Runs autonomously as part of prep.
9
+ - Invokes `gen image -i <frame> -i <product>` with the given prompt.
10
+ - Writes output to <project>/frames/frame_v{N}.png (versioned, never
11
+ overwrites).
12
+ - Prints the output path so the caller can show it to the user in
13
+ the preview bundle.
14
+
15
+ The --prompt argument is the *image edit instruction* (e.g. "swap the coffee
16
+ cup on the table for this water bottle, match lighting"), not the motion
17
+ prompt used for video generation.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from _project import resolve_project, next_version, append_log
27
+
28
+
29
+ def main() -> int:
30
+ ap = argparse.ArgumentParser()
31
+ ap.add_argument("--project", required=True)
32
+ ap.add_argument("--frame", required=True, help="source frame image")
33
+ ap.add_argument("--product", required=True, help="product reference image")
34
+ ap.add_argument("--prompt", required=True, help="image edit instruction")
35
+ ap.add_argument("--seed", type=int, default=None)
36
+ args = ap.parse_args()
37
+
38
+ project_dir = resolve_project(args.project)
39
+
40
+ frame = Path(args.frame)
41
+ product = Path(args.product)
42
+ for p, label in ((frame, "frame"), (product, "product")):
43
+ if not p.is_file():
44
+ print(f"ERROR: {label} not found at {p}", file=sys.stderr)
45
+ return 1
46
+
47
+ out = next_version(project_dir / "frames", "frame_", ".png")
48
+ cmd = ["gen", "image", "-i", str(frame), "-i", str(product),
49
+ "-o", str(out), "-p", args.prompt]
50
+ if args.seed is not None:
51
+ cmd += ["--seed", str(args.seed)]
52
+
53
+ print(f"Running: {' '.join(cmd)}")
54
+ r = subprocess.run(cmd)
55
+ if r.returncode != 0:
56
+ print(f"ERROR: gen image failed with exit {r.returncode}", file=sys.stderr)
57
+ return r.returncode
58
+
59
+ print(f"\nEdited frame: {out}")
60
+ print("This frame will be included in the preview bundle.")
61
+ append_log(project_dir, f"first_frame_edited → {out.name}")
62
+ return 0
63
+
64
+
65
+ if __name__ == "__main__":
66
+ sys.exit(main())
@@ -0,0 +1,108 @@
1
+ """Phase 1: extract equidistant frames from a video for Claude Opus analysis.
2
+
3
+ Usage:
4
+ python scripts/extract_frames.py --project <name> --video <path> [--count 10] [--grid]
5
+
6
+ Behavior:
7
+ - No gate required. Runs autonomously as part of prep.
8
+ - Extracts N equidistant frames to <project>/frames/extract_vN/ via ffmpeg.
9
+ - If --grid is passed, additionally stitches frames into a 5×2 (or 3×4)
10
+ contact sheet with ffmpeg's tile filter — this is what Claude Opus
11
+ reads for frame-by-frame analysis.
12
+
13
+ The frame set is the input to Phase 1 prompt generation. Each call creates a
14
+ new versioned subdirectory so earlier extractions are preserved.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from _project import resolve_project, next_version, append_log
24
+
25
+
26
+ def _duration(video: Path) -> float:
27
+ r = subprocess.run(
28
+ ["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
29
+ "-of", "default=noprint_wrappers=1:nokey=1", str(video)],
30
+ capture_output=True, text=True, check=True,
31
+ )
32
+ return float(r.stdout.strip())
33
+
34
+
35
+ def _extract_at(video: Path, t: float, out: Path) -> None:
36
+ subprocess.run(
37
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
38
+ "-ss", f"{t:.2f}", "-i", str(video),
39
+ "-frames:v", "1", "-q:v", "2", str(out)],
40
+ check=True,
41
+ )
42
+
43
+
44
+ def _tile(frames: list[Path], out: Path, cols: int, rows: int) -> None:
45
+ # use ffmpeg concat + tile to build a contact sheet
46
+ # write a temp concat list of still images
47
+ concat = out.parent / "_concat.txt"
48
+ concat.write_text(
49
+ "\n".join(f"file '{p.as_posix()}'\nduration 0.04" for p in frames)
50
+ + f"\nfile '{frames[-1].as_posix()}'\n",
51
+ encoding="utf-8",
52
+ )
53
+ subprocess.run(
54
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
55
+ "-f", "concat", "-safe", "0", "-i", str(concat),
56
+ "-vf", f"tile={cols}x{rows}", "-frames:v", "1", str(out)],
57
+ check=True,
58
+ )
59
+ concat.unlink(missing_ok=True)
60
+
61
+
62
+ def main() -> int:
63
+ ap = argparse.ArgumentParser()
64
+ ap.add_argument("--project", required=True)
65
+ ap.add_argument("--video", required=True)
66
+ ap.add_argument("--count", type=int, default=10)
67
+ ap.add_argument("--grid", action="store_true")
68
+ args = ap.parse_args()
69
+
70
+ project_dir = resolve_project(args.project)
71
+
72
+ video = Path(args.video)
73
+ if not video.is_file():
74
+ print(f"ERROR: video not found at {video}", file=sys.stderr)
75
+ return 1
76
+
77
+ dur = _duration(video)
78
+ if dur <= 0:
79
+ print(f"ERROR: could not read duration from {video}", file=sys.stderr)
80
+ return 1
81
+
82
+ frames_root = project_dir / "frames"
83
+ out_dir = next_version(frames_root, "extract_", "")
84
+ out_dir.mkdir(parents=True)
85
+
86
+ step = dur / (args.count + 1)
87
+ frames = []
88
+ for i in range(1, args.count + 1):
89
+ t = step * i
90
+ p = out_dir / f"frame_{i:02d}_t{t:.2f}s.jpg"
91
+ _extract_at(video, t, p)
92
+ frames.append(p)
93
+ print(f" extracted t={t:.2f}s → {p.name}")
94
+
95
+ if args.grid and frames:
96
+ cols = 5 if args.count % 5 == 0 else 4
97
+ rows = (args.count + cols - 1) // cols
98
+ grid_out = out_dir / "grid.jpg"
99
+ _tile(frames, grid_out, cols, rows)
100
+ print(f"\nGrid: {grid_out}")
101
+
102
+ print(f"\nExtracted {len(frames)} frames to {out_dir}")
103
+ append_log(project_dir, f"frames_extracted → {out_dir.name}")
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ sys.exit(main())
@@ -0,0 +1,59 @@
1
+ """Phase 3 (no-audio path): generate video via `gen video` (Wan 2.6 I2V).
2
+
3
+ Usage:
4
+ python scripts/gen_video.py --project <name> --frame <path> \
5
+ [--duration 10]
6
+
7
+ Use this instead of kling_generate.py when the video does NOT need audio /
8
+ lip sync (cost ~$0.02 vs ~$1). Prompt is read from <project>/prompt.md.
9
+
10
+ Gate: requires preview_confirmed.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from _gate import require_gate
20
+ from _project import resolve_project, next_version, append_log
21
+
22
+
23
+ def main() -> int:
24
+ ap = argparse.ArgumentParser()
25
+ ap.add_argument("--project", required=True)
26
+ ap.add_argument("--frame", required=True)
27
+ ap.add_argument("--duration", type=int, default=10, choices=[5, 10])
28
+ args = ap.parse_args()
29
+
30
+ project_dir = resolve_project(args.project)
31
+ require_gate(project_dir, "preview_confirmed")
32
+
33
+ frame = Path(args.frame)
34
+ if not frame.is_file():
35
+ print(f"ERROR: frame not found at {frame}", file=sys.stderr)
36
+ return 1
37
+
38
+ prompt_path = project_dir / "prompt.md"
39
+ if not prompt_path.is_file():
40
+ print(f"ERROR: {prompt_path} missing. Write prompt.md first.", file=sys.stderr)
41
+ return 1
42
+ prompt = prompt_path.read_text(encoding="utf-8").strip()
43
+
44
+ out = next_version(project_dir / "videos", f"video_{args.duration}s_", ".mp4")
45
+ cmd = ["gen", "video", "-i", str(frame), "-o", str(out),
46
+ "-p", prompt, "--duration", str(args.duration)]
47
+ print(f"Running: {' '.join(cmd[:4])} … (prompt trimmed)")
48
+ r = subprocess.run(cmd)
49
+ if r.returncode != 0:
50
+ print(f"ERROR: gen video failed with exit {r.returncode}", file=sys.stderr)
51
+ return r.returncode
52
+
53
+ print(f"\nDone: {out}")
54
+ append_log(project_dir, f"video_generated_silent → {out.name}")
55
+ return 0
56
+
57
+
58
+ if __name__ == "__main__":
59
+ sys.exit(main())
@@ -0,0 +1,103 @@
1
+ """Initialize a new video-clone project directory + phase state.
2
+
3
+ Usage:
4
+ python scripts/init_project.py --name <project-name> --task-type video_clone
5
+ python scripts/init_project.py --name <project-name> --task-type video_gen
6
+
7
+ Creates:
8
+ <gen-output>/video-clone/<name>/
9
+ source/
10
+ frames/
11
+ videos/
12
+ .state/phase.json (copied from assets/phase-state-template.json)
13
+
14
+ By default <gen-output> is `./gen-output`. Override with GEN_OUTPUT_ROOT env.
15
+
16
+ Refuses to overwrite an existing project directory — pick a new name or
17
+ bump a version suffix (e.g. handheld-phone-swap-v2).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+
28
+ VALID_TASK_TYPES = ("video_clone", "video_gen")
29
+
30
+ SKILL_ROOT = Path(__file__).parent.parent
31
+ TEMPLATE = SKILL_ROOT / "assets" / "phase-state-template.json"
32
+
33
+
34
+ def _gen_output_root() -> Path:
35
+ override = os.environ.get("GEN_OUTPUT_ROOT")
36
+ if override:
37
+ return Path(override)
38
+ return Path.cwd() / "gen-output"
39
+
40
+
41
+ def _now_iso() -> str:
42
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
43
+
44
+
45
+ def main() -> int:
46
+ ap = argparse.ArgumentParser(description="Initialize a video-clone project.")
47
+ ap.add_argument("--name", required=True, help="Project name (slug).")
48
+ ap.add_argument(
49
+ "--task-type",
50
+ required=True,
51
+ choices=VALID_TASK_TYPES,
52
+ help="video_clone or video_gen",
53
+ )
54
+ args = ap.parse_args()
55
+
56
+ if not TEMPLATE.is_file():
57
+ print(f"ERROR: state template missing at {TEMPLATE}", file=sys.stderr)
58
+ return 1
59
+
60
+ base = _gen_output_root() / "video-clone" / args.name
61
+ if base.exists():
62
+ print(
63
+ f"ERROR: project already exists at {base}\n"
64
+ f"Pick a new name or bump a version suffix (e.g. {args.name}-v2).",
65
+ file=sys.stderr,
66
+ )
67
+ return 1
68
+
69
+ for sub in ("source", "frames", "videos", ".state"):
70
+ (base / sub).mkdir(parents=True)
71
+
72
+ state = json.loads(TEMPLATE.read_text(encoding="utf-8"))
73
+ state["project"] = args.name
74
+ state["task_type"] = args.task_type
75
+ state["created_at"] = _now_iso()
76
+ state["history"].append({"event": "project_created", "at": state["created_at"]})
77
+
78
+ (base / ".state" / "phase.json").write_text(
79
+ json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8"
80
+ )
81
+
82
+ print(f"Created project: {base}")
83
+ print(f" phase state: {base / '.state' / 'phase.json'}")
84
+ print()
85
+ print("Next steps:")
86
+ print(" 1. (Phase 0 sanity check) Tell the user one sentence describing your")
87
+ print(" understanding of the task and reference workflow. Wait for 'yes'.")
88
+ print(" 2. Run prep autonomously: analyze_source → extract_frames → write")
89
+ print(" prompt.md → edit_first_frame → write cost.json → preview.py")
90
+ print(f" 3. Show preview to user, then run:")
91
+ print(
92
+ f" python scripts/confirm.py --project {args.name} "
93
+ f"--quote \"<user's actual confirmation>\""
94
+ )
95
+ print(f" 4. Run kling_generate.py / gen_video.py → assemble.py")
96
+ print()
97
+ print(f"At any time, check status:")
98
+ print(f" python scripts/status.py --project {args.name}")
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())