@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.
Files changed (154) hide show
  1. package/AGENTS.md +7 -7
  2. package/CHANGELOG.md +27 -0
  3. package/CLAUDE.md +8 -8
  4. package/analyse-app-logs/SKILL.md +3 -3
  5. package/bin/apollo-toolkit.ts +7 -0
  6. package/codex/codex-memory-manager/SKILL.md +2 -2
  7. package/codex/learn-skill-from-conversations/SKILL.md +3 -3
  8. package/dist/bin/apollo-toolkit.d.ts +2 -0
  9. package/dist/bin/apollo-toolkit.js +7 -0
  10. package/dist/lib/cli.d.ts +41 -0
  11. package/dist/lib/cli.js +655 -0
  12. package/dist/lib/installer.d.ts +59 -0
  13. package/dist/lib/installer.js +404 -0
  14. package/dist/lib/tool-runner.d.ts +19 -0
  15. package/dist/lib/tool-runner.js +536 -0
  16. package/dist/lib/tools/architecture.d.ts +2 -0
  17. package/dist/lib/tools/architecture.js +34 -0
  18. package/dist/lib/tools/create-specs.d.ts +2 -0
  19. package/dist/lib/tools/create-specs.js +175 -0
  20. package/dist/lib/tools/docs-to-voice.d.ts +2 -0
  21. package/dist/lib/tools/docs-to-voice.js +705 -0
  22. package/dist/lib/tools/enforce-video-aspect-ratio.d.ts +2 -0
  23. package/dist/lib/tools/enforce-video-aspect-ratio.js +312 -0
  24. package/dist/lib/tools/extract-conversations.d.ts +2 -0
  25. package/dist/lib/tools/extract-conversations.js +105 -0
  26. package/dist/lib/tools/extract-pdf-text.d.ts +2 -0
  27. package/dist/lib/tools/extract-pdf-text.js +92 -0
  28. package/dist/lib/tools/filter-logs.d.ts +2 -0
  29. package/dist/lib/tools/filter-logs.js +94 -0
  30. package/dist/lib/tools/find-github-issues.d.ts +2 -0
  31. package/dist/lib/tools/find-github-issues.js +176 -0
  32. package/dist/lib/tools/generate-storyboard-images.d.ts +2 -0
  33. package/dist/lib/tools/generate-storyboard-images.js +419 -0
  34. package/dist/lib/tools/log-cli-utils.d.ts +35 -0
  35. package/dist/lib/tools/log-cli-utils.js +233 -0
  36. package/dist/lib/tools/open-github-issue.d.ts +2 -0
  37. package/dist/lib/tools/open-github-issue.js +750 -0
  38. package/dist/lib/tools/read-github-issue.d.ts +2 -0
  39. package/dist/lib/tools/read-github-issue.js +134 -0
  40. package/dist/lib/tools/render-error-book.d.ts +2 -0
  41. package/dist/lib/tools/render-error-book.js +265 -0
  42. package/dist/lib/tools/render-katex.d.ts +2 -0
  43. package/dist/lib/tools/render-katex.js +294 -0
  44. package/dist/lib/tools/review-threads.d.ts +2 -0
  45. package/dist/lib/tools/review-threads.js +491 -0
  46. package/dist/lib/tools/search-logs.d.ts +2 -0
  47. package/dist/lib/tools/search-logs.js +164 -0
  48. package/dist/lib/tools/sync-memory-index.d.ts +2 -0
  49. package/dist/lib/tools/sync-memory-index.js +113 -0
  50. package/dist/lib/tools/validate-openai-agent-config.d.ts +2 -0
  51. package/dist/lib/tools/validate-openai-agent-config.js +184 -0
  52. package/dist/lib/tools/validate-skill-frontmatter.d.ts +2 -0
  53. package/dist/lib/tools/validate-skill-frontmatter.js +118 -0
  54. package/dist/lib/types.d.ts +82 -0
  55. package/dist/lib/types.js +2 -0
  56. package/dist/lib/updater.d.ts +34 -0
  57. package/dist/lib/updater.js +112 -0
  58. package/dist/lib/utils/format.d.ts +2 -0
  59. package/dist/lib/utils/format.js +6 -0
  60. package/dist/lib/utils/terminal.d.ts +12 -0
  61. package/dist/lib/utils/terminal.js +26 -0
  62. package/docs-to-voice/SKILL.md +0 -1
  63. package/generate-spec/SKILL.md +1 -1
  64. package/katex/SKILL.md +1 -2
  65. package/lib/cli.ts +780 -0
  66. package/lib/installer.ts +466 -0
  67. package/lib/tool-runner.ts +561 -0
  68. package/lib/tools/architecture.ts +34 -0
  69. package/lib/tools/create-specs.ts +204 -0
  70. package/lib/tools/docs-to-voice.ts +799 -0
  71. package/lib/tools/enforce-video-aspect-ratio.ts +368 -0
  72. package/lib/tools/extract-conversations.ts +114 -0
  73. package/lib/tools/extract-pdf-text.ts +99 -0
  74. package/lib/tools/filter-logs.ts +118 -0
  75. package/lib/tools/find-github-issues.ts +211 -0
  76. package/lib/tools/generate-storyboard-images.ts +455 -0
  77. package/lib/tools/log-cli-utils.ts +262 -0
  78. package/lib/tools/open-github-issue.ts +930 -0
  79. package/lib/tools/read-github-issue.ts +179 -0
  80. package/lib/tools/render-error-book.ts +300 -0
  81. package/lib/tools/render-katex.ts +325 -0
  82. package/lib/tools/review-threads.ts +590 -0
  83. package/lib/tools/search-logs.ts +200 -0
  84. package/lib/tools/sync-memory-index.ts +114 -0
  85. package/lib/tools/validate-openai-agent-config.ts +209 -0
  86. package/lib/tools/validate-skill-frontmatter.ts +124 -0
  87. package/lib/types.ts +90 -0
  88. package/lib/updater.ts +165 -0
  89. package/lib/utils/format.ts +7 -0
  90. package/lib/utils/terminal.ts +22 -0
  91. package/open-github-issue/SKILL.md +2 -2
  92. package/optimise-skill/SKILL.md +1 -1
  93. package/package.json +13 -4
  94. package/resources/project-architecture/assets/architecture.css +764 -0
  95. package/resources/project-architecture/assets/viewer.client.js +144 -0
  96. package/resources/project-architecture/index.html +42 -0
  97. package/review-spec-related-changes/SKILL.md +1 -1
  98. package/solve-issues-found-during-review/SKILL.md +2 -1
  99. package/tsconfig.json +28 -0
  100. package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
  101. package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
  102. package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
  103. package/analyse-app-logs/scripts/filter_logs_by_time.py +0 -64
  104. package/analyse-app-logs/scripts/log_cli_utils.py +0 -112
  105. package/analyse-app-logs/scripts/search_logs.py +0 -137
  106. package/analyse-app-logs/tests/test_filter_logs_by_time.py +0 -95
  107. package/analyse-app-logs/tests/test_search_logs.py +0 -100
  108. package/codex/codex-memory-manager/scripts/extract_recent_conversations.py +0 -369
  109. package/codex/codex-memory-manager/scripts/sync_memory_index.py +0 -130
  110. package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +0 -177
  111. package/codex/codex-memory-manager/tests/test_memory_template.py +0 -37
  112. package/codex/codex-memory-manager/tests/test_sync_memory_index.py +0 -84
  113. package/codex/learn-skill-from-conversations/scripts/extract_recent_conversations.py +0 -369
  114. package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +0 -177
  115. package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
  116. package/docs-to-voice/scripts/docs_to_voice.py +0 -1385
  117. package/docs-to-voice/scripts/docs_to_voice.sh +0 -11
  118. package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +0 -210
  119. package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +0 -115
  120. package/docs-to-voice/tests/test_docs_to_voice_settings.py +0 -43
  121. package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +0 -51
  122. package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +0 -57
  123. package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  124. package/generate-spec/scripts/create-specs +0 -215
  125. package/generate-spec/tests/test_create_specs.py +0 -200
  126. package/init-project-html/scripts/architecture-bootstrap-render.js +0 -16
  127. package/init-project-html/scripts/architecture.js +0 -296
  128. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  129. package/katex/scripts/render_katex.py +0 -247
  130. package/katex/scripts/render_katex.sh +0 -11
  131. package/katex/tests/test_render_katex.py +0 -174
  132. package/learning-error-book/scripts/render_error_book_json_to_pdf.py +0 -590
  133. package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +0 -134
  134. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  135. package/open-github-issue/scripts/open_github_issue.py +0 -705
  136. package/open-github-issue/tests/test_open_github_issue.py +0 -381
  137. package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +0 -763
  138. package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +0 -177
  139. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  140. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  141. package/read-github-issue/scripts/find_issues.py +0 -148
  142. package/read-github-issue/scripts/read_issue.py +0 -108
  143. package/read-github-issue/tests/test_find_issues.py +0 -127
  144. package/read-github-issue/tests/test_read_issue.py +0 -109
  145. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  146. package/resolve-review-comments/scripts/review_threads.py +0 -425
  147. package/resolve-review-comments/tests/test_review_threads.py +0 -74
  148. package/scripts/validate_openai_agent_config.py +0 -209
  149. package/scripts/validate_skill_frontmatter.py +0 -131
  150. package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
  151. package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +0 -350
  152. package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +0 -194
  153. package/weekly-financial-event-report/scripts/extract_pdf_text_pdfkit.swift +0 -99
  154. 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())
@@ -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()