@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,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())
|