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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ # Tellers Network Generate Image Tool
2
+ __version__ = "0.2.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ generate-image = tlnw_generate_image.cli:main
@@ -0,0 +1,4 @@
1
+ openai>=1.0.0
2
+ requests>=2.25.0
3
+ pillow>=9.0.0
4
+ python-dotenv>=0.19.0
@@ -0,0 +1 @@
1
+ tlnw_generate_image