@laitszkin/apollo-toolkit 2.0.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 (204) hide show
  1. package/AGENTS.md +62 -0
  2. package/CHANGELOG.md +100 -0
  3. package/LICENSE +21 -0
  4. package/README.md +144 -0
  5. package/align-project-documents/SKILL.md +94 -0
  6. package/align-project-documents/agents/openai.yaml +4 -0
  7. package/analyse-app-logs/LICENSE +21 -0
  8. package/analyse-app-logs/README.md +126 -0
  9. package/analyse-app-logs/SKILL.md +121 -0
  10. package/analyse-app-logs/agents/openai.yaml +4 -0
  11. package/analyse-app-logs/references/investigation-checklist.md +58 -0
  12. package/analyse-app-logs/references/log-signal-patterns.md +52 -0
  13. package/answering-questions-with-research/SKILL.md +46 -0
  14. package/answering-questions-with-research/agents/openai.yaml +4 -0
  15. package/bin/apollo-toolkit.js +7 -0
  16. package/commit-and-push/LICENSE +21 -0
  17. package/commit-and-push/README.md +26 -0
  18. package/commit-and-push/SKILL.md +70 -0
  19. package/commit-and-push/agents/openai.yaml +4 -0
  20. package/commit-and-push/references/branch-naming.md +15 -0
  21. package/commit-and-push/references/commit-messages.md +19 -0
  22. package/deep-research-topics/LICENSE +21 -0
  23. package/deep-research-topics/README.md +43 -0
  24. package/deep-research-topics/SKILL.md +84 -0
  25. package/deep-research-topics/agents/openai.yaml +4 -0
  26. package/develop-new-features/LICENSE +21 -0
  27. package/develop-new-features/README.md +52 -0
  28. package/develop-new-features/SKILL.md +105 -0
  29. package/develop-new-features/agents/openai.yaml +4 -0
  30. package/develop-new-features/references/testing-e2e.md +35 -0
  31. package/develop-new-features/references/testing-integration.md +42 -0
  32. package/develop-new-features/references/testing-property-based.md +44 -0
  33. package/develop-new-features/references/testing-unit.md +37 -0
  34. package/discover-edge-cases/CHANGELOG.md +19 -0
  35. package/discover-edge-cases/LICENSE +21 -0
  36. package/discover-edge-cases/README.md +87 -0
  37. package/discover-edge-cases/SKILL.md +124 -0
  38. package/discover-edge-cases/agents/openai.yaml +4 -0
  39. package/discover-edge-cases/references/architecture-edge-cases.md +41 -0
  40. package/discover-edge-cases/references/code-edge-cases.md +46 -0
  41. package/docs-to-voice/.env.example +106 -0
  42. package/docs-to-voice/CHANGELOG.md +71 -0
  43. package/docs-to-voice/LICENSE +21 -0
  44. package/docs-to-voice/README.md +118 -0
  45. package/docs-to-voice/SKILL.md +107 -0
  46. package/docs-to-voice/agents/openai.yaml +4 -0
  47. package/docs-to-voice/scripts/docs_to_voice.py +1385 -0
  48. package/docs-to-voice/scripts/docs_to_voice.sh +11 -0
  49. package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +210 -0
  50. package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +115 -0
  51. package/docs-to-voice/tests/test_docs_to_voice_settings.py +43 -0
  52. package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +57 -0
  53. package/enhance-existing-features/CHANGELOG.md +35 -0
  54. package/enhance-existing-features/LICENSE +21 -0
  55. package/enhance-existing-features/README.md +54 -0
  56. package/enhance-existing-features/SKILL.md +120 -0
  57. package/enhance-existing-features/agents/openai.yaml +4 -0
  58. package/enhance-existing-features/references/e2e-tests.md +25 -0
  59. package/enhance-existing-features/references/integration-tests.md +30 -0
  60. package/enhance-existing-features/references/property-based-tests.md +33 -0
  61. package/enhance-existing-features/references/unit-tests.md +29 -0
  62. package/feature-propose/LICENSE +21 -0
  63. package/feature-propose/README.md +23 -0
  64. package/feature-propose/SKILL.md +107 -0
  65. package/feature-propose/agents/openai.yaml +4 -0
  66. package/feature-propose/references/enhancement-features.md +25 -0
  67. package/feature-propose/references/important-features.md +25 -0
  68. package/feature-propose/references/mvp-features.md +25 -0
  69. package/feature-propose/references/performance-features.md +25 -0
  70. package/financial-research/SKILL.md +208 -0
  71. package/financial-research/agents/openai.yaml +4 -0
  72. package/financial-research/assets/weekly_market_report_template.md +45 -0
  73. package/fix-github-issues/SKILL.md +98 -0
  74. package/fix-github-issues/agents/openai.yaml +4 -0
  75. package/fix-github-issues/scripts/list_issues.py +148 -0
  76. package/fix-github-issues/tests/test_list_issues.py +127 -0
  77. package/generate-spec/LICENSE +21 -0
  78. package/generate-spec/README.md +61 -0
  79. package/generate-spec/SKILL.md +96 -0
  80. package/generate-spec/agents/openai.yaml +4 -0
  81. package/generate-spec/references/templates/checklist.md +78 -0
  82. package/generate-spec/references/templates/spec.md +55 -0
  83. package/generate-spec/references/templates/tasks.md +35 -0
  84. package/generate-spec/scripts/create-specs +123 -0
  85. package/harden-app-security/CHANGELOG.md +27 -0
  86. package/harden-app-security/LICENSE +21 -0
  87. package/harden-app-security/README.md +46 -0
  88. package/harden-app-security/SKILL.md +127 -0
  89. package/harden-app-security/agents/openai.yaml +4 -0
  90. package/harden-app-security/references/agent-attack-catalog.md +117 -0
  91. package/harden-app-security/references/common-software-attack-catalog.md +168 -0
  92. package/harden-app-security/references/red-team-extreme-scenarios.md +81 -0
  93. package/harden-app-security/references/risk-checklist.md +78 -0
  94. package/harden-app-security/references/security-test-patterns-agent.md +101 -0
  95. package/harden-app-security/references/security-test-patterns-finance.md +88 -0
  96. package/harden-app-security/references/test-snippets.md +73 -0
  97. package/improve-observability/SKILL.md +114 -0
  98. package/improve-observability/agents/openai.yaml +4 -0
  99. package/learn-skill-from-conversations/CHANGELOG.md +15 -0
  100. package/learn-skill-from-conversations/LICENSE +22 -0
  101. package/learn-skill-from-conversations/README.md +47 -0
  102. package/learn-skill-from-conversations/SKILL.md +85 -0
  103. package/learn-skill-from-conversations/agents/openai.yaml +4 -0
  104. package/learn-skill-from-conversations/scripts/extract_recent_conversations.py +369 -0
  105. package/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +176 -0
  106. package/learning-error-book/SKILL.md +112 -0
  107. package/learning-error-book/agents/openai.yaml +4 -0
  108. package/learning-error-book/assets/error_book_template.md +66 -0
  109. package/learning-error-book/scripts/render_markdown_to_pdf.py +367 -0
  110. package/lib/cli.js +338 -0
  111. package/lib/installer.js +225 -0
  112. package/maintain-project-constraints/SKILL.md +109 -0
  113. package/maintain-project-constraints/agents/openai.yaml +4 -0
  114. package/maintain-skill-catalog/README.md +18 -0
  115. package/maintain-skill-catalog/SKILL.md +66 -0
  116. package/maintain-skill-catalog/agents/openai.yaml +4 -0
  117. package/novel-to-short-video/CHANGELOG.md +53 -0
  118. package/novel-to-short-video/LICENSE +21 -0
  119. package/novel-to-short-video/README.md +63 -0
  120. package/novel-to-short-video/SKILL.md +233 -0
  121. package/novel-to-short-video/agents/openai.yaml +4 -0
  122. package/novel-to-short-video/references/plan-template.md +71 -0
  123. package/novel-to-short-video/references/roles-json.md +41 -0
  124. package/open-github-issue/LICENSE +21 -0
  125. package/open-github-issue/README.md +97 -0
  126. package/open-github-issue/SKILL.md +119 -0
  127. package/open-github-issue/agents/openai.yaml +4 -0
  128. package/open-github-issue/scripts/open_github_issue.py +380 -0
  129. package/open-github-issue/tests/test_open_github_issue.py +159 -0
  130. package/open-source-pr-workflow/CHANGELOG.md +32 -0
  131. package/open-source-pr-workflow/LICENSE +21 -0
  132. package/open-source-pr-workflow/README.md +23 -0
  133. package/open-source-pr-workflow/SKILL.md +123 -0
  134. package/open-source-pr-workflow/agents/openai.yaml +4 -0
  135. package/openai-text-to-image-storyboard/.env.example +10 -0
  136. package/openai-text-to-image-storyboard/CHANGELOG.md +49 -0
  137. package/openai-text-to-image-storyboard/LICENSE +21 -0
  138. package/openai-text-to-image-storyboard/README.md +99 -0
  139. package/openai-text-to-image-storyboard/SKILL.md +107 -0
  140. package/openai-text-to-image-storyboard/agents/openai.yaml +4 -0
  141. package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +763 -0
  142. package/package.json +36 -0
  143. package/record-spending/SKILL.md +113 -0
  144. package/record-spending/agents/openai.yaml +4 -0
  145. package/record-spending/references/account-format.md +33 -0
  146. package/record-spending/references/workbook-layout.md +84 -0
  147. package/resolve-review-comments/SKILL.md +122 -0
  148. package/resolve-review-comments/agents/openai.yaml +4 -0
  149. package/resolve-review-comments/references/adoption-criteria.md +23 -0
  150. package/resolve-review-comments/scripts/review_threads.py +425 -0
  151. package/resolve-review-comments/tests/test_review_threads.py +74 -0
  152. package/review-change-set/LICENSE +21 -0
  153. package/review-change-set/README.md +55 -0
  154. package/review-change-set/SKILL.md +103 -0
  155. package/review-change-set/agents/openai.yaml +4 -0
  156. package/review-codebases/LICENSE +21 -0
  157. package/review-codebases/README.md +67 -0
  158. package/review-codebases/SKILL.md +109 -0
  159. package/review-codebases/agents/openai.yaml +4 -0
  160. package/scripts/install_skills.ps1 +283 -0
  161. package/scripts/install_skills.sh +262 -0
  162. package/scripts/validate_openai_agent_config.py +194 -0
  163. package/scripts/validate_skill_frontmatter.py +110 -0
  164. package/specs-to-project-docs/LICENSE +21 -0
  165. package/specs-to-project-docs/README.md +57 -0
  166. package/specs-to-project-docs/SKILL.md +111 -0
  167. package/specs-to-project-docs/agents/openai.yaml +4 -0
  168. package/specs-to-project-docs/references/templates/architecture.md +29 -0
  169. package/specs-to-project-docs/references/templates/configuration.md +29 -0
  170. package/specs-to-project-docs/references/templates/developer-guide.md +33 -0
  171. package/specs-to-project-docs/references/templates/docs-index.md +39 -0
  172. package/specs-to-project-docs/references/templates/features.md +25 -0
  173. package/specs-to-project-docs/references/templates/getting-started.md +38 -0
  174. package/specs-to-project-docs/references/templates/readme.md +49 -0
  175. package/systematic-debug/LICENSE +21 -0
  176. package/systematic-debug/README.md +81 -0
  177. package/systematic-debug/SKILL.md +59 -0
  178. package/systematic-debug/agents/openai.yaml +4 -0
  179. package/text-to-short-video/.env.example +36 -0
  180. package/text-to-short-video/LICENSE +21 -0
  181. package/text-to-short-video/README.md +82 -0
  182. package/text-to-short-video/SKILL.md +221 -0
  183. package/text-to-short-video/agents/openai.yaml +4 -0
  184. package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +350 -0
  185. package/version-release/CHANGELOG.md +53 -0
  186. package/version-release/LICENSE +21 -0
  187. package/version-release/README.md +28 -0
  188. package/version-release/SKILL.md +94 -0
  189. package/version-release/agents/openai.yaml +4 -0
  190. package/version-release/references/branch-naming.md +15 -0
  191. package/version-release/references/changelog-writing.md +8 -0
  192. package/version-release/references/commit-messages.md +19 -0
  193. package/version-release/references/readme-writing.md +12 -0
  194. package/version-release/references/semantic-versioning.md +12 -0
  195. package/video-production/CHANGELOG.md +104 -0
  196. package/video-production/LICENSE +18 -0
  197. package/video-production/README.md +68 -0
  198. package/video-production/SKILL.md +213 -0
  199. package/video-production/agents/openai.yaml +4 -0
  200. package/video-production/references/plan-template.md +54 -0
  201. package/video-production/references/roles-json.md +41 -0
  202. package/weekly-financial-event-report/SKILL.md +195 -0
  203. package/weekly-financial-event-report/agents/openai.yaml +4 -0
  204. package/weekly-financial-event-report/assets/financial_event_report_template.md +53 -0
@@ -0,0 +1,763 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import base64
6
+ import io
7
+ import json
8
+ import math
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib import error, request
14
+
15
+ INVALID_PATH_CHARS = re.compile(r"[\\/:*?\"<>|]+")
16
+ SKILL_DIR = Path(__file__).resolve().parent.parent
17
+ DEFAULT_ENV_FILE = SKILL_DIR / ".env"
18
+ CHARACTER_SKELETON_FIELDS = ("id", "name", "appearance", "outfit", "description")
19
+
20
+
21
+ def parse_args() -> argparse.Namespace:
22
+ parser = argparse.ArgumentParser(
23
+ description="Generate storyboard images from agent-decided prompts via an OpenAI-compatible image API.",
24
+ )
25
+ parser.add_argument("--content-name", required=True, help="Output subfolder name under pictures/")
26
+ parser.add_argument("--project-dir", default=".", help="Project root path (default: current directory)")
27
+ parser.add_argument(
28
+ "--env-file",
29
+ default=str(DEFAULT_ENV_FILE),
30
+ help=f"Environment file path (default: {DEFAULT_ENV_FILE})",
31
+ )
32
+ parser.add_argument(
33
+ "--api-url",
34
+ help="API base URL for /images/generations (or OPENAI_API_URL)",
35
+ )
36
+ parser.add_argument(
37
+ "--api-key",
38
+ help="API key for /images/generations (or OPENAI_API_KEY)",
39
+ )
40
+
41
+ prompt_source = parser.add_mutually_exclusive_group(required=True)
42
+ prompt_source.add_argument(
43
+ "--prompts-file",
44
+ help="Path to a JSON file containing prompt entries (list mode or structured characters/scenes mode)",
45
+ )
46
+ prompt_source.add_argument(
47
+ "--prompt",
48
+ action="append",
49
+ help="Image prompt text; pass multiple times to generate multiple images",
50
+ )
51
+
52
+ parser.add_argument(
53
+ "--image-model",
54
+ help="Image model for /images/generations (or OPENAI_IMAGE_MODEL, default: gpt-image-1)",
55
+ )
56
+ parser.add_argument(
57
+ "--aspect-ratio",
58
+ help=(
59
+ "Image aspect ratio, e.g. 16:9 or 4:3 "
60
+ "(or OPENAI_IMAGE_RATIO / OPENAI_IMAGE_ASPECT_RATIO). "
61
+ "If set, output is center-cropped to this ratio. "
62
+ "If omitted, use model/API default size."
63
+ ),
64
+ )
65
+ parser.add_argument(
66
+ "--image-size",
67
+ "--size",
68
+ dest="image_size",
69
+ help=(
70
+ "Optional image size for OpenAI-compatible providers that expect size, "
71
+ "e.g. 1024x768 or 1024x1024 (or OPENAI_IMAGE_SIZE)."
72
+ ),
73
+ )
74
+ parser.add_argument(
75
+ "--quality",
76
+ help="Optional image quality parameter (or OPENAI_IMAGE_QUALITY)",
77
+ )
78
+ parser.add_argument(
79
+ "--style",
80
+ help="Optional image style parameter (or OPENAI_IMAGE_STYLE)",
81
+ )
82
+ return parser.parse_args()
83
+
84
+
85
+ def required_env(name: str, env_file: Path | None = None) -> str:
86
+ value = os.getenv(name, "").strip()
87
+ if not value:
88
+ location_hint = f" and {env_file}" if env_file else ""
89
+ raise SystemExit(f"Missing required configuration: {name}. Set it in environment{location_hint}.")
90
+ return value
91
+
92
+
93
+ def required_arg_or_env(
94
+ arg_value: str | None,
95
+ *,
96
+ arg_name: str,
97
+ env_name: str,
98
+ env_file: Path | None = None,
99
+ ) -> str:
100
+ if arg_value is not None:
101
+ value = arg_value.strip()
102
+ if not value:
103
+ raise SystemExit(f"{arg_name} cannot be empty.")
104
+ return value
105
+ return required_env(env_name, env_file)
106
+
107
+
108
+ def sanitize_component(name: str, fallback: str) -> str:
109
+ cleaned = INVALID_PATH_CHARS.sub("_", name.strip())
110
+ cleaned = re.sub(r"\s+", "_", cleaned)
111
+ cleaned = re.sub(r"_+", "_", cleaned).strip("._")
112
+ return cleaned or fallback
113
+
114
+
115
+ def normalize_aspect_ratio(value: str | None) -> str | None:
116
+ if value is None:
117
+ return None
118
+ candidate = value.strip()
119
+ if not candidate:
120
+ return None
121
+ if not re.fullmatch(r"\d{1,3}:\d{1,3}", candidate):
122
+ raise SystemExit("Invalid aspect ratio. Use format like 16:9 or 4:3.")
123
+ width, height = parse_ratio_pair(candidate)
124
+ if width <= 0 or height <= 0:
125
+ raise SystemExit("Invalid aspect ratio. Width and height must be positive integers.")
126
+ return candidate
127
+
128
+
129
+ def first_nonempty_env(*names: str) -> str | None:
130
+ for name in names:
131
+ value = os.getenv(name)
132
+ if value is not None and value.strip():
133
+ return value
134
+ return None
135
+
136
+
137
+ def parse_ratio_pair(value: str) -> tuple[int, int]:
138
+ left, right = value.split(":", 1)
139
+ return int(left), int(right)
140
+
141
+
142
+ def normalize_image_size(value: str | None) -> str | None:
143
+ if value is None:
144
+ return None
145
+ candidate = value.strip().lower()
146
+ if not candidate:
147
+ return None
148
+ if not re.fullmatch(r"\d{2,5}x\d{2,5}", candidate):
149
+ raise SystemExit("Invalid image size. Use format like 1024x768.")
150
+ width_str, height_str = candidate.split("x", 1)
151
+ if int(width_str) <= 0 or int(height_str) <= 0:
152
+ raise SystemExit("Invalid image size. Width and height must be positive integers.")
153
+ return candidate
154
+
155
+
156
+ def parse_image_dimensions(image_bytes: bytes) -> tuple[int, int] | None:
157
+ if image_bytes.startswith(b"\x89PNG\r\n\x1a\n") and len(image_bytes) >= 24:
158
+ width = int.from_bytes(image_bytes[16:20], "big")
159
+ height = int.from_bytes(image_bytes[20:24], "big")
160
+ if width > 0 and height > 0:
161
+ return width, height
162
+
163
+ if image_bytes.startswith(b"\xFF\xD8"):
164
+ index = 2
165
+ while index + 1 < len(image_bytes):
166
+ while index < len(image_bytes) and image_bytes[index] != 0xFF:
167
+ index += 1
168
+ if index + 1 >= len(image_bytes):
169
+ break
170
+ marker = image_bytes[index + 1]
171
+ index += 2
172
+
173
+ if marker in {0xD8, 0xD9}:
174
+ continue
175
+ if marker == 0xDA:
176
+ break
177
+ if index + 2 > len(image_bytes):
178
+ break
179
+
180
+ segment_length = int.from_bytes(image_bytes[index : index + 2], "big")
181
+ if segment_length < 2 or index + segment_length > len(image_bytes):
182
+ break
183
+
184
+ if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
185
+ if segment_length >= 7:
186
+ height = int.from_bytes(image_bytes[index + 3 : index + 5], "big")
187
+ width = int.from_bytes(image_bytes[index + 5 : index + 7], "big")
188
+ if width > 0 and height > 0:
189
+ return width, height
190
+
191
+ index += segment_length
192
+
193
+ return None
194
+
195
+
196
+ def simplify_ratio(width: int, height: int) -> str:
197
+ factor = math.gcd(width, height)
198
+ return f"{width // factor}:{height // factor}"
199
+
200
+
201
+ def is_aspect_ratio_mismatch(
202
+ dimensions: tuple[int, int],
203
+ requested_ratio: str,
204
+ tolerance: float = 0.05,
205
+ ) -> bool:
206
+ width, height = dimensions
207
+ req_width, req_height = parse_ratio_pair(requested_ratio)
208
+ diff = abs(width * req_height - height * req_width)
209
+ allowed = req_width * height * tolerance
210
+ return diff > allowed
211
+
212
+
213
+ def suggest_size_for_ratio(requested_ratio: str) -> str:
214
+ req_width, req_height = parse_ratio_pair(requested_ratio)
215
+ base_width = 1024
216
+ suggested_height = max(2, int(round(base_width * req_height / req_width)))
217
+ if suggested_height % 2 != 0:
218
+ suggested_height += 1
219
+ return f"{base_width}x{suggested_height}"
220
+
221
+
222
+ def center_crop_to_aspect_ratio(
223
+ image_bytes: bytes,
224
+ requested_ratio: str,
225
+ ) -> tuple[bytes, tuple[int, int], tuple[int, int], bool]:
226
+ try:
227
+ from PIL import Image
228
+ except ImportError as exc:
229
+ raise RuntimeError("Aspect-ratio crop requires Pillow. Install it with `pip install pillow`.") from exc
230
+
231
+ req_width, req_height = parse_ratio_pair(requested_ratio)
232
+ with Image.open(io.BytesIO(image_bytes)) as source:
233
+ source_width, source_height = source.size
234
+ scale = min(source_width // req_width, source_height // req_height)
235
+ if scale < 1:
236
+ raise RuntimeError(
237
+ f"Cannot crop {source_width}x{source_height} to aspect ratio {requested_ratio}."
238
+ )
239
+
240
+ target_width = req_width * scale
241
+ target_height = req_height * scale
242
+ source_size = (source_width, source_height)
243
+ target_size = (target_width, target_height)
244
+
245
+ if target_size == source_size:
246
+ return image_bytes, source_size, target_size, False
247
+
248
+ left = (source_width - target_width) // 2
249
+ top = (source_height - target_height) // 2
250
+ right = left + target_width
251
+ bottom = top + target_height
252
+
253
+ cropped = source.crop((left, top, right, bottom))
254
+ if cropped.mode not in {"RGB", "RGBA", "L", "LA", "P"}:
255
+ cropped = cropped.convert("RGB")
256
+
257
+ output = io.BytesIO()
258
+ # Output filename extension is .png, so write normalized PNG bytes.
259
+ cropped.save(output, format="PNG")
260
+ return output.getvalue(), source_size, target_size, True
261
+
262
+
263
+ def unique_path(path: Path) -> Path:
264
+ if not path.exists():
265
+ return path
266
+ stem = path.stem
267
+ suffix = path.suffix
268
+ parent = path.parent
269
+ index = 2
270
+ while True:
271
+ candidate = parent / f"{stem}_{index}{suffix}"
272
+ if not candidate.exists():
273
+ return candidate
274
+ index += 1
275
+
276
+
277
+ def parse_dotenv_line(content: str, line_no: int, file_path: Path) -> tuple[str, str] | None:
278
+ stripped = content.strip()
279
+ if not stripped or stripped.startswith("#"):
280
+ return None
281
+ if stripped.startswith("export "):
282
+ stripped = stripped[7:].strip()
283
+
284
+ if "=" not in stripped:
285
+ raise SystemExit(f"Invalid .env format at {file_path}:{line_no}: missing =")
286
+
287
+ key, value = stripped.split("=", 1)
288
+ key = key.strip()
289
+ value = value.strip()
290
+
291
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
292
+ raise SystemExit(f"Invalid .env key at {file_path}:{line_no}: {key}")
293
+
294
+ if value and value[0] in {"'", '"'}:
295
+ quote = value[0]
296
+ if len(value) >= 2 and value[-1] == quote:
297
+ value = value[1:-1]
298
+ else:
299
+ value = value.split(" #", 1)[0].strip()
300
+
301
+ return key, value
302
+
303
+
304
+ def load_dotenv_file(env_file: Path, override: bool = False) -> bool:
305
+ if not env_file.exists():
306
+ return False
307
+
308
+ for line_no, line in enumerate(env_file.read_text(encoding="utf-8").splitlines(), start=1):
309
+ parsed = parse_dotenv_line(line, line_no, env_file)
310
+ if not parsed:
311
+ continue
312
+ key, value = parsed
313
+ if override or key not in os.environ:
314
+ os.environ[key] = value
315
+ return True
316
+
317
+
318
+ def normalize_base_url(base_url: str) -> str:
319
+ base = base_url.strip().rstrip("/")
320
+ if not base:
321
+ raise SystemExit("OPENAI_API_URL cannot be empty.")
322
+ return base
323
+
324
+
325
+ def post_json(base_url: str, endpoint: str, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
326
+ url = f"{normalize_base_url(base_url)}{endpoint}"
327
+ body = json.dumps(payload).encode("utf-8")
328
+ req = request.Request(
329
+ url,
330
+ data=body,
331
+ headers={
332
+ "Authorization": f"Bearer {api_key}",
333
+ "Content-Type": "application/json",
334
+ },
335
+ method="POST",
336
+ )
337
+ try:
338
+ with request.urlopen(req, timeout=180) as response:
339
+ return json.loads(response.read().decode("utf-8"))
340
+ except error.HTTPError as exc:
341
+ detail = exc.read().decode("utf-8", errors="replace")
342
+ raise RuntimeError(f"HTTP {exc.code} when calling {url}: {detail}") from exc
343
+ except error.URLError as exc:
344
+ raise RuntimeError(f"Network error when calling {url}: {exc.reason}") from exc
345
+
346
+
347
+ def fetch_binary(url: str) -> bytes:
348
+ req = request.Request(url, method="GET")
349
+ with request.urlopen(req, timeout=180) as response:
350
+ return response.read()
351
+
352
+
353
+ def parse_prompt_entries_list(parsed: list[Any], prompts_file: Path) -> list[dict[str, str]]:
354
+ normalized: list[dict[str, str]] = []
355
+ for index, item in enumerate(parsed, start=1):
356
+ if isinstance(item, str):
357
+ prompt = item.strip()
358
+ title = f"scene-{index}"
359
+ elif isinstance(item, dict):
360
+ prompt = str(item.get("prompt", "")).strip()
361
+ title = str(item.get("title") or f"scene-{index}").strip() or f"scene-{index}"
362
+ else:
363
+ raise SystemExit(
364
+ f"Invalid item type in {prompts_file} at index {index}: expected string or object"
365
+ )
366
+
367
+ if not prompt:
368
+ raise SystemExit(f"Empty prompt in {prompts_file} at index {index}")
369
+
370
+ normalized.append({"title": title, "prompt": prompt})
371
+
372
+ if not normalized:
373
+ raise SystemExit(f"No prompts found in {prompts_file}")
374
+
375
+ return normalized
376
+
377
+
378
+ def parse_character_profiles(
379
+ raw_characters: Any,
380
+ prompts_file: Path,
381
+ ) -> dict[str, dict[str, str]]:
382
+ if raw_characters is None:
383
+ return {}
384
+
385
+ if not isinstance(raw_characters, list):
386
+ raise SystemExit(
387
+ f"Invalid character definition in {prompts_file}: 'characters' must be an array when provided"
388
+ )
389
+
390
+ characters: dict[str, dict[str, str]] = {}
391
+ for index, item in enumerate(raw_characters, start=1):
392
+ if not isinstance(item, dict):
393
+ raise SystemExit(
394
+ f"Invalid character definition in {prompts_file} at characters[{index - 1}]: expected object"
395
+ )
396
+
397
+ profile = {field: str(item.get(field, "")).strip() for field in CHARACTER_SKELETON_FIELDS}
398
+ missing = [field for field in ("id", "name", "appearance", "outfit", "description") if not profile[field]]
399
+ if missing:
400
+ joined = ", ".join(missing)
401
+ raise SystemExit(
402
+ f"Invalid character definition in {prompts_file} at characters[{index - 1}]: "
403
+ f"missing required fields: {joined}"
404
+ )
405
+
406
+ character_id = profile["id"]
407
+ if character_id in characters:
408
+ raise SystemExit(
409
+ f"Invalid character definition in {prompts_file}: duplicate character id '{character_id}'"
410
+ )
411
+ characters[character_id] = profile
412
+
413
+ return characters
414
+
415
+
416
+ def parse_scene_character_ids(
417
+ raw_character_ids: Any,
418
+ prompts_file: Path,
419
+ scene_index: int,
420
+ ) -> list[str]:
421
+ if raw_character_ids is None:
422
+ return []
423
+ if not isinstance(raw_character_ids, list):
424
+ raise SystemExit(
425
+ f"Invalid scene in {prompts_file} at scenes[{scene_index - 1}]: "
426
+ "'character_ids' must be an array when provided"
427
+ )
428
+
429
+ character_ids: list[str] = []
430
+ for idx, raw_id in enumerate(raw_character_ids, start=1):
431
+ character_id = str(raw_id).strip()
432
+ if not character_id:
433
+ raise SystemExit(
434
+ f"Invalid scene in {prompts_file} at scenes[{scene_index - 1}]: "
435
+ f"character_ids[{idx - 1}] cannot be empty"
436
+ )
437
+ if character_id in character_ids:
438
+ raise SystemExit(
439
+ f"Invalid scene in {prompts_file} at scenes[{scene_index - 1}]: "
440
+ f"duplicate character id '{character_id}' in character_ids"
441
+ )
442
+ character_ids.append(character_id)
443
+ return character_ids
444
+
445
+
446
+ def build_scene_json_prompt(
447
+ scene: dict[str, Any],
448
+ scene_index: int,
449
+ prompts_file: Path,
450
+ character_profiles: dict[str, dict[str, str]],
451
+ ) -> dict[str, str]:
452
+ title = str(scene.get("title") or f"scene-{scene_index}").strip() or f"scene-{scene_index}"
453
+ description = str(scene.get("description", "")).strip()
454
+ if not description:
455
+ raise SystemExit(
456
+ f"Invalid scene in {prompts_file} at scenes[{scene_index - 1}]: 'description' is required"
457
+ )
458
+
459
+ character_ids = parse_scene_character_ids(scene.get("character_ids"), prompts_file, scene_index)
460
+ raw_character_descriptions = scene.get("character_descriptions")
461
+ if raw_character_descriptions is None:
462
+ raw_character_descriptions = {}
463
+ if not isinstance(raw_character_descriptions, dict):
464
+ raise SystemExit(
465
+ f"Invalid scene in {prompts_file} at scenes[{scene_index - 1}]: "
466
+ "'character_descriptions' must be an object when provided"
467
+ )
468
+
469
+ normalized_description_overrides = {
470
+ str(key).strip(): str(value).strip()
471
+ for key, value in raw_character_descriptions.items()
472
+ if str(key).strip()
473
+ }
474
+
475
+ unknown_override_ids = sorted(set(normalized_description_overrides.keys()) - set(character_ids))
476
+ if unknown_override_ids:
477
+ joined = ", ".join(unknown_override_ids)
478
+ raise SystemExit(
479
+ f"Invalid scene in {prompts_file} at scenes[{scene_index - 1}]: "
480
+ f"'character_descriptions' has ids not listed in character_ids: {joined}"
481
+ )
482
+
483
+ scene_characters: list[dict[str, str]] = []
484
+ for character_id in character_ids:
485
+ profile = character_profiles.get(character_id)
486
+ if not profile:
487
+ raise SystemExit(
488
+ f"Invalid scene in {prompts_file} at scenes[{scene_index - 1}]: "
489
+ f"character id '{character_id}' is not defined in top-level 'characters'"
490
+ )
491
+
492
+ character_prompt = dict(profile)
493
+ if character_id in normalized_description_overrides:
494
+ description_override = normalized_description_overrides[character_id]
495
+ if not description_override:
496
+ raise SystemExit(
497
+ f"Invalid scene in {prompts_file} at scenes[{scene_index - 1}]: "
498
+ f"character_descriptions['{character_id}'] cannot be empty"
499
+ )
500
+ character_prompt["description"] = description_override
501
+ scene_characters.append(character_prompt)
502
+
503
+ prompt_payload: dict[str, Any] = {
504
+ "scene_title": title,
505
+ "description": description,
506
+ }
507
+ if scene_characters:
508
+ prompt_payload["characters"] = scene_characters
509
+
510
+ style = scene.get("style")
511
+ if style is not None:
512
+ style_text = str(style).strip()
513
+ if style_text:
514
+ prompt_payload["style"] = style_text
515
+
516
+ camera = scene.get("camera")
517
+ if camera is not None:
518
+ camera_text = str(camera).strip()
519
+ if camera_text:
520
+ prompt_payload["camera"] = camera_text
521
+
522
+ lighting = scene.get("lighting")
523
+ if lighting is not None:
524
+ lighting_text = str(lighting).strip()
525
+ if lighting_text:
526
+ prompt_payload["lighting"] = lighting_text
527
+
528
+ prompt = json.dumps(prompt_payload, ensure_ascii=False, separators=(",", ":"))
529
+ return {"title": title, "prompt": prompt}
530
+
531
+
532
+ def parse_structured_prompts_file(parsed: dict[str, Any], prompts_file: Path) -> list[dict[str, str]]:
533
+ scenes = parsed.get("scenes")
534
+ if not isinstance(scenes, list):
535
+ raise SystemExit(
536
+ f"Invalid prompt file {prompts_file}: object mode requires a top-level 'scenes' array"
537
+ )
538
+ if not scenes:
539
+ raise SystemExit(f"Invalid prompt file {prompts_file}: 'scenes' cannot be empty")
540
+
541
+ character_profiles = parse_character_profiles(parsed.get("characters"), prompts_file)
542
+ normalized: list[dict[str, str]] = []
543
+ for index, scene in enumerate(scenes, start=1):
544
+ if not isinstance(scene, dict):
545
+ raise SystemExit(
546
+ f"Invalid scene in {prompts_file} at scenes[{index - 1}]: expected object"
547
+ )
548
+ normalized.append(
549
+ build_scene_json_prompt(
550
+ scene=scene,
551
+ scene_index=index,
552
+ prompts_file=prompts_file,
553
+ character_profiles=character_profiles,
554
+ )
555
+ )
556
+
557
+ return normalized
558
+
559
+
560
+ def parse_prompts_file(prompts_file: Path) -> list[dict[str, str]]:
561
+ raw = prompts_file.read_text(encoding="utf-8")
562
+ try:
563
+ parsed = json.loads(raw)
564
+ except json.JSONDecodeError as exc:
565
+ raise SystemExit(f"Invalid JSON in {prompts_file}: {exc}") from exc
566
+
567
+ if isinstance(parsed, list):
568
+ return parse_prompt_entries_list(parsed, prompts_file)
569
+ if isinstance(parsed, dict):
570
+ return parse_structured_prompts_file(parsed, prompts_file)
571
+
572
+ raise SystemExit(
573
+ f"Invalid prompt file {prompts_file}: top-level JSON must be an array or an object"
574
+ )
575
+
576
+
577
+ def build_prompt_items(prompts_file: str | None, prompt_values: list[str] | None) -> list[dict[str, str]]:
578
+ if prompts_file:
579
+ return parse_prompts_file(Path(prompts_file).expanduser())
580
+
581
+ if not prompt_values:
582
+ raise SystemExit("At least one --prompt is required when --prompts-file is not set")
583
+
584
+ items: list[dict[str, str]] = []
585
+ for index, prompt in enumerate(prompt_values, start=1):
586
+ text = prompt.strip()
587
+ if not text:
588
+ raise SystemExit(f"Empty --prompt at position {index}")
589
+ items.append({"title": f"scene-{index}", "prompt": text})
590
+ return items
591
+
592
+
593
+ def generate_image(
594
+ base_url: str,
595
+ api_key: str,
596
+ image_model: str,
597
+ prompt: str,
598
+ aspect_ratio: str | None,
599
+ image_size: str | None,
600
+ quality: str | None,
601
+ style: str | None,
602
+ ) -> tuple[bytes, str | None]:
603
+ payload: dict[str, Any] = {
604
+ "model": image_model,
605
+ "prompt": prompt,
606
+ }
607
+ if aspect_ratio:
608
+ payload["aspect_ratio"] = aspect_ratio
609
+ if image_size:
610
+ payload["size"] = image_size
611
+ if quality:
612
+ payload["quality"] = quality
613
+ if style:
614
+ payload["style"] = style
615
+
616
+ response = post_json(base_url, "/images/generations", api_key, payload)
617
+ data = response.get("data")
618
+ if not isinstance(data, list) or not data:
619
+ raise RuntimeError("Image generation response has no data.")
620
+
621
+ first = data[0]
622
+ if not isinstance(first, dict):
623
+ raise RuntimeError("Unexpected image data format.")
624
+
625
+ revised_prompt = first.get("revised_prompt")
626
+ if isinstance(first.get("b64_json"), str):
627
+ raw = base64.b64decode(first["b64_json"])
628
+ return raw, revised_prompt if isinstance(revised_prompt, str) else None
629
+
630
+ if isinstance(first.get("url"), str):
631
+ raw = fetch_binary(first["url"])
632
+ return raw, revised_prompt if isinstance(revised_prompt, str) else None
633
+
634
+ raise RuntimeError("Image payload missing b64_json/url.")
635
+
636
+
637
+ def main() -> int:
638
+ args = parse_args()
639
+
640
+ project_dir = Path(args.project_dir).expanduser().resolve()
641
+ env_file = Path(args.env_file).expanduser()
642
+ if not env_file.is_absolute():
643
+ env_file = SKILL_DIR / env_file
644
+ load_dotenv_file(env_file, override=False)
645
+
646
+ api_url = required_arg_or_env(
647
+ args.api_url,
648
+ arg_name="--api-url",
649
+ env_name="OPENAI_API_URL",
650
+ env_file=env_file,
651
+ )
652
+ api_key = required_arg_or_env(
653
+ args.api_key,
654
+ arg_name="--api-key",
655
+ env_name="OPENAI_API_KEY",
656
+ env_file=env_file,
657
+ )
658
+
659
+ if args.image_model is not None:
660
+ image_model = args.image_model.strip()
661
+ if not image_model:
662
+ raise SystemExit("--image-model cannot be empty.")
663
+ else:
664
+ image_model = os.getenv("OPENAI_IMAGE_MODEL", "gpt-image-1")
665
+ aspect_ratio = normalize_aspect_ratio(
666
+ args.aspect_ratio
667
+ if args.aspect_ratio is not None
668
+ else first_nonempty_env("OPENAI_IMAGE_RATIO", "OPENAI_IMAGE_ASPECT_RATIO")
669
+ )
670
+ image_size = normalize_image_size(
671
+ args.image_size if args.image_size is not None else os.getenv("OPENAI_IMAGE_SIZE")
672
+ )
673
+ quality = args.quality if args.quality is not None else os.getenv("OPENAI_IMAGE_QUALITY")
674
+ style = args.style if args.style is not None else os.getenv("OPENAI_IMAGE_STYLE")
675
+
676
+ prompts = build_prompt_items(args.prompts_file, args.prompt)
677
+
678
+ content_dir_name = sanitize_component(args.content_name, "untitled-content")
679
+ output_dir = project_dir / "pictures" / content_dir_name
680
+ output_dir.mkdir(parents=True, exist_ok=True)
681
+
682
+ records: list[dict[str, Any]] = []
683
+ for index, item in enumerate(prompts, start=1):
684
+ title_slug = sanitize_component(item["title"], f"scene-{index}")
685
+ filename = f"{index:02d}_{title_slug}.png"
686
+ image_path = unique_path(output_dir / filename)
687
+
688
+ image_bytes, revised_prompt = generate_image(
689
+ base_url=api_url,
690
+ api_key=api_key,
691
+ image_model=image_model,
692
+ prompt=item["prompt"],
693
+ aspect_ratio=aspect_ratio,
694
+ image_size=image_size,
695
+ quality=quality,
696
+ style=style,
697
+ )
698
+ source_dimensions = parse_image_dimensions(image_bytes)
699
+ if aspect_ratio:
700
+ try:
701
+ image_bytes, crop_source, crop_target, crop_applied = center_crop_to_aspect_ratio(
702
+ image_bytes=image_bytes,
703
+ requested_ratio=aspect_ratio,
704
+ )
705
+ if crop_applied:
706
+ print(
707
+ "[INFO] "
708
+ f"Applied center crop for aspect ratio {aspect_ratio}: "
709
+ f"{crop_source[0]}x{crop_source[1]} -> {crop_target[0]}x{crop_target[1]}."
710
+ )
711
+ except RuntimeError as exc:
712
+ print(f"[WARN] {exc}")
713
+
714
+ if source_dimensions and is_aspect_ratio_mismatch(source_dimensions, aspect_ratio):
715
+ actual_width, actual_height = source_dimensions
716
+ suggested_size = suggest_size_for_ratio(aspect_ratio)
717
+ print(
718
+ "[WARN] "
719
+ f"Requested aspect ratio {aspect_ratio}, provider returned {actual_width}x{actual_height} "
720
+ f"(~{simplify_ratio(actual_width, actual_height)}). "
721
+ f"Post-process crop has been applied when possible. "
722
+ f"For better quality, try --image-size {suggested_size} or set OPENAI_IMAGE_SIZE={suggested_size}."
723
+ )
724
+
725
+ image_path.write_bytes(image_bytes)
726
+ dimensions = parse_image_dimensions(image_bytes)
727
+
728
+ record: dict[str, Any] = {
729
+ "index": index,
730
+ "title": item["title"],
731
+ "prompt": item["prompt"],
732
+ "file": str(image_path),
733
+ }
734
+ if source_dimensions and dimensions and source_dimensions != dimensions:
735
+ record["source_width"] = source_dimensions[0]
736
+ record["source_height"] = source_dimensions[1]
737
+ if dimensions:
738
+ record["width"] = dimensions[0]
739
+ record["height"] = dimensions[1]
740
+ if revised_prompt:
741
+ record["revised_prompt"] = revised_prompt
742
+ records.append(record)
743
+ print(f"[OK] Generated {image_path}")
744
+
745
+ summary = {
746
+ "content_name": args.content_name,
747
+ "project_dir": str(project_dir),
748
+ "output_dir": str(output_dir),
749
+ "image_model": image_model,
750
+ "images": records,
751
+ }
752
+ if aspect_ratio:
753
+ summary["aspect_ratio"] = aspect_ratio
754
+ if image_size:
755
+ summary["image_size"] = image_size
756
+ summary_path = output_dir / "storyboard.json"
757
+ summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
758
+ print(f"[OK] Wrote plan to {summary_path}")
759
+ return 0
760
+
761
+
762
+ if __name__ == "__main__":
763
+ raise SystemExit(main())