ismdrobiul489 commited on
Commit
db23a91
·
1 Parent(s): 22e5b51

Add heading with background support: centered layout, heading BG, fact text outline/shadow

Browse files
modules/fact_image/router.py CHANGED
@@ -42,16 +42,30 @@ async def create_fact_image(
42
 
43
  - **model**: Image generation model (nvidia, cloudflare, pexels)
44
  - **image_prompt**: Prompt for background image
 
 
45
  - **fact_text**: The fact/quote to overlay on the image
46
  - **duration**: Video duration in seconds (4-7)
47
  """
48
- logger.info(f"New fact-image request: model={request.model}, duration={request.duration}s")
 
 
 
 
 
 
 
 
 
 
49
 
50
  job_id = creator.add_to_queue(
51
  model=request.model,
52
  image_prompt=request.image_prompt,
53
  fact_text=request.fact_text,
54
- duration=request.duration
 
 
55
  )
56
 
57
  return FactImageResponse(
 
42
 
43
  - **model**: Image generation model (nvidia, cloudflare, pexels)
44
  - **image_prompt**: Prompt for background image
45
+ - **fact_heading**: Optional heading text (e.g., 'Psychological Hack')
46
+ - **heading_background**: Heading background style config
47
  - **fact_text**: The fact/quote to overlay on the image
48
  - **duration**: Video duration in seconds (4-7)
49
  """
50
+ logger.info(f"New fact-image request: model={request.model}, heading='{request.fact_heading}', duration={request.duration}s")
51
+
52
+ # Convert heading_background to dict if present
53
+ heading_bg_dict = None
54
+ if request.heading_background:
55
+ heading_bg_dict = {
56
+ "enabled": request.heading_background.enabled,
57
+ "color": request.heading_background.color,
58
+ "padding": request.heading_background.padding,
59
+ "corner_radius": request.heading_background.corner_radius
60
+ }
61
 
62
  job_id = creator.add_to_queue(
63
  model=request.model,
64
  image_prompt=request.image_prompt,
65
  fact_text=request.fact_text,
66
+ duration=request.duration,
67
+ fact_heading=request.fact_heading,
68
+ heading_background=heading_bg_dict
69
  )
70
 
71
  return FactImageResponse(
modules/fact_image/schemas.py CHANGED
@@ -24,6 +24,14 @@ class JobStatus(str, Enum):
24
  failed = "failed"
25
 
26
 
 
 
 
 
 
 
 
 
27
  class FactImageRequest(BaseModel):
28
  """Request schema for creating a fact image video"""
29
  model: ImageModel = Field(
@@ -36,6 +44,15 @@ class FactImageRequest(BaseModel):
36
  max_length=500,
37
  description="Prompt for generating the background image"
38
  )
 
 
 
 
 
 
 
 
 
39
  fact_text: str = Field(
40
  ...,
41
  min_length=5,
 
24
  failed = "failed"
25
 
26
 
27
+ class HeadingBackground(BaseModel):
28
+ """Heading background style configuration"""
29
+ enabled: bool = True
30
+ color: str = Field(default="rgba(0, 0, 0, 0.45)", description="Background color (rgba or hex)")
31
+ padding: int = Field(default=22, ge=5, le=50, description="Padding around text")
32
+ corner_radius: int = Field(default=28, ge=0, le=50, description="Corner radius for rounded edges")
33
+
34
+
35
  class FactImageRequest(BaseModel):
36
  """Request schema for creating a fact image video"""
37
  model: ImageModel = Field(
 
44
  max_length=500,
45
  description="Prompt for generating the background image"
46
  )
47
+ fact_heading: Optional[str] = Field(
48
+ default=None,
49
+ max_length=50,
50
+ description="Optional heading text (e.g., 'Psychological Hack')"
51
+ )
52
+ heading_background: Optional[HeadingBackground] = Field(
53
+ default=None,
54
+ description="Heading background style configuration"
55
+ )
56
  fact_text: str = Field(
57
  ...,
58
  min_length=5,
modules/fact_image/services/fact_creator.py CHANGED
@@ -68,7 +68,9 @@ class FactCreator:
68
  model: ImageModel,
69
  image_prompt: str,
70
  fact_text: str,
71
- duration: int = 5
 
 
72
  ) -> str:
73
  """
74
  Add fact-image job to queue.
@@ -82,6 +84,8 @@ class FactCreator:
82
  "id": job_id,
83
  "model": model,
84
  "image_prompt": image_prompt,
 
 
85
  "fact_text": fact_text,
86
  "duration": duration,
87
  "status": JobStatus.queued,
@@ -229,7 +233,9 @@ class FactCreator:
229
  self.text_overlay.add_text(
230
  image_path=image_path,
231
  text=job["fact_text"],
232
- output_path=overlay_path
 
 
233
  )
234
 
235
  job["progress"] = 60
 
68
  model: ImageModel,
69
  image_prompt: str,
70
  fact_text: str,
71
+ duration: int = 5,
72
+ fact_heading: str = None,
73
+ heading_background: dict = None
74
  ) -> str:
75
  """
76
  Add fact-image job to queue.
 
84
  "id": job_id,
85
  "model": model,
86
  "image_prompt": image_prompt,
87
+ "fact_heading": fact_heading,
88
+ "heading_background": heading_background,
89
  "fact_text": fact_text,
90
  "duration": duration,
91
  "status": JobStatus.queued,
 
233
  self.text_overlay.add_text(
234
  image_path=image_path,
235
  text=job["fact_text"],
236
+ output_path=overlay_path,
237
+ heading=job.get("fact_heading"),
238
+ heading_background=job.get("heading_background")
239
  )
240
 
241
  job["progress"] = 60
modules/fact_image/services/text_overlay.py CHANGED
@@ -1,11 +1,12 @@
1
  """
2
  Text Overlay Service
3
  Renders text on images using PIL/Pillow
 
4
  """
5
  import logging
6
- import textwrap
7
  from pathlib import Path
8
- from typing import Tuple, Optional
9
  from PIL import Image, ImageDraw, ImageFont
10
 
11
  logger = logging.getLogger(__name__)
@@ -15,15 +16,23 @@ class TextOverlay:
15
  """
16
  Service for adding text overlay to images.
17
  Optimized for fact/motivational content on vertical videos.
 
 
 
 
 
18
  """
19
 
20
  # Default settings
21
  TARGET_WIDTH = 1080
22
  TARGET_HEIGHT = 1920
23
- TEXT_AREA_TOP = 0.55 # Text starts at 55% from top
24
- TEXT_AREA_BOTTOM = 0.90 # Text ends at 90% from top
25
  PADDING_X = 60 # Horizontal padding
26
 
 
 
 
 
 
27
  def __init__(self, font_path: Optional[str] = None):
28
  """
29
  Initialize text overlay service.
@@ -34,28 +43,34 @@ class TextOverlay:
34
  self.font_path = font_path
35
  self._font_cache = {}
36
 
37
- def _get_font(self, size: int) -> ImageFont.FreeTypeFont:
38
  """Get font at specified size (cached)"""
39
- if size not in self._font_cache:
 
40
  try:
41
  if self.font_path and Path(self.font_path).exists():
42
- self._font_cache[size] = ImageFont.truetype(self.font_path, size)
43
  else:
44
  # Try common system fonts
45
- for font_name in ['DejaVuSans-Bold.ttf', 'Arial.ttf', 'Roboto-Bold.ttf']:
 
 
 
 
 
46
  try:
47
- self._font_cache[size] = ImageFont.truetype(font_name, size)
48
  break
49
  except:
50
  continue
51
  else:
52
  # Fallback to default
53
- self._font_cache[size] = ImageFont.load_default()
54
  except Exception as e:
55
  logger.warning(f"Font loading failed: {e}, using default")
56
- self._font_cache[size] = ImageFont.load_default()
57
 
58
- return self._font_cache[size]
59
 
60
  def _wrap_text(self, text: str, max_words_per_line: int = 5) -> str:
61
  """
@@ -80,54 +95,71 @@ class TextOverlay:
80
 
81
  return '\n'.join(lines)
82
 
83
- def _calculate_font_size(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  self,
85
- text: str,
86
  draw: ImageDraw.ImageDraw,
87
- max_width: int,
88
- max_height: int,
89
- min_size: int = 40,
90
- max_size: int = 80
91
- ) -> int:
92
- """Calculate optimal font size to fit text in given area"""
93
- for size in range(max_size, min_size - 1, -2):
94
- font = self._get_font(size)
95
- wrapped = self._wrap_text(text)
96
-
97
- # Get text bounding box
98
- bbox = draw.multiline_textbbox((0, 0), wrapped, font=font)
99
- text_width = bbox[2] - bbox[0]
100
- text_height = bbox[3] - bbox[1]
101
-
102
- if text_width <= max_width and text_height <= max_height:
103
- return size
104
 
105
- return min_size
 
 
106
 
107
  def add_text(
108
  self,
109
  image_path: Path,
110
  text: str,
111
  output_path: Path,
 
 
112
  text_color: Tuple[int, int, int] = (255, 255, 255),
113
  shadow_color: Tuple[int, int, int] = (0, 0, 0),
114
- shadow_offset: int = 3
 
115
  ) -> Path:
116
  """
117
  Add text overlay to image.
118
 
119
  Args:
120
  image_path: Path to input image
121
- text: Text to overlay
122
  output_path: Path for output image
 
 
123
  text_color: RGB color for text (default: white)
124
  shadow_color: RGB color for shadow (default: black)
125
  shadow_offset: Shadow offset in pixels
 
126
 
127
  Returns:
128
  Path to output image
129
  """
130
- logger.info(f"Adding text overlay: {text[:50]}...")
131
 
132
  # Load image
133
  img = Image.open(image_path).convert('RGBA')
@@ -136,51 +168,119 @@ class TextOverlay:
136
  if img.size != (self.TARGET_WIDTH, self.TARGET_HEIGHT):
137
  img = img.resize((self.TARGET_WIDTH, self.TARGET_HEIGHT), Image.LANCZOS)
138
 
139
- # Create drawing context
140
- draw = ImageDraw.Draw(img)
 
141
 
142
- # Calculate text area
143
- text_area_y_start = int(self.TARGET_HEIGHT * self.TEXT_AREA_TOP)
144
- text_area_y_end = int(self.TARGET_HEIGHT * self.TEXT_AREA_BOTTOM)
145
- max_width = self.TARGET_WIDTH - (2 * self.PADDING_X)
146
- max_height = text_area_y_end - text_area_y_start
147
 
148
- # Calculate optimal font size
149
- font_size = self._calculate_font_size(text, draw, max_width, max_height)
150
- font = self._get_font(font_size)
151
 
152
- # Wrap text
153
  wrapped_text = self._wrap_text(text)
154
 
155
- # Calculate text position (centered)
156
- bbox = draw.multiline_textbbox((0, 0), wrapped_text, font=font)
157
- text_width = bbox[2] - bbox[0]
158
- text_height = bbox[3] - bbox[1]
 
 
 
 
 
 
159
 
160
- x = (self.TARGET_WIDTH - text_width) // 2
161
- y = text_area_y_start + (max_height - text_height) // 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
  # Draw shadow
164
  draw.multiline_text(
165
- (x + shadow_offset, y + shadow_offset),
166
  wrapped_text,
167
- font=font,
168
- fill=shadow_color,
169
  align='center'
170
  )
171
 
172
  # Draw main text
173
  draw.multiline_text(
174
- (x, y),
175
  wrapped_text,
176
- font=font,
177
- fill=text_color,
178
  align='center'
179
  )
180
 
 
 
 
181
  # Save output
182
  img = img.convert('RGB')
183
  img.save(output_path, 'PNG', quality=95)
184
 
185
  logger.info(f"Text overlay saved: {output_path}")
186
  return output_path
 
 
1
  """
2
  Text Overlay Service
3
  Renders text on images using PIL/Pillow
4
+ Supports heading with background + fact text with shadow
5
  """
6
  import logging
7
+ import re
8
  from pathlib import Path
9
+ from typing import Tuple, Optional, Dict, Any
10
  from PIL import Image, ImageDraw, ImageFont
11
 
12
  logger = logging.getLogger(__name__)
 
16
  """
17
  Service for adding text overlay to images.
18
  Optimized for fact/motivational content on vertical videos.
19
+
20
+ Layout:
21
+ - Heading (bold, with background) - centered
22
+ - Fact text (with shadow/outline) - centered below heading
23
+ - All content vertically centered in middle of image
24
  """
25
 
26
  # Default settings
27
  TARGET_WIDTH = 1080
28
  TARGET_HEIGHT = 1920
 
 
29
  PADDING_X = 60 # Horizontal padding
30
 
31
+ # Font sizes
32
+ HEADING_FONT_SIZE = 52
33
+ TEXT_FONT_SIZE = 42
34
+ LINE_SPACING = 1.3
35
+
36
  def __init__(self, font_path: Optional[str] = None):
37
  """
38
  Initialize text overlay service.
 
43
  self.font_path = font_path
44
  self._font_cache = {}
45
 
46
+ def _get_font(self, size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
47
  """Get font at specified size (cached)"""
48
+ cache_key = (size, bold)
49
+ if cache_key not in self._font_cache:
50
  try:
51
  if self.font_path and Path(self.font_path).exists():
52
+ self._font_cache[cache_key] = ImageFont.truetype(self.font_path, size)
53
  else:
54
  # Try common system fonts
55
+ if bold:
56
+ font_names = ['DejaVuSans-Bold.ttf', 'Arial-Bold.ttf', 'Roboto-Bold.ttf', 'FreeSansBold.ttf']
57
+ else:
58
+ font_names = ['DejaVuSans.ttf', 'Arial.ttf', 'Roboto-Regular.ttf', 'FreeSans.ttf']
59
+
60
+ for font_name in font_names:
61
  try:
62
+ self._font_cache[cache_key] = ImageFont.truetype(font_name, size)
63
  break
64
  except:
65
  continue
66
  else:
67
  # Fallback to default
68
+ self._font_cache[cache_key] = ImageFont.load_default()
69
  except Exception as e:
70
  logger.warning(f"Font loading failed: {e}, using default")
71
+ self._font_cache[cache_key] = ImageFont.load_default()
72
 
73
+ return self._font_cache[cache_key]
74
 
75
  def _wrap_text(self, text: str, max_words_per_line: int = 5) -> str:
76
  """
 
95
 
96
  return '\n'.join(lines)
97
 
98
+ def _parse_rgba(self, color_str: str) -> Tuple[int, int, int, int]:
99
+ """Parse rgba color string to tuple"""
100
+ if color_str.startswith('rgba'):
101
+ # Parse rgba(r, g, b, a)
102
+ match = re.match(r'rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)', color_str)
103
+ if match:
104
+ r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3))
105
+ a = int(float(match.group(4)) * 255)
106
+ return (r, g, b, a)
107
+ elif color_str.startswith('#'):
108
+ # Parse hex color
109
+ hex_color = color_str.lstrip('#')
110
+ if len(hex_color) == 6:
111
+ r = int(hex_color[0:2], 16)
112
+ g = int(hex_color[2:4], 16)
113
+ b = int(hex_color[4:6], 16)
114
+ return (r, g, b, 255)
115
+
116
+ # Default: semi-transparent black
117
+ return (0, 0, 0, 115)
118
+
119
+ def _draw_rounded_rect(
120
  self,
 
121
  draw: ImageDraw.ImageDraw,
122
+ xy: Tuple[int, int, int, int],
123
+ radius: int,
124
+ fill: Tuple[int, int, int, int]
125
+ ):
126
+ """Draw a rounded rectangle"""
127
+ x1, y1, x2, y2 = xy
 
 
 
 
 
 
 
 
 
 
 
128
 
129
+ # For RGBA support, we need to create a separate image
130
+ # and composite it
131
+ draw.rounded_rectangle(xy, radius=radius, fill=fill)
132
 
133
  def add_text(
134
  self,
135
  image_path: Path,
136
  text: str,
137
  output_path: Path,
138
+ heading: Optional[str] = None,
139
+ heading_background: Optional[Dict[str, Any]] = None,
140
  text_color: Tuple[int, int, int] = (255, 255, 255),
141
  shadow_color: Tuple[int, int, int] = (0, 0, 0),
142
+ shadow_offset: int = 3,
143
+ outline_width: int = 2
144
  ) -> Path:
145
  """
146
  Add text overlay to image.
147
 
148
  Args:
149
  image_path: Path to input image
150
+ text: Fact text to overlay
151
  output_path: Path for output image
152
+ heading: Optional heading text
153
+ heading_background: Heading background config {enabled, color, padding, corner_radius}
154
  text_color: RGB color for text (default: white)
155
  shadow_color: RGB color for shadow (default: black)
156
  shadow_offset: Shadow offset in pixels
157
+ outline_width: Text outline width
158
 
159
  Returns:
160
  Path to output image
161
  """
162
+ logger.info(f"Adding text overlay: heading='{heading}', text='{text[:30]}...'")
163
 
164
  # Load image
165
  img = Image.open(image_path).convert('RGBA')
 
168
  if img.size != (self.TARGET_WIDTH, self.TARGET_HEIGHT):
169
  img = img.resize((self.TARGET_WIDTH, self.TARGET_HEIGHT), Image.LANCZOS)
170
 
171
+ # Create overlay layer for transparency support
172
+ overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
173
+ draw = ImageDraw.Draw(overlay)
174
 
175
+ # Get fonts
176
+ heading_font = self._get_font(self.HEADING_FONT_SIZE, bold=True)
177
+ text_font = self._get_font(self.TEXT_FONT_SIZE, bold=False)
 
 
178
 
179
+ # Calculate max width
180
+ max_width = self.TARGET_WIDTH - (2 * self.PADDING_X)
 
181
 
182
+ # Prepare wrapped text
183
  wrapped_text = self._wrap_text(text)
184
 
185
+ # Calculate text dimensions
186
+ text_bbox = draw.multiline_textbbox((0, 0), wrapped_text, font=text_font)
187
+ text_width = text_bbox[2] - text_bbox[0]
188
+ text_height = text_bbox[3] - text_bbox[1]
189
+
190
+ # Calculate heading dimensions if present
191
+ heading_height = 0
192
+ heading_width = 0
193
+ heading_bg_padding = 22
194
+ heading_bg_radius = 28
195
 
196
+ if heading:
197
+ heading_bbox = draw.textbbox((0, 0), heading, font=heading_font)
198
+ heading_width = heading_bbox[2] - heading_bbox[0]
199
+ heading_height = heading_bbox[3] - heading_bbox[1]
200
+
201
+ if heading_background and heading_background.get('enabled', True):
202
+ heading_bg_padding = heading_background.get('padding', 22)
203
+ heading_bg_radius = heading_background.get('corner_radius', 28)
204
+
205
+ # Calculate total content height
206
+ gap_between = 40 if heading else 0 # Gap between heading and text
207
+ total_height = text_height + (heading_height + heading_bg_padding * 2 + gap_between if heading else 0)
208
+
209
+ # Center everything vertically in the middle of the image
210
+ start_y = (self.TARGET_HEIGHT - total_height) // 2
211
+
212
+ current_y = start_y
213
+
214
+ # Draw heading with background
215
+ if heading:
216
+ heading_x = (self.TARGET_WIDTH - heading_width) // 2
217
+
218
+ # Draw background if enabled
219
+ if heading_background and heading_background.get('enabled', True):
220
+ bg_color = self._parse_rgba(heading_background.get('color', 'rgba(0, 0, 0, 0.45)'))
221
+
222
+ bg_x1 = heading_x - heading_bg_padding
223
+ bg_y1 = current_y - heading_bg_padding // 2
224
+ bg_x2 = heading_x + heading_width + heading_bg_padding
225
+ bg_y2 = current_y + heading_height + heading_bg_padding // 2
226
+
227
+ self._draw_rounded_rect(
228
+ draw,
229
+ (bg_x1, bg_y1, bg_x2, bg_y2),
230
+ heading_bg_radius,
231
+ bg_color
232
+ )
233
+
234
+ # Draw heading text (white)
235
+ draw.text(
236
+ (heading_x, current_y),
237
+ heading,
238
+ font=heading_font,
239
+ fill=(255, 255, 255, 255)
240
+ )
241
+
242
+ current_y += heading_height + heading_bg_padding + gap_between
243
+
244
+ # Draw fact text with shadow/outline
245
+ text_x = (self.TARGET_WIDTH - text_width) // 2
246
+
247
+ # Draw outline (multiple directions for thickness)
248
+ for dx in range(-outline_width, outline_width + 1):
249
+ for dy in range(-outline_width, outline_width + 1):
250
+ if dx != 0 or dy != 0:
251
+ draw.multiline_text(
252
+ (text_x + dx, current_y + dy),
253
+ wrapped_text,
254
+ font=text_font,
255
+ fill=(0, 0, 0, 200),
256
+ align='center'
257
+ )
258
 
259
  # Draw shadow
260
  draw.multiline_text(
261
+ (text_x + shadow_offset, current_y + shadow_offset),
262
  wrapped_text,
263
+ font=text_font,
264
+ fill=(0, 0, 0, 150),
265
  align='center'
266
  )
267
 
268
  # Draw main text
269
  draw.multiline_text(
270
+ (text_x, current_y),
271
  wrapped_text,
272
+ font=text_font,
273
+ fill=(255, 255, 255, 255),
274
  align='center'
275
  )
276
 
277
+ # Composite overlay onto image
278
+ img = Image.alpha_composite(img, overlay)
279
+
280
  # Save output
281
  img = img.convert('RGB')
282
  img.save(output_path, 'PNG', quality=95)
283
 
284
  logger.info(f"Text overlay saved: {output_path}")
285
  return output_path
286
+
static/index.html CHANGED
@@ -409,6 +409,34 @@
409
  without text.</small>
410
  </div>
411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  <div class="form-group">
413
  <label>Fact Text *</label>
414
  <textarea id="factText" rows="3"
@@ -518,6 +546,12 @@
518
  `;
519
  });
520
 
 
 
 
 
 
 
521
  // Fact Image Form
522
  document.getElementById('factForm').addEventListener('submit', async (e) => {
523
  e.preventDefault();
@@ -526,6 +560,8 @@
526
  status.innerHTML = '⏳ Generating fact video...';
527
  status.classList.remove('hidden');
528
 
 
 
529
  const data = {
530
  model: document.getElementById('factModel').value,
531
  image_prompt: document.getElementById('factImagePrompt').value,
@@ -533,6 +569,17 @@
533
  duration: parseInt(document.getElementById('factDuration').value)
534
  };
535
 
 
 
 
 
 
 
 
 
 
 
 
536
  try {
537
  const res = await fetch('/api/fact-image/', {
538
  method: 'POST',
 
409
  without text.</small>
410
  </div>
411
 
412
+ <div class="form-group">
413
+ <label>Fact Heading (Optional)</label>
414
+ <input type="text" id="factHeading" placeholder="e.g., Psychological Hack" maxlength="50">
415
+ <small style="color: var(--text-secondary);">Bold heading with background - appears above fact
416
+ text.</small>
417
+ </div>
418
+
419
+ <div class="form-row" id="headingStyleRow" style="display: none;">
420
+ <div class="form-group">
421
+ <label>Heading BG Color</label>
422
+ <select id="headingBgColor">
423
+ <option value="rgba(0, 0, 0, 0.45)">Black (45%)</option>
424
+ <option value="rgba(0, 0, 0, 0.6)">Black (60%)</option>
425
+ <option value="rgba(99, 102, 241, 0.7)">Purple</option>
426
+ <option value="rgba(236, 72, 153, 0.7)">Pink</option>
427
+ <option value="rgba(34, 197, 94, 0.7)">Green</option>
428
+ </select>
429
+ </div>
430
+ <div class="form-group">
431
+ <label>Corner Radius</label>
432
+ <select id="headingBgRadius">
433
+ <option value="15">Small (15px)</option>
434
+ <option value="28" selected>Medium (28px)</option>
435
+ <option value="40">Large (40px)</option>
436
+ </select>
437
+ </div>
438
+ </div>
439
+
440
  <div class="form-group">
441
  <label>Fact Text *</label>
442
  <textarea id="factText" rows="3"
 
546
  `;
547
  });
548
 
549
+ // Toggle heading style row when heading is entered
550
+ document.getElementById('factHeading').addEventListener('input', (e) => {
551
+ const styleRow = document.getElementById('headingStyleRow');
552
+ styleRow.style.display = e.target.value.trim() ? 'grid' : 'none';
553
+ });
554
+
555
  // Fact Image Form
556
  document.getElementById('factForm').addEventListener('submit', async (e) => {
557
  e.preventDefault();
 
560
  status.innerHTML = '⏳ Generating fact video...';
561
  status.classList.remove('hidden');
562
 
563
+ const heading = document.getElementById('factHeading').value.trim();
564
+
565
  const data = {
566
  model: document.getElementById('factModel').value,
567
  image_prompt: document.getElementById('factImagePrompt').value,
 
569
  duration: parseInt(document.getElementById('factDuration').value)
570
  };
571
 
572
+ // Add heading if provided
573
+ if (heading) {
574
+ data.fact_heading = heading;
575
+ data.heading_background = {
576
+ enabled: true,
577
+ color: document.getElementById('headingBgColor').value,
578
+ padding: 22,
579
+ corner_radius: parseInt(document.getElementById('headingBgRadius').value)
580
+ };
581
+ }
582
+
583
  try {
584
  const res = await fetch('/api/fact-image/', {
585
  method: 'POST',