tlnw-generate-image 0.2.0__tar.gz
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.
- tlnw_generate_image-0.2.0/PKG-INFO +42 -0
- tlnw_generate_image-0.2.0/README.md +28 -0
- tlnw_generate_image-0.2.0/pyproject.toml +29 -0
- tlnw_generate_image-0.2.0/setup.cfg +4 -0
- tlnw_generate_image-0.2.0/tests/test_generate_image.py +176 -0
- tlnw_generate_image-0.2.0/tlnw_generate_image/__init__.py +2 -0
- tlnw_generate_image-0.2.0/tlnw_generate_image/cli.py +127 -0
- tlnw_generate_image-0.2.0/tlnw_generate_image/core.py +401 -0
- tlnw_generate_image-0.2.0/tlnw_generate_image.egg-info/PKG-INFO +42 -0
- tlnw_generate_image-0.2.0/tlnw_generate_image.egg-info/SOURCES.txt +12 -0
- tlnw_generate_image-0.2.0/tlnw_generate_image.egg-info/dependency_links.txt +1 -0
- tlnw_generate_image-0.2.0/tlnw_generate_image.egg-info/entry_points.txt +2 -0
- tlnw_generate_image-0.2.0/tlnw_generate_image.egg-info/requires.txt +4 -0
- tlnw_generate_image-0.2.0/tlnw_generate_image.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tlnw-generate-image
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Generate illustrations and images dynamically using OpenAI DALL-E and Google Imagen models.
|
|
5
|
+
Author-email: Tellers Network <admin@tlnw.uk>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: openai>=1.0.0
|
|
11
|
+
Requires-Dist: requests>=2.25.0
|
|
12
|
+
Requires-Dist: pillow>=9.0.0
|
|
13
|
+
Requires-Dist: python-dotenv>=0.19.0
|
|
14
|
+
|
|
15
|
+
# Tellers Network Image Generation Tool (`tlnw-generate-image`)
|
|
16
|
+
|
|
17
|
+
`tlnw-generate-image` is a standalone, publishable package that unifies AI image generation for stories and posts in the Tellers Network ecosystem. It supports OpenAI (DALL-E 2, DALL-E 3, and legacy `gpt-image-1.5`) as well as Google Gemini Imagen 4 models.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
```bash
|
|
21
|
+
pip install tlnw-generate-image
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
- **Extensible OOP Provider Architecture**: Clean separation of model providers (`DalleProvider`, `GptImageProvider`, `GoogleImagenProvider`) with registry-based dynamic routing.
|
|
26
|
+
- **Intelligent Size & Quality Preset Mapping**: Standardizes orientation presets (`landscape`, `portrait`, `square`) and qualities, automatically converting them to provider-native formats: aspect ratios (e.g. `"16:9"`) for Google Imagen, or concrete dimensions (e.g. `"1792x1024"`) for OpenAI.
|
|
27
|
+
- **Custom Dimension Resolution**: Automatically maps exact resolution values (e.g., `1920x1080` or `1536x1024.txt` in file names) to the closest supported native aspect ratios when routing to Imagen, while passing OpenAI dimensions as-is.
|
|
28
|
+
- **Auto-resolution of Output Sizing**: Extracts target sizes from prompt filename trailing suffixes (e.g., `-1536x1024.txt`).
|
|
29
|
+
- **Prompt Composition**: Concatenates local directory `illustration-master-prompt.txt` automatically with individual scene prompts.
|
|
30
|
+
- **Automatic WebP Conversion**: Generates and optimizes WebP formats (`featured.webp` and `thumbnail.webp` with max-dimension constraints).
|
|
31
|
+
- **Automated Jekyll/Hugo Updating**: Automatically updates the article frontmatter's `images` block.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
Generate illustration for a story folder:
|
|
35
|
+
```bash
|
|
36
|
+
generate-image /path/to/story_folder --model dall-e-3
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Process specific prompt files:
|
|
40
|
+
```bash
|
|
41
|
+
generate-image --file /path/to/prompt-1536x1024.txt --model imagen-4-ultra
|
|
42
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Tellers Network Image Generation Tool (`tlnw-generate-image`)
|
|
2
|
+
|
|
3
|
+
`tlnw-generate-image` is a standalone, publishable package that unifies AI image generation for stories and posts in the Tellers Network ecosystem. It supports OpenAI (DALL-E 2, DALL-E 3, and legacy `gpt-image-1.5`) as well as Google Gemini Imagen 4 models.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
```bash
|
|
7
|
+
pip install tlnw-generate-image
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
- **Extensible OOP Provider Architecture**: Clean separation of model providers (`DalleProvider`, `GptImageProvider`, `GoogleImagenProvider`) with registry-based dynamic routing.
|
|
12
|
+
- **Intelligent Size & Quality Preset Mapping**: Standardizes orientation presets (`landscape`, `portrait`, `square`) and qualities, automatically converting them to provider-native formats: aspect ratios (e.g. `"16:9"`) for Google Imagen, or concrete dimensions (e.g. `"1792x1024"`) for OpenAI.
|
|
13
|
+
- **Custom Dimension Resolution**: Automatically maps exact resolution values (e.g., `1920x1080` or `1536x1024.txt` in file names) to the closest supported native aspect ratios when routing to Imagen, while passing OpenAI dimensions as-is.
|
|
14
|
+
- **Auto-resolution of Output Sizing**: Extracts target sizes from prompt filename trailing suffixes (e.g., `-1536x1024.txt`).
|
|
15
|
+
- **Prompt Composition**: Concatenates local directory `illustration-master-prompt.txt` automatically with individual scene prompts.
|
|
16
|
+
- **Automatic WebP Conversion**: Generates and optimizes WebP formats (`featured.webp` and `thumbnail.webp` with max-dimension constraints).
|
|
17
|
+
- **Automated Jekyll/Hugo Updating**: Automatically updates the article frontmatter's `images` block.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
Generate illustration for a story folder:
|
|
21
|
+
```bash
|
|
22
|
+
generate-image /path/to/story_folder --model dall-e-3
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Process specific prompt files:
|
|
26
|
+
```bash
|
|
27
|
+
generate-image --file /path/to/prompt-1536x1024.txt --model imagen-4-ultra
|
|
28
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tlnw-generate-image"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Generate illustrations and images dynamically using OpenAI DALL-E and Google Imagen models."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Tellers Network", email = "admin@tlnw.uk" }
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"openai>=1.0.0",
|
|
16
|
+
"requests>=2.25.0",
|
|
17
|
+
"pillow>=9.0.0",
|
|
18
|
+
"python-dotenv>=0.19.0",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
generate-image = "tlnw_generate_image.cli:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = ["tlnw_generate_image"]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest import mock
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import pytest
|
|
7
|
+
except ImportError:
|
|
8
|
+
class MockPytest:
|
|
9
|
+
class RaisesContext:
|
|
10
|
+
def __init__(self, expected_exception):
|
|
11
|
+
self.expected_exception = expected_exception
|
|
12
|
+
self.value = None
|
|
13
|
+
def __enter__(self):
|
|
14
|
+
return self
|
|
15
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
16
|
+
if exc_type is None:
|
|
17
|
+
raise AssertionError(f"Did not raise {self.expected_exception}")
|
|
18
|
+
if issubclass(exc_type, self.expected_exception):
|
|
19
|
+
self.value = exc_val
|
|
20
|
+
return True
|
|
21
|
+
return False
|
|
22
|
+
def raises(self, expected_exception):
|
|
23
|
+
return self.RaisesContext(expected_exception)
|
|
24
|
+
pytest = MockPytest()
|
|
25
|
+
|
|
26
|
+
from tlnw_generate_image.core import (
|
|
27
|
+
get_default_quality,
|
|
28
|
+
normalize_quality_for_model,
|
|
29
|
+
_resolve_size_for_orientation,
|
|
30
|
+
combine_master_prompt,
|
|
31
|
+
update_index_frontmatter_images,
|
|
32
|
+
update_figure_shortcodes_in_index,
|
|
33
|
+
load_master_prompt,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def test_get_default_quality():
|
|
37
|
+
assert get_default_quality("imagen-4") == "standard"
|
|
38
|
+
assert get_default_quality("gpt-image-1.5") == "medium"
|
|
39
|
+
assert get_default_quality("dall-e-3") == "standard"
|
|
40
|
+
assert get_default_quality("unknown-model") == "standard"
|
|
41
|
+
|
|
42
|
+
def test_normalize_quality_for_model():
|
|
43
|
+
# gpt-image mapping
|
|
44
|
+
assert normalize_quality_for_model("gpt-image-1.5", "standard") == "medium"
|
|
45
|
+
assert normalize_quality_for_model("gpt-image-1.5", "hd") == "high"
|
|
46
|
+
assert normalize_quality_for_model("gpt-image-1.5", "low") == "low"
|
|
47
|
+
|
|
48
|
+
# dall-e-3 mapping
|
|
49
|
+
assert normalize_quality_for_model("dall-e-3", "hd") == "hd"
|
|
50
|
+
assert normalize_quality_for_model("dall-e-3", "high") == "hd"
|
|
51
|
+
assert normalize_quality_for_model("dall-e-3", "medium") == "standard"
|
|
52
|
+
|
|
53
|
+
# imagen mapping
|
|
54
|
+
assert normalize_quality_for_model("imagen-4.0", "medium") == "standard"
|
|
55
|
+
assert normalize_quality_for_model("imagen-4.0", "hd") == "high"
|
|
56
|
+
|
|
57
|
+
def test_resolve_size_for_orientation():
|
|
58
|
+
assert _resolve_size_for_orientation("gpt-image-1.5", "square") == "1024x1024"
|
|
59
|
+
assert _resolve_size_for_orientation("dall-e-3", "landscape") == "1792x1024"
|
|
60
|
+
assert _resolve_size_for_orientation("dall-e-3", "portrait") == "1024x1792"
|
|
61
|
+
assert _resolve_size_for_orientation("gpt-image-1.5", "landscape") == "1536x1024"
|
|
62
|
+
assert _resolve_size_for_orientation("gpt-image-1.5", "portrait") == "1024x1536"
|
|
63
|
+
|
|
64
|
+
with pytest.raises(ValueError):
|
|
65
|
+
_resolve_size_for_orientation("dall-e-2", "landscape")
|
|
66
|
+
|
|
67
|
+
def test_combine_master_prompt():
|
|
68
|
+
assert combine_master_prompt("", "Specific scene") == "Specific scene"
|
|
69
|
+
assert combine_master_prompt("Master Style Note", "Scene details") == "Master Style Note\n\nScene details"
|
|
70
|
+
|
|
71
|
+
def test_load_master_prompt(tmp_path=None):
|
|
72
|
+
if tmp_path is None:
|
|
73
|
+
import tempfile
|
|
74
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
75
|
+
_test_load_master_prompt_logic(Path(tmpdir))
|
|
76
|
+
else:
|
|
77
|
+
_test_load_master_prompt_logic(tmp_path)
|
|
78
|
+
|
|
79
|
+
def _test_load_master_prompt_logic(tmp_path):
|
|
80
|
+
# Missing master prompt
|
|
81
|
+
assert load_master_prompt(tmp_path) == ""
|
|
82
|
+
|
|
83
|
+
# Existing master prompt
|
|
84
|
+
master_file = tmp_path / "illustration-master-prompt.txt"
|
|
85
|
+
master_file.write_text("Paint in impressionism style.", encoding="utf-8")
|
|
86
|
+
assert load_master_prompt(tmp_path) == "Paint in impressionism style."
|
|
87
|
+
|
|
88
|
+
def test_update_index_frontmatter_images(tmp_path=None):
|
|
89
|
+
with mock.patch('tlnw_generate_image.core.update_images_frontmatter_with_fmu', return_value=False):
|
|
90
|
+
if tmp_path is None:
|
|
91
|
+
import tempfile
|
|
92
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
93
|
+
_test_update_index_frontmatter_images_logic(Path(tmpdir))
|
|
94
|
+
else:
|
|
95
|
+
_test_update_index_frontmatter_images_logic(tmp_path)
|
|
96
|
+
|
|
97
|
+
def _test_update_index_frontmatter_images_logic(tmp_path):
|
|
98
|
+
index_md = tmp_path / "index.md"
|
|
99
|
+
|
|
100
|
+
# Standard Jekyll/Hugo markdown frontmatter
|
|
101
|
+
initial_content = """---
|
|
102
|
+
title: My Story
|
|
103
|
+
summary: A nice summary
|
|
104
|
+
image: some-image.jpg
|
|
105
|
+
---
|
|
106
|
+
This is some content.
|
|
107
|
+
"""
|
|
108
|
+
index_md.write_text(initial_content, encoding="utf-8")
|
|
109
|
+
|
|
110
|
+
# Update and check
|
|
111
|
+
res = update_index_frontmatter_images(index_md, "thumbnail.webp")
|
|
112
|
+
assert res is not None
|
|
113
|
+
assert res.exists()
|
|
114
|
+
|
|
115
|
+
updated_content = res.read_text(encoding="utf-8")
|
|
116
|
+
assert "images:" in updated_content
|
|
117
|
+
assert "- thumbnail.webp" in updated_content
|
|
118
|
+
|
|
119
|
+
# Re-updating with the same image name should return None (no change) or keep it idempotent
|
|
120
|
+
res2 = update_index_frontmatter_images(index_md, "thumbnail.webp")
|
|
121
|
+
assert res2 is None # Matches the core logic return None when nothing needs update
|
|
122
|
+
|
|
123
|
+
def test_update_figure_shortcodes_in_index(tmp_path=None):
|
|
124
|
+
if tmp_path is None:
|
|
125
|
+
import tempfile
|
|
126
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
127
|
+
_test_update_figure_shortcodes_in_index_logic(Path(tmpdir))
|
|
128
|
+
else:
|
|
129
|
+
_test_update_figure_shortcodes_in_index_logic(tmp_path)
|
|
130
|
+
|
|
131
|
+
def _test_update_figure_shortcodes_in_index_logic(tmp_path):
|
|
132
|
+
index_md = tmp_path / "index.md"
|
|
133
|
+
content = """---
|
|
134
|
+
title: Post
|
|
135
|
+
---
|
|
136
|
+
{{< figure src="illustration.png" title="Story" >}}
|
|
137
|
+
"""
|
|
138
|
+
index_md.write_text(content, encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
updated = update_figure_shortcodes_in_index(index_md)
|
|
141
|
+
assert updated is True
|
|
142
|
+
|
|
143
|
+
new_content = index_md.read_text(encoding="utf-8")
|
|
144
|
+
assert '{{< figure src="illustration.webp"' in new_content
|
|
145
|
+
|
|
146
|
+
def test_provider_registry():
|
|
147
|
+
from tlnw_generate_image.providers import get_provider_for_model
|
|
148
|
+
from tlnw_generate_image.providers.dalle import DalleProvider
|
|
149
|
+
from tlnw_generate_image.providers.gpt_image import GptImageProvider
|
|
150
|
+
from tlnw_generate_image.providers.google_imagen import GoogleImagenProvider
|
|
151
|
+
|
|
152
|
+
assert isinstance(get_provider_for_model("dall-e-3"), DalleProvider)
|
|
153
|
+
assert isinstance(get_provider_for_model("dall-e-2"), DalleProvider)
|
|
154
|
+
assert isinstance(get_provider_for_model("gpt-image-1.5"), GptImageProvider)
|
|
155
|
+
assert isinstance(get_provider_for_model("imagen-4-ultra"), GoogleImagenProvider)
|
|
156
|
+
# Default fallback
|
|
157
|
+
assert isinstance(get_provider_for_model("custom-unsupported-model"), DalleProvider)
|
|
158
|
+
|
|
159
|
+
def test_google_imagen_size_mapping():
|
|
160
|
+
from tlnw_generate_image.providers import get_provider_for_model
|
|
161
|
+
provider = get_provider_for_model("imagen-4")
|
|
162
|
+
|
|
163
|
+
# Preset orientations
|
|
164
|
+
assert provider.resolve_size("imagen-4", "square") == "1:1"
|
|
165
|
+
assert provider.resolve_size("imagen-4", "landscape") == "16:9"
|
|
166
|
+
assert provider.resolve_size("imagen-4", "portrait") == "9:16"
|
|
167
|
+
|
|
168
|
+
# Exact aspect ratio overrides
|
|
169
|
+
assert provider.resolve_size("imagen-4", "square", size="4:3") == "4:3"
|
|
170
|
+
assert provider.resolve_size("imagen-4", "square", size="16:9") == "16:9"
|
|
171
|
+
|
|
172
|
+
# Resolution parsing to closest aspect ratio
|
|
173
|
+
assert provider.resolve_size("imagen-4", "square", size="1024x1024") == "1:1"
|
|
174
|
+
assert provider.resolve_size("imagen-4", "square", size="1536x1024") == "16:9"
|
|
175
|
+
assert provider.resolve_size("imagen-4", "square", size="1920x1080") == "16:9"
|
|
176
|
+
assert provider.resolve_size("imagen-4", "square", size="1080x1920") == "9:16"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
from .core import process_story_folder, get_default_quality, normalize_quality_for_model
|
|
9
|
+
from .providers import get_provider_for_model
|
|
10
|
+
|
|
11
|
+
# Setup local logger
|
|
12
|
+
logger = logging.getLogger("tlnw-generate-image")
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
# Load env variables (first local .env, then system env)
|
|
16
|
+
load_dotenv()
|
|
17
|
+
|
|
18
|
+
parser = argparse.ArgumentParser(description="Generate illustrations for story folders and specific scene prompt files.")
|
|
19
|
+
|
|
20
|
+
# Core positional / file targets
|
|
21
|
+
parser.add_argument("story_folders", nargs="*", help="Story folder paths to process.")
|
|
22
|
+
parser.add_argument("--file", action="append", help="Specific prompt file(s) to process directly.")
|
|
23
|
+
|
|
24
|
+
# Basename and pattern configs
|
|
25
|
+
parser.add_argument("--basename", "-b", help="Custom basename for prompt files and output images (e.g., will look for <basename>-prompt.txt).")
|
|
26
|
+
parser.add_argument("--file-pattern", "-p", help="File pattern to match prompt files in a folder (e.g. 'illustration-prompt-cover-*.txt').")
|
|
27
|
+
|
|
28
|
+
# Model configuration
|
|
29
|
+
parser.add_argument("--model", "-m", default="gpt-image-1.5",
|
|
30
|
+
help="Image generation model to use. Defaults to 'gpt-image-1.5'. Supports dall-e-3, imagen-4-ultra, etc.")
|
|
31
|
+
|
|
32
|
+
# Quality & Sizing options
|
|
33
|
+
parser.add_argument("--quality", "-q", choices=["low", "medium", "high", "standard", "hd", "auto"],
|
|
34
|
+
help="Image quality. Defaults to 'medium' for GPT models and 'standard' for Gemini Imagen models.")
|
|
35
|
+
parser.add_argument("--size", help="Explicit image size (e.g. 1536x1024) to override model/orientation defaults.")
|
|
36
|
+
|
|
37
|
+
# Orientation options (mutually exclusive)
|
|
38
|
+
orientation_group = parser.add_mutually_exclusive_group()
|
|
39
|
+
orientation_group.add_argument("--landscape", action="store_true", help="Generate landscape format (1536x1024 for GPT-image, 1792x1024 for DALL-E 3).")
|
|
40
|
+
orientation_group.add_argument("--portrait", action="store_true", help="Generate portrait format (1024x1536 for GPT-image, 1024x1792 for DALL-E 3).")
|
|
41
|
+
|
|
42
|
+
# Reference and Output adjustments
|
|
43
|
+
parser.add_argument("--reference", dest="reference_images", action="append", default=[],
|
|
44
|
+
help="Reference image path(s) to influence image edits (DALL-E only).")
|
|
45
|
+
parser.add_argument("--output-prefix", dest="output_prefix", default=None,
|
|
46
|
+
help="Prefix to prepend to generated output filenames (e.g. 'podcast').")
|
|
47
|
+
|
|
48
|
+
# Format and Execution behavior options
|
|
49
|
+
parser.add_argument("--format", choices=["PNG", "WEBP", "JPEG", "JPG"], default="PNG", help="Image output format. Default is PNG.")
|
|
50
|
+
parser.add_argument("--skip-update", "-s", action="store_true", help="Skip updating frontmatter inside the Jekyll index.md file.")
|
|
51
|
+
parser.add_argument("--force", "-f", action="store_true", help="Force regeneration of illustrations even if they already exist.")
|
|
52
|
+
|
|
53
|
+
# Verbose debugging
|
|
54
|
+
parser.add_argument("--debug", action="store_true", help="Enable verbose debug-level logging.")
|
|
55
|
+
|
|
56
|
+
args = parser.parse_args()
|
|
57
|
+
|
|
58
|
+
# Configure Logging based on Debug option
|
|
59
|
+
log_level = logging.DEBUG if args.debug else logging.INFO
|
|
60
|
+
logging.basicConfig(level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
|
61
|
+
logger.setLevel(log_level)
|
|
62
|
+
|
|
63
|
+
# Determine orientation string
|
|
64
|
+
if args.landscape:
|
|
65
|
+
orientation = "landscape"
|
|
66
|
+
elif args.portrait:
|
|
67
|
+
orientation = "portrait"
|
|
68
|
+
else:
|
|
69
|
+
orientation = "square"
|
|
70
|
+
|
|
71
|
+
# Default quality resolving
|
|
72
|
+
quality = args.quality if args.quality else get_default_quality(args.model)
|
|
73
|
+
|
|
74
|
+
# API key environment verification based on model family
|
|
75
|
+
try:
|
|
76
|
+
provider = get_provider_for_model(args.model)
|
|
77
|
+
provider.validate_env(args.model)
|
|
78
|
+
except ValueError as e:
|
|
79
|
+
print(f"Error: {e}")
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
# Execute file-based targets if specified
|
|
83
|
+
if args.file:
|
|
84
|
+
logger.info("Processing %d specific file(s)...", len(args.file))
|
|
85
|
+
for item in args.file:
|
|
86
|
+
process_story_folder(
|
|
87
|
+
item,
|
|
88
|
+
model=args.model,
|
|
89
|
+
quality=quality,
|
|
90
|
+
orientation=orientation,
|
|
91
|
+
reference_images=args.reference_images,
|
|
92
|
+
output_prefix=args.output_prefix,
|
|
93
|
+
size=args.size,
|
|
94
|
+
skip_update=args.skip_update,
|
|
95
|
+
force=args.force,
|
|
96
|
+
file_pattern=args.file_pattern,
|
|
97
|
+
basename=args.basename
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Execute folder-based targets if specified
|
|
101
|
+
if args.story_folders:
|
|
102
|
+
logger.info("Processing %d story folder(s)...", len(args.story_folders))
|
|
103
|
+
for story_folder in args.story_folders:
|
|
104
|
+
logger.info("Processing: %s", story_folder)
|
|
105
|
+
process_story_folder(
|
|
106
|
+
story_folder,
|
|
107
|
+
model=args.model,
|
|
108
|
+
quality=quality,
|
|
109
|
+
orientation=orientation,
|
|
110
|
+
reference_images=args.reference_images,
|
|
111
|
+
output_prefix=args.output_prefix,
|
|
112
|
+
size=args.size,
|
|
113
|
+
skip_update=args.skip_update,
|
|
114
|
+
force=args.force,
|
|
115
|
+
file_pattern=args.file_pattern,
|
|
116
|
+
basename=args.basename
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if not args.file and not args.story_folders:
|
|
120
|
+
print("Error: No files (--file) or story folders specified.")
|
|
121
|
+
parser.print_help()
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
|
|
124
|
+
print("\nAll tasks processed!")
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import re
|
|
5
|
+
import contextlib
|
|
6
|
+
import base64
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import requests
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from .providers import get_provider_for_model
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("tlnw-generate-image")
|
|
15
|
+
|
|
16
|
+
FEATURED_WEBP_FILENAME = "featured.webp"
|
|
17
|
+
THUMBNAIL_WEBP_FILENAME = "thumbnail.webp"
|
|
18
|
+
THUMBNAIL_MAX_DIMENSION = 768
|
|
19
|
+
THUMBNAIL_WEBP_QUALITY = 75
|
|
20
|
+
|
|
21
|
+
def get_default_quality(model):
|
|
22
|
+
"""Return default quality by model family."""
|
|
23
|
+
provider = get_provider_for_model(model)
|
|
24
|
+
return provider.get_default_quality(model)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_quality_for_model(model, quality):
|
|
28
|
+
"""Normalize cross-model quality values to model-specific acceptable values."""
|
|
29
|
+
provider = get_provider_for_model(model)
|
|
30
|
+
return provider.normalize_quality(model, quality)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_size_for_orientation(model, orientation):
|
|
34
|
+
"""Map a requested orientation to an OpenAI-compatible image size."""
|
|
35
|
+
provider = get_provider_for_model(model)
|
|
36
|
+
return provider.resolve_size(model, orientation)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_master_prompt(story_folder: Path) -> str:
|
|
40
|
+
"""Load the shared illustration master prompt if the story provides one."""
|
|
41
|
+
master_prompt_path = story_folder / "illustration-master-prompt.txt"
|
|
42
|
+
if not master_prompt_path.exists():
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
return master_prompt_path.read_text(encoding="utf-8").strip()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.warning("Failed to load master prompt from %s: %s", master_prompt_path, e)
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def combine_master_prompt(master_prompt_text: str, prompt_text: str) -> str:
|
|
53
|
+
"""Prepend the shared master prompt to an individual scene prompt."""
|
|
54
|
+
if not master_prompt_text:
|
|
55
|
+
return prompt_text
|
|
56
|
+
|
|
57
|
+
return f"{master_prompt_text.rstrip()}\n\n{prompt_text.lstrip()}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def generate_image(prompt, out_path, model="gpt-image-1.5", size="1024x1024", quality="medium", reference_images: list[str] | None = None):
|
|
61
|
+
"""Route to correct image generation provider based on the model."""
|
|
62
|
+
provider = get_provider_for_model(model)
|
|
63
|
+
return provider.generate(
|
|
64
|
+
prompt,
|
|
65
|
+
out_path,
|
|
66
|
+
model=model,
|
|
67
|
+
size=size,
|
|
68
|
+
quality=quality,
|
|
69
|
+
reference_images=reference_images
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def convert_png_to_webp(
|
|
74
|
+
png_path: Path,
|
|
75
|
+
webp_path: Path | None = None,
|
|
76
|
+
*,
|
|
77
|
+
max_dimension: int | None = None,
|
|
78
|
+
lossless: bool = True,
|
|
79
|
+
quality: int | None = None,
|
|
80
|
+
) -> Path:
|
|
81
|
+
target_path = webp_path or png_path.with_suffix(".webp")
|
|
82
|
+
logger.debug(
|
|
83
|
+
"Converting PNG to WebP: source=%s target=%s max_dimension=%s lossless=%s quality=%s",
|
|
84
|
+
png_path, target_path, max_dimension, lossless, quality,
|
|
85
|
+
)
|
|
86
|
+
with Image.open(png_path) as img:
|
|
87
|
+
if img.mode not in ("RGB", "RGBA"):
|
|
88
|
+
img = img.convert("RGBA")
|
|
89
|
+
else:
|
|
90
|
+
img = img.copy()
|
|
91
|
+
|
|
92
|
+
if max_dimension is not None:
|
|
93
|
+
img.thumbnail((max_dimension, max_dimension), Image.Resampling.LANCZOS)
|
|
94
|
+
|
|
95
|
+
save_kwargs = {"format": "WEBP"}
|
|
96
|
+
if lossless:
|
|
97
|
+
save_kwargs["lossless"] = True
|
|
98
|
+
else:
|
|
99
|
+
save_kwargs["lossless"] = False
|
|
100
|
+
save_kwargs["method"] = 6
|
|
101
|
+
if quality is not None:
|
|
102
|
+
save_kwargs["quality"] = quality
|
|
103
|
+
|
|
104
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
img.save(target_path, **save_kwargs)
|
|
106
|
+
return target_path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def update_images_frontmatter_with_fmu(index_path: Path, image_name: str = "illustration.webp") -> bool:
|
|
110
|
+
"""Update the images frontmatter field via the 'fmu' external command."""
|
|
111
|
+
command = [
|
|
112
|
+
"fmu",
|
|
113
|
+
"update",
|
|
114
|
+
str(index_path),
|
|
115
|
+
"--name",
|
|
116
|
+
"images",
|
|
117
|
+
"--compute",
|
|
118
|
+
f"=flat_list({image_name})",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
result = subprocess.run(
|
|
123
|
+
command,
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
encoding="utf-8",
|
|
127
|
+
errors="replace",
|
|
128
|
+
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
|
129
|
+
)
|
|
130
|
+
if result.returncode == 0:
|
|
131
|
+
return True
|
|
132
|
+
logger.warning("fmu command failed with return code %d. Stderr: %s", result.returncode, result.stderr)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
logger.debug("fmu command not found on PATH. Standard YAML parsing will be used.")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.warning("Error running fmu: %s", e)
|
|
137
|
+
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def update_index_frontmatter_images(index_path: Path, image_name: str = THUMBNAIL_WEBP_FILENAME) -> Path | None:
|
|
142
|
+
"""Update standard frontmatter images block in index.md using robust inline parsing."""
|
|
143
|
+
if not index_path.exists():
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
# First attempt updating using 'fmu' command if possible
|
|
147
|
+
if update_images_frontmatter_with_fmu(index_path, image_name):
|
|
148
|
+
logger.debug("Successfully updated frontmatter via fmu.")
|
|
149
|
+
# Re-read file to return updated path
|
|
150
|
+
return index_path
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
text = index_path.read_text(encoding="utf-8")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.warning("Failed to read %s: %s", index_path, e)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
lines = text.splitlines()
|
|
159
|
+
logger.debug("Updating frontmatter images in %s to include %s", index_path, image_name)
|
|
160
|
+
|
|
161
|
+
if not lines or lines[0].strip() != "---":
|
|
162
|
+
logger.warning("Skipping frontmatter update for %s: missing YAML frontmatter", index_path)
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
frontmatter_end = lines.index("---", 1)
|
|
167
|
+
except ValueError:
|
|
168
|
+
logger.warning("Skipping frontmatter update for %s: unterminated YAML frontmatter", index_path)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
frontmatter_lines = lines[1:frontmatter_end]
|
|
172
|
+
thumbnail_entry = f"- {image_name}"
|
|
173
|
+
|
|
174
|
+
images_index = next((i for i, line in enumerate(frontmatter_lines) if line.strip() == "images:"), None)
|
|
175
|
+
if images_index is None:
|
|
176
|
+
insert_after = len(frontmatter_lines)
|
|
177
|
+
for key in ("image:", "summary:", "url:"):
|
|
178
|
+
key_index = next((i for i, line in enumerate(frontmatter_lines) if line.strip().startswith(key)), None)
|
|
179
|
+
if key_index is not None:
|
|
180
|
+
insert_after = key_index + 1
|
|
181
|
+
break
|
|
182
|
+
frontmatter_lines[insert_after:insert_after] = ["images:", thumbnail_entry]
|
|
183
|
+
else:
|
|
184
|
+
block_end = images_index + 1
|
|
185
|
+
while block_end < len(frontmatter_lines) and frontmatter_lines[block_end].lstrip().startswith("-"):
|
|
186
|
+
if frontmatter_lines[block_end].strip() == thumbnail_entry:
|
|
187
|
+
# Value already exists, nothing to update
|
|
188
|
+
return None
|
|
189
|
+
block_end += 1
|
|
190
|
+
frontmatter_lines.insert(block_end, thumbnail_entry)
|
|
191
|
+
|
|
192
|
+
updated_text = "\n".join([lines[0], *frontmatter_lines, lines[frontmatter_end], *lines[frontmatter_end + 1:]])
|
|
193
|
+
if text.endswith("\n"):
|
|
194
|
+
updated_text += "\n"
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
index_path.write_text(updated_text, encoding="utf-8")
|
|
198
|
+
return index_path
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.warning("Failed to write updated frontmatter to %s: %s", index_path, e)
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def update_figure_shortcodes_in_index(index_path: Path) -> bool:
|
|
205
|
+
"""Replace any standard markdown figure shortcodes referencing .png with .webp."""
|
|
206
|
+
if not index_path.exists():
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
content = index_path.read_text(encoding="utf-8")
|
|
211
|
+
if '{{< figure src="illustration.png"' in content:
|
|
212
|
+
updated_content = content.replace('{{< figure src="illustration.png"', '{{< figure src="illustration.webp"')
|
|
213
|
+
index_path.write_text(updated_content, encoding="utf-8")
|
|
214
|
+
logger.info("Updated figure shortcode references in: %s", index_path)
|
|
215
|
+
return True
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.warning("Failed to update figure shortcodes in %s: %s", index_path, e)
|
|
218
|
+
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def generate_image_from_prompt(
|
|
223
|
+
prompt_path: Path,
|
|
224
|
+
out_path: Path,
|
|
225
|
+
model="dall-e-3",
|
|
226
|
+
quality="medium",
|
|
227
|
+
orientation="square",
|
|
228
|
+
reference_images: list[str] | None = None,
|
|
229
|
+
size: str | None = None
|
|
230
|
+
) -> Path:
|
|
231
|
+
"""Generate image based on prompt read from the filesystem, resolving size automatically."""
|
|
232
|
+
try:
|
|
233
|
+
prompt = prompt_path.read_text(encoding="utf-8").strip()
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.error("Failed to read prompt file %s: %s", prompt_path, e)
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
|
|
238
|
+
logger.debug("Loaded prompt file %s (%d characters)", prompt_path, len(prompt))
|
|
239
|
+
|
|
240
|
+
# Try to parse the size from the prompt file's name if not specified
|
|
241
|
+
if not size:
|
|
242
|
+
# Extract trailing suffix matching -<width>x<height>
|
|
243
|
+
size_match = re.search(r'-(\d+)x(\d+)(?:\.txt)?$', prompt_path.name)
|
|
244
|
+
if size_match:
|
|
245
|
+
size = f"{size_match.group(1)}x{size_match.group(2)}"
|
|
246
|
+
logger.debug("Parsed size %s from prompt filename %s", size, prompt_path.name)
|
|
247
|
+
|
|
248
|
+
if not size:
|
|
249
|
+
try:
|
|
250
|
+
size = _resolve_size_for_orientation(model, orientation)
|
|
251
|
+
except ValueError as exc:
|
|
252
|
+
print(f"ERROR: {exc}")
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
|
|
255
|
+
print(f"Generating image for: {out_path} using model: {model}, quality: {quality}, orientation: {orientation}, size: {size}")
|
|
256
|
+
|
|
257
|
+
# Prepend master prompt if present in the folder
|
|
258
|
+
master_text = load_master_prompt(prompt_path.parent)
|
|
259
|
+
prompt = combine_master_prompt(master_text, prompt)
|
|
260
|
+
|
|
261
|
+
result_path = generate_image(
|
|
262
|
+
prompt,
|
|
263
|
+
str(out_path),
|
|
264
|
+
model=model,
|
|
265
|
+
size=size,
|
|
266
|
+
quality=quality,
|
|
267
|
+
reference_images=reference_images
|
|
268
|
+
)
|
|
269
|
+
return Path(result_path)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def process_story_folder(
|
|
273
|
+
source: str,
|
|
274
|
+
model="dall-e-3",
|
|
275
|
+
quality="medium",
|
|
276
|
+
orientation="square",
|
|
277
|
+
reference_images: list[str] | None = None,
|
|
278
|
+
output_prefix: str | None = None,
|
|
279
|
+
size: str | None = None,
|
|
280
|
+
skip_update=False,
|
|
281
|
+
force=False,
|
|
282
|
+
file_pattern=None,
|
|
283
|
+
basename=None,
|
|
284
|
+
):
|
|
285
|
+
"""Unified handler that can process entire story folders or individual files with optional filters."""
|
|
286
|
+
source_path = Path(source)
|
|
287
|
+
if not source_path.exists():
|
|
288
|
+
print(f"Error: Path not found: {source_path}")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# Determine prompt files
|
|
292
|
+
prompt_files = []
|
|
293
|
+
|
|
294
|
+
if source_path.is_file():
|
|
295
|
+
prompt_files = [source_path]
|
|
296
|
+
else:
|
|
297
|
+
# Source is a directory
|
|
298
|
+
if basename:
|
|
299
|
+
candidate = source_path / f"{basename}-prompt.txt"
|
|
300
|
+
if candidate.exists():
|
|
301
|
+
prompt_files = [candidate]
|
|
302
|
+
elif file_pattern:
|
|
303
|
+
import fnmatch
|
|
304
|
+
try:
|
|
305
|
+
files = os.listdir(source_path)
|
|
306
|
+
prompt_files = [source_path / f for f in files if fnmatch.fnmatch(f, file_pattern)]
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.error("Failed to list directory %s: %s", source_path, e)
|
|
309
|
+
return
|
|
310
|
+
else:
|
|
311
|
+
# Default lookup: illustration-prompt*.txt
|
|
312
|
+
prompt_files = sorted(source_path.glob("illustration-prompt*.txt"))
|
|
313
|
+
# Fallback to legacy file name if none found
|
|
314
|
+
if not prompt_files:
|
|
315
|
+
legacy = source_path / "illustration-prompt.txt"
|
|
316
|
+
if legacy.exists():
|
|
317
|
+
prompt_files = [legacy]
|
|
318
|
+
|
|
319
|
+
if not prompt_files:
|
|
320
|
+
print(f"No prompt files found in: {source}")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
for prompt_file in prompt_files:
|
|
324
|
+
# Check master prompt and skip it if it was matched accidentally
|
|
325
|
+
if prompt_file.name == "illustration-master-prompt.txt":
|
|
326
|
+
logger.debug("Skipping master prompt file: %s", prompt_file)
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
# Determine output PNG base name
|
|
330
|
+
if source_path.is_file():
|
|
331
|
+
# Source is file: out base name is prompt name minus '-prompt'
|
|
332
|
+
stem = prompt_file.stem
|
|
333
|
+
out_base = stem.replace("-prompt", "", 1)
|
|
334
|
+
png_filename = f"{out_base}.png"
|
|
335
|
+
if output_prefix:
|
|
336
|
+
png_filename = f"{output_prefix}-{png_filename}"
|
|
337
|
+
png_path = prompt_file.with_name(png_filename)
|
|
338
|
+
else:
|
|
339
|
+
# Source is directory: default output filename is featured.png or base derived from prompt
|
|
340
|
+
stem = prompt_file.stem
|
|
341
|
+
out_base = stem.replace("-prompt", "", 1)
|
|
342
|
+
# Default to "featured.png" for standard single-illustration case, or derivative
|
|
343
|
+
if out_base == "illustration":
|
|
344
|
+
png_filename = "featured.png"
|
|
345
|
+
else:
|
|
346
|
+
png_filename = f"{out_base}.png"
|
|
347
|
+
|
|
348
|
+
if output_prefix:
|
|
349
|
+
png_filename = f"{output_prefix}-{png_filename}"
|
|
350
|
+
png_path = source_path / png_filename
|
|
351
|
+
|
|
352
|
+
# Derive WebP and final target paths
|
|
353
|
+
featured_webp_name = FEATURED_WEBP_FILENAME
|
|
354
|
+
thumbnail_webp_name = THUMBNAIL_WEBP_FILENAME
|
|
355
|
+
if output_prefix:
|
|
356
|
+
featured_webp_name = f"{output_prefix}-{FEATURED_WEBP_FILENAME}"
|
|
357
|
+
thumbnail_webp_name = f"{output_prefix}-{THUMBNAIL_WEBP_FILENAME}"
|
|
358
|
+
|
|
359
|
+
featured_webp_path = png_path.with_name(featured_webp_name)
|
|
360
|
+
thumbnail_webp_path = png_path.with_name(thumbnail_webp_name)
|
|
361
|
+
|
|
362
|
+
# Skip regeneration if not forced and target outputs already exist
|
|
363
|
+
if not force and (featured_webp_path.exists() or png_path.exists()):
|
|
364
|
+
print(f"Skipping {png_path.name} (already exists)")
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# Generate PNG
|
|
368
|
+
generated_png = generate_image_from_prompt(
|
|
369
|
+
prompt_file,
|
|
370
|
+
png_path,
|
|
371
|
+
model=model,
|
|
372
|
+
quality=quality,
|
|
373
|
+
orientation=orientation,
|
|
374
|
+
reference_images=reference_images,
|
|
375
|
+
size=size
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Convert to featured/optimized WebPs
|
|
379
|
+
convert_png_to_webp(generated_png, featured_webp_path)
|
|
380
|
+
convert_png_to_webp(
|
|
381
|
+
generated_png,
|
|
382
|
+
thumbnail_webp_path,
|
|
383
|
+
max_dimension=THUMBNAIL_MAX_DIMENSION,
|
|
384
|
+
lossless=False,
|
|
385
|
+
quality=THUMBNAIL_WEBP_QUALITY,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
print(f"Saved: {generated_png}")
|
|
389
|
+
print(f"Saved: {featured_webp_path}")
|
|
390
|
+
print(f"Saved: {thumbnail_webp_path}")
|
|
391
|
+
|
|
392
|
+
# Update Jekyll/Hugo post if index.md exists
|
|
393
|
+
if not skip_update:
|
|
394
|
+
article_dir = source_path if source_path.is_dir() else source_path.parent
|
|
395
|
+
index_path = article_dir / "index.md"
|
|
396
|
+
if index_path.exists():
|
|
397
|
+
updated_index = update_index_frontmatter_images(index_path, image_name=thumbnail_webp_name)
|
|
398
|
+
if updated_index:
|
|
399
|
+
print(f"Updated: {updated_index}")
|
|
400
|
+
# Also replace figure references
|
|
401
|
+
update_figure_shortcodes_in_index(index_path)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tlnw-generate-image
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Generate illustrations and images dynamically using OpenAI DALL-E and Google Imagen models.
|
|
5
|
+
Author-email: Tellers Network <admin@tlnw.uk>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: openai>=1.0.0
|
|
11
|
+
Requires-Dist: requests>=2.25.0
|
|
12
|
+
Requires-Dist: pillow>=9.0.0
|
|
13
|
+
Requires-Dist: python-dotenv>=0.19.0
|
|
14
|
+
|
|
15
|
+
# Tellers Network Image Generation Tool (`tlnw-generate-image`)
|
|
16
|
+
|
|
17
|
+
`tlnw-generate-image` is a standalone, publishable package that unifies AI image generation for stories and posts in the Tellers Network ecosystem. It supports OpenAI (DALL-E 2, DALL-E 3, and legacy `gpt-image-1.5`) as well as Google Gemini Imagen 4 models.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
```bash
|
|
21
|
+
pip install tlnw-generate-image
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
- **Extensible OOP Provider Architecture**: Clean separation of model providers (`DalleProvider`, `GptImageProvider`, `GoogleImagenProvider`) with registry-based dynamic routing.
|
|
26
|
+
- **Intelligent Size & Quality Preset Mapping**: Standardizes orientation presets (`landscape`, `portrait`, `square`) and qualities, automatically converting them to provider-native formats: aspect ratios (e.g. `"16:9"`) for Google Imagen, or concrete dimensions (e.g. `"1792x1024"`) for OpenAI.
|
|
27
|
+
- **Custom Dimension Resolution**: Automatically maps exact resolution values (e.g., `1920x1080` or `1536x1024.txt` in file names) to the closest supported native aspect ratios when routing to Imagen, while passing OpenAI dimensions as-is.
|
|
28
|
+
- **Auto-resolution of Output Sizing**: Extracts target sizes from prompt filename trailing suffixes (e.g., `-1536x1024.txt`).
|
|
29
|
+
- **Prompt Composition**: Concatenates local directory `illustration-master-prompt.txt` automatically with individual scene prompts.
|
|
30
|
+
- **Automatic WebP Conversion**: Generates and optimizes WebP formats (`featured.webp` and `thumbnail.webp` with max-dimension constraints).
|
|
31
|
+
- **Automated Jekyll/Hugo Updating**: Automatically updates the article frontmatter's `images` block.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
Generate illustration for a story folder:
|
|
35
|
+
```bash
|
|
36
|
+
generate-image /path/to/story_folder --model dall-e-3
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Process specific prompt files:
|
|
40
|
+
```bash
|
|
41
|
+
generate-image --file /path/to/prompt-1536x1024.txt --model imagen-4-ultra
|
|
42
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_generate_image.py
|
|
4
|
+
tlnw_generate_image/__init__.py
|
|
5
|
+
tlnw_generate_image/cli.py
|
|
6
|
+
tlnw_generate_image/core.py
|
|
7
|
+
tlnw_generate_image.egg-info/PKG-INFO
|
|
8
|
+
tlnw_generate_image.egg-info/SOURCES.txt
|
|
9
|
+
tlnw_generate_image.egg-info/dependency_links.txt
|
|
10
|
+
tlnw_generate_image.egg-info/entry_points.txt
|
|
11
|
+
tlnw_generate_image.egg-info/requires.txt
|
|
12
|
+
tlnw_generate_image.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tlnw_generate_image
|