Skip to content

Commit fc90de2

Browse files
committed
Add DALL-E 3 AI image generation for high-quality blog images
1 parent 7bb8e39 commit fc90de2

4 files changed

Lines changed: 152 additions & 153 deletions

File tree

Lines changed: 138 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,59 @@
11
"""
2-
Generate images for posts that don't have one.
2+
Generate AI images for posts using DALL-E 3.
33
4-
Creates gradient backgrounds with title text overlay.
4+
Creates unique, high-quality images based on post titles and tags.
55
Images are saved to MEDIA_ROOT/posts/
66
77
Usage:
88
python manage.py generate_images
99
python manage.py generate_images --overwrite # Regenerate all
10+
11+
Requires OPENAI_API_KEY environment variable.
1012
"""
11-
import hashlib
1213
import os
14+
import requests
1315
from io import BytesIO
1416
from django.core.management.base import BaseCommand
1517
from django.core.files.base import ContentFile
1618
from django.conf import settings
1719
from portfolio.models import Post
1820

1921
try:
20-
from PIL import Image, ImageDraw, ImageFont
22+
from openai import OpenAI
23+
HAS_OPENAI = True
24+
except ImportError:
25+
HAS_OPENAI = False
26+
27+
try:
28+
from PIL import Image
2129
HAS_PILLOW = True
2230
except ImportError:
2331
HAS_PILLOW = False
2432

2533

2634
class Command(BaseCommand):
27-
help = 'Generate images for posts without images'
28-
29-
# Color schemes based on tags
30-
COLOR_SCHEMES = {
31-
'rust': [(183, 65, 14), (255, 107, 53)],
32-
'python': [(55, 118, 171), (255, 212, 59)],
33-
'java': [(176, 114, 25), (237, 139, 0)],
34-
'javascript': [(247, 223, 30), (50, 51, 48)],
35-
'react': [(97, 218, 251), (32, 35, 42)],
36-
'blockchain': [(247, 147, 26), (20, 21, 26)],
37-
'kubernetes': [(50, 108, 229), (255, 255, 255)],
38-
'aws': [(255, 153, 0), (35, 47, 62)],
39-
'django': [(12, 75, 51), (44, 160, 101)],
40-
'go': [(0, 173, 216), (255, 255, 255)],
41-
'android': [(61, 220, 132), (255, 255, 255)],
42-
'ai': [(138, 43, 226), (255, 105, 180)],
43-
'security': [(220, 20, 60), (25, 25, 25)],
44-
'career': [(70, 130, 180), (255, 255, 255)],
45-
'education': [(34, 139, 34), (255, 255, 255)],
46-
'project': [(255, 69, 0), (255, 215, 0)],
47-
'certifications': [(75, 0, 130), (238, 130, 238)],
48-
'skills': [(0, 128, 128), (255, 255, 255)],
49-
'default': [(99, 102, 241), (168, 85, 247)],
35+
help = 'Generate AI images for posts using DALL-E 3'
36+
37+
# Style keywords based on tags
38+
STYLE_HINTS = {
39+
'rust': 'rust-colored, industrial, metallic gears',
40+
'python': 'blue and yellow, snake motif, clean code',
41+
'java': 'coffee themed, orange and brown, enterprise',
42+
'javascript': 'yellow and black, dynamic, web nodes',
43+
'react': 'blue atoms, component blocks, modern UI',
44+
'blockchain': 'golden chains, distributed nodes, crypto',
45+
'kubernetes': 'blue containers, orchestration, cloud pods',
46+
'aws': 'orange cloud, infrastructure, scalable',
47+
'django': 'green, web framework, python elegant',
48+
'go': 'cyan gopher, concurrent, fast',
49+
'android': 'green robot, mobile, apps',
50+
'ai': 'neural networks, purple gradients, futuristic',
51+
'security': 'red shields, locks, cyber protection',
52+
'career': 'professional, blue suit, growth chart',
53+
'education': 'books, graduation, academic green',
54+
'project': 'blueprints, building blocks, orange',
55+
'certifications': 'badges, certificates, achievements',
56+
'skills': 'toolbox, expertise icons, teal',
5057
}
5158

5259
def add_arguments(self, parser):
@@ -55,128 +62,93 @@ def add_arguments(self, parser):
5562
action='store_true',
5663
help='Regenerate images even for posts that have one'
5764
)
65+
parser.add_argument(
66+
'--dry-run',
67+
action='store_true',
68+
help='Show prompts without generating images'
69+
)
5870

59-
def get_color_scheme(self, post):
60-
"""Get color scheme based on post tags."""
71+
def get_style_hint(self, post):
72+
"""Get style hints based on post tags."""
6173
tags = [t.caption.lower() for t in post.tags.all()]
74+
hints = []
6275

6376
for tag in tags:
64-
for key in self.COLOR_SCHEMES:
77+
for key, hint in self.STYLE_HINTS.items():
6578
if key in tag:
66-
return self.COLOR_SCHEMES[key]
67-
68-
return self.COLOR_SCHEMES['default']
69-
70-
def wrap_text(self, text, font, max_width, draw):
71-
"""Wrap text to fit within max_width."""
72-
words = text.split()
73-
lines = []
74-
current_line = []
75-
76-
for word in words:
77-
current_line.append(word)
78-
test_line = ' '.join(current_line)
79-
bbox = draw.textbbox((0, 0), test_line, font=font)
80-
width = bbox[2] - bbox[0]
81-
82-
if width > max_width:
83-
if len(current_line) == 1:
84-
lines.append(current_line[0])
85-
current_line = []
86-
else:
87-
current_line.pop()
88-
lines.append(' '.join(current_line))
89-
current_line = [word]
90-
91-
if current_line:
92-
lines.append(' '.join(current_line))
93-
94-
return lines[:3] # Max 3 lines
95-
96-
def create_gradient(self, width, height, color1, color2):
97-
"""Create a diagonal gradient image."""
98-
image = Image.new('RGB', (width, height))
99-
100-
for y in range(height):
101-
for x in range(width):
102-
# Diagonal gradient
103-
ratio = (x + y) / (width + height)
104-
r = int(color1[0] * (1 - ratio) + color2[0] * ratio)
105-
g = int(color1[1] * (1 - ratio) + color2[1] * ratio)
106-
b = int(color1[2] * (1 - ratio) + color2[2] * ratio)
107-
image.putpixel((x, y), (r, g, b))
108-
109-
return image
110-
111-
def generate_image(self, post):
112-
"""Generate an image for a post."""
113-
width, height = 800, 400
114-
color1, color2 = self.get_color_scheme(post)
115-
116-
# Create gradient background
117-
image = self.create_gradient(width, height, color1, color2)
118-
draw = ImageDraw.Draw(image)
119-
120-
# Try to load a font, fall back to default
121-
font_size = 56
122-
try:
123-
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
124-
except (IOError, OSError):
125-
try:
126-
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
127-
except (IOError, OSError):
128-
font = ImageFont.load_default()
129-
130-
# Wrap and draw title
131-
title = post.title[:80] # Limit title length
132-
lines = self.wrap_text(title, font, width - 80, draw)
133-
134-
# Calculate vertical position to center text
135-
line_height = font_size + 14
136-
total_height = len(lines) * line_height
137-
y_start = (height - total_height) // 2
138-
139-
# Draw text with strong outline for better readability
140-
for i, line in enumerate(lines):
141-
bbox = draw.textbbox((0, 0), line, font=font)
142-
text_width = bbox[2] - bbox[0]
143-
x = (width - text_width) // 2
144-
y = y_start + i * line_height
145-
146-
# Draw black outline (multiple passes for thickness)
147-
outline_color = (0, 0, 0)
148-
for offset_x in range(-3, 4):
149-
for offset_y in range(-3, 4):
150-
if offset_x != 0 or offset_y != 0:
151-
draw.text((x + offset_x, y + offset_y), line, font=font, fill=outline_color)
152-
153-
# Main white text
154-
draw.text((x, y), line, font=font, fill=(255, 255, 255))
155-
156-
# Add tag indicator at bottom with outline
157-
tags = [t.caption for t in post.tags.all()[:3]]
158-
if tags:
159-
tag_text = ' | '.join(tags)
160-
try:
161-
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22)
162-
except (IOError, OSError):
163-
small_font = font
164-
tag_x, tag_y = 20, height - 40
165-
# Outline for tags
166-
for ox in range(-2, 3):
167-
for oy in range(-2, 3):
168-
if ox != 0 or oy != 0:
169-
draw.text((tag_x + ox, tag_y + oy), tag_text.upper(), font=small_font, fill=(0, 0, 0))
170-
draw.text((tag_x, tag_y), tag_text.upper(), font=small_font, fill=(255, 255, 255))
171-
172-
return image
79+
hints.append(hint)
80+
break
81+
82+
return ', '.join(hints[:2]) if hints else 'modern tech, professional'
83+
84+
def create_prompt(self, post):
85+
"""Create a DALL-E prompt for the post."""
86+
title = post.title[:100]
87+
style_hint = self.get_style_hint(post)
88+
89+
prompt = f"""Create a modern, professional blog header image for an article titled "{title}".
90+
91+
Style: Abstract, minimalist tech illustration with {style_hint}.
92+
Requirements:
93+
- Clean, modern design suitable for a tech blog
94+
- No text or letters in the image
95+
- Soft gradients and geometric shapes
96+
- Professional color palette
97+
- 16:9 aspect ratio composition
98+
- Suitable as a blog post thumbnail"""
99+
100+
return prompt
101+
102+
def generate_with_dalle(self, client, prompt):
103+
"""Generate image using DALL-E 3."""
104+
response = client.images.generate(
105+
model="dall-e-3",
106+
prompt=prompt,
107+
size="1792x1024", # Closest to 16:9
108+
quality="standard",
109+
n=1,
110+
)
173111

174-
def handle(self, *args, **options):
112+
image_url = response.data[0].url
113+
114+
# Download the image
115+
img_response = requests.get(image_url)
116+
img_response.raise_for_status()
117+
118+
return img_response.content
119+
120+
def resize_image(self, image_data, target_size=(800, 400)):
121+
"""Resize image to target dimensions."""
175122
if not HAS_PILLOW:
176-
self.stdout.write(self.style.ERROR('Pillow is required. Run: pip install Pillow'))
123+
return image_data
124+
125+
img = Image.open(BytesIO(image_data))
126+
img = img.resize(target_size, Image.Resampling.LANCZOS)
127+
128+
buffer = BytesIO()
129+
img.save(buffer, format='PNG', optimize=True)
130+
buffer.seek(0)
131+
132+
return buffer.read()
133+
134+
def handle(self, *args, **options):
135+
api_key = os.environ.get('OPENAI_API_KEY')
136+
137+
if not api_key:
138+
self.stdout.write(self.style.ERROR(
139+
'OPENAI_API_KEY environment variable is required.\n'
140+
'Set it with: fly secrets set OPENAI_API_KEY="sk-..." --app deveric-blog'
141+
))
142+
return
143+
144+
if not HAS_OPENAI:
145+
self.stdout.write(self.style.ERROR('OpenAI package required. Run: pip install openai'))
177146
return
178147

148+
client = OpenAI(api_key=api_key)
149+
179150
overwrite = options['overwrite']
151+
dry_run = options['dry_run']
180152

181153
if overwrite:
182154
posts = Post.objects.all()
@@ -191,26 +163,43 @@ def handle(self, *args, **options):
191163
posts_media = os.path.join(settings.MEDIA_ROOT, 'posts')
192164
os.makedirs(posts_media, exist_ok=True)
193165

166+
total = posts.count()
167+
self.stdout.write(f'Generating images for {total} posts...\n')
168+
self.stdout.write(f'Estimated cost: ${total * 0.04:.2f} - ${total * 0.08:.2f}\n')
169+
170+
if dry_run:
171+
self.stdout.write('\n--- DRY RUN (no images generated) ---\n')
172+
for post in posts[:5]:
173+
prompt = self.create_prompt(post)
174+
self.stdout.write(f'\n{post.title[:50]}...')
175+
self.stdout.write(f'Prompt: {prompt[:200]}...\n')
176+
return
177+
194178
generated = 0
195-
for post in posts:
179+
failed = 0
180+
181+
for i, post in enumerate(posts, 1):
196182
try:
197-
image = self.generate_image(post)
183+
self.stdout.write(f'[{i}/{total}] {post.title[:40]}... ', ending='')
184+
185+
prompt = self.create_prompt(post)
186+
image_data = self.generate_with_dalle(client, prompt)
198187

199-
# Save to BytesIO
200-
buffer = BytesIO()
201-
image.save(buffer, format='PNG', optimize=True)
202-
buffer.seek(0)
188+
# Resize to blog dimensions
189+
image_data = self.resize_image(image_data)
203190

204191
# Generate filename from slug
205192
filename = f"{post.slug[:50]}.png"
206193

207194
# Save to post
208-
post.image.save(filename, ContentFile(buffer.read()), save=True)
195+
post.image.save(filename, ContentFile(image_data), save=True)
209196

210197
generated += 1
211-
self.stdout.write(f' Generated: {post.title[:50]}...')
198+
self.stdout.write(self.style.SUCCESS('Done'))
212199

213200
except Exception as e:
214-
self.stdout.write(self.style.WARNING(f' Failed for {post.title[:30]}: {e}'))
201+
failed += 1
202+
self.stdout.write(self.style.WARNING(f'Failed: {e}'))
215203

216-
self.stdout.write(self.style.SUCCESS(f'\nDone! Generated {generated} images.'))
204+
self.stdout.write(self.style.SUCCESS(f'\nComplete! Generated: {generated}, Failed: {failed}'))
205+
self.stdout.write(f'Estimated cost: ~${generated * 0.04:.2f}')

portfolio/static/all-posts.css

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,16 @@ ul {
8484
.post__content p,
8585
.post__content .excerpt,
8686
.post__content .excerpt p {
87-
font-size: 0.85rem;
87+
font-size: 0.8rem;
8888
color: #4a4a4a;
8989
margin: 0;
90-
line-height: 1.4;
90+
line-height: 1.3;
91+
word-wrap: break-word;
92+
overflow-wrap: break-word;
93+
hyphens: none;
94+
-webkit-hyphens: none;
9195
}
9296

9397
.post__content .excerpt a {
9498
color: #390281;
95-
text-decoration: underline;
9699
}

portfolio/static/home.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,16 @@ body {
160160

161161
.post .excerpt,
162162
.post .excerpt p {
163-
font-size: 0.9rem;
163+
font-size: 0.85rem;
164164
color: #666;
165165
margin: 0.5rem 0 0 0;
166166
line-height: 1.4;
167+
word-wrap: break-word;
168+
overflow-wrap: break-word;
169+
hyphens: none;
170+
-webkit-hyphens: none;
171+
max-height: 4.5rem;
172+
overflow: hidden;
167173
}
168174

169175
.post .excerpt a {

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ typing_extensions==4.12.2
1515
urllib3==2.2.2
1616
whitenoise==6.4.0
1717
markdown==3.5.1
18+
openai==1.12.0

0 commit comments

Comments
 (0)