Nav3005 commited on
Commit
fa6f8e0
Β·
verified Β·
1 Parent(s): db6ca8c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +401 -469
app.py CHANGED
@@ -18,47 +18,50 @@ from fastapi.responses import FileResponse, JSONResponse
18
  from fastapi.middleware.cors import CORSMiddleware
19
  from pydantic import BaseModel, Field
20
 
21
-
22
  # ========================================
23
  # CONFIGURATION SECTION - CUSTOMIZE HERE
24
  # ========================================
25
 
26
  REDDIT_CONFIG = {
27
- 'template_file': 'reddit_template.png',
28
- 'font_file': 'RFDewi-Bold.ttf',
29
- 'font_size_max': 180,
30
- 'font_size_min': 16,
31
- 'text_wrap_width': 35,
32
- 'text_color': 'black',
33
- 'line_spacing': 10,
34
- 'text_box_width_percent': 0.85,
35
- 'text_box_height_percent': 0.65,
36
- 'y_offset': 20,
37
  }
38
 
39
  SUBTITLE_CONFIG = {
40
- 'font_file': 'LilitaOne-Regular.ttf',
41
- 'font_name': 'Lilita One',
42
- 'font_size_default': 10,
43
- 'position_alignment': 5,
44
- 'margin_left': 50,
45
- 'margin_right': 70,
46
- 'margin_vertical': 20,
47
- 'line_spacing': 2,
48
  }
 
49
 
50
  VIDEO_CONFIG = {
51
- 'reddit_scale_percent': 0.75,
52
- 'fade_start_percent': 0.70,
53
- 'fade_end_percent': 0.85,
54
- 'promo_percent': 0.094,
55
- 'fade_color_rgb': (218, 207, 195),
56
  }
57
 
 
58
  # ========================================
59
  # END CONFIGURATION SECTION
60
  # ========================================
61
 
 
 
62
 
63
  # ============================================
64
  # FINDS BOOK TITLE TO SPLIT CTA AND BODY SCRIPT
@@ -76,18 +79,25 @@ def find_title_and_cta(srt_path, book_title):
76
  if len(lines) >= 3:
77
  subtitle_text = ' '.join(lines[2:])
78
  if book_title_lower in subtitle_text.lower():
 
79
  times = lines[1].split(' --> ')
80
  title_time = srt_time_to_ms(times[0]) / 1000.0
 
81
  cta_time = None
82
  cta_text_parts = []
 
 
83
  if i + 1 < len(blocks):
84
  next_block_lines = blocks[i + 1].strip().split('\n')
85
  if len(next_block_lines) >= 3:
86
  cta_time = srt_time_to_ms(next_block_lines[1].split(' --> ')[0]) / 1000.0
 
 
87
  for j in range(i + 1, len(blocks)):
88
  next_lines = blocks[j].strip().split('\n')
89
  if len(next_lines) >= 3:
90
  cta_text_parts.append(' '.join(next_lines[2:]).strip())
 
91
  cta_text = ' '.join(cta_text_parts) if cta_text_parts else None
92
  return title_time, cta_time, cta_text
93
  return None, None, None
@@ -95,248 +105,197 @@ def find_title_and_cta(srt_path, book_title):
95
  print(f"Error finding title and CTA: {e}")
96
  return None, None, None
97
 
98
-
99
  def setup_custom_fonts_hf(temp_dir):
100
- try:
101
- fonts_dir = os.path.join(temp_dir, 'fonts')
102
- os.makedirs(fonts_dir, exist_ok=True)
103
- script_dir = os.path.dirname(os.path.abspath(__file__))
104
- repo_fonts_dir = os.path.join(script_dir, 'fonts')
105
- fonts_to_copy = []
106
- if os.path.exists(repo_fonts_dir):
107
- for font_file in os.listdir(repo_fonts_dir):
108
- if font_file.endswith(('.ttf', '.otf', '.TTF', '.OTF')):
109
- fonts_to_copy.append(os.path.join(repo_fonts_dir, font_file))
110
- for item in [REDDIT_CONFIG['font_file'], SUBTITLE_CONFIG['font_file']]:
111
- font_path = os.path.join(script_dir, item)
112
- if os.path.exists(font_path) and font_path not in fonts_to_copy:
113
- fonts_to_copy.append(font_path)
114
- for src in fonts_to_copy:
115
- dst = os.path.join(fonts_dir, os.path.basename(src))
116
- shutil.copy(src, dst)
117
- if fonts_to_copy:
118
- fonts_conf = f"""<?xml version="1.0"?>
119
  <fontconfig><dir>{fonts_dir}</dir><cachedir>{temp_dir}/cache</cachedir></fontconfig>"""
120
- conf_path = os.path.join(temp_dir, 'fonts.conf')
121
- with open(conf_path, 'w') as f:
122
- f.write(fonts_conf)
123
- env = os.environ.copy()
124
- env['FONTCONFIG_FILE'] = conf_path
125
- env['FONTCONFIG_PATH'] = temp_dir
126
- return env
127
- return os.environ.copy()
128
- except Exception:
129
- return os.environ.copy()
130
-
131
 
132
  def download_file_from_url(url, output_dir, filename):
133
- try:
134
- response = requests.get(url, stream=True, timeout=30)
135
- response.raise_for_status()
136
- file_path = os.path.join(output_dir, filename)
137
- with open(file_path, 'wb') as f:
138
- for chunk in response.iter_content(chunk_size=8192):
139
- f.write(chunk)
140
- return file_path
141
- except Exception as e:
142
- raise Exception(f"Failed to download file: {str(e)}")
143
-
144
 
145
  def download_book_cover(book_id, output_dir):
146
- try:
147
- image_url = f"https://books.google.com/books/publisher/content/images/frontcover/{book_id}"
148
- response = requests.get(image_url, timeout=30)
149
- response.raise_for_status()
150
- image_path = os.path.join(output_dir, 'book_cover.png')
151
- with open(image_path, 'wb') as f:
152
- f.write(response.content)
153
- Image.open(image_path).verify()
154
- return image_path
155
- except Exception as e:
156
- raise Exception(f"Failed to download book cover: {str(e)}")
157
-
158
 
159
  def decode_base64_image(base64_string, output_dir):
160
- try:
161
- if ',' in base64_string and 'base64' in base64_string:
162
- base64_string = base64_string.split(',', 1)[1]
163
- image_data = base64.b64decode(base64_string.strip())
164
- Image.open(BytesIO(image_data)).verify()
165
- output_path = os.path.join(output_dir, f"book_cover_b64_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png")
166
- Image.open(BytesIO(image_data)).save(output_path, 'PNG')
167
- return output_path
168
- except Exception as e:
169
- raise Exception(f"Base64 decode failed: {str(e)}")
170
-
171
 
172
  def validate_book_cover_input(book_cover_file, book_cover_url, book_cover_base64, book_id, temp_dir):
173
- has_file = book_cover_file is not None
174
- has_url = bool(book_cover_url and book_cover_url.strip())
175
- has_base64 = bool(book_cover_base64 and book_cover_base64.strip())
176
- has_id = bool(book_id and book_id.strip())
177
- methods_count = sum([has_file, has_url, has_base64, has_id])
178
- if methods_count == 0:
179
- return None, None
180
- if methods_count > 1:
181
- return None, "❌ Book Cover: Use only ONE method (file, url, base64, or book_id)"
182
- try:
183
- if has_file:
184
- return str(book_cover_file.name if hasattr(book_cover_file, 'name') else book_cover_file), None
185
- if has_url:
186
- return download_file_from_url(book_cover_url.strip(), temp_dir, f"book_cover_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"), None
187
- if has_base64:
188
- return decode_base64_image(book_cover_base64.strip(), temp_dir), None
189
- if has_id:
190
- return download_book_cover(book_id.strip(), temp_dir), None
191
- except Exception as e:
192
- return None, f"❌ Book cover error: {str(e)}"
193
- return None, None
194
-
195
 
196
  def get_video_info(video_path):
197
- try:
198
- cmd_res = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=s=x:p=0", video_path]
199
- result = subprocess.run(cmd_res, capture_output=True, text=True, check=True)
200
- width, height = result.stdout.strip().split('x')
201
- cmd_fps = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", video_path]
202
- result = subprocess.run(cmd_fps, capture_output=True, text=True, check=True)
203
- fps_str = result.stdout.strip()
204
- fps = float(fps_str.split('/')[0]) / float(fps_str.split('/')[1]) if '/' in fps_str else float(fps_str)
205
- return int(width), int(height), fps
206
- except Exception as e:
207
- raise Exception(f"Failed to get video info: {str(e)}")
208
-
209
 
210
  def get_audio_duration(audio_path):
211
- try:
212
- cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", audio_path]
213
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
214
- return float(result.stdout.strip())
215
- except Exception as e:
216
- raise Exception(f"Failed to get audio duration: {str(e)}")
217
-
218
 
219
  def extract_first_subtitle(srt_path):
220
- try:
221
- with open(srt_path, 'r', encoding='utf-8') as f:
222
- content = f.read()
223
- blocks = re.split(r'\n\s*\n', content.strip())
224
- if not blocks:
225
- return "No subtitle found", 0.0, 3.0
226
- first_block = blocks[0].strip().split('\n')
227
- if len(first_block) >= 3:
228
- times = first_block[1].split(' --> ')
229
- def time_to_sec(t):
230
- h, m, s = t.split(':')
231
- s, ms = s.split(',')
232
- return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
233
- return ' '.join(first_block[2:]).strip(), time_to_sec(times[0].strip()), time_to_sec(times[1].strip())
234
- return "No subtitle found", 0.0, 3.0
235
- except Exception as e:
236
- raise Exception(f"Failed to extract first subtitle: {str(e)}")
237
-
238
 
239
  def create_reddit_card_with_text(template_path, hook_text, output_dir, config=REDDIT_CONFIG):
240
- try:
241
- template = Image.open(template_path).convert('RGBA')
242
- temp_w, temp_h = template.size
243
- box_w = int(temp_w * config['text_box_width_percent'])
244
- box_h = int(temp_h * config['text_box_height_percent'])
245
- script_dir = os.path.dirname(os.path.abspath(__file__))
246
- font_paths = [os.path.join(script_dir, 'fonts', config['font_file']), os.path.join(script_dir, config['font_file'])]
247
- best_font_size = config['font_size_max']
248
- best_wrapped_text = hook_text
249
- for font_size in range(config['font_size_max'], config['font_size_min'] - 1, -2):
250
- font = None
251
- for fp in font_paths:
252
- if os.path.exists(fp):
253
- try:
254
- font = ImageFont.truetype(fp, font_size)
255
- break
256
- except:
257
- pass
258
- if font is None:
259
- font = ImageFont.load_default()
260
- wrapped = textwrap.fill(hook_text, width=config['text_wrap_width'])
261
- draw = ImageDraw.Draw(template)
262
- bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, spacing=config['line_spacing'])
263
- if (bbox[2] - bbox[0] <= box_w and bbox[3] - bbox[1] <= box_h):
264
- best_font_size = font_size
265
- best_wrapped_text = wrapped
266
- break
267
- font = None
268
- for fp in font_paths:
269
- if os.path.exists(fp):
270
- try:
271
- font = ImageFont.truetype(fp, best_font_size)
272
- break
273
- except:
274
- pass
275
- if font is None:
276
- font = ImageFont.load_default()
277
- draw = ImageDraw.Draw(template)
278
- bbox = draw.multiline_textbbox((0, 0), best_wrapped_text, font=font, spacing=config['line_spacing'])
279
- x = (temp_w - (bbox[2] - bbox[0])) / 2
280
- y = (temp_h - (bbox[3] - bbox[1])) / 2 + config['y_offset']
281
- draw.multiline_text((x, y), best_wrapped_text, fill=config['text_color'], font=font, spacing=config['line_spacing'], align='left')
282
- output_path = os.path.join(output_dir, 'reddit_card_composite.png')
283
- template.save(output_path, 'PNG')
284
- return output_path
285
- except Exception as e:
286
- raise Exception(f"Failed to create Reddit card: {str(e)}")
287
-
288
 
289
  def validate_and_get_file(uploaded_file, url_string, file_type, temp_dir):
290
- has_upload = uploaded_file is not None
291
- has_url = url_string and url_string.strip()
292
- if not has_upload and not has_url:
293
- return None, f"❌ Please provide {file_type}"
294
- if has_upload and has_url:
295
- return None, f"❌ Use only ONE method for {file_type}"
296
- if has_upload:
297
- return str(uploaded_file.name if hasattr(uploaded_file, 'name') else uploaded_file), None
298
- if has_url:
299
- try:
300
- fname = f"{file_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{url_string.split('/')[-1] or 'file'}"
301
- return download_file_from_url(url_string.strip(), temp_dir, fname), None
302
- except Exception as e:
303
- return None, f"❌ Error downloading {file_type}: {str(e)}"
304
- return None, "❌ Unknown error"
305
-
306
 
307
  def srt_time_to_ms(time_str):
308
- h, m, s = time_str.strip().split(':')
309
- s, ms = s.split(',')
310
- return int(h) * 3600000 + int(m) * 60000 + int(s) * 1000 + int(ms)
311
-
312
 
313
  def ms_to_ass_time(ms):
314
- h, ms = divmod(ms, 3600000)
315
- m, ms = divmod(ms, 60000)
316
- s, ms = divmod(ms, 1000)
317
- cs = ms // 10
318
- return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
319
 
320
-
321
- # -----------------------
322
  # BODY SCRIPT HIGHLIGHTS ASS
323
- # -----------------------
324
- def create_word_by_word_highlight_ass(srt_path, output_dir, highlight_color='yellow',
325
- font_size=None, skip_first=False, config=SUBTITLE_CONFIG,
326
- cta_start_time_sec=None):
327
- if font_size is None:
328
- font_size = config['font_size_default']
329
- color_map = {
330
- 'yellow': ('&H00000000', '&H0000FFFF'), 'orange': ('&H0000A5FF', '&H00000000'),
331
- 'green': ('&H0000FF00', '&H00000000'), 'cyan': ('&H00FFFF00', '&H00000000'),
332
- 'pink': ('&H00FF69B4', '&H00000000'), 'red': ('&H000000FF', '&H00FFFFFF'),
333
- 'blue': ('&H00FF0000', '&H00FFFFFF'),
334
- }
335
- highlight_bg, highlight_text = color_map.get(highlight_color.lower(), ('&H00000000', '&H0000FFFF'))
336
- with open(srt_path, 'r', encoding='utf-8') as f:
337
- srt_content = f.read()
338
- ass_path = os.path.join(output_dir, 'word_highlight_subtitles.ass')
339
- ass_header = f"""[Script Info]
340
  Title: Word-by-Word Highlight Subtitles
341
  ScriptType: v4.00+
342
  [V4+ Styles]
@@ -345,54 +304,46 @@ Style: Default,{config['font_name']},{font_size},&H00FFFFFF,&H00FFFFFF,&H0000000
345
  [Events]
346
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
347
  """
348
- srt_blocks = re.split(r'\n\s*\n', srt_content.strip())
349
- ass_events = []
350
- start_index = 1 if skip_first else 0
351
- for block in srt_blocks[start_index:]:
352
- lines = block.strip().split('\n')
353
- if len(lines) >= 3:
354
- times = lines[1].split(' --> ')
355
- if len(times) == 2:
356
- start_ms = srt_time_to_ms(times[0])
357
- if cta_start_time_sec is not None and (start_ms / 1000.0) >= cta_start_time_sec - 0.1:
358
- break
359
- end_ms = srt_time_to_ms(times[1])
360
- words = ' '.join(lines[2:]).split()
361
- if not words:
362
- continue
363
- time_per_word = (end_ms - start_ms) / len(words)
364
- for i, word in enumerate(words):
365
- word_start = start_ms + int(i * time_per_word)
366
- word_end = start_ms + int((i + 1) * time_per_word)
367
- if i == len(words) - 1:
368
- word_end = end_ms
369
- text_parts = [
370
- f"{{\\c{highlight_text}\\3c{highlight_bg}\\bord5}}{w}{{\\r}}" if j == i else w
371
- for j, w in enumerate(words)
372
- ]
373
- ass_events.append(f"Dialogue: 0,{ms_to_ass_time(word_start)},{ms_to_ass_time(word_end)},Default,,0,0,0,,{' '.join(text_parts)}")
374
- with open(ass_path, 'w', encoding='utf-8') as f:
375
- f.write(ass_header)
376
- f.write('\n'.join(ass_events))
377
- return ass_path
378
-
379
-
380
- # -----------------------
381
  # CTA HIGHLIGHTS ASS
382
- # -----------------------
383
  def create_cta_highlight_ass(srt_path, output_dir, start_sec, font_size, video_width, video_height, highlight_color='yellow', config=SUBTITLE_CONFIG):
 
384
  color_map = {
385
  'yellow': ('&H00000000', '&H0000FFFF'), 'orange': ('&H0000A5FF', '&H00000000'),
386
  'green': ('&H0000FF00', '&H00000000'), 'cyan': ('&H00FFFF00', '&H00000000'),
387
  'pink': ('&H00FF69B4', '&H00000000'), 'red': ('&H000000FF', '&H00FFFFFF'),
388
- 'blue': ('&H00FF0000', '&H00FFFFFF'),
389
  }
390
  highlight_bg, highlight_text = color_map.get(highlight_color.lower(), ('&H00000000', '&H0000FFFF'))
391
- margin_lr = int(video_width * 0.125) + 40
392
 
393
- with open(srt_path, 'r', encoding='utf-8') as f:
394
- srt_content = f.read()
395
  ass_path = os.path.join(output_dir, 'cta_animated_subtitles.ass')
 
396
  ass_header = f"""[Script Info]
397
  Title: CTA Animated Subtitles
398
  ScriptType: v4.00+
@@ -403,9 +354,11 @@ WrapStyle: 1
403
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
404
  Style: Default,{config['font_name']},{font_size},&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,0,0,0,0,100,85,0,0,3,15,0,5,{margin_lr},{margin_lr},0,1
405
  [Events]
406
- Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
407
- """
408
  srt_blocks = re.split(r'\n\s*\n', srt_content.strip())
 
 
409
  all_cta_words = []
410
  for block in srt_blocks:
411
  lines = block.strip().split('\n')
@@ -413,220 +366,199 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
413
  times = lines[1].split(' --> ')
414
  if len(times) == 2:
415
  start_ms = srt_time_to_ms(times[0])
416
- if (start_ms / 1000.0) < start_sec - 0.1:
417
- continue
418
  end_ms = srt_time_to_ms(times[1])
419
  words = ' '.join(lines[2:]).split()
420
- if not words:
421
- continue
422
  time_per_word = (end_ms - start_ms) / len(words)
423
  for i, word in enumerate(words):
424
  w_start = start_ms + int(i * time_per_word)
425
  w_end = end_ms if i == len(words) - 1 else start_ms + int((i + 1) * time_per_word)
426
  all_cta_words.append({'word': word, 'start': w_start, 'end': w_end})
427
 
 
428
  chunks = []
429
  i = 0
430
  total_words = len(all_cta_words)
 
431
  while i < total_words:
432
  remaining = total_words - i
433
- take = remaining if 10 < remaining <= 13 else min(10, remaining)
434
- chunks.append(all_cta_words[i: i + take])
 
 
 
 
435
  i += take
436
 
 
437
  ass_events = []
438
  for chunk in chunks:
439
  chunk_text_only = [item['word'] for item in chunk]
 
440
  for idx, info in enumerate(chunk):
441
  w_start = info['start']
442
- w_end = chunk[idx + 1]['start'] if idx + 1 < len(chunk) else info['end']
443
- text_parts = [
444
- f"{{\\c{highlight_text}}}{word_str}{{\\r}}" if j == idx else word_str
445
- for j, word_str in enumerate(chunk_text_only)
446
- ]
447
- ass_events.append(f"Dialogue: 1,{ms_to_ass_time(w_start)},{ms_to_ass_time(w_end)},Default,,0,0,0,,{' '.join(text_parts)}")
448
-
449
- with open(ass_path, 'w', encoding='utf-8') as f:
 
 
 
 
 
 
450
  f.write(ass_header + '\n'.join(ass_events))
451
  return ass_path
452
 
453
-
454
  # =========================
455
  # MAIN STITCH FUNCTION
456
  # =========================
457
- def stitch_media(
458
- video_file, video_url,
459
- audio_file, audio_url,
460
- subtitle_file, subtitle_url,
461
- book_cover_file, book_cover_url, book_cover_base64, book_id,
462
- book_title,
463
- enable_highlight, highlight_color, font_size,
464
- crf_quality=23
465
- ):
466
- temp_dir = tempfile.mkdtemp()
467
- status_msg = "πŸš€ Starting video stitching...\n"
468
- try:
469
- ffmpeg_env = setup_custom_fonts_hf(temp_dir)
470
-
471
- video_path, v_err = validate_and_get_file(video_file, video_url, 'video', temp_dir)
472
- if v_err: return None, v_err
473
-
474
- audio_path, a_err = validate_and_get_file(audio_file, audio_url, 'audio', temp_dir)
475
- if a_err: return None, a_err
476
-
477
- subtitle_path, s_err = validate_and_get_file(subtitle_file, subtitle_url, 'subtitle', temp_dir)
478
- if s_err: return None, s_err
479
-
480
- video_width, video_height, video_fps = get_video_info(video_path)
481
- audio_duration = get_audio_duration(audio_path)
482
-
483
- script_dir = os.path.dirname(os.path.abspath(__file__))
484
- reddit_template_path = os.path.join(script_dir, REDDIT_CONFIG['template_file'])
485
- has_reddit_template = os.path.exists(reddit_template_path)
486
-
487
- first_sub_start = 0
488
- first_sub_end = 0
489
- reddit_card_path = None
490
- if has_reddit_template:
491
- try:
492
- first_sub_text, first_sub_start, first_sub_end = extract_first_subtitle(subtitle_path)
493
- status_msg += f"\nπŸ“± Reddit Overlay: '{first_sub_text[:30]}...'\n"
494
- reddit_card_path = create_reddit_card_with_text(reddit_template_path, first_sub_text, temp_dir, REDDIT_CONFIG)
495
- except Exception as e:
496
- status_msg += f" β€’ ⚠️ Reddit card failed: {str(e)}\n"
497
- has_reddit_template = False
498
-
499
- # --- 1. Find CTA Info ---
500
- title_timestamp, cta_timestamp, cta_text_raw = find_title_and_cta(subtitle_path, book_title)
501
- book_appears_at = title_timestamp if title_timestamp is not None else audio_duration * (1 - VIDEO_CONFIG['promo_percent'])
502
- box_appears_at = cta_timestamp if cta_timestamp is not None else book_appears_at + 1.5
503
-
504
- if title_timestamp: status_msg += f"\nπŸ“– Book title at {title_timestamp:.2f}s\n"
505
- if cta_timestamp: status_msg += f"πŸ–€ CTA text starts at {cta_timestamp:.2f}s\n"
506
-
507
- # --- 2. Prepare Dynamic CTA ---
508
- cta_ass_path = None
509
- cta_sub_escaped = None
510
- if cta_text_raw:
511
- status_msg += "πŸ–€ Generating Instagram-style dynamic CTA...\n"
512
- cta_font_size = int(video_width * 0.060)
513
- cta_ass_path = create_cta_highlight_ass(
514
- subtitle_path, temp_dir, box_appears_at,
515
- cta_font_size, video_width, video_height, highlight_color
516
- )
517
- cta_sub_escaped = cta_ass_path.replace('\\', '/').replace(':', '\\:')
518
-
519
- # --- 3. Process Main Subtitles ---
520
- if enable_highlight:
521
- status_msg += f"\n✨ Processing subtitles...\n"
522
- main_subtitle_path = create_word_by_word_highlight_ass(
523
- subtitle_path, temp_dir, highlight_color, font_size,
524
- skip_first=has_reddit_template, config=SUBTITLE_CONFIG,
525
- cta_start_time_sec=title_timestamp
526
- )
527
- else:
528
- main_subtitle_path = subtitle_path
529
 
530
- main_sub_escaped = main_subtitle_path.replace('\\', '/').replace(':', '\\:')
531
-
532
- # --- 4. Book Cover ---
533
- book_cover_path, book_error = validate_book_cover_input(
534
- book_cover_file, book_cover_url, book_cover_base64, book_id, temp_dir
535
- )
536
- if book_error: return None, book_error
537
- if book_cover_path is None: return None, "❌ Book cover required."
538
-
539
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
540
- output_path = os.path.join(temp_dir, f"final_{timestamp}.mp4")
541
-
542
- fade_starts_at = audio_duration * VIDEO_CONFIG['fade_start_percent']
543
- fade_ends_at = audio_duration * VIDEO_CONFIG['fade_end_percent']
544
- fade_out_duration = fade_ends_at - fade_starts_at
545
- promo_duration = audio_duration * VIDEO_CONFIG['promo_percent']
546
- solid_color_duration = max(0, book_appears_at - fade_ends_at)
547
- main_video_duration = fade_ends_at
548
- cover_segment_duration = promo_duration
549
- fade_color_hex = "#dacfc3"
550
-
551
- try:
552
- # Step 1: Main video with fade-out
553
- main_segment_path = os.path.join(temp_dir, f"main_{timestamp}.mp4")
554
- subprocess.run([
555
- "ffmpeg", "-stream_loop", "-1", "-i", video_path,
556
- "-t", str(main_video_duration),
557
- "-vf", f"fps={video_fps},scale={video_width}:{video_height},fade=t=out:st={fade_starts_at}:d={fade_out_duration}:c={fade_color_hex}",
558
- "-c:v", "libx264", "-crf", str(crf_quality), "-pix_fmt", "yuv420p", "-an", "-y", main_segment_path
559
- ], check=True, capture_output=True, text=True, env=ffmpeg_env)
560
-
561
- # Step 2: Solid color hold
562
- solid_color_path = os.path.join(temp_dir, f"solid_{timestamp}.mp4")
563
- subprocess.run([
564
- "ffmpeg", "-f", "lavfi",
565
- "-i", f"color=c={fade_color_hex}:s={video_width}x{video_height}:d={solid_color_duration}:r={video_fps}",
566
- "-c:v", "libx264", "-crf", str(crf_quality), "-pix_fmt", "yuv420p", "-y", solid_color_path
567
- ], check=True, capture_output=True, text=True, env=ffmpeg_env)
568
-
569
- # Step 3: Book cover segment
570
- cover_segment_path = os.path.join(temp_dir, f"cover_{timestamp}.mp4")
571
- subprocess.run([
572
- "ffmpeg", "-loop", "1", "-i", book_cover_path,
573
- "-t", str(cover_segment_duration),
574
- "-vf", f"scale={video_width}:{video_height},setsar=1,fps={video_fps}",
575
- "-c:v", "libx264", "-crf", str(crf_quality), "-pix_fmt", "yuv420p", "-an", "-y", cover_segment_path
576
- ], check=True, capture_output=True, text=True, env=ffmpeg_env)
577
-
578
- # Step 4: Concat list
579
- concat_list_path = os.path.join(temp_dir, f"concat_{timestamp}.txt")
580
- with open(concat_list_path, 'w') as f:
581
- f.write(f"file '{main_segment_path}'\n")
582
- f.write(f"file '{solid_color_path}'\n")
583
- f.write(f"file '{cover_segment_path}'\n")
584
-
585
- # Step 5: Build filter graph
586
- input_cmd = ["ffmpeg", "-f", "concat", "-safe", "0", "-i", concat_list_path]
587
- curr_idx = 1
588
- curr_stream = "[0:v]"
589
- filter_complex = ""
590
-
591
- if has_reddit_template:
592
- input_cmd += ["-loop", "1", "-i", reddit_card_path]
593
- filter_complex += f"[{curr_idx}:v]scale={video_width}*{VIDEO_CONFIG['reddit_scale_percent']}:-1[reddit];{curr_stream}[reddit]overlay=(W-w)/2:(H-h)/2:enable='between(t,{first_sub_start},{first_sub_end})'[v1];"
594
- curr_stream = "[v1]"
595
- curr_idx += 1
596
- else:
597
- filter_complex += f"{curr_stream}copy[v1];"
598
- curr_stream = "[v1]"
599
-
600
- filter_complex += f"{curr_stream}ass={main_sub_escaped}[v2];"
601
- curr_stream = "[v2]"
602
-
603
- if cta_ass_path:
604
- filter_complex += f"{curr_stream}ass={cta_sub_escaped}[v_final]"
605
- else:
606
- filter_complex += f"{curr_stream}copy[v_final]"
607
-
608
- input_cmd += ["-i", audio_path]
609
- cmd_final = input_cmd + [
610
- "-filter_complex", filter_complex,
611
- "-map", "[v_final]", "-map", f"{curr_idx}:a",
612
- "-c:v", "libx264", "-crf", str(crf_quality),
613
- "-c:a", "aac", "-pix_fmt", "yuv420p", "-shortest", "-y", output_path
614
- ]
615
-
616
- status_msg += "🎬 Rendering final video...\n"
617
- subprocess.run(cmd_final, check=True, capture_output=True, text=True, env=ffmpeg_env)
618
-
619
- except subprocess.CalledProcessError as e:
620
- return None, f"❌ FFmpeg error:\n{e.stderr[-1000:] if e.stderr else str(e)}"
621
- except Exception as e:
622
- return None, f"❌ Error: {str(e)}"
623
-
624
- if os.path.exists(output_path):
625
- return output_path, f"βœ… Success!\n\n{status_msg}"
626
- return None, "❌ Output not created"
627
-
628
- except Exception as e:
629
- return None, f"❌ Error: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
630
 
631
 
632
  # ========================================
@@ -697,8 +629,8 @@ async def stitch_upload(
697
  raise HTTPException(status_code=422, detail=f"❌ Invalid video format: {video_file.content_type}")
698
  if audio_file and audio_file.content_type not in {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/aac", "audio/mp4", "audio/x-m4a"}:
699
  raise HTTPException(status_code=422, detail=f"❌ Invalid audio format: {audio_file.content_type}")
700
- if subtitle_file and not subtitle_file.filename.endswith('.srt'):
701
- raise HTTPException(status_code=422, detail="❌ Subtitle must be a .srt file")
702
  if book_cover_file and book_cover_file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
703
  raise HTTPException(status_code=422, detail="❌ Book cover must be jpeg, png, or webp")
704
 
 
18
  from fastapi.middleware.cors import CORSMiddleware
19
  from pydantic import BaseModel, Field
20
 
 
21
  # ========================================
22
  # CONFIGURATION SECTION - CUSTOMIZE HERE
23
  # ========================================
24
 
25
  REDDIT_CONFIG = {
26
+ 'template_file': 'reddit_template.png',
27
+ 'font_file': 'RFDewi-Bold.ttf',
28
+ 'font_size_max': 180,
29
+ 'font_size_min': 16,
30
+ 'text_wrap_width': 35,
31
+ 'text_color': 'black',
32
+ 'line_spacing': 10,
33
+ 'text_box_width_percent': 0.85,
34
+ 'text_box_height_percent': 0.65,
35
+ 'y_offset': 20,
36
  }
37
 
38
  SUBTITLE_CONFIG = {
39
+ 'font_file': 'LilitaOne-Regular.ttf',
40
+ 'font_name': 'Lilita One',
41
+ 'font_size_default': 10,
42
+ 'position_alignment': 5,
43
+ 'margin_left': 50,
44
+ 'margin_right': 70,
45
+ 'margin_vertical': 20,
46
+ 'line_spacing': 2
47
  }
48
+ # go to line 462 if you want to increase/decrease CTA part's font size!!!
49
 
50
  VIDEO_CONFIG = {
51
+ 'reddit_scale_percent': 0.75,
52
+ 'fade_start_percent': 0.70,
53
+ 'fade_end_percent': 0.85,
54
+ 'promo_percent': 0.094,
55
+ 'fade_color_rgb': (218, 207, 195),
56
  }
57
 
58
+
59
  # ========================================
60
  # END CONFIGURATION SECTION
61
  # ========================================
62
 
63
+ # Add static ffmpeg to PATH
64
+ static_ffmpeg.add_paths()
65
 
66
  # ============================================
67
  # FINDS BOOK TITLE TO SPLIT CTA AND BODY SCRIPT
 
79
  if len(lines) >= 3:
80
  subtitle_text = ' '.join(lines[2:])
81
  if book_title_lower in subtitle_text.lower():
82
+ # 1. Get the time the title is spoken
83
  times = lines[1].split(' --> ')
84
  title_time = srt_time_to_ms(times[0]) / 1000.0
85
+
86
  cta_time = None
87
  cta_text_parts = []
88
+
89
+ # 2. Get the time the ACTUAL CTA text starts
90
  if i + 1 < len(blocks):
91
  next_block_lines = blocks[i + 1].strip().split('\n')
92
  if len(next_block_lines) >= 3:
93
  cta_time = srt_time_to_ms(next_block_lines[1].split(' --> ')[0]) / 1000.0
94
+
95
+ # 3. Grab all remaining text for the CTA
96
  for j in range(i + 1, len(blocks)):
97
  next_lines = blocks[j].strip().split('\n')
98
  if len(next_lines) >= 3:
99
  cta_text_parts.append(' '.join(next_lines[2:]).strip())
100
+
101
  cta_text = ' '.join(cta_text_parts) if cta_text_parts else None
102
  return title_time, cta_time, cta_text
103
  return None, None, None
 
105
  print(f"Error finding title and CTA: {e}")
106
  return None, None, None
107
 
 
108
  def setup_custom_fonts_hf(temp_dir):
109
+ try:
110
+ fonts_dir = os.path.join(temp_dir, 'fonts')
111
+ os.makedirs(fonts_dir, exist_ok=True)
112
+ script_dir = os.path.dirname(os.path.abspath(__file__))
113
+ repo_fonts_dir = os.path.join(script_dir, 'fonts')
114
+ fonts_to_copy = []
115
+ if os.path.exists(repo_fonts_dir):
116
+ for font_file in os.listdir(repo_fonts_dir):
117
+ if font_file.endswith(('.ttf', '.otf', '.TTF', '.OTF')):
118
+ fonts_to_copy.append(os.path.join(repo_fonts_dir, font_file))
119
+ for item in [REDDIT_CONFIG['font_file'], SUBTITLE_CONFIG['font_file']]:
120
+ font_path = os.path.join(script_dir, item)
121
+ if os.path.exists(font_path) and font_path not in fonts_to_copy:
122
+ fonts_to_copy.append(font_path)
123
+ for src in fonts_to_copy:
124
+ dst = os.path.join(fonts_dir, os.path.basename(src))
125
+ shutil.copy(src, dst)
126
+ if fonts_to_copy:
127
+ fonts_conf = f"""<?xml version="1.0"?>
128
  <fontconfig><dir>{fonts_dir}</dir><cachedir>{temp_dir}/cache</cachedir></fontconfig>"""
129
+ conf_path = os.path.join(temp_dir, 'fonts.conf')
130
+ with open(conf_path, 'w') as f:
131
+ f.write(fonts_conf)
132
+ env = os.environ.copy()
133
+ env['FONTCONFIG_FILE'] = conf_path
134
+ env['FONTCONFIG_PATH'] = temp_dir
135
+ return env
136
+ return os.environ.copy()
137
+ except Exception as e: return os.environ.copy()
 
 
138
 
139
  def download_file_from_url(url, output_dir, filename):
140
+ try:
141
+ response = requests.get(url, stream=True, timeout=30)
142
+ response.raise_for_status()
143
+ file_path = os.path.join(output_dir, filename)
144
+ with open(file_path, 'wb') as f:
145
+ for chunk in response.iter_content(chunk_size=8192): f.write(chunk)
146
+ return file_path
147
+ except Exception as e: raise Exception(f"Failed to download file: {str(e)}")
 
 
 
148
 
149
  def download_book_cover(book_id, output_dir):
150
+ try:
151
+ image_url = f"https://books.google.com/books/publisher/content/images/frontcover/{book_id}"
152
+ response = requests.get(image_url, timeout=30)
153
+ response.raise_for_status()
154
+ image_path = os.path.join(output_dir, 'book_cover.png')
155
+ with open(image_path, 'wb') as f: f.write(response.content)
156
+ Image.open(image_path).verify()
157
+ return image_path
158
+ except Exception as e: raise Exception(f"Failed to download book cover: {str(e)}")
 
 
 
159
 
160
  def decode_base64_image(base64_string, output_dir):
161
+ try:
162
+ if ',' in base64_string and 'base64' in base64_string:
163
+ base64_string = base64_string.split(',', 1)[1]
164
+ image_data = base64.b64decode(base64_string.strip())
165
+ Image.open(BytesIO(image_data)).verify()
166
+ output_path = os.path.join(output_dir, f"book_cover_b64_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png")
167
+ Image.open(BytesIO(image_data)).save(output_path, 'PNG')
168
+ return output_path
169
+ except Exception as e: raise Exception(f"Base64 decode failed: {str(e)}")
 
 
170
 
171
  def validate_book_cover_input(book_cover_file, book_cover_url, book_cover_base64, book_id, temp_dir):
172
+ has_file = book_cover_file is not None
173
+ has_url = bool(book_cover_url and book_cover_url.strip())
174
+ has_base64 = bool(book_cover_base64 and book_cover_base64.strip())
175
+ has_id = bool(book_id and book_id.strip())
176
+ methods_count = sum([has_file, has_url, has_base64, has_id])
177
+ if methods_count == 0: return None, None
178
+ if methods_count > 1: return None, "❌ Book Cover: Use only ONE method"
179
+ try:
180
+ if has_file: return str(book_cover_file.name if hasattr(book_cover_file, 'name') else book_cover_file), None
181
+ if has_url: return download_file_from_url(book_cover_url.strip(), temp_dir, f"book_cover_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"), None
182
+ if has_base64: return decode_base64_image(book_cover_base64.strip(), temp_dir), None
183
+ if has_id: return download_book_cover(book_id.strip(), temp_dir), None
184
+ except Exception as e: return None, f"❌ Book cover error: {str(e)}"
185
+ return None, None
 
 
 
 
 
 
 
 
186
 
187
  def get_video_info(video_path):
188
+ try:
189
+ cmd_res = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=s=x:p=0", video_path]
190
+ result = subprocess.run(cmd_res, capture_output=True, text=True, check=True)
191
+ width, height = result.stdout.strip().split('x')
192
+ cmd_fps = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", video_path]
193
+ result = subprocess.run(cmd_fps, capture_output=True, text=True, check=True)
194
+ fps_str = result.stdout.strip()
195
+ fps = float(fps_str.split('/')[0]) / float(fps_str.split('/')[1]) if '/' in fps_str else float(fps_str)
196
+ return int(width), int(height), fps
197
+ except Exception as e: raise Exception(f"Failed to get video info: {str(e)}")
 
 
198
 
199
  def get_audio_duration(audio_path):
200
+ try:
201
+ cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", audio_path]
202
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
203
+ return float(result.stdout.strip())
204
+ except Exception as e: raise Exception(f"Failed to get audio duration: {str(e)}")
 
 
205
 
206
  def extract_first_subtitle(srt_path):
207
+ try:
208
+ with open(srt_path, 'r', encoding='utf-8') as f: content = f.read()
209
+ blocks = re.split(r'\n\s*\n', content.strip())
210
+ if not blocks: return "No subtitle found", 0.0, 3.0
211
+ first_block = blocks[0].strip().split('\n')
212
+ if len(first_block) >= 3:
213
+ times = first_block[1].split(' --> ')
214
+ def time_to_sec(t):
215
+ h, m, s = t.split(':')
216
+ s, ms = s.split(',')
217
+ return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
218
+ return ' '.join(first_block[2:]).strip(), time_to_sec(times[0].strip()), time_to_sec(times[1].strip())
219
+ return "No subtitle found", 0.0, 3.0
220
+ except Exception as e: raise Exception(f"Failed to extract first subtitle: {str(e)}")
 
 
 
 
221
 
222
  def create_reddit_card_with_text(template_path, hook_text, output_dir, config=REDDIT_CONFIG):
223
+ try:
224
+ template = Image.open(template_path).convert('RGBA')
225
+ temp_w, temp_h = template.size
226
+ box_w = int(temp_w * config['text_box_width_percent'])
227
+ box_h = int(temp_h * config['text_box_height_percent'])
228
+ script_dir = os.path.dirname(os.path.abspath(__file__))
229
+ font_paths = [os.path.join(script_dir, 'fonts', config['font_file']), os.path.join(script_dir, config['font_file'])]
230
+ best_font_size = config['font_size_max']
231
+ best_wrapped_text = hook_text
232
+ for font_size in range(config['font_size_max'], config['font_size_min'] - 1, -2):
233
+ font = None
234
+ for fp in font_paths:
235
+ if os.path.exists(fp):
236
+ try: font = ImageFont.truetype(fp, font_size); break
237
+ except: pass
238
+ if font is None: font = ImageFont.load_default()
239
+ wrapped = textwrap.fill(hook_text, width=config['text_wrap_width'])
240
+ draw = ImageDraw.Draw(template)
241
+ bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, spacing=config['line_spacing'])
242
+ if (bbox[2]-bbox[0] <= box_w and bbox[3]-bbox[1] <= box_h):
243
+ best_font_size = font_size; best_wrapped_text = wrapped; break
244
+ font = None
245
+ for fp in font_paths:
246
+ if os.path.exists(fp):
247
+ try: font = ImageFont.truetype(fp, best_font_size); break
248
+ except: pass
249
+ if font is None: font = ImageFont.load_default()
250
+ draw = ImageDraw.Draw(template)
251
+ bbox = draw.multiline_textbbox((0, 0), best_wrapped_text, font=font, spacing=config['line_spacing'])
252
+ x = (temp_w - (bbox[2]-bbox[0])) / 2
253
+ y = (temp_h - (bbox[3]-bbox[1])) / 2 + config['y_offset']
254
+ draw.multiline_text((x, y), best_wrapped_text, fill=config['text_color'], font=font, spacing=config['line_spacing'], align='left')
255
+ output_path = os.path.join(output_dir, 'reddit_card_composite.png')
256
+ template.save(output_path, 'PNG')
257
+ return output_path
258
+ except Exception as e: raise Exception(f"Failed to create Reddit card: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
  def validate_and_get_file(uploaded_file, url_string, file_type, temp_dir):
261
+ has_upload = uploaded_file is not None
262
+ has_url = url_string and url_string.strip()
263
+ if not has_upload and not has_url: return None, f"❌ Please provide {file_type}"
264
+ if has_upload and has_url: return None, f"❌ Use only ONE method for {file_type}"
265
+ if has_upload: return str(uploaded_file.name if hasattr(uploaded_file, 'name') else uploaded_file), None
266
+ if has_url:
267
+ try:
268
+ fname = f"{file_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{url_string.split('/')[-1] if url_string.split('/')[-1] else 'file'}"
269
+ return download_file_from_url(url_string.strip(), temp_dir, fname), None
270
+ except Exception as e: return None, f"❌ Error downloading {file_type}: {str(e)}"
271
+ return None, f"❌ Unknown error"
 
 
 
 
 
272
 
273
  def srt_time_to_ms(time_str):
274
+ h, m, s = time_str.strip().split(':')
275
+ s, ms = s.split(',')
276
+ return int(h)*3600000 + int(m)*60000 + int(s)*1000 + int(ms)
 
277
 
278
  def ms_to_ass_time(ms):
279
+ h, ms = divmod(ms, 3600000)
280
+ m, ms = divmod(ms, 60000)
281
+ s, ms = divmod(ms, 1000)
282
+ cs = ms // 10
283
+ return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
284
 
285
+ #-----------------------
 
286
  # BODY SCRIPT HIGHLIGHTS ASS
287
+ #-----------------------
288
+ def create_word_by_word_highlight_ass(srt_path, output_dir, highlight_color='yellow',
289
+ font_size=None, skip_first=False, config=SUBTITLE_CONFIG,
290
+ cta_start_time_sec=None):
291
+ """Convert SRT to ASS. Stops before cta_start_time_sec."""
292
+ if font_size is None: font_size = config['font_size_default']
293
+ color_map = {'yellow': ('&H00000000', '&H0000FFFF'), 'orange': ('&H0000A5FF', '&H00000000'), 'green': ('&H0000FF00', '&H00000000'), 'cyan': ('&H00FFFF00', '&H00000000'), 'pink': ('&H00FF69B4', '&H00000000'), 'red': ('&H000000FF', '&H00FFFFFF'), 'blue': ('&H00FF0000', '&H00FFFFFF')}
294
+ highlight_bg, highlight_text = color_map.get(highlight_color.lower(), ('&H00000000', '&H0000FFFF'))
295
+
296
+ with open(srt_path, 'r', encoding='utf-8') as f: srt_content = f.read()
297
+ ass_path = os.path.join(output_dir, 'word_highlight_subtitles.ass')
298
+ ass_header = f"""[Script Info]
 
 
 
 
 
299
  Title: Word-by-Word Highlight Subtitles
300
  ScriptType: v4.00+
301
  [V4+ Styles]
 
304
  [Events]
305
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
306
  """
307
+ srt_blocks = re.split(r'\n\s*\n', srt_content.strip())
308
+ ass_events = []
309
+ start_index = 1 if skip_first else 0
310
+ for block in srt_blocks[start_index:]:
311
+ lines = block.strip().split('\n')
312
+ if len(lines) >= 3:
313
+ times = lines[1].split(' --> ')
314
+ if len(times) == 2:
315
+ start_ms = srt_time_to_ms(times[0])
316
+ if cta_start_time_sec is not None and (start_ms / 1000.0) >= cta_start_time_sec - 0.1: break
317
+ end_ms = srt_time_to_ms(times[1])
318
+ words = ' '.join(lines[2:]).split()
319
+ if not words: continue
320
+ time_per_word = (end_ms - start_ms) / len(words)
321
+ for i, word in enumerate(words):
322
+ word_start = start_ms + int(i * time_per_word)
323
+ word_end = start_ms + int((i + 1) * time_per_word)
324
+ if i == len(words) - 1: word_end = end_ms
325
+ text_parts = [f"{{\\c{highlight_text}\\3c{highlight_bg}\\bord5}}{w}{{\\r}}" if j == i else w for j, w in enumerate(words)]
326
+ ass_events.append(f"Dialogue: 0,{ms_to_ass_time(word_start)},{ms_to_ass_time(word_end)},Default,,0,0,0,,{' '.join(text_parts)}")
327
+ with open(ass_path, 'w', encoding='utf-8') as f: f.write(ass_header); f.write('\n'.join(ass_events))
328
+ return ass_path
329
+
330
+ #-----------------------
 
 
 
 
 
 
 
 
 
331
  # CTA HIGHLIGHTS ASS
332
+ #-----------------------
333
  def create_cta_highlight_ass(srt_path, output_dir, start_sec, font_size, video_width, video_height, highlight_color='yellow', config=SUBTITLE_CONFIG):
334
+ """Groups CTA words into frames of max 10, but merges leftovers if they are < 3 words."""
335
  color_map = {
336
  'yellow': ('&H00000000', '&H0000FFFF'), 'orange': ('&H0000A5FF', '&H00000000'),
337
  'green': ('&H0000FF00', '&H00000000'), 'cyan': ('&H00FFFF00', '&H00000000'),
338
  'pink': ('&H00FF69B4', '&H00000000'), 'red': ('&H000000FF', '&H00FFFFFF'),
339
+ 'blue': ('&H00FF0000', '&H00FFFFFF')
340
  }
341
  highlight_bg, highlight_text = color_map.get(highlight_color.lower(), ('&H00000000', '&H0000FFFF'))
342
+ margin_lr = int(video_width * 0.125) + 40
343
 
344
+ with open(srt_path, 'r', encoding='utf-8') as f: srt_content = f.read()
 
345
  ass_path = os.path.join(output_dir, 'cta_animated_subtitles.ass')
346
+
347
  ass_header = f"""[Script Info]
348
  Title: CTA Animated Subtitles
349
  ScriptType: v4.00+
 
354
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
355
  Style: Default,{config['font_name']},{font_size},&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,0,0,0,0,100,85,0,0,3,15,0,5,{margin_lr},{margin_lr},0,1
356
  [Events]
357
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"""
358
+
359
  srt_blocks = re.split(r'\n\s*\n', srt_content.strip())
360
+
361
+ # 1. Flatten all CTA words into a single timed stream
362
  all_cta_words = []
363
  for block in srt_blocks:
364
  lines = block.strip().split('\n')
 
366
  times = lines[1].split(' --> ')
367
  if len(times) == 2:
368
  start_ms = srt_time_to_ms(times[0])
369
+ if (start_ms / 1000.0) < start_sec - 0.1: continue
370
+
371
  end_ms = srt_time_to_ms(times[1])
372
  words = ' '.join(lines[2:]).split()
373
+ if not words: continue
374
+
375
  time_per_word = (end_ms - start_ms) / len(words)
376
  for i, word in enumerate(words):
377
  w_start = start_ms + int(i * time_per_word)
378
  w_end = end_ms if i == len(words) - 1 else start_ms + int((i + 1) * time_per_word)
379
  all_cta_words.append({'word': word, 'start': w_start, 'end': w_end})
380
 
381
+ # 2. Group words into chunks with "Don't leave 1 or 2 words alone" logic
382
  chunks = []
383
  i = 0
384
  total_words = len(all_cta_words)
385
+
386
  while i < total_words:
387
  remaining = total_words - i
388
+ if 10 < remaining <= 13:
389
+ take = remaining
390
+ else:
391
+ take = min(10, remaining)
392
+
393
+ chunks.append(all_cta_words[i : i + take])
394
  i += take
395
 
396
+ # 3. Generate ASS Dialogue lines for each chunk
397
  ass_events = []
398
  for chunk in chunks:
399
  chunk_text_only = [item['word'] for item in chunk]
400
+
401
  for idx, info in enumerate(chunk):
402
  w_start = info['start']
403
+ # Match the start of the next word to avoid background box flickering
404
+ w_end = chunk[idx+1]['start'] if idx + 1 < len(chunk) else info['end']
405
+
406
+ text_parts = []
407
+ for j, word_str in enumerate(chunk_text_only):
408
+ if j == idx:
409
+ text_parts.append(f"{{\\c{highlight_text}}}{word_str}{{\\r}}")
410
+ else:
411
+ text_parts.append(word_str)
412
+
413
+ styled_text = ' '.join(text_parts)
414
+ ass_events.append(f"Dialogue: 1,{ms_to_ass_time(w_start)},{ms_to_ass_time(w_end)},Default,,0,0,0,,{styled_text}")
415
+
416
+ with open(ass_path, 'w', encoding='utf-8') as f:
417
  f.write(ass_header + '\n'.join(ass_events))
418
  return ass_path
419
 
 
420
  # =========================
421
  # MAIN STITCH FUNCTION
422
  # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
 
424
+ def stitch_media(video_file, video_url, audio_file, audio_url, subtitle_file, subtitle_url, book_cover_file, book_cover_url, book_cover_base64, book_id, book_title, enable_highlight, highlight_color, font_size, crf_quality=23):
425
+ temp_dir = tempfile.mkdtemp()
426
+ status_msg = "πŸš€ Starting video stitching...\n"
427
+ try:
428
+ ffmpeg_env = setup_custom_fonts_hf(temp_dir)
429
+ video_path, v_err = validate_and_get_file(video_file, video_url, 'video', temp_dir)
430
+ if v_err: return None, v_err
431
+ audio_path, a_err = validate_and_get_file(audio_file, audio_url, 'audio', temp_dir)
432
+ if a_err: return None, a_err
433
+ subtitle_path, s_err = validate_and_get_file(subtitle_file, subtitle_url, 'subtitle', temp_dir)
434
+ if s_err: return None, s_err
435
+
436
+ video_width, video_height, video_fps = get_video_info(video_path)
437
+ audio_duration = get_audio_duration(audio_path)
438
+
439
+ script_dir = os.path.dirname(os.path.abspath(__file__))
440
+ reddit_template_path = os.path.join(script_dir, REDDIT_CONFIG['template_file'])
441
+ has_reddit_template = os.path.exists(reddit_template_path)
442
+
443
+ first_sub_start = 0
444
+ first_sub_end = 0
445
+ if has_reddit_template:
446
+ try:
447
+ first_sub_text, first_sub_start, first_sub_end = extract_first_subtitle(subtitle_path)
448
+ status_msg += f"\nπŸ“± Reddit Overlay: '{first_sub_text[:30]}...'\n"
449
+ reddit_card_path = create_reddit_card_with_text(reddit_template_path, first_sub_text, temp_dir, REDDIT_CONFIG)
450
+ except Exception as e:
451
+ status_msg += f" β€’ ⚠️ Reddit card failed: {str(e)}\n"
452
+ has_reddit_template = False
453
+
454
+ # --- 1. Find CTA Info ---
455
+ title_timestamp, cta_timestamp, cta_text_raw = find_title_and_cta(subtitle_path, book_title)
456
+ book_appears_at = title_timestamp if title_timestamp is not None else audio_duration * (1 - VIDEO_CONFIG['promo_percent'])
457
+
458
+ box_appears_at = cta_timestamp if cta_timestamp is not None else book_appears_at + 1.5
459
+
460
+ if title_timestamp: status_msg += f"\nπŸ“– Book title at {title_timestamp:.2f}s\n"
461
+ if cta_timestamp: status_msg += f"πŸ–€ CTA text starts at {cta_timestamp:.2f}s\n"
462
+
463
+ # --- 2. Prepare Dynamic CTA Text ---
464
+ cta_ass_path = None
465
+ if cta_text_raw:
466
+ status_msg += "πŸ–€ Generating Instagram-style dynamic CTA...\n"
467
+ cta_font_size = int(video_width * 0.060) #INCREASE / DECREASE CTA FONT SIZE HERE
468
+
469
+ cta_ass_path = create_cta_highlight_ass(
470
+ subtitle_path, temp_dir, box_appears_at,
471
+ cta_font_size, video_width, video_height, highlight_color
472
+ )
473
+ cta_sub_escaped = cta_ass_path.replace('\\', '/').replace(':', '\\:')
474
+
475
+ # --- 3. Process Main Subtitles ---
476
+ if enable_highlight:
477
+ status_msg += f"\n✨ Processing subtitles...\n"
478
+ main_subtitle_path = create_word_by_word_highlight_ass(
479
+ subtitle_path, temp_dir, highlight_color, font_size,
480
+ skip_first=has_reddit_template, config=SUBTITLE_CONFIG,
481
+ cta_start_time_sec=title_timestamp
482
+ )
483
+ else:
484
+ main_subtitle_path = subtitle_path
485
+
486
+ main_sub_escaped = main_subtitle_path.replace('\\', '/').replace(':', '\\:')
487
+
488
+ book_cover_path, book_error = validate_book_cover_input(book_cover_file, book_cover_url, book_cover_base64, book_id, temp_dir)
489
+ if book_error: return None, book_error
490
+ has_book_cover = book_cover_path is not None
491
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
492
+ output_path = os.path.join(temp_dir, f"final_{timestamp}.mp4")
493
+
494
+ fade_starts_at = audio_duration * VIDEO_CONFIG['fade_start_percent']
495
+ fade_ends_at = audio_duration * VIDEO_CONFIG['fade_end_percent']
496
+ fade_out_duration = fade_ends_at - fade_starts_at
497
+ promo_duration = audio_duration * VIDEO_CONFIG['promo_percent']
498
+ solid_color_duration = max(0, book_appears_at - fade_ends_at)
499
+ main_video_duration = fade_ends_at
500
+ cover_segment_duration = promo_duration
501
+ fade_color_hex = "#dacfc3"
502
+
503
+ if has_book_cover:
504
+ try:
505
+ main_segment_path = os.path.join(temp_dir, f"main_{timestamp}.mp4")
506
+ cmd_main = ["ffmpeg", "-stream_loop", "-1", "-i", video_path, "-t", str(main_video_duration), "-vf", f"fps={video_fps},scale={video_width}:{video_height},fade=t=out:st={fade_starts_at}:d={fade_out_duration}:c={fade_color_hex}", "-c:v", "libx264", "-crf", str(crf_quality), "-pix_fmt", "yuv420p", "-an", "-y", main_segment_path]
507
+ subprocess.run(cmd_main, check=True, capture_output=True, text=True, env=ffmpeg_env)
508
+
509
+ solid_color_path = os.path.join(temp_dir, f"solid_{timestamp}.mp4")
510
+ cmd_solid = ["ffmpeg", "-f", "lavfi", "-i", f"color=c={fade_color_hex}:s={video_width}x{video_height}:d={solid_color_duration}:r={video_fps}", "-c:v", "libx264", "-crf", str(crf_quality), "-pix_fmt", "yuv420p", "-y", solid_color_path]
511
+ subprocess.run(cmd_solid, check=True, capture_output=True, text=True, env=ffmpeg_env)
512
+
513
+ cover_segment_path = os.path.join(temp_dir, f"cover_{timestamp}.mp4")
514
+ # Removed the fade-in effect here for a clean hard cut
515
+ cmd_cover = ["ffmpeg", "-loop", "1", "-i", book_cover_path, "-t", str(cover_segment_duration), "-vf", f"scale={video_width}:{video_height},setsar=1,fps={video_fps}", "-c:v", "libx264", "-crf", str(crf_quality), "-pix_fmt", "yuv420p", "-an", "-y", cover_segment_path]
516
+ subprocess.run(cmd_cover, check=True, capture_output=True, text=True, env=ffmpeg_env)
517
+
518
+ concat_list_path = os.path.join(temp_dir, f"concat_{timestamp}.txt")
519
+ with open(concat_list_path, 'w') as f:
520
+ f.write(f"file '{main_segment_path}'\n"); f.write(f"file '{solid_color_path}'\n"); f.write(f"file '{cover_segment_path}'\n")
521
+
522
+ #--- 4. Build the Filter Graph ---
523
+ input_cmd = ["ffmpeg", "-f", "concat", "-safe", "0", "-i", concat_list_path]
524
+ curr_idx = 1
525
+ curr_stream = "[0:v]"
526
+
527
+ # Layer 1: Reddit Card
528
+ if has_reddit_template:
529
+ input_cmd += ["-loop", "1", "-i", reddit_card_path]
530
+ filter_complex = f"[{curr_idx}:v]scale={video_width}*{VIDEO_CONFIG['reddit_scale_percent']}:-1[reddit];{curr_stream}[reddit]overlay=(W-w)/2:(H-h)/2:enable='between(t,{first_sub_start},{first_sub_end})'[v1];"
531
+ curr_stream, curr_idx = "[v1]", curr_idx + 1
532
+ else:
533
+ filter_complex = f"{curr_stream}copy[v1];"; curr_stream = "[v1]"
534
+
535
+ # Layer 2: Main Subtitles (Auto-stops right before CTA)
536
+ filter_complex += f"{curr_stream}ass={main_sub_escaped}[v2];"; curr_stream = "[v2]"
537
+
538
+ # Layer 3: Animated CTA Subtitles Overlay (Dynamic Box is built-in!)
539
+ if cta_ass_path:
540
+ filter_complex += f"{curr_stream}ass={cta_sub_escaped}[v_final]"
541
+ else:
542
+ filter_complex += f"{curr_stream}copy[v_final]"
543
+
544
+ input_cmd += ["-i", audio_path]
545
+ cmd_final = input_cmd + [
546
+ "-filter_complex", filter_complex,
547
+ "-map", "[v_final]", "-map", f"{curr_idx}:a",
548
+ "-c:v", "libx264", "-crf", str(crf_quality),
549
+ "-c:a", "aac", "-pix_fmt", "yuv420p", "-shortest", "-y", output_path
550
+ ]
551
+
552
+ status_msg += "🎬 Rendering final video...\n"
553
+ subprocess.run(cmd_final, check=True, capture_output=True, text=True, env=ffmpeg_env)
554
+
555
+ except subprocess.CalledProcessError as e: return None, f"❌ FFmpeg error:\n{e.stderr[-1000:] if e.stderr else str(e)}"
556
+ except Exception as e: return None, f"❌ Error: {str(e)}"
557
+ else: return None, "❌ Book cover required."
558
+
559
+ if os.path.exists(output_path): return output_path, f"βœ… Success!"
560
+ else: return None, "❌ Output not created"
561
+ except Exception as e: return None, f"❌ Error: {str(e)}"
562
 
563
 
564
  # ========================================
 
629
  raise HTTPException(status_code=422, detail=f"❌ Invalid video format: {video_file.content_type}")
630
  if audio_file and audio_file.content_type not in {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/aac", "audio/mp4", "audio/x-m4a"}:
631
  raise HTTPException(status_code=422, detail=f"❌ Invalid audio format: {audio_file.content_type}")
632
+ if subtitle_file and not (subtitle_file.filename.endswith('.srt') or subtitle_file.filename.endswith('.json')):
633
+ raise HTTPException(status_code=422, detail="❌ Subtitle must be a .srt or .json file")
634
  if book_cover_file and book_cover_file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
635
  raise HTTPException(status_code=422, detail="❌ Book cover must be jpeg, png, or webp")
636