@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,148 +0,0 @@
1
- """Unit tests for _gate.py — single-gate model + caller audit + old-schema rejection."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import sys
6
- import tempfile
7
- from pathlib import Path
8
-
9
- # Add scripts dir to path so we can import _gate directly.
10
- SCRIPTS_DIR = Path(__file__).parent
11
- sys.path.insert(0, str(SCRIPTS_DIR))
12
-
13
- import _gate # noqa: E402
14
-
15
- TEMPLATE = json.loads(
16
- (SCRIPTS_DIR.parent / "assets" / "phase-state-template.json").read_text(encoding="utf-8")
17
- )
18
-
19
-
20
- def _fresh_project(tmp: Path, name: str = "p1") -> Path:
21
- p = tmp / name
22
- (p / ".state").mkdir(parents=True)
23
- state = json.loads(json.dumps(TEMPLATE)) # deep copy
24
- state["project"] = name
25
- state["task_type"] = "video_clone"
26
- (p / ".state" / "phase.json").write_text(json.dumps(state), encoding="utf-8")
27
- return p
28
-
29
-
30
- def test_set_gate_writes_caller_to_history():
31
- with tempfile.TemporaryDirectory() as td:
32
- p = _fresh_project(Path(td))
33
- _gate.set_gate(p, "preview_confirmed", "OK 开始", caller="confirm.py")
34
- state = _gate.load_state(p)
35
- history = state["history"]
36
- assert len(history) == 1
37
- assert history[0]["event"] == "preview_confirmed"
38
- assert history[0]["caller"] == "confirm.py"
39
- assert history[0]["user_quote"] == "OK 开始"
40
- assert state["gates"]["preview_confirmed"]["status"] is True
41
- print("PASS test_set_gate_writes_caller_to_history")
42
-
43
-
44
- def test_set_gate_missing_caller_raises_typeerror():
45
- with tempfile.TemporaryDirectory() as td:
46
- p = _fresh_project(Path(td))
47
- try:
48
- _gate.set_gate(p, "preview_confirmed", "OK") # type: ignore[call-arg]
49
- except TypeError:
50
- print("PASS test_set_gate_missing_caller_raises_typeerror")
51
- return
52
- raise AssertionError("expected TypeError")
53
-
54
-
55
- def test_set_gate_empty_caller_raises_valueerror():
56
- with tempfile.TemporaryDirectory() as td:
57
- p = _fresh_project(Path(td))
58
- try:
59
- _gate.set_gate(p, "preview_confirmed", "OK", caller=" ")
60
- except ValueError:
61
- print("PASS test_set_gate_empty_caller_raises_valueerror")
62
- return
63
- raise AssertionError("expected ValueError")
64
-
65
-
66
- def test_set_gate_records_literal_caller_value():
67
- """Bypass via 'caller=ad-hoc' is allowed but leaves visible evidence."""
68
- with tempfile.TemporaryDirectory() as td:
69
- p = _fresh_project(Path(td))
70
- _gate.set_gate(p, "preview_confirmed", "ok", caller="ad-hoc")
71
- history = _gate.load_state(p)["history"]
72
- assert history[0]["caller"] == "ad-hoc"
73
- print("PASS test_set_gate_records_literal_caller_value")
74
-
75
-
76
- def test_require_gate_unset_exits_one():
77
- with tempfile.TemporaryDirectory() as td:
78
- p = _fresh_project(Path(td))
79
- try:
80
- _gate.require_gate(p, "preview_confirmed")
81
- except SystemExit as e:
82
- assert e.code == 1
83
- print("PASS test_require_gate_unset_exits_one")
84
- return
85
- raise AssertionError("expected SystemExit(1)")
86
-
87
-
88
- def test_require_gate_set_returns_none():
89
- with tempfile.TemporaryDirectory() as td:
90
- p = _fresh_project(Path(td))
91
- _gate.set_gate(p, "preview_confirmed", "ok", caller="confirm.py")
92
- assert _gate.require_gate(p, "preview_confirmed") is None
93
- print("PASS test_require_gate_set_returns_none")
94
-
95
-
96
- def test_old_3gate_schema_rejected():
97
- """An old PR #65 phase.json should be rejected, not silently mishandled."""
98
- with tempfile.TemporaryDirectory() as td:
99
- p = Path(td) / "old"
100
- (p / ".state").mkdir(parents=True)
101
- old_state = {
102
- "schema_version": 1,
103
- "project": "old",
104
- "task_type": "video_clone",
105
- "created_at": "2026-01-01T00:00:00Z",
106
- "current_phase": 0,
107
- "gates": {
108
- "plan_confirmed": {"status": False, "confirmed_at": None, "user_quote": None},
109
- "prompt_confirmed": {"status": False, "confirmed_at": None, "user_quote": None},
110
- "frame_confirmed": {"status": False, "confirmed_at": None, "user_quote": None},
111
- },
112
- "history": [],
113
- }
114
- (p / ".state" / "phase.json").write_text(json.dumps(old_state), encoding="utf-8")
115
- try:
116
- _gate.require_gate(p, "preview_confirmed")
117
- except SystemExit as e:
118
- assert e.code == 1
119
- print("PASS test_old_3gate_schema_rejected")
120
- return
121
- raise AssertionError("expected SystemExit(1)")
122
-
123
-
124
- def test_save_state_atomic():
125
- """Verify save_state writes via tmp+replace (no partial-write race)."""
126
- with tempfile.TemporaryDirectory() as td:
127
- p = _fresh_project(Path(td))
128
- state = _gate.load_state(p)
129
- state["project"] = "renamed"
130
- _gate.save_state(p, state)
131
- # Verify no orphan .json.tmp file
132
- tmp = (p / ".state" / "phase.json.tmp")
133
- assert not tmp.exists()
134
- # Verify the new value persisted
135
- assert _gate.load_state(p)["project"] == "renamed"
136
- print("PASS test_save_state_atomic")
137
-
138
-
139
- if __name__ == "__main__":
140
- test_set_gate_writes_caller_to_history()
141
- test_set_gate_missing_caller_raises_typeerror()
142
- test_set_gate_empty_caller_raises_valueerror()
143
- test_set_gate_records_literal_caller_value()
144
- test_require_gate_unset_exits_one()
145
- test_require_gate_set_returns_none()
146
- test_old_3gate_schema_rejected()
147
- test_save_state_atomic()
148
- print("\nAll 8 _gate tests passed.")
@@ -1,56 +0,0 @@
1
- """Tiny helper to resolve a project directory under gen-output/video-clone/.
2
-
3
- Used by all executor scripts so they share the same GEN_OUTPUT_ROOT convention.
4
- """
5
- from __future__ import annotations
6
-
7
- import os
8
- import sys
9
- from pathlib import Path
10
-
11
-
12
- def gen_output_root() -> Path:
13
- override = os.environ.get("GEN_OUTPUT_ROOT")
14
- if override:
15
- return Path(override)
16
- return Path.cwd() / "gen-output"
17
-
18
-
19
- def resolve_project(name: str) -> Path:
20
- path = gen_output_root() / "video-clone" / name
21
- if not path.is_dir():
22
- print(
23
- f"ERROR: project directory not found at {path}\n"
24
- f"Run: python scripts/init_project.py --name {name} --task-type video_clone",
25
- file=sys.stderr,
26
- )
27
- sys.exit(1)
28
- return path
29
-
30
-
31
- def next_version(dir_path: Path, prefix: str, suffix: str) -> Path:
32
- """Return dir_path / f'{prefix}v{N}{suffix}' with N = highest existing + 1.
33
-
34
- Example: next_version(frames_dir, 'frame_', '.png') →
35
- frames_dir/frame_v3.png (if v1, v2 already exist)
36
- """
37
- n = 1
38
- while (dir_path / f"{prefix}v{n}{suffix}").exists():
39
- n += 1
40
- return dir_path / f"{prefix}v{n}{suffix}"
41
-
42
-
43
- def append_log(project_dir: Path, event: str) -> None:
44
- """Append one human-readable event line to <project_dir>/log.md.
45
-
46
- Format: 'HH:MM:SS <event>'. Used by status.py to render the History
47
- section. This is the human-readable ledger; phase.json:history[] is the
48
- machine-significance audit ledger and stays gate-events-only.
49
- """
50
- from datetime import datetime, timezone
51
- log = project_dir / "log.md"
52
- line = f"{datetime.now(timezone.utc).strftime('%H:%M:%S')} {event}\n"
53
- if not log.exists():
54
- log.write_text("# Log\n\n", encoding="utf-8")
55
- with log.open("a", encoding="utf-8") as f:
56
- f.write(line)
@@ -1,113 +0,0 @@
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())
@@ -1,52 +0,0 @@
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.")
@@ -1,106 +0,0 @@
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())
@@ -1,12 +0,0 @@
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())
@@ -1,66 +0,0 @@
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())
@@ -1,108 +0,0 @@
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())