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 .
55Images are saved to MEDIA_ROOT/posts/
66
77Usage:
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
1213import os
14+ import requests
1315from io import BytesIO
1416from django .core .management .base import BaseCommand
1517from django .core .files .base import ContentFile
1618from django .conf import settings
1719from portfolio .models import Post
1820
1921try :
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
2230except ImportError :
2331 HAS_PILLOW = False
2432
2533
2634class 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'\n Done! Generated { generated } images.' ))
204+ self .stdout .write (self .style .SUCCESS (f'\n Complete! Generated: { generated } , Failed: { failed } ' ))
205+ self .stdout .write (f'Estimated cost: ~${ generated * 0.04 :.2f} ' )
0 commit comments