Commit ·
ee36c8e
1
Parent(s): 800faa1
feat: Major optimizations - Quiz dynamic fonts, TTS 1.2x speed, Video stream copy (10x faster), Single API call, Fact Image dynamic fonts, Text Story position fix
Browse files- modules/fact_image/services/text_overlay.py +65 -13
- modules/quiz_reel/router.py +2 -2
- modules/quiz_reel/services/quiz_frame.py +132 -17
- modules/text_story/router.py +1 -1
- modules/text_story/services/renderer.py +1 -1
- modules/video_creator/services/libraries/ffmpeg_utils.py +5 -4
- modules/video_creator/services/libraries/tts_client.py +5 -3
- modules/video_creator/services/short_creator.py +16 -35
modules/fact_image/services/text_overlay.py
CHANGED
|
@@ -77,23 +77,33 @@ class TextOverlay:
|
|
| 77 |
|
| 78 |
return self._font_cache[cache_key]
|
| 79 |
|
| 80 |
-
def _wrap_text(self, text: str, max_words_per_line: int = 4) -> str:
|
| 81 |
"""
|
| 82 |
Wrap text for optimal display.
|
| 83 |
|
| 84 |
Rules:
|
| 85 |
-
- Max
|
|
|
|
| 86 |
- Natural line breaks
|
| 87 |
"""
|
| 88 |
words = text.split()
|
| 89 |
lines = []
|
| 90 |
current_line = []
|
|
|
|
| 91 |
|
| 92 |
for word in words:
|
| 93 |
-
|
| 94 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
lines.append(' '.join(current_line))
|
| 96 |
-
current_line = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
if current_line:
|
| 99 |
lines.append(' '.join(current_line))
|
|
@@ -177,17 +187,59 @@ class TextOverlay:
|
|
| 177 |
overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
|
| 178 |
draw = ImageDraw.Draw(overlay)
|
| 179 |
|
| 180 |
-
#
|
| 181 |
-
heading_font = self._get_font(self.HEADING_FONT_SIZE, bold=True)
|
| 182 |
-
text_font = self._get_font(self.TEXT_FONT_SIZE, bold=True) # Fact text also bold
|
| 183 |
-
|
| 184 |
-
# Calculate max width
|
| 185 |
max_width = self.TARGET_WIDTH - (2 * self.PADDING_X)
|
| 186 |
|
| 187 |
-
#
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
-
# Calculate text dimensions
|
| 191 |
text_bbox = draw.multiline_textbbox((0, 0), wrapped_text, font=text_font)
|
| 192 |
text_width = text_bbox[2] - text_bbox[0]
|
| 193 |
text_height = text_bbox[3] - text_bbox[1]
|
|
|
|
| 77 |
|
| 78 |
return self._font_cache[cache_key]
|
| 79 |
|
| 80 |
+
def _wrap_text(self, text: str, max_words_per_line: int = 4, max_chars_per_line: int = 25) -> str:
|
| 81 |
"""
|
| 82 |
Wrap text for optimal display.
|
| 83 |
|
| 84 |
Rules:
|
| 85 |
+
- Max characters per line to prevent overflow
|
| 86 |
+
- Max words per line for readability
|
| 87 |
- Natural line breaks
|
| 88 |
"""
|
| 89 |
words = text.split()
|
| 90 |
lines = []
|
| 91 |
current_line = []
|
| 92 |
+
current_char_count = 0
|
| 93 |
|
| 94 |
for word in words:
|
| 95 |
+
word_len = len(word)
|
| 96 |
+
# Check if adding this word would exceed limits
|
| 97 |
+
new_char_count = current_char_count + word_len + (1 if current_line else 0)
|
| 98 |
+
|
| 99 |
+
if (len(current_line) >= max_words_per_line or
|
| 100 |
+
new_char_count > max_chars_per_line) and current_line:
|
| 101 |
lines.append(' '.join(current_line))
|
| 102 |
+
current_line = [word]
|
| 103 |
+
current_char_count = word_len
|
| 104 |
+
else:
|
| 105 |
+
current_line.append(word)
|
| 106 |
+
current_char_count = new_char_count
|
| 107 |
|
| 108 |
if current_line:
|
| 109 |
lines.append(' '.join(current_line))
|
|
|
|
| 187 |
overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
|
| 188 |
draw = ImageDraw.Draw(overlay)
|
| 189 |
|
| 190 |
+
# Calculate max width (with padding on sides)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
max_width = self.TARGET_WIDTH - (2 * self.PADDING_X)
|
| 192 |
|
| 193 |
+
# Dynamic font size for HEADING based on text length
|
| 194 |
+
heading_text_length = len(heading) if heading else 0
|
| 195 |
+
if heading_text_length <= 15:
|
| 196 |
+
heading_font_size = 80 # Large for short heading
|
| 197 |
+
elif heading_text_length <= 25:
|
| 198 |
+
heading_font_size = 70
|
| 199 |
+
elif heading_text_length <= 35:
|
| 200 |
+
heading_font_size = 60
|
| 201 |
+
elif heading_text_length <= 50:
|
| 202 |
+
heading_font_size = 50
|
| 203 |
+
else:
|
| 204 |
+
heading_font_size = 42 # Minimum for very long heading
|
| 205 |
+
|
| 206 |
+
# Dynamic font size for FACT TEXT based on text length
|
| 207 |
+
text_length = len(text)
|
| 208 |
+
if text_length <= 50:
|
| 209 |
+
text_font_size = 60 # Large for short text
|
| 210 |
+
elif text_length <= 100:
|
| 211 |
+
text_font_size = 52
|
| 212 |
+
elif text_length <= 150:
|
| 213 |
+
text_font_size = 46
|
| 214 |
+
elif text_length <= 200:
|
| 215 |
+
text_font_size = 40
|
| 216 |
+
elif text_length <= 300:
|
| 217 |
+
text_font_size = 36
|
| 218 |
+
else:
|
| 219 |
+
text_font_size = 32 # Minimum for very long text
|
| 220 |
+
|
| 221 |
+
# Get fonts with dynamic sizes (both bold)
|
| 222 |
+
heading_font = self._get_font(heading_font_size, bold=True)
|
| 223 |
+
text_font = self._get_font(text_font_size, bold=True)
|
| 224 |
+
|
| 225 |
+
# Word wrap text with dynamic font to fit max_width
|
| 226 |
+
words = text.split()
|
| 227 |
+
lines = []
|
| 228 |
+
current_line = ""
|
| 229 |
+
for word in words:
|
| 230 |
+
test_line = current_line + " " + word if current_line else word
|
| 231 |
+
bbox = draw.textbbox((0, 0), test_line, font=text_font)
|
| 232 |
+
if bbox[2] - bbox[0] <= max_width:
|
| 233 |
+
current_line = test_line
|
| 234 |
+
else:
|
| 235 |
+
if current_line:
|
| 236 |
+
lines.append(current_line)
|
| 237 |
+
current_line = word
|
| 238 |
+
if current_line:
|
| 239 |
+
lines.append(current_line)
|
| 240 |
+
wrapped_text = "\n".join(lines)
|
| 241 |
|
| 242 |
+
# Calculate text dimensions with wrapped text
|
| 243 |
text_bbox = draw.multiline_textbbox((0, 0), wrapped_text, font=text_font)
|
| 244 |
text_width = text_bbox[2] - text_bbox[0]
|
| 245 |
text_height = text_bbox[3] - text_bbox[1]
|
modules/quiz_reel/router.py
CHANGED
|
@@ -79,7 +79,7 @@ async def generate_quiz_video(job_id: str, quizzes: list, voice: str):
|
|
| 79 |
question_audio_path = os.path.join(temp_dir, f"question_{i}.wav")
|
| 80 |
|
| 81 |
logger.info(f"Generating TTS for question {i+1}: {question_text[:30]}...")
|
| 82 |
-
audio_bytes, duration = await tts_client.generate(text=question_text, voice=voice)
|
| 83 |
with open(question_audio_path, "wb") as f:
|
| 84 |
f.write(audio_bytes)
|
| 85 |
|
|
@@ -90,7 +90,7 @@ async def generate_quiz_video(job_id: str, quizzes: list, voice: str):
|
|
| 90 |
answer_audio_path = os.path.join(temp_dir, f"answer_{i}.wav")
|
| 91 |
|
| 92 |
logger.info(f"Generating TTS for answer {i+1}: {answer_text[:30]}...")
|
| 93 |
-
audio_bytes, duration = await tts_client.generate(text=answer_text, voice=voice)
|
| 94 |
with open(answer_audio_path, "wb") as f:
|
| 95 |
f.write(audio_bytes)
|
| 96 |
|
|
|
|
| 79 |
question_audio_path = os.path.join(temp_dir, f"question_{i}.wav")
|
| 80 |
|
| 81 |
logger.info(f"Generating TTS for question {i+1}: {question_text[:30]}...")
|
| 82 |
+
audio_bytes, duration = await tts_client.generate(text=question_text, voice=voice, speed=1.2)
|
| 83 |
with open(question_audio_path, "wb") as f:
|
| 84 |
f.write(audio_bytes)
|
| 85 |
|
|
|
|
| 90 |
answer_audio_path = os.path.join(temp_dir, f"answer_{i}.wav")
|
| 91 |
|
| 92 |
logger.info(f"Generating TTS for answer {i+1}: {answer_text[:30]}...")
|
| 93 |
+
audio_bytes, duration = await tts_client.generate(text=answer_text, voice=voice, speed=1.2)
|
| 94 |
with open(answer_audio_path, "wb") as f:
|
| 95 |
f.write(audio_bytes)
|
| 96 |
|
modules/quiz_reel/services/quiz_frame.py
CHANGED
|
@@ -182,13 +182,37 @@ class QuizFrameGenerator:
|
|
| 182 |
img = self._create_gradient_background()
|
| 183 |
draw = ImageDraw.Draw(img)
|
| 184 |
|
| 185 |
-
# Hook text with background box
|
| 186 |
if hook:
|
| 187 |
hook_text = hook.upper()
|
| 188 |
hook_y = int(self.HEIGHT * self.HOOK_Y)
|
| 189 |
|
| 190 |
-
# Calculate
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
text_width = bbox[2] - bbox[0]
|
| 193 |
text_height = bbox[3] - bbox[1]
|
| 194 |
|
|
@@ -204,9 +228,11 @@ class QuizFrameGenerator:
|
|
| 204 |
self._draw_rounded_rect(draw, (box_x1, box_y1, box_x2, box_y2),
|
| 205 |
self.HOOK_BG_COLOR, radius=25)
|
| 206 |
|
| 207 |
-
# Draw hook text centered in box
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
| 210 |
|
| 211 |
# Question card
|
| 212 |
card_x1 = int(self.WIDTH * 0.05)
|
|
@@ -277,9 +303,10 @@ class QuizFrameGenerator:
|
|
| 277 |
x = (self.WIDTH - text_width) // 2
|
| 278 |
draw.text((x, text_start_y + i * line_height), line, fill=self.QUESTION_TEXT, font=question_font)
|
| 279 |
|
| 280 |
-
# Options
|
| 281 |
option_width = int(self.WIDTH * 0.85)
|
| 282 |
option_x = (self.WIDTH - option_width) // 2
|
|
|
|
| 283 |
|
| 284 |
for i, (key, value) in enumerate(options.items()):
|
| 285 |
y_pos = int(self.HEIGHT * (self.OPTIONS_START + i * self.OPTIONS_GAP))
|
|
@@ -289,12 +316,37 @@ class QuizFrameGenerator:
|
|
| 289 |
(option_x, y_pos, option_x + option_width, y_pos + self.OPTION_HEIGHT),
|
| 290 |
self.OPTION_DEFAULT_BG, radius=50)
|
| 291 |
|
| 292 |
-
# Option text
|
| 293 |
option_text = f"{key}. {value}"
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
text_height = bbox[3] - bbox[1]
|
| 296 |
text_y = y_pos + (self.OPTION_HEIGHT - text_height) // 2
|
| 297 |
-
draw.text((option_x + 30, text_y), option_text, fill=self.OPTION_TEXT, font=
|
| 298 |
|
| 299 |
# Bottom right removed (was sparkle icon)
|
| 300 |
|
|
@@ -492,7 +544,7 @@ class QuizFrameGenerator:
|
|
| 492 |
checkmark_y = y_pos + self.OPTION_HEIGHT // 2
|
| 493 |
self._draw_checkmark(draw, (checkmark_x, checkmark_y))
|
| 494 |
|
| 495 |
-
# Explain section with heading
|
| 496 |
if explain:
|
| 497 |
# Options end at approximately 67% (OPTIONS_START + 3*OPTIONS_GAP + OPTION_HEIGHT)
|
| 498 |
# Explain text at EXPLAIN_Y (72%)
|
|
@@ -502,7 +554,6 @@ class QuizFrameGenerator:
|
|
| 502 |
heading_y = (options_end_y + explain_text_y) // 2 # Midpoint
|
| 503 |
|
| 504 |
# "Explanation" heading (bold, bigger font, different color - cyan/teal)
|
| 505 |
-
heading_color = (56, 189, 248) # Sky blue #38BDF8
|
| 506 |
heading_text = "Explanation"
|
| 507 |
|
| 508 |
# Load 52px font for heading
|
|
@@ -516,12 +567,76 @@ class QuizFrameGenerator:
|
|
| 516 |
except:
|
| 517 |
pass
|
| 518 |
|
| 519 |
-
#
|
| 520 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
|
| 522 |
-
#
|
| 523 |
-
|
| 524 |
-
|
|
|
|
|
|
|
| 525 |
|
| 526 |
# Bottom right removed (was sparkle icon)
|
| 527 |
|
|
|
|
| 182 |
img = self._create_gradient_background()
|
| 183 |
draw = ImageDraw.Draw(img)
|
| 184 |
|
| 185 |
+
# Hook text with background box and dynamic font sizing
|
| 186 |
if hook:
|
| 187 |
hook_text = hook.upper()
|
| 188 |
hook_y = int(self.HEIGHT * self.HOOK_Y)
|
| 189 |
|
| 190 |
+
# Calculate dynamic font size based on text length
|
| 191 |
+
text_length = len(hook_text)
|
| 192 |
+
if text_length <= 10:
|
| 193 |
+
hook_font_size = 72 # Default large
|
| 194 |
+
elif text_length <= 15:
|
| 195 |
+
hook_font_size = 60
|
| 196 |
+
elif text_length <= 20:
|
| 197 |
+
hook_font_size = 50
|
| 198 |
+
elif text_length <= 30:
|
| 199 |
+
hook_font_size = 40
|
| 200 |
+
else:
|
| 201 |
+
hook_font_size = 32 # Minimum size
|
| 202 |
+
|
| 203 |
+
# Load hook font with calculated size
|
| 204 |
+
hook_font = self.font_hook
|
| 205 |
+
for path in ["C:/Windows/Fonts/arial.ttf", "C:/Windows/Fonts/ArialBD.ttf",
|
| 206 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"]:
|
| 207 |
+
if os.path.exists(path):
|
| 208 |
+
try:
|
| 209 |
+
hook_font = ImageFont.truetype(path, hook_font_size)
|
| 210 |
+
break
|
| 211 |
+
except:
|
| 212 |
+
pass
|
| 213 |
+
|
| 214 |
+
# Calculate hook text dimensions with dynamic font
|
| 215 |
+
bbox = draw.textbbox((0, 0), hook_text, font=hook_font)
|
| 216 |
text_width = bbox[2] - bbox[0]
|
| 217 |
text_height = bbox[3] - bbox[1]
|
| 218 |
|
|
|
|
| 228 |
self._draw_rounded_rect(draw, (box_x1, box_y1, box_x2, box_y2),
|
| 229 |
self.HOOK_BG_COLOR, radius=25)
|
| 230 |
|
| 231 |
+
# Draw hook text centered in box with dynamic font
|
| 232 |
+
bbox = draw.textbbox((0, 0), hook_text, font=hook_font)
|
| 233 |
+
text_width = bbox[2] - bbox[0]
|
| 234 |
+
x = (self.WIDTH - text_width) // 2
|
| 235 |
+
draw.text((x, hook_y), hook_text, fill=self.HOOK_COLOR, font=hook_font)
|
| 236 |
|
| 237 |
# Question card
|
| 238 |
card_x1 = int(self.WIDTH * 0.05)
|
|
|
|
| 303 |
x = (self.WIDTH - text_width) // 2
|
| 304 |
draw.text((x, text_start_y + i * line_height), line, fill=self.QUESTION_TEXT, font=question_font)
|
| 305 |
|
| 306 |
+
# Options - with dynamic font sizing
|
| 307 |
option_width = int(self.WIDTH * 0.85)
|
| 308 |
option_x = (self.WIDTH - option_width) // 2
|
| 309 |
+
max_option_text_width = option_width - 60 # Leave padding
|
| 310 |
|
| 311 |
for i, (key, value) in enumerate(options.items()):
|
| 312 |
y_pos = int(self.HEIGHT * (self.OPTIONS_START + i * self.OPTIONS_GAP))
|
|
|
|
| 316 |
(option_x, y_pos, option_x + option_width, y_pos + self.OPTION_HEIGHT),
|
| 317 |
self.OPTION_DEFAULT_BG, radius=50)
|
| 318 |
|
| 319 |
+
# Option text with dynamic font size (like question)
|
| 320 |
option_text = f"{key}. {value}"
|
| 321 |
+
text_length = len(option_text)
|
| 322 |
+
|
| 323 |
+
# Calculate optimal font size based on text length
|
| 324 |
+
if text_length <= 20:
|
| 325 |
+
font_size = 36 # Default size
|
| 326 |
+
elif text_length <= 35:
|
| 327 |
+
font_size = 32
|
| 328 |
+
elif text_length <= 50:
|
| 329 |
+
font_size = 28
|
| 330 |
+
elif text_length <= 70:
|
| 331 |
+
font_size = 24
|
| 332 |
+
else:
|
| 333 |
+
font_size = 20 # Minimum size
|
| 334 |
+
|
| 335 |
+
# Load font with calculated size
|
| 336 |
+
option_font = self.font_option
|
| 337 |
+
for path in ["C:/Windows/Fonts/arial.ttf", "C:/Windows/Fonts/ArialBD.ttf",
|
| 338 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"]:
|
| 339 |
+
if os.path.exists(path):
|
| 340 |
+
try:
|
| 341 |
+
option_font = ImageFont.truetype(path, font_size)
|
| 342 |
+
break
|
| 343 |
+
except:
|
| 344 |
+
pass
|
| 345 |
+
|
| 346 |
+
bbox = draw.textbbox((0, 0), option_text, font=option_font)
|
| 347 |
text_height = bbox[3] - bbox[1]
|
| 348 |
text_y = y_pos + (self.OPTION_HEIGHT - text_height) // 2
|
| 349 |
+
draw.text((option_x + 30, text_y), option_text, fill=self.OPTION_TEXT, font=option_font)
|
| 350 |
|
| 351 |
# Bottom right removed (was sparkle icon)
|
| 352 |
|
|
|
|
| 544 |
checkmark_y = y_pos + self.OPTION_HEIGHT // 2
|
| 545 |
self._draw_checkmark(draw, (checkmark_x, checkmark_y))
|
| 546 |
|
| 547 |
+
# Explain section with heading and white background box
|
| 548 |
if explain:
|
| 549 |
# Options end at approximately 67% (OPTIONS_START + 3*OPTIONS_GAP + OPTION_HEIGHT)
|
| 550 |
# Explain text at EXPLAIN_Y (72%)
|
|
|
|
| 554 |
heading_y = (options_end_y + explain_text_y) // 2 # Midpoint
|
| 555 |
|
| 556 |
# "Explanation" heading (bold, bigger font, different color - cyan/teal)
|
|
|
|
| 557 |
heading_text = "Explanation"
|
| 558 |
|
| 559 |
# Load 52px font for heading
|
|
|
|
| 567 |
except:
|
| 568 |
pass
|
| 569 |
|
| 570 |
+
# Dynamic font size for explain text based on length
|
| 571 |
+
explain_length = len(explain)
|
| 572 |
+
if explain_length <= 30:
|
| 573 |
+
explain_font_size = 44 # Large for short text
|
| 574 |
+
elif explain_length <= 60:
|
| 575 |
+
explain_font_size = 38
|
| 576 |
+
elif explain_length <= 100:
|
| 577 |
+
explain_font_size = 32
|
| 578 |
+
elif explain_length <= 150:
|
| 579 |
+
explain_font_size = 28
|
| 580 |
+
else:
|
| 581 |
+
explain_font_size = 24 # Minimum for very long text
|
| 582 |
+
|
| 583 |
+
# Load explain font with calculated size
|
| 584 |
+
explain_font = self.font_explain
|
| 585 |
+
for path in ["C:/Windows/Fonts/arial.ttf", "C:/Windows/Fonts/ArialBD.ttf",
|
| 586 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"]:
|
| 587 |
+
if os.path.exists(path):
|
| 588 |
+
try:
|
| 589 |
+
explain_font = ImageFont.truetype(path, explain_font_size)
|
| 590 |
+
break
|
| 591 |
+
except:
|
| 592 |
+
pass
|
| 593 |
+
|
| 594 |
+
# Word wrap explain text to fit in box
|
| 595 |
+
max_explain_width = int(self.WIDTH * 0.8)
|
| 596 |
+
words = explain.split()
|
| 597 |
+
lines = []
|
| 598 |
+
current_line = ""
|
| 599 |
+
for word in words:
|
| 600 |
+
test_line = current_line + " " + word if current_line else word
|
| 601 |
+
bbox = draw.textbbox((0, 0), test_line, font=explain_font)
|
| 602 |
+
if bbox[2] - bbox[0] <= max_explain_width:
|
| 603 |
+
current_line = test_line
|
| 604 |
+
else:
|
| 605 |
+
if current_line:
|
| 606 |
+
lines.append(current_line)
|
| 607 |
+
current_line = word
|
| 608 |
+
if current_line:
|
| 609 |
+
lines.append(current_line)
|
| 610 |
+
|
| 611 |
+
wrapped_explain = "\n".join(lines)
|
| 612 |
+
|
| 613 |
+
# Calculate wrapped text dimensions
|
| 614 |
+
explain_bbox = draw.multiline_textbbox((0, 0), wrapped_explain, font=explain_font)
|
| 615 |
+
explain_width = explain_bbox[2] - explain_bbox[0]
|
| 616 |
+
explain_height = explain_bbox[3] - explain_bbox[1]
|
| 617 |
+
|
| 618 |
+
# Fixed box size (relative to screen width)
|
| 619 |
+
box_width = int(self.WIDTH * 0.9)
|
| 620 |
+
box_padding_x = 40
|
| 621 |
+
box_padding_y = 25
|
| 622 |
+
box_x1 = (self.WIDTH - box_width) // 2
|
| 623 |
+
box_x2 = box_x1 + box_width
|
| 624 |
+
box_y1 = heading_y - 20 # Above heading
|
| 625 |
+
box_y2 = explain_text_y + 30 + explain_height + box_padding_y + 20 # Below explain text
|
| 626 |
+
|
| 627 |
+
# Draw white rounded rectangle background
|
| 628 |
+
self._draw_rounded_rect(draw, (box_x1, box_y1, box_x2, box_y2),
|
| 629 |
+
(255, 255, 255), radius=20)
|
| 630 |
+
|
| 631 |
+
# Draw heading centered (now on white bg, use darker color)
|
| 632 |
+
heading_color_on_white = (30, 100, 150) # Darker blue for white bg
|
| 633 |
+
self._draw_text_centered(draw, heading_text, heading_y, heading_font, heading_color_on_white)
|
| 634 |
|
| 635 |
+
# Draw wrapped explain text centered (use dark text on white bg)
|
| 636 |
+
explain_text_color = (50, 50, 50) # Dark gray for visibility
|
| 637 |
+
text_x = (self.WIDTH - explain_width) // 2
|
| 638 |
+
draw.multiline_text((text_x, explain_text_y + 30), wrapped_explain,
|
| 639 |
+
fill=explain_text_color, font=explain_font, align="center")
|
| 640 |
|
| 641 |
# Bottom right removed (was sparkle icon)
|
| 642 |
|
modules/text_story/router.py
CHANGED
|
@@ -310,7 +310,7 @@ OUTPUT FORMAT (strict JSON):
|
|
| 310 |
{{"sender": "A", "text": "What's up?"}},
|
| 311 |
...
|
| 312 |
],
|
| 313 |
-
"ending_text": "
|
| 314 |
}}
|
| 315 |
|
| 316 |
Only output valid JSON, nothing else."""
|
|
|
|
| 310 |
{{"sender": "A", "text": "What's up?"}},
|
| 311 |
...
|
| 312 |
],
|
| 313 |
+
"ending_text": ""
|
| 314 |
}}
|
| 315 |
|
| 316 |
Only output valid JSON, nothing else."""
|
modules/text_story/services/renderer.py
CHANGED
|
@@ -26,7 +26,7 @@ CANVAS_HEIGHT = 1920
|
|
| 26 |
# - Small avatar next to other person's messages
|
| 27 |
|
| 28 |
LAYOUT = {
|
| 29 |
-
"top_margin":
|
| 30 |
"side_margin": 50, # More side margin (left/right)
|
| 31 |
"chat_box_width": 980, # Narrower card (1080 - 50*2)
|
| 32 |
"chat_box_radius": 35, # Rounded corners
|
|
|
|
| 26 |
# - Small avatar next to other person's messages
|
| 27 |
|
| 28 |
LAYOUT = {
|
| 29 |
+
"top_margin": 500, # 26% from top (moved up 8-10% from original 35%)
|
| 30 |
"side_margin": 50, # More side margin (left/right)
|
| 31 |
"chat_box_width": 980, # Narrower card (1080 - 50*2)
|
| 32 |
"chat_box_radius": 35, # Rounded corners
|
modules/video_creator/services/libraries/ffmpeg_utils.py
CHANGED
|
@@ -135,6 +135,8 @@ class FFmpegUtils:
|
|
| 135 |
def cut_video(input_path: Path, output_path: Path, start_time: float, duration: float):
|
| 136 |
"""
|
| 137 |
Cut a segment from a video file using FFmpeg.
|
|
|
|
|
|
|
| 138 |
|
| 139 |
Args:
|
| 140 |
input_path: Source video
|
|
@@ -145,12 +147,11 @@ class FFmpegUtils:
|
|
| 145 |
try:
|
| 146 |
cmd = [
|
| 147 |
"ffmpeg",
|
| 148 |
-
"-ss", str(start_time),
|
| 149 |
"-i", str(input_path),
|
| 150 |
"-t", str(duration),
|
| 151 |
-
"-c:v", "
|
| 152 |
-
"-
|
| 153 |
-
"-c:a", "aac",
|
| 154 |
"-y",
|
| 155 |
str(output_path)
|
| 156 |
]
|
|
|
|
| 135 |
def cut_video(input_path: Path, output_path: Path, start_time: float, duration: float):
|
| 136 |
"""
|
| 137 |
Cut a segment from a video file using FFmpeg.
|
| 138 |
+
Uses stream copy for 10x faster cutting (no re-encoding).
|
| 139 |
+
Audio is removed since TTS is used separately.
|
| 140 |
|
| 141 |
Args:
|
| 142 |
input_path: Source video
|
|
|
|
| 147 |
try:
|
| 148 |
cmd = [
|
| 149 |
"ffmpeg",
|
| 150 |
+
"-ss", str(start_time), # Seek to start (before -i for fast seeking)
|
| 151 |
"-i", str(input_path),
|
| 152 |
"-t", str(duration),
|
| 153 |
+
"-c:v", "copy", # Stream copy - no re-encode (10x faster!)
|
| 154 |
+
"-an", # Remove audio (TTS is used)
|
|
|
|
| 155 |
"-y",
|
| 156 |
str(output_path)
|
| 157 |
]
|
modules/video_creator/services/libraries/tts_client.py
CHANGED
|
@@ -19,20 +19,21 @@ class TTSClient:
|
|
| 19 |
self.api_url = api_url.rstrip('/')
|
| 20 |
logger.info(f"Using cloud TTS API at {self.api_url}")
|
| 21 |
|
| 22 |
-
async def generate(self, text: str, voice: str) -> Tuple[bytes, float]:
|
| 23 |
"""
|
| 24 |
Generate speech from text
|
| 25 |
|
| 26 |
Args:
|
| 27 |
text: Text to convert to speech
|
| 28 |
voice: Voice identifier (e.g., 'af_heart', 'am_adam')
|
|
|
|
| 29 |
|
| 30 |
Returns:
|
| 31 |
Tuple of (audio_bytes, duration_seconds)
|
| 32 |
"""
|
| 33 |
endpoint = f"{self.api_url}/v1/audio/speech"
|
| 34 |
|
| 35 |
-
logger.debug(f"Generating audio with voice={voice}, text_length={len(text)}")
|
| 36 |
|
| 37 |
async with aiohttp.ClientSession() as session:
|
| 38 |
async with session.post(
|
|
@@ -40,7 +41,8 @@ class TTSClient:
|
|
| 40 |
json={
|
| 41 |
"model": "kokoro",
|
| 42 |
"input": text,
|
| 43 |
-
"voice": voice
|
|
|
|
| 44 |
},
|
| 45 |
headers={"Content-Type": "application/json"},
|
| 46 |
timeout=aiohttp.ClientTimeout(total=120)
|
|
|
|
| 19 |
self.api_url = api_url.rstrip('/')
|
| 20 |
logger.info(f"Using cloud TTS API at {self.api_url}")
|
| 21 |
|
| 22 |
+
async def generate(self, text: str, voice: str, speed: float = 1.0) -> Tuple[bytes, float]:
|
| 23 |
"""
|
| 24 |
Generate speech from text
|
| 25 |
|
| 26 |
Args:
|
| 27 |
text: Text to convert to speech
|
| 28 |
voice: Voice identifier (e.g., 'af_heart', 'am_adam')
|
| 29 |
+
speed: Speech speed multiplier (0.5-2.0, default 1.0)
|
| 30 |
|
| 31 |
Returns:
|
| 32 |
Tuple of (audio_bytes, duration_seconds)
|
| 33 |
"""
|
| 34 |
endpoint = f"{self.api_url}/v1/audio/speech"
|
| 35 |
|
| 36 |
+
logger.debug(f"Generating audio with voice={voice}, speed={speed}, text_length={len(text)}")
|
| 37 |
|
| 38 |
async with aiohttp.ClientSession() as session:
|
| 39 |
async with session.post(
|
|
|
|
| 41 |
json={
|
| 42 |
"model": "kokoro",
|
| 43 |
"input": text,
|
| 44 |
+
"voice": voice,
|
| 45 |
+
"speed": speed
|
| 46 |
},
|
| 47 |
headers={"Content-Type": "application/json"},
|
| 48 |
timeout=aiohttp.ClientTimeout(total=120)
|
modules/video_creator/services/short_creator.py
CHANGED
|
@@ -197,66 +197,47 @@ class ShortCreator:
|
|
| 197 |
# Ensure keywords is a list for find_video
|
| 198 |
search_keywords = keywords if isinstance(keywords, list) else [keywords]
|
| 199 |
|
| 200 |
-
#
|
| 201 |
-
|
| 202 |
-
pixabay_video = None
|
| 203 |
selected_video = None
|
| 204 |
-
video_source = None
|
| 205 |
|
| 206 |
-
# Try Pexels (if configured)
|
| 207 |
if self.pexels:
|
| 208 |
try:
|
| 209 |
logger.debug(f"Searching Pexels for: {search_keywords}")
|
| 210 |
-
|
| 211 |
search_keywords,
|
| 212 |
search_duration,
|
| 213 |
exclude_video_ids,
|
| 214 |
orientation
|
| 215 |
)
|
|
|
|
|
|
|
|
|
|
| 216 |
except Exception as e:
|
| 217 |
logger.warning(f"Pexels search failed: {e}")
|
| 218 |
|
| 219 |
-
# Try Pixabay
|
| 220 |
-
if self.pixabay:
|
| 221 |
try:
|
| 222 |
-
logger.debug(f"Searching Pixabay for: {search_keywords}")
|
| 223 |
-
|
| 224 |
search_keywords,
|
| 225 |
search_duration,
|
| 226 |
exclude_video_ids,
|
| 227 |
orientation
|
| 228 |
)
|
|
|
|
|
|
|
|
|
|
| 229 |
except Exception as e:
|
| 230 |
logger.warning(f"Pixabay search failed: {e}")
|
| 231 |
|
| 232 |
-
# Smart Selection: Pick best video based on duration match
|
| 233 |
-
if pexels_video and pixabay_video:
|
| 234 |
-
# Both found - pick the one closer to required duration
|
| 235 |
-
pexels_dur_diff = abs(pexels_video.get("duration", 0) - search_duration)
|
| 236 |
-
pixabay_dur_diff = abs(pixabay_video.get("duration", 0) - search_duration)
|
| 237 |
-
|
| 238 |
-
if pexels_dur_diff <= pixabay_dur_diff:
|
| 239 |
-
selected_video = pexels_video
|
| 240 |
-
video_source = "Pexels"
|
| 241 |
-
else:
|
| 242 |
-
selected_video = pixabay_video
|
| 243 |
-
video_source = "Pixabay"
|
| 244 |
-
|
| 245 |
-
logger.info(f"Both APIs found videos - selected {video_source} (better duration match)")
|
| 246 |
-
|
| 247 |
-
elif pexels_video:
|
| 248 |
-
selected_video = pexels_video
|
| 249 |
-
video_source = "Pexels"
|
| 250 |
-
logger.info(f"Found video on Pexels for '{keyword}'")
|
| 251 |
-
|
| 252 |
-
elif pixabay_video:
|
| 253 |
-
selected_video = pixabay_video
|
| 254 |
-
video_source = "Pixabay"
|
| 255 |
-
logger.info(f"Found video on Pixabay for '{keyword}'")
|
| 256 |
-
|
| 257 |
# If no video found on either platform
|
| 258 |
if not selected_video:
|
| 259 |
raise Exception(f"No video found for {search_keywords} on any platform")
|
|
|
|
| 260 |
|
| 261 |
video_path = self.config.temp_dir_path / f"{temp_vid_id}.mp4"
|
| 262 |
temp_files.append(video_path)
|
|
|
|
| 197 |
# Ensure keywords is a list for find_video
|
| 198 |
search_keywords = keywords if isinstance(keywords, list) else [keywords]
|
| 199 |
|
| 200 |
+
# PRIORITY: Try ONE platform only to save API quota
|
| 201 |
+
# Priority: Pexels first, Pixabay as fallback
|
|
|
|
| 202 |
selected_video = None
|
| 203 |
+
video_source = None
|
| 204 |
|
| 205 |
+
# Try Pexels FIRST (if configured)
|
| 206 |
if self.pexels:
|
| 207 |
try:
|
| 208 |
logger.debug(f"Searching Pexels for: {search_keywords}")
|
| 209 |
+
selected_video = self.pexels.find_video(
|
| 210 |
search_keywords,
|
| 211 |
search_duration,
|
| 212 |
exclude_video_ids,
|
| 213 |
orientation
|
| 214 |
)
|
| 215 |
+
if selected_video:
|
| 216 |
+
video_source = "Pexels"
|
| 217 |
+
logger.info(f"Found video on Pexels for '{keyword}'")
|
| 218 |
except Exception as e:
|
| 219 |
logger.warning(f"Pexels search failed: {e}")
|
| 220 |
|
| 221 |
+
# FALLBACK: Try Pixabay ONLY if Pexels didn't find anything
|
| 222 |
+
if not selected_video and self.pixabay:
|
| 223 |
try:
|
| 224 |
+
logger.debug(f"Searching Pixabay (fallback) for: {search_keywords}")
|
| 225 |
+
selected_video = self.pixabay.find_video(
|
| 226 |
search_keywords,
|
| 227 |
search_duration,
|
| 228 |
exclude_video_ids,
|
| 229 |
orientation
|
| 230 |
)
|
| 231 |
+
if selected_video:
|
| 232 |
+
video_source = "Pixabay"
|
| 233 |
+
logger.info(f"Found video on Pixabay for '{keyword}'")
|
| 234 |
except Exception as e:
|
| 235 |
logger.warning(f"Pixabay search failed: {e}")
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
# If no video found on either platform
|
| 238 |
if not selected_video:
|
| 239 |
raise Exception(f"No video found for {search_keywords} on any platform")
|
| 240 |
+
|
| 241 |
|
| 242 |
video_path = self.config.temp_dir_path / f"{temp_vid_id}.mp4"
|
| 243 |
temp_files.append(video_path)
|