@laitszkin/apollo-toolkit 3.13.2 → 3.14.0
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/AGENTS.md +7 -7
- package/CHANGELOG.md +27 -0
- package/CLAUDE.md +8 -8
- package/analyse-app-logs/SKILL.md +3 -3
- package/bin/apollo-toolkit.ts +7 -0
- package/codex/codex-memory-manager/SKILL.md +2 -2
- package/codex/learn-skill-from-conversations/SKILL.md +3 -3
- package/dist/bin/apollo-toolkit.d.ts +2 -0
- package/dist/bin/apollo-toolkit.js +7 -0
- package/dist/lib/cli.d.ts +41 -0
- package/dist/lib/cli.js +655 -0
- package/dist/lib/installer.d.ts +59 -0
- package/dist/lib/installer.js +404 -0
- package/dist/lib/tool-runner.d.ts +19 -0
- package/dist/lib/tool-runner.js +536 -0
- package/dist/lib/tools/architecture.d.ts +2 -0
- package/dist/lib/tools/architecture.js +34 -0
- package/dist/lib/tools/create-specs.d.ts +2 -0
- package/dist/lib/tools/create-specs.js +175 -0
- package/dist/lib/tools/docs-to-voice.d.ts +2 -0
- package/dist/lib/tools/docs-to-voice.js +705 -0
- package/dist/lib/tools/enforce-video-aspect-ratio.d.ts +2 -0
- package/dist/lib/tools/enforce-video-aspect-ratio.js +312 -0
- package/dist/lib/tools/extract-conversations.d.ts +2 -0
- package/dist/lib/tools/extract-conversations.js +105 -0
- package/dist/lib/tools/extract-pdf-text.d.ts +2 -0
- package/dist/lib/tools/extract-pdf-text.js +92 -0
- package/dist/lib/tools/filter-logs.d.ts +2 -0
- package/dist/lib/tools/filter-logs.js +94 -0
- package/dist/lib/tools/find-github-issues.d.ts +2 -0
- package/dist/lib/tools/find-github-issues.js +176 -0
- package/dist/lib/tools/generate-storyboard-images.d.ts +2 -0
- package/dist/lib/tools/generate-storyboard-images.js +419 -0
- package/dist/lib/tools/log-cli-utils.d.ts +35 -0
- package/dist/lib/tools/log-cli-utils.js +233 -0
- package/dist/lib/tools/open-github-issue.d.ts +2 -0
- package/dist/lib/tools/open-github-issue.js +750 -0
- package/dist/lib/tools/read-github-issue.d.ts +2 -0
- package/dist/lib/tools/read-github-issue.js +134 -0
- package/dist/lib/tools/render-error-book.d.ts +2 -0
- package/dist/lib/tools/render-error-book.js +265 -0
- package/dist/lib/tools/render-katex.d.ts +2 -0
- package/dist/lib/tools/render-katex.js +294 -0
- package/dist/lib/tools/review-threads.d.ts +2 -0
- package/dist/lib/tools/review-threads.js +491 -0
- package/dist/lib/tools/search-logs.d.ts +2 -0
- package/dist/lib/tools/search-logs.js +164 -0
- package/dist/lib/tools/sync-memory-index.d.ts +2 -0
- package/dist/lib/tools/sync-memory-index.js +113 -0
- package/dist/lib/tools/validate-openai-agent-config.d.ts +2 -0
- package/dist/lib/tools/validate-openai-agent-config.js +184 -0
- package/dist/lib/tools/validate-skill-frontmatter.d.ts +2 -0
- package/dist/lib/tools/validate-skill-frontmatter.js +118 -0
- package/dist/lib/types.d.ts +82 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/updater.d.ts +34 -0
- package/dist/lib/updater.js +112 -0
- package/dist/lib/utils/format.d.ts +2 -0
- package/dist/lib/utils/format.js +6 -0
- package/dist/lib/utils/terminal.d.ts +12 -0
- package/dist/lib/utils/terminal.js +26 -0
- package/docs-to-voice/SKILL.md +0 -1
- package/generate-spec/SKILL.md +1 -1
- package/katex/SKILL.md +1 -2
- package/lib/cli.ts +780 -0
- package/lib/installer.ts +466 -0
- package/lib/tool-runner.ts +561 -0
- package/lib/tools/architecture.ts +34 -0
- package/lib/tools/create-specs.ts +204 -0
- package/lib/tools/docs-to-voice.ts +799 -0
- package/lib/tools/enforce-video-aspect-ratio.ts +368 -0
- package/lib/tools/extract-conversations.ts +114 -0
- package/lib/tools/extract-pdf-text.ts +99 -0
- package/lib/tools/filter-logs.ts +118 -0
- package/lib/tools/find-github-issues.ts +211 -0
- package/lib/tools/generate-storyboard-images.ts +455 -0
- package/lib/tools/log-cli-utils.ts +262 -0
- package/lib/tools/open-github-issue.ts +930 -0
- package/lib/tools/read-github-issue.ts +179 -0
- package/lib/tools/render-error-book.ts +300 -0
- package/lib/tools/render-katex.ts +325 -0
- package/lib/tools/review-threads.ts +590 -0
- package/lib/tools/search-logs.ts +200 -0
- package/lib/tools/sync-memory-index.ts +114 -0
- package/lib/tools/validate-openai-agent-config.ts +209 -0
- package/lib/tools/validate-skill-frontmatter.ts +124 -0
- package/lib/types.ts +90 -0
- package/lib/updater.ts +165 -0
- package/lib/utils/format.ts +7 -0
- package/lib/utils/terminal.ts +22 -0
- package/open-github-issue/SKILL.md +2 -2
- package/optimise-skill/SKILL.md +1 -1
- package/package.json +13 -4
- package/resources/project-architecture/assets/architecture.css +764 -0
- package/resources/project-architecture/assets/viewer.client.js +144 -0
- package/resources/project-architecture/index.html +42 -0
- package/review-spec-related-changes/SKILL.md +1 -1
- package/solve-issues-found-during-review/SKILL.md +2 -1
- package/tsconfig.json +28 -0
- package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/filter_logs_by_time.py +0 -64
- package/analyse-app-logs/scripts/log_cli_utils.py +0 -112
- package/analyse-app-logs/scripts/search_logs.py +0 -137
- package/analyse-app-logs/tests/test_filter_logs_by_time.py +0 -95
- package/analyse-app-logs/tests/test_search_logs.py +0 -100
- package/codex/codex-memory-manager/scripts/extract_recent_conversations.py +0 -369
- package/codex/codex-memory-manager/scripts/sync_memory_index.py +0 -130
- package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +0 -177
- package/codex/codex-memory-manager/tests/test_memory_template.py +0 -37
- package/codex/codex-memory-manager/tests/test_sync_memory_index.py +0 -84
- package/codex/learn-skill-from-conversations/scripts/extract_recent_conversations.py +0 -369
- package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +0 -177
- package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
- package/docs-to-voice/scripts/docs_to_voice.py +0 -1385
- package/docs-to-voice/scripts/docs_to_voice.sh +0 -11
- package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +0 -210
- package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +0 -115
- package/docs-to-voice/tests/test_docs_to_voice_settings.py +0 -43
- package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +0 -51
- package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +0 -57
- package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
- package/generate-spec/scripts/create-specs +0 -215
- package/generate-spec/tests/test_create_specs.py +0 -200
- package/init-project-html/scripts/architecture-bootstrap-render.js +0 -16
- package/init-project-html/scripts/architecture.js +0 -296
- package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
- package/katex/scripts/render_katex.py +0 -247
- package/katex/scripts/render_katex.sh +0 -11
- package/katex/tests/test_render_katex.py +0 -174
- package/learning-error-book/scripts/render_error_book_json_to_pdf.py +0 -590
- package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +0 -134
- package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
- package/open-github-issue/scripts/open_github_issue.py +0 -705
- package/open-github-issue/tests/test_open_github_issue.py +0 -381
- package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +0 -763
- package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +0 -177
- package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/find_issues.py +0 -148
- package/read-github-issue/scripts/read_issue.py +0 -108
- package/read-github-issue/tests/test_find_issues.py +0 -127
- package/read-github-issue/tests/test_read_issue.py +0 -109
- package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
- package/resolve-review-comments/scripts/review_threads.py +0 -425
- package/resolve-review-comments/tests/test_review_threads.py +0 -74
- package/scripts/validate_openai_agent_config.py +0 -209
- package/scripts/validate_skill_frontmatter.py +0 -131
- package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
- package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +0 -350
- package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +0 -194
- package/weekly-financial-event-report/scripts/extract_pdf_text_pdfkit.swift +0 -99
- package/weekly-financial-event-report/tests/test_extract_pdf_text_pdfkit.py +0 -64
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Validate SKILL.md frontmatter for all top-level skills."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
import re
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
import yaml
|
|
12
|
-
|
|
13
|
-
NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
14
|
-
REQUIRED_KEYS = {"name", "description"}
|
|
15
|
-
MAX_DESCRIPTION_LENGTH = 1024
|
|
16
|
-
|
|
17
|
-
HELP_EPILOG = """Examples:
|
|
18
|
-
apltk validate-skill-frontmatter
|
|
19
|
-
Result: prints either a pass summary or one error per invalid top-level SKILL.md frontmatter file.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def repo_root() -> Path:
|
|
24
|
-
return Path(__file__).resolve().parent.parent
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def iter_skill_dirs(root: Path) -> list[Path]:
|
|
28
|
-
return sorted(path for path in root.iterdir() if path.is_dir() and (path / "SKILL.md").is_file())
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def extract_frontmatter(content: str) -> str:
|
|
32
|
-
lines = content.splitlines()
|
|
33
|
-
if not lines or lines[0].strip() != "---":
|
|
34
|
-
raise ValueError("SKILL.md must start with YAML frontmatter delimiter '---'.")
|
|
35
|
-
|
|
36
|
-
for index in range(1, len(lines)):
|
|
37
|
-
if lines[index].strip() == "---":
|
|
38
|
-
return "\n".join(lines[1:index])
|
|
39
|
-
|
|
40
|
-
raise ValueError("SKILL.md frontmatter is missing the closing '---' delimiter.")
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def validate_skill(skill_dir: Path) -> list[str]:
|
|
44
|
-
errors: list[str] = []
|
|
45
|
-
skill_md = skill_dir / "SKILL.md"
|
|
46
|
-
|
|
47
|
-
try:
|
|
48
|
-
content = skill_md.read_text(encoding="utf-8")
|
|
49
|
-
except OSError as exc:
|
|
50
|
-
return [f"{skill_md}: cannot read file ({exc})."]
|
|
51
|
-
|
|
52
|
-
try:
|
|
53
|
-
frontmatter_text = extract_frontmatter(content)
|
|
54
|
-
except ValueError as exc:
|
|
55
|
-
return [f"{skill_md}: {exc}"]
|
|
56
|
-
|
|
57
|
-
try:
|
|
58
|
-
frontmatter = yaml.safe_load(frontmatter_text)
|
|
59
|
-
except yaml.YAMLError as exc:
|
|
60
|
-
return [f"{skill_md}: invalid YAML in frontmatter ({exc})."]
|
|
61
|
-
|
|
62
|
-
if not isinstance(frontmatter, dict):
|
|
63
|
-
return [f"{skill_md}: frontmatter must be a YAML mapping."]
|
|
64
|
-
|
|
65
|
-
keys = set(frontmatter.keys())
|
|
66
|
-
if keys != REQUIRED_KEYS:
|
|
67
|
-
missing = sorted(REQUIRED_KEYS - keys)
|
|
68
|
-
extra = sorted(keys - REQUIRED_KEYS)
|
|
69
|
-
if missing:
|
|
70
|
-
errors.append(f"{skill_md}: missing required frontmatter keys: {', '.join(missing)}.")
|
|
71
|
-
if extra:
|
|
72
|
-
errors.append(f"{skill_md}: unsupported frontmatter keys: {', '.join(extra)}.")
|
|
73
|
-
|
|
74
|
-
name = frontmatter.get("name")
|
|
75
|
-
if not isinstance(name, str) or not name.strip():
|
|
76
|
-
errors.append(f"{skill_md}: 'name' must be a non-empty string.")
|
|
77
|
-
else:
|
|
78
|
-
normalized_name = name.strip()
|
|
79
|
-
if not NAME_PATTERN.fullmatch(normalized_name):
|
|
80
|
-
errors.append(
|
|
81
|
-
f"{skill_md}: 'name' must be kebab-case (lowercase letters, digits, and hyphens)."
|
|
82
|
-
)
|
|
83
|
-
if normalized_name != skill_dir.name:
|
|
84
|
-
errors.append(
|
|
85
|
-
f"{skill_md}: frontmatter name '{normalized_name}' must match folder name '{skill_dir.name}'."
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
description = frontmatter.get("description")
|
|
89
|
-
if not isinstance(description, str) or not description.strip():
|
|
90
|
-
errors.append(f"{skill_md}: 'description' must be a non-empty string.")
|
|
91
|
-
elif len(description) > MAX_DESCRIPTION_LENGTH:
|
|
92
|
-
errors.append(
|
|
93
|
-
f"{skill_md}: invalid description: exceeds maximum length of "
|
|
94
|
-
f"{MAX_DESCRIPTION_LENGTH} characters"
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
return errors
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def build_parser() -> argparse.ArgumentParser:
|
|
101
|
-
return argparse.ArgumentParser(
|
|
102
|
-
description="Validate SKILL.md frontmatter for all top-level skills.",
|
|
103
|
-
epilog=HELP_EPILOG,
|
|
104
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def main(argv: list[str] | None = None) -> int:
|
|
109
|
-
build_parser().parse_args(argv)
|
|
110
|
-
root = repo_root()
|
|
111
|
-
skill_dirs = iter_skill_dirs(root)
|
|
112
|
-
if not skill_dirs:
|
|
113
|
-
print("No top-level skill directories found.")
|
|
114
|
-
return 1
|
|
115
|
-
|
|
116
|
-
all_errors: list[str] = []
|
|
117
|
-
for skill_dir in skill_dirs:
|
|
118
|
-
all_errors.extend(validate_skill(skill_dir))
|
|
119
|
-
|
|
120
|
-
if all_errors:
|
|
121
|
-
print("SKILL.md frontmatter validation failed:")
|
|
122
|
-
for error in all_errors:
|
|
123
|
-
print(f"- {error}")
|
|
124
|
-
return 1
|
|
125
|
-
|
|
126
|
-
print(f"SKILL.md frontmatter validation passed for {len(skill_dirs)} skills.")
|
|
127
|
-
return 0
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if __name__ == "__main__":
|
|
131
|
-
sys.exit(main())
|
|
Binary file
|
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
|
-
import shutil
|
|
9
|
-
import subprocess
|
|
10
|
-
import tempfile
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
SKILL_DIR = Path(__file__).resolve().parent.parent
|
|
14
|
-
DEFAULT_ENV_FILE = SKILL_DIR / ".env"
|
|
15
|
-
SIZE_PATTERN = re.compile(r"^(\d{2,5})x(\d{2,5})$")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def parse_args() -> argparse.Namespace:
|
|
19
|
-
parser = argparse.ArgumentParser(
|
|
20
|
-
description=(
|
|
21
|
-
"Enforce final video aspect ratio and size. "
|
|
22
|
-
"If input aspect ratio differs from target, center-crop then scale."
|
|
23
|
-
)
|
|
24
|
-
)
|
|
25
|
-
parser.add_argument("--input-video", required=True, help="Path to rendered input video.")
|
|
26
|
-
parser.add_argument(
|
|
27
|
-
"--output-video",
|
|
28
|
-
help=(
|
|
29
|
-
"Path to processed output video. If omitted, write next to input as "
|
|
30
|
-
"<name>_aspect_fixed.mp4."
|
|
31
|
-
),
|
|
32
|
-
)
|
|
33
|
-
parser.add_argument(
|
|
34
|
-
"--in-place",
|
|
35
|
-
action="store_true",
|
|
36
|
-
help="Overwrite input file in place (uses a temporary file then replaces input).",
|
|
37
|
-
)
|
|
38
|
-
parser.add_argument(
|
|
39
|
-
"--target-size",
|
|
40
|
-
help="Target size in WIDTHxHEIGHT format, for example 1080x1920.",
|
|
41
|
-
)
|
|
42
|
-
parser.add_argument("--target-width", type=int, help="Target width in pixels.")
|
|
43
|
-
parser.add_argument("--target-height", type=int, help="Target height in pixels.")
|
|
44
|
-
parser.add_argument(
|
|
45
|
-
"--env-file",
|
|
46
|
-
default=str(DEFAULT_ENV_FILE),
|
|
47
|
-
help=f"Path to .env file (default: {DEFAULT_ENV_FILE}).",
|
|
48
|
-
)
|
|
49
|
-
parser.add_argument("--force", action="store_true", help="Overwrite output if it exists.")
|
|
50
|
-
parser.add_argument("--ffmpeg-bin", default="ffmpeg", help="ffmpeg executable name or path.")
|
|
51
|
-
parser.add_argument("--ffprobe-bin", default="ffprobe", help="ffprobe executable name or path.")
|
|
52
|
-
return parser.parse_args()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def parse_dotenv_line(content: str, line_no: int, file_path: Path) -> tuple[str, str] | None:
|
|
56
|
-
stripped = content.strip()
|
|
57
|
-
if not stripped or stripped.startswith("#"):
|
|
58
|
-
return None
|
|
59
|
-
if stripped.startswith("export "):
|
|
60
|
-
stripped = stripped[7:].strip()
|
|
61
|
-
|
|
62
|
-
if "=" not in stripped:
|
|
63
|
-
raise SystemExit(f"Invalid .env format at {file_path}:{line_no}: missing =")
|
|
64
|
-
|
|
65
|
-
key, value = stripped.split("=", 1)
|
|
66
|
-
key = key.strip()
|
|
67
|
-
value = value.strip()
|
|
68
|
-
|
|
69
|
-
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
70
|
-
raise SystemExit(f"Invalid .env key at {file_path}:{line_no}: {key}")
|
|
71
|
-
|
|
72
|
-
if value and value[0] in {"'", '"'}:
|
|
73
|
-
quote = value[0]
|
|
74
|
-
if len(value) >= 2 and value[-1] == quote:
|
|
75
|
-
value = value[1:-1]
|
|
76
|
-
else:
|
|
77
|
-
value = value.split(" #", 1)[0].strip()
|
|
78
|
-
|
|
79
|
-
return key, value
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def load_dotenv_file(env_file: Path, override: bool = False) -> bool:
|
|
83
|
-
if not env_file.exists():
|
|
84
|
-
return False
|
|
85
|
-
|
|
86
|
-
for line_no, line in enumerate(env_file.read_text(encoding="utf-8").splitlines(), start=1):
|
|
87
|
-
parsed = parse_dotenv_line(line, line_no, env_file)
|
|
88
|
-
if not parsed:
|
|
89
|
-
continue
|
|
90
|
-
key, value = parsed
|
|
91
|
-
if override or key not in os.environ:
|
|
92
|
-
os.environ[key] = value
|
|
93
|
-
return True
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def parse_size(value: str) -> tuple[int, int]:
|
|
97
|
-
match = SIZE_PATTERN.fullmatch(value.strip().lower())
|
|
98
|
-
if not match:
|
|
99
|
-
raise SystemExit("Invalid size format. Use WIDTHxHEIGHT, for example 1080x1920.")
|
|
100
|
-
width = int(match.group(1))
|
|
101
|
-
height = int(match.group(2))
|
|
102
|
-
if width <= 0 or height <= 0:
|
|
103
|
-
raise SystemExit("Width and height must be positive integers.")
|
|
104
|
-
return width, height
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def required_command(command: str) -> None:
|
|
108
|
-
if shutil.which(command):
|
|
109
|
-
return
|
|
110
|
-
raise SystemExit(f"Missing required command: {command}")
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def probe_video_size(video_path: Path, ffprobe_bin: str) -> tuple[int, int]:
|
|
114
|
-
cmd = [
|
|
115
|
-
ffprobe_bin,
|
|
116
|
-
"-v",
|
|
117
|
-
"error",
|
|
118
|
-
"-select_streams",
|
|
119
|
-
"v:0",
|
|
120
|
-
"-show_entries",
|
|
121
|
-
"stream=width,height",
|
|
122
|
-
"-of",
|
|
123
|
-
"json",
|
|
124
|
-
str(video_path),
|
|
125
|
-
]
|
|
126
|
-
try:
|
|
127
|
-
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
128
|
-
except subprocess.CalledProcessError as exc:
|
|
129
|
-
stderr = exc.stderr.strip()
|
|
130
|
-
raise SystemExit(f"ffprobe failed for {video_path}: {stderr}") from exc
|
|
131
|
-
|
|
132
|
-
try:
|
|
133
|
-
payload = json.loads(result.stdout)
|
|
134
|
-
except json.JSONDecodeError as exc:
|
|
135
|
-
raise SystemExit(f"Unable to parse ffprobe output for {video_path}.") from exc
|
|
136
|
-
|
|
137
|
-
streams = payload.get("streams")
|
|
138
|
-
if not isinstance(streams, list) or not streams:
|
|
139
|
-
raise SystemExit(f"No video stream found in {video_path}.")
|
|
140
|
-
|
|
141
|
-
first = streams[0]
|
|
142
|
-
if not isinstance(first, dict):
|
|
143
|
-
raise SystemExit(f"Unexpected ffprobe stream payload for {video_path}.")
|
|
144
|
-
|
|
145
|
-
width = first.get("width")
|
|
146
|
-
height = first.get("height")
|
|
147
|
-
if not isinstance(width, int) or not isinstance(height, int) or width <= 0 or height <= 0:
|
|
148
|
-
raise SystemExit(f"Invalid video dimensions from ffprobe for {video_path}.")
|
|
149
|
-
return width, height
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def even_floor(value: int, minimum: int = 2) -> int:
|
|
153
|
-
floored = value if value % 2 == 0 else value - 1
|
|
154
|
-
return max(floored, minimum)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def build_video_filter(
|
|
158
|
-
input_width: int,
|
|
159
|
-
input_height: int,
|
|
160
|
-
target_width: int,
|
|
161
|
-
target_height: int,
|
|
162
|
-
) -> tuple[str | None, bool]:
|
|
163
|
-
same_ratio = input_width * target_height == input_height * target_width
|
|
164
|
-
same_size = input_width == target_width and input_height == target_height
|
|
165
|
-
|
|
166
|
-
if same_ratio and same_size:
|
|
167
|
-
return None, False
|
|
168
|
-
|
|
169
|
-
if same_ratio:
|
|
170
|
-
return f"scale={target_width}:{target_height}", False
|
|
171
|
-
|
|
172
|
-
input_wider = input_width * target_height > input_height * target_width
|
|
173
|
-
if input_wider:
|
|
174
|
-
crop_width = input_height * target_width // target_height
|
|
175
|
-
crop_height = input_height
|
|
176
|
-
else:
|
|
177
|
-
crop_width = input_width
|
|
178
|
-
crop_height = input_width * target_height // target_width
|
|
179
|
-
|
|
180
|
-
crop_width = min(even_floor(crop_width), even_floor(input_width))
|
|
181
|
-
crop_height = min(even_floor(crop_height), even_floor(input_height))
|
|
182
|
-
|
|
183
|
-
offset_x = max((input_width - crop_width) // 2, 0)
|
|
184
|
-
offset_y = max((input_height - crop_height) // 2, 0)
|
|
185
|
-
|
|
186
|
-
return f"crop={crop_width}:{crop_height}:{offset_x}:{offset_y},scale={target_width}:{target_height}", True
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def resolve_target_size(args: argparse.Namespace) -> tuple[int, int]:
|
|
190
|
-
if args.target_size and (args.target_width is not None or args.target_height is not None):
|
|
191
|
-
raise SystemExit("Use either --target-size or --target-width/--target-height, not both.")
|
|
192
|
-
|
|
193
|
-
if args.target_size:
|
|
194
|
-
return parse_size(args.target_size)
|
|
195
|
-
|
|
196
|
-
env_width = os.getenv("TEXT_TO_SHORT_VIDEO_WIDTH", "").strip()
|
|
197
|
-
env_height = os.getenv("TEXT_TO_SHORT_VIDEO_HEIGHT", "").strip()
|
|
198
|
-
|
|
199
|
-
if args.target_width is not None:
|
|
200
|
-
width = args.target_width
|
|
201
|
-
else:
|
|
202
|
-
try:
|
|
203
|
-
width = int(env_width or "1080")
|
|
204
|
-
except ValueError as exc:
|
|
205
|
-
raise SystemExit("TEXT_TO_SHORT_VIDEO_WIDTH must be an integer.") from exc
|
|
206
|
-
|
|
207
|
-
if args.target_height is not None:
|
|
208
|
-
height = args.target_height
|
|
209
|
-
else:
|
|
210
|
-
try:
|
|
211
|
-
height = int(env_height or "1920")
|
|
212
|
-
except ValueError as exc:
|
|
213
|
-
raise SystemExit("TEXT_TO_SHORT_VIDEO_HEIGHT must be an integer.") from exc
|
|
214
|
-
|
|
215
|
-
if width <= 0 or height <= 0:
|
|
216
|
-
raise SystemExit("Target width and height must be positive integers.")
|
|
217
|
-
return width, height
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def resolve_output_path(args: argparse.Namespace, input_video: Path) -> Path:
|
|
221
|
-
if args.in_place and args.output_video:
|
|
222
|
-
raise SystemExit("Do not pass --output-video with --in-place.")
|
|
223
|
-
|
|
224
|
-
if args.in_place:
|
|
225
|
-
return input_video
|
|
226
|
-
|
|
227
|
-
if args.output_video:
|
|
228
|
-
output_video = Path(args.output_video).expanduser().resolve()
|
|
229
|
-
else:
|
|
230
|
-
output_video = input_video.with_name(f"{input_video.stem}_aspect_fixed.mp4")
|
|
231
|
-
|
|
232
|
-
if output_video == input_video:
|
|
233
|
-
raise SystemExit("Output path equals input path. Use --in-place to replace the input file.")
|
|
234
|
-
|
|
235
|
-
return output_video
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def copy_if_needed(source: Path, target: Path, force: bool) -> None:
|
|
239
|
-
if source == target:
|
|
240
|
-
return
|
|
241
|
-
if target.exists() and not force:
|
|
242
|
-
raise SystemExit(f"Output already exists: {target}. Use --force to overwrite.")
|
|
243
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
-
shutil.copy2(source, target)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def main() -> int:
|
|
248
|
-
args = parse_args()
|
|
249
|
-
|
|
250
|
-
input_video = Path(args.input_video).expanduser().resolve()
|
|
251
|
-
if not input_video.is_file():
|
|
252
|
-
raise SystemExit(f"Input video not found: {input_video}")
|
|
253
|
-
|
|
254
|
-
env_file = Path(args.env_file).expanduser()
|
|
255
|
-
if not env_file.is_absolute():
|
|
256
|
-
env_file = SKILL_DIR / env_file
|
|
257
|
-
load_dotenv_file(env_file, override=False)
|
|
258
|
-
|
|
259
|
-
target_width, target_height = resolve_target_size(args)
|
|
260
|
-
output_video = resolve_output_path(args, input_video)
|
|
261
|
-
|
|
262
|
-
required_command(args.ffmpeg_bin)
|
|
263
|
-
required_command(args.ffprobe_bin)
|
|
264
|
-
|
|
265
|
-
input_width, input_height = probe_video_size(input_video, args.ffprobe_bin)
|
|
266
|
-
filter_expression, crop_applied = build_video_filter(
|
|
267
|
-
input_width=input_width,
|
|
268
|
-
input_height=input_height,
|
|
269
|
-
target_width=target_width,
|
|
270
|
-
target_height=target_height,
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
if filter_expression is None:
|
|
274
|
-
print(
|
|
275
|
-
f"[INFO] Video already matches target size and aspect ratio: "
|
|
276
|
-
f"{input_width}x{input_height}."
|
|
277
|
-
)
|
|
278
|
-
copy_if_needed(input_video, output_video, args.force)
|
|
279
|
-
if input_video != output_video:
|
|
280
|
-
print(f"[OK] Copied original video to: {output_video}")
|
|
281
|
-
return 0
|
|
282
|
-
|
|
283
|
-
replace_in_place = args.in_place
|
|
284
|
-
if replace_in_place:
|
|
285
|
-
temp_fd, temp_name = tempfile.mkstemp(
|
|
286
|
-
prefix=f"{input_video.stem}_aspect_tmp_",
|
|
287
|
-
suffix=".mp4",
|
|
288
|
-
dir=str(input_video.parent),
|
|
289
|
-
)
|
|
290
|
-
os.close(temp_fd)
|
|
291
|
-
temp_output = Path(temp_name)
|
|
292
|
-
else:
|
|
293
|
-
temp_output = output_video
|
|
294
|
-
if temp_output.exists() and not args.force:
|
|
295
|
-
raise SystemExit(f"Output already exists: {temp_output}. Use --force to overwrite.")
|
|
296
|
-
temp_output.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
-
|
|
298
|
-
ffmpeg_cmd = [
|
|
299
|
-
args.ffmpeg_bin,
|
|
300
|
-
"-hide_banner",
|
|
301
|
-
"-loglevel",
|
|
302
|
-
"error",
|
|
303
|
-
"-y" if (args.force or replace_in_place) else "-n",
|
|
304
|
-
"-i",
|
|
305
|
-
str(input_video),
|
|
306
|
-
"-vf",
|
|
307
|
-
filter_expression,
|
|
308
|
-
"-map",
|
|
309
|
-
"0:v:0",
|
|
310
|
-
"-map",
|
|
311
|
-
"0:a?",
|
|
312
|
-
"-c:v",
|
|
313
|
-
"libx264",
|
|
314
|
-
"-preset",
|
|
315
|
-
"medium",
|
|
316
|
-
"-crf",
|
|
317
|
-
"18",
|
|
318
|
-
"-c:a",
|
|
319
|
-
"aac",
|
|
320
|
-
"-movflags",
|
|
321
|
-
"+faststart",
|
|
322
|
-
str(temp_output),
|
|
323
|
-
]
|
|
324
|
-
|
|
325
|
-
try:
|
|
326
|
-
subprocess.run(ffmpeg_cmd, check=True)
|
|
327
|
-
except subprocess.CalledProcessError as exc:
|
|
328
|
-
if replace_in_place and temp_output.exists():
|
|
329
|
-
temp_output.unlink(missing_ok=True)
|
|
330
|
-
raise SystemExit(f"ffmpeg failed with exit code {exc.returncode}.") from exc
|
|
331
|
-
|
|
332
|
-
if replace_in_place:
|
|
333
|
-
temp_output.replace(input_video)
|
|
334
|
-
final_output = input_video
|
|
335
|
-
else:
|
|
336
|
-
final_output = output_video
|
|
337
|
-
|
|
338
|
-
final_width, final_height = probe_video_size(final_output, args.ffprobe_bin)
|
|
339
|
-
print(
|
|
340
|
-
f"[OK] Processed video written: {final_output}\n"
|
|
341
|
-
f"[INFO] Input size: {input_width}x{input_height}\n"
|
|
342
|
-
f"[INFO] Target size: {target_width}x{target_height}\n"
|
|
343
|
-
f"[INFO] Output size: {final_width}x{final_height}\n"
|
|
344
|
-
f"[INFO] Center crop applied: {'yes' if crop_applied else 'no'}"
|
|
345
|
-
)
|
|
346
|
-
return 0
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if __name__ == "__main__":
|
|
350
|
-
raise SystemExit(main())
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import importlib.util
|
|
6
|
-
import io
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import random
|
|
10
|
-
import re
|
|
11
|
-
import tempfile
|
|
12
|
-
import types
|
|
13
|
-
import unittest
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from unittest.mock import patch
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "enforce_video_aspect_ratio.py"
|
|
19
|
-
SPEC = importlib.util.spec_from_file_location("enforce_video_aspect_ratio", SCRIPT_PATH)
|
|
20
|
-
MODULE = importlib.util.module_from_spec(SPEC)
|
|
21
|
-
SPEC.loader.exec_module(MODULE)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def parse_crop(expression: str) -> tuple[int, int, int, int] | None:
|
|
25
|
-
match = re.search(r"crop=(\d+):(\d+):(\d+):(\d+)", expression)
|
|
26
|
-
if not match:
|
|
27
|
-
return None
|
|
28
|
-
return tuple(int(value) for value in match.groups())
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class EnforceVideoAspectRatioTests(unittest.TestCase):
|
|
32
|
-
def test_parse_size_property_round_trips_positive_dimensions(self) -> None:
|
|
33
|
-
generator = random.Random(20260418)
|
|
34
|
-
for _ in range(150):
|
|
35
|
-
width = generator.randint(1, 9999)
|
|
36
|
-
height = generator.randint(1, 9999)
|
|
37
|
-
raw = f"{width}x{height}"
|
|
38
|
-
with self.subTest(raw=raw):
|
|
39
|
-
self.assertEqual(MODULE.parse_size(raw), (width, height))
|
|
40
|
-
|
|
41
|
-
def test_load_dotenv_file_parses_quotes_and_respects_override(self) -> None:
|
|
42
|
-
with tempfile.TemporaryDirectory() as temp_dir:
|
|
43
|
-
env_file = Path(temp_dir) / ".env"
|
|
44
|
-
env_file.write_text(
|
|
45
|
-
'TEXT_TO_SHORT_VIDEO_WIDTH="720"\n'
|
|
46
|
-
"TEXT_TO_SHORT_VIDEO_HEIGHT=1280 # comment\n",
|
|
47
|
-
encoding="utf-8",
|
|
48
|
-
)
|
|
49
|
-
original = os.environ.get("TEXT_TO_SHORT_VIDEO_WIDTH")
|
|
50
|
-
os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"] = "999"
|
|
51
|
-
try:
|
|
52
|
-
loaded = MODULE.load_dotenv_file(env_file, override=False)
|
|
53
|
-
self.assertTrue(loaded)
|
|
54
|
-
self.assertEqual(os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"], "999")
|
|
55
|
-
self.assertEqual(os.environ["TEXT_TO_SHORT_VIDEO_HEIGHT"], "1280")
|
|
56
|
-
MODULE.load_dotenv_file(env_file, override=True)
|
|
57
|
-
self.assertEqual(os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"], "720")
|
|
58
|
-
finally:
|
|
59
|
-
if original is None:
|
|
60
|
-
os.environ.pop("TEXT_TO_SHORT_VIDEO_WIDTH", None)
|
|
61
|
-
else:
|
|
62
|
-
os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"] = original
|
|
63
|
-
os.environ.pop("TEXT_TO_SHORT_VIDEO_HEIGHT", None)
|
|
64
|
-
|
|
65
|
-
def test_build_video_filter_property_keeps_crop_within_bounds(self) -> None:
|
|
66
|
-
generator = random.Random(20260418)
|
|
67
|
-
for _ in range(200):
|
|
68
|
-
input_width = generator.randint(32, 4096)
|
|
69
|
-
input_height = generator.randint(32, 4096)
|
|
70
|
-
target_width = generator.randint(32, 2160)
|
|
71
|
-
target_height = generator.randint(32, 3840)
|
|
72
|
-
|
|
73
|
-
expression, crop_applied = MODULE.build_video_filter(
|
|
74
|
-
input_width=input_width,
|
|
75
|
-
input_height=input_height,
|
|
76
|
-
target_width=target_width,
|
|
77
|
-
target_height=target_height,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
with self.subTest(
|
|
81
|
-
input_width=input_width,
|
|
82
|
-
input_height=input_height,
|
|
83
|
-
target_width=target_width,
|
|
84
|
-
target_height=target_height,
|
|
85
|
-
expression=expression,
|
|
86
|
-
):
|
|
87
|
-
if expression is None:
|
|
88
|
-
self.assertFalse(crop_applied)
|
|
89
|
-
self.assertEqual((input_width, input_height), (target_width, target_height))
|
|
90
|
-
continue
|
|
91
|
-
|
|
92
|
-
self.assertIn(f"scale={target_width}:{target_height}", expression)
|
|
93
|
-
crop = parse_crop(expression)
|
|
94
|
-
if crop is None:
|
|
95
|
-
self.assertFalse(crop_applied)
|
|
96
|
-
self.assertEqual(input_width * target_height, input_height * target_width)
|
|
97
|
-
continue
|
|
98
|
-
|
|
99
|
-
crop_width, crop_height, offset_x, offset_y = crop
|
|
100
|
-
self.assertTrue(crop_applied)
|
|
101
|
-
self.assertEqual(crop_width % 2, 0)
|
|
102
|
-
self.assertEqual(crop_height % 2, 0)
|
|
103
|
-
self.assertGreaterEqual(crop_width, 2)
|
|
104
|
-
self.assertGreaterEqual(crop_height, 2)
|
|
105
|
-
self.assertLessEqual(crop_width, input_width)
|
|
106
|
-
self.assertLessEqual(crop_height, input_height)
|
|
107
|
-
self.assertGreaterEqual(offset_x, 0)
|
|
108
|
-
self.assertGreaterEqual(offset_y, 0)
|
|
109
|
-
self.assertLessEqual(offset_x + crop_width, input_width)
|
|
110
|
-
self.assertLessEqual(offset_y + crop_height, input_height)
|
|
111
|
-
|
|
112
|
-
def test_resolve_output_path_enforces_in_place_rules(self) -> None:
|
|
113
|
-
input_video = Path("/tmp/input.mp4")
|
|
114
|
-
args = types.SimpleNamespace(in_place=False, output_video=None)
|
|
115
|
-
self.assertEqual(MODULE.resolve_output_path(args, input_video), Path("/tmp/input_aspect_fixed.mp4"))
|
|
116
|
-
|
|
117
|
-
args = types.SimpleNamespace(in_place=True, output_video=None)
|
|
118
|
-
self.assertEqual(MODULE.resolve_output_path(args, input_video), input_video)
|
|
119
|
-
|
|
120
|
-
args = types.SimpleNamespace(in_place=True, output_video="/tmp/out.mp4")
|
|
121
|
-
with self.assertRaises(SystemExit):
|
|
122
|
-
MODULE.resolve_output_path(args, input_video)
|
|
123
|
-
|
|
124
|
-
def test_main_copies_file_when_video_already_matches_target(self) -> None:
|
|
125
|
-
with tempfile.TemporaryDirectory() as temp_dir:
|
|
126
|
-
input_video = Path(temp_dir) / "input.mp4"
|
|
127
|
-
input_video.write_bytes(b"video")
|
|
128
|
-
output_video = Path(temp_dir) / "copy.mp4"
|
|
129
|
-
args = types.SimpleNamespace(
|
|
130
|
-
input_video=str(input_video),
|
|
131
|
-
output_video=str(output_video),
|
|
132
|
-
in_place=False,
|
|
133
|
-
target_size="640x360",
|
|
134
|
-
target_width=None,
|
|
135
|
-
target_height=None,
|
|
136
|
-
env_file=str(Path(temp_dir) / ".env"),
|
|
137
|
-
force=False,
|
|
138
|
-
ffmpeg_bin="ffmpeg",
|
|
139
|
-
ffprobe_bin="ffprobe",
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
with patch.object(MODULE, "parse_args", return_value=args), patch.object(
|
|
143
|
-
MODULE, "load_dotenv_file", return_value=False
|
|
144
|
-
), patch.object(MODULE, "required_command"), patch.object(
|
|
145
|
-
MODULE, "probe_video_size", return_value=(640, 360)
|
|
146
|
-
), patch("sys.stdout", new_callable=io.StringIO) as stdout:
|
|
147
|
-
exit_code = MODULE.main()
|
|
148
|
-
|
|
149
|
-
self.assertEqual(exit_code, 0)
|
|
150
|
-
self.assertEqual(output_video.read_bytes(), b"video")
|
|
151
|
-
self.assertIn("already matches target size", stdout.getvalue())
|
|
152
|
-
|
|
153
|
-
def test_main_runs_ffmpeg_and_reports_output_dimensions(self) -> None:
|
|
154
|
-
with tempfile.TemporaryDirectory() as temp_dir:
|
|
155
|
-
input_video = Path(temp_dir) / "input.mp4"
|
|
156
|
-
output_video = Path(temp_dir) / "output.mp4"
|
|
157
|
-
input_video.write_bytes(b"video")
|
|
158
|
-
args = types.SimpleNamespace(
|
|
159
|
-
input_video=str(input_video),
|
|
160
|
-
output_video=str(output_video),
|
|
161
|
-
in_place=False,
|
|
162
|
-
target_size="1080x1920",
|
|
163
|
-
target_width=None,
|
|
164
|
-
target_height=None,
|
|
165
|
-
env_file=str(Path(temp_dir) / ".env"),
|
|
166
|
-
force=True,
|
|
167
|
-
ffmpeg_bin="ffmpeg",
|
|
168
|
-
ffprobe_bin="ffprobe",
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
def fake_run(command, check):
|
|
172
|
-
self.assertIn("-vf", command)
|
|
173
|
-
Path(command[-1]).write_bytes(b"processed")
|
|
174
|
-
return types.SimpleNamespace(returncode=0)
|
|
175
|
-
|
|
176
|
-
with patch.object(MODULE, "parse_args", return_value=args), patch.object(
|
|
177
|
-
MODULE, "load_dotenv_file", return_value=False
|
|
178
|
-
), patch.object(MODULE, "required_command"), patch.object(
|
|
179
|
-
MODULE,
|
|
180
|
-
"probe_video_size",
|
|
181
|
-
side_effect=[(1920, 1080), (1080, 1920)],
|
|
182
|
-
), patch.object(MODULE.subprocess, "run", side_effect=fake_run), patch(
|
|
183
|
-
"sys.stdout", new_callable=io.StringIO
|
|
184
|
-
) as stdout:
|
|
185
|
-
exit_code = MODULE.main()
|
|
186
|
-
|
|
187
|
-
self.assertEqual(exit_code, 0)
|
|
188
|
-
self.assertTrue(output_video.is_file())
|
|
189
|
-
self.assertIn("Center crop applied: yes", stdout.getvalue())
|
|
190
|
-
self.assertIn("Output size: 1080x1920", stdout.getvalue())
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if __name__ == "__main__":
|
|
194
|
-
unittest.main()
|