@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.
- package/AGENTS.md +62 -0
- package/CHANGELOG.md +100 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/align-project-documents/SKILL.md +94 -0
- package/align-project-documents/agents/openai.yaml +4 -0
- package/analyse-app-logs/LICENSE +21 -0
- package/analyse-app-logs/README.md +126 -0
- package/analyse-app-logs/SKILL.md +121 -0
- package/analyse-app-logs/agents/openai.yaml +4 -0
- package/analyse-app-logs/references/investigation-checklist.md +58 -0
- package/analyse-app-logs/references/log-signal-patterns.md +52 -0
- package/answering-questions-with-research/SKILL.md +46 -0
- package/answering-questions-with-research/agents/openai.yaml +4 -0
- package/bin/apollo-toolkit.js +7 -0
- package/commit-and-push/LICENSE +21 -0
- package/commit-and-push/README.md +26 -0
- package/commit-and-push/SKILL.md +70 -0
- package/commit-and-push/agents/openai.yaml +4 -0
- package/commit-and-push/references/branch-naming.md +15 -0
- package/commit-and-push/references/commit-messages.md +19 -0
- package/deep-research-topics/LICENSE +21 -0
- package/deep-research-topics/README.md +43 -0
- package/deep-research-topics/SKILL.md +84 -0
- package/deep-research-topics/agents/openai.yaml +4 -0
- package/develop-new-features/LICENSE +21 -0
- package/develop-new-features/README.md +52 -0
- package/develop-new-features/SKILL.md +105 -0
- package/develop-new-features/agents/openai.yaml +4 -0
- package/develop-new-features/references/testing-e2e.md +35 -0
- package/develop-new-features/references/testing-integration.md +42 -0
- package/develop-new-features/references/testing-property-based.md +44 -0
- package/develop-new-features/references/testing-unit.md +37 -0
- package/discover-edge-cases/CHANGELOG.md +19 -0
- package/discover-edge-cases/LICENSE +21 -0
- package/discover-edge-cases/README.md +87 -0
- package/discover-edge-cases/SKILL.md +124 -0
- package/discover-edge-cases/agents/openai.yaml +4 -0
- package/discover-edge-cases/references/architecture-edge-cases.md +41 -0
- package/discover-edge-cases/references/code-edge-cases.md +46 -0
- package/docs-to-voice/.env.example +106 -0
- package/docs-to-voice/CHANGELOG.md +71 -0
- package/docs-to-voice/LICENSE +21 -0
- package/docs-to-voice/README.md +118 -0
- package/docs-to-voice/SKILL.md +107 -0
- package/docs-to-voice/agents/openai.yaml +4 -0
- package/docs-to-voice/scripts/docs_to_voice.py +1385 -0
- package/docs-to-voice/scripts/docs_to_voice.sh +11 -0
- package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +210 -0
- package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +115 -0
- package/docs-to-voice/tests/test_docs_to_voice_settings.py +43 -0
- package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +57 -0
- package/enhance-existing-features/CHANGELOG.md +35 -0
- package/enhance-existing-features/LICENSE +21 -0
- package/enhance-existing-features/README.md +54 -0
- package/enhance-existing-features/SKILL.md +120 -0
- package/enhance-existing-features/agents/openai.yaml +4 -0
- package/enhance-existing-features/references/e2e-tests.md +25 -0
- package/enhance-existing-features/references/integration-tests.md +30 -0
- package/enhance-existing-features/references/property-based-tests.md +33 -0
- package/enhance-existing-features/references/unit-tests.md +29 -0
- package/feature-propose/LICENSE +21 -0
- package/feature-propose/README.md +23 -0
- package/feature-propose/SKILL.md +107 -0
- package/feature-propose/agents/openai.yaml +4 -0
- package/feature-propose/references/enhancement-features.md +25 -0
- package/feature-propose/references/important-features.md +25 -0
- package/feature-propose/references/mvp-features.md +25 -0
- package/feature-propose/references/performance-features.md +25 -0
- package/financial-research/SKILL.md +208 -0
- package/financial-research/agents/openai.yaml +4 -0
- package/financial-research/assets/weekly_market_report_template.md +45 -0
- package/fix-github-issues/SKILL.md +98 -0
- package/fix-github-issues/agents/openai.yaml +4 -0
- package/fix-github-issues/scripts/list_issues.py +148 -0
- package/fix-github-issues/tests/test_list_issues.py +127 -0
- package/generate-spec/LICENSE +21 -0
- package/generate-spec/README.md +61 -0
- package/generate-spec/SKILL.md +96 -0
- package/generate-spec/agents/openai.yaml +4 -0
- package/generate-spec/references/templates/checklist.md +78 -0
- package/generate-spec/references/templates/spec.md +55 -0
- package/generate-spec/references/templates/tasks.md +35 -0
- package/generate-spec/scripts/create-specs +123 -0
- package/harden-app-security/CHANGELOG.md +27 -0
- package/harden-app-security/LICENSE +21 -0
- package/harden-app-security/README.md +46 -0
- package/harden-app-security/SKILL.md +127 -0
- package/harden-app-security/agents/openai.yaml +4 -0
- package/harden-app-security/references/agent-attack-catalog.md +117 -0
- package/harden-app-security/references/common-software-attack-catalog.md +168 -0
- package/harden-app-security/references/red-team-extreme-scenarios.md +81 -0
- package/harden-app-security/references/risk-checklist.md +78 -0
- package/harden-app-security/references/security-test-patterns-agent.md +101 -0
- package/harden-app-security/references/security-test-patterns-finance.md +88 -0
- package/harden-app-security/references/test-snippets.md +73 -0
- package/improve-observability/SKILL.md +114 -0
- package/improve-observability/agents/openai.yaml +4 -0
- package/learn-skill-from-conversations/CHANGELOG.md +15 -0
- package/learn-skill-from-conversations/LICENSE +22 -0
- package/learn-skill-from-conversations/README.md +47 -0
- package/learn-skill-from-conversations/SKILL.md +85 -0
- package/learn-skill-from-conversations/agents/openai.yaml +4 -0
- package/learn-skill-from-conversations/scripts/extract_recent_conversations.py +369 -0
- package/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +176 -0
- package/learning-error-book/SKILL.md +112 -0
- package/learning-error-book/agents/openai.yaml +4 -0
- package/learning-error-book/assets/error_book_template.md +66 -0
- package/learning-error-book/scripts/render_markdown_to_pdf.py +367 -0
- package/lib/cli.js +338 -0
- package/lib/installer.js +225 -0
- package/maintain-project-constraints/SKILL.md +109 -0
- package/maintain-project-constraints/agents/openai.yaml +4 -0
- package/maintain-skill-catalog/README.md +18 -0
- package/maintain-skill-catalog/SKILL.md +66 -0
- package/maintain-skill-catalog/agents/openai.yaml +4 -0
- package/novel-to-short-video/CHANGELOG.md +53 -0
- package/novel-to-short-video/LICENSE +21 -0
- package/novel-to-short-video/README.md +63 -0
- package/novel-to-short-video/SKILL.md +233 -0
- package/novel-to-short-video/agents/openai.yaml +4 -0
- package/novel-to-short-video/references/plan-template.md +71 -0
- package/novel-to-short-video/references/roles-json.md +41 -0
- package/open-github-issue/LICENSE +21 -0
- package/open-github-issue/README.md +97 -0
- package/open-github-issue/SKILL.md +119 -0
- package/open-github-issue/agents/openai.yaml +4 -0
- package/open-github-issue/scripts/open_github_issue.py +380 -0
- package/open-github-issue/tests/test_open_github_issue.py +159 -0
- package/open-source-pr-workflow/CHANGELOG.md +32 -0
- package/open-source-pr-workflow/LICENSE +21 -0
- package/open-source-pr-workflow/README.md +23 -0
- package/open-source-pr-workflow/SKILL.md +123 -0
- package/open-source-pr-workflow/agents/openai.yaml +4 -0
- package/openai-text-to-image-storyboard/.env.example +10 -0
- package/openai-text-to-image-storyboard/CHANGELOG.md +49 -0
- package/openai-text-to-image-storyboard/LICENSE +21 -0
- package/openai-text-to-image-storyboard/README.md +99 -0
- package/openai-text-to-image-storyboard/SKILL.md +107 -0
- package/openai-text-to-image-storyboard/agents/openai.yaml +4 -0
- package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +763 -0
- package/package.json +36 -0
- package/record-spending/SKILL.md +113 -0
- package/record-spending/agents/openai.yaml +4 -0
- package/record-spending/references/account-format.md +33 -0
- package/record-spending/references/workbook-layout.md +84 -0
- package/resolve-review-comments/SKILL.md +122 -0
- package/resolve-review-comments/agents/openai.yaml +4 -0
- package/resolve-review-comments/references/adoption-criteria.md +23 -0
- package/resolve-review-comments/scripts/review_threads.py +425 -0
- package/resolve-review-comments/tests/test_review_threads.py +74 -0
- package/review-change-set/LICENSE +21 -0
- package/review-change-set/README.md +55 -0
- package/review-change-set/SKILL.md +103 -0
- package/review-change-set/agents/openai.yaml +4 -0
- package/review-codebases/LICENSE +21 -0
- package/review-codebases/README.md +67 -0
- package/review-codebases/SKILL.md +109 -0
- package/review-codebases/agents/openai.yaml +4 -0
- package/scripts/install_skills.ps1 +283 -0
- package/scripts/install_skills.sh +262 -0
- package/scripts/validate_openai_agent_config.py +194 -0
- package/scripts/validate_skill_frontmatter.py +110 -0
- package/specs-to-project-docs/LICENSE +21 -0
- package/specs-to-project-docs/README.md +57 -0
- package/specs-to-project-docs/SKILL.md +111 -0
- package/specs-to-project-docs/agents/openai.yaml +4 -0
- package/specs-to-project-docs/references/templates/architecture.md +29 -0
- package/specs-to-project-docs/references/templates/configuration.md +29 -0
- package/specs-to-project-docs/references/templates/developer-guide.md +33 -0
- package/specs-to-project-docs/references/templates/docs-index.md +39 -0
- package/specs-to-project-docs/references/templates/features.md +25 -0
- package/specs-to-project-docs/references/templates/getting-started.md +38 -0
- package/specs-to-project-docs/references/templates/readme.md +49 -0
- package/systematic-debug/LICENSE +21 -0
- package/systematic-debug/README.md +81 -0
- package/systematic-debug/SKILL.md +59 -0
- package/systematic-debug/agents/openai.yaml +4 -0
- package/text-to-short-video/.env.example +36 -0
- package/text-to-short-video/LICENSE +21 -0
- package/text-to-short-video/README.md +82 -0
- package/text-to-short-video/SKILL.md +221 -0
- package/text-to-short-video/agents/openai.yaml +4 -0
- package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +350 -0
- package/version-release/CHANGELOG.md +53 -0
- package/version-release/LICENSE +21 -0
- package/version-release/README.md +28 -0
- package/version-release/SKILL.md +94 -0
- package/version-release/agents/openai.yaml +4 -0
- package/version-release/references/branch-naming.md +15 -0
- package/version-release/references/changelog-writing.md +8 -0
- package/version-release/references/commit-messages.md +19 -0
- package/version-release/references/readme-writing.md +12 -0
- package/version-release/references/semantic-versioning.md +12 -0
- package/video-production/CHANGELOG.md +104 -0
- package/video-production/LICENSE +18 -0
- package/video-production/README.md +68 -0
- package/video-production/SKILL.md +213 -0
- package/video-production/agents/openai.yaml +4 -0
- package/video-production/references/plan-template.md +54 -0
- package/video-production/references/roles-json.md +41 -0
- package/weekly-financial-event-report/SKILL.md +195 -0
- package/weekly-financial-event-report/agents/openai.yaml +4 -0
- 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())
|