@optima-chat/optima-agent 0.8.91 → 0.8.92
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 +360 -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 +39 -72
- 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 +182 -0
- package/.claude/skills/video-clone/scripts/preflight.py +95 -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,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())
|