huuyfytryr commited on
Commit
457f453
Β·
1 Parent(s): f7d5742

Fix video filters, text overlay, burn subtitles (en, hi, ur), add ZIP download and Beautify preset

Browse files
Files changed (5) hide show
  1. app.py +125 -4
  2. static/css/style.css +6 -0
  3. static/index.html +50 -6
  4. static/js/app.js +3 -0
  5. utils/video_effects.py +82 -34
app.py CHANGED
@@ -556,6 +556,63 @@ def merge_videos():
556
  traceback.print_exc()
557
  return jsonify({'success': False, 'error': str(e)}), 500
558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  @app.route('/api/clip', methods=['POST'])
560
  def clip_youtube_video():
561
  """
@@ -578,19 +635,29 @@ def clip_youtube_video():
578
  except ValueError:
579
  b_width = 0
580
 
 
 
 
 
 
 
 
 
581
  filters = {
582
  'aspect_ratio': request.form.get('aspect_ratio', 'original'),
583
- 'mirror': request.form.get('mirror', 'true') == 'true',
584
  'zoom': request.form.get('zoom', 'true') == 'true',
 
 
 
585
  'speed': float(request.form.get('speed', 1.04)),
586
  'pitch_shift': float(request.form.get('pitch_shift', 0.8)),
587
  'border_color': request.form.get('border_color', 'none'),
588
  'border_width': b_width,
589
  'text_overlay': request.form.get('text_overlay', ''),
590
- 'color_shift': request.form.get('color_shift', 'false') == 'true'
591
  }
592
 
593
- job_id = str(uuid.uuid4())
594
  raw_download_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_raw.mp4")
595
 
596
  dl_success = False
@@ -735,7 +802,7 @@ def clip_youtube_video():
735
  if os.path.exists(out_path):
736
  processed_files.append(out_filename)
737
 
738
- # Cleanup raw downloaded file & sliced folder
739
  if os.path.exists(raw_download_path):
740
  os.remove(raw_download_path)
741
 
@@ -743,6 +810,12 @@ def clip_youtube_video():
743
  if os.path.exists(temp_clips_dir):
744
  shutil.rmtree(temp_clips_dir)
745
 
 
 
 
 
 
 
746
  return jsonify({
747
  'success': True,
748
  'message': f'YouTube video processed into {len(processed_files)} clips!',
@@ -750,6 +823,18 @@ def clip_youtube_video():
750
  })
751
 
752
  except Exception as e:
 
 
 
 
 
 
 
 
 
 
 
 
753
  import traceback
754
  traceback.print_exc()
755
  return jsonify({'success': False, 'error': str(e)}), 500
@@ -856,6 +941,42 @@ def download_file(filename):
856
  return send_from_directory(OUTPUT_FOLDER, filename)
857
 
858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  if __name__ == '__main__':
860
  import os
861
  port = int(os.environ.get('PORT', 5005))
 
556
  traceback.print_exc()
557
  return jsonify({'success': False, 'error': str(e)}), 500
558
 
559
+ def download_youtube_subtitles(url, lang, job_id):
560
+ """
561
+ Downloads subtitles or auto-generated subtitles for a YouTube video using yt-dlp.
562
+ Respects rate limits by employing cookies, proxy, and user impersonation.
563
+ """
564
+ ytdlp_bin = get_pip_binary('yt-dlp')
565
+ has_cookies = os.path.exists(COOKIES_FILE_PATH) and os.path.getsize(COOKIES_FILE_PATH) > 0
566
+ yt_proxy = os.environ.get('YT_PROXY', '')
567
+
568
+ try:
569
+ import curl_cffi
570
+ has_curl_cffi = True
571
+ except ImportError:
572
+ has_curl_cffi = False
573
+
574
+ output_template = os.path.join(UPLOAD_FOLDER, f"{job_id}_subs")
575
+
576
+ cmd = [
577
+ ytdlp_bin, '--skip-download',
578
+ '--write-subs', '--write-auto-subs',
579
+ '--sub-format', 'srt',
580
+ '--sub-langs', lang,
581
+ '--geo-bypass', '--no-check-certificates', '-4',
582
+ '--socket-timeout', '15', '--retries', '1',
583
+ '-o', f"{output_template}.%(ext)s"
584
+ ]
585
+
586
+ if has_cookies:
587
+ cmd.extend(['--cookies', COOKIES_FILE_PATH])
588
+ if yt_proxy:
589
+ cmd.extend(['--proxy', yt_proxy])
590
+ if has_curl_cffi:
591
+ cmd.extend(['--impersonate', 'chrome'])
592
+ cmd.extend(['--extractor-args', 'youtube:player_client=ios,android,web'])
593
+ else:
594
+ cmd.extend(['--extractor-args', 'youtube:player_client=default,-tv'])
595
+
596
+ cmd.append(url)
597
+
598
+ print(f"[subtitles] Requesting subtitles ({lang}) for {url} ...")
599
+ try:
600
+ res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
601
+ print(f"[subtitles] yt-dlp exit status: {res.returncode}")
602
+
603
+ # Look for matching srt file in UPLOAD_FOLDER
604
+ import glob
605
+ matches = glob.glob(os.path.join(UPLOAD_FOLDER, f"{job_id}_subs.*.srt"))
606
+ if matches:
607
+ print(f"[subtitles] Found downloaded subtitles: {matches[0]}")
608
+ return matches[0]
609
+ else:
610
+ stderr_out = res.stderr.decode('utf-8', errors='ignore')[-300:]
611
+ print(f"[subtitles] Subtitle download returned no srt file. Stderr: {stderr_out}")
612
+ except Exception as e:
613
+ print(f"[subtitles] Exception during subtitle download: {e}")
614
+ return None
615
+
616
  @app.route('/api/clip', methods=['POST'])
617
  def clip_youtube_video():
618
  """
 
635
  except ValueError:
636
  b_width = 0
637
 
638
+ job_id = str(uuid.uuid4())
639
+
640
+ # Subtitle Handling
641
+ caption_lang = request.form.get('caption_lang', 'none')
642
+ subtitle_file_path = None
643
+ if caption_lang != 'none' and url:
644
+ subtitle_file_path = download_youtube_subtitles(url, caption_lang, job_id)
645
+
646
  filters = {
647
  'aspect_ratio': request.form.get('aspect_ratio', 'original'),
648
+ 'mirror_mode': request.form.get('mirror_mode', 'none'),
649
  'zoom': request.form.get('zoom', 'true') == 'true',
650
+ 'color_filter': request.form.get('color_filter', 'none'),
651
+ 'vignette': request.form.get('vignette', 'false') == 'true',
652
+ 'noise_grain': request.form.get('noise_grain', 'false') == 'true',
653
  'speed': float(request.form.get('speed', 1.04)),
654
  'pitch_shift': float(request.form.get('pitch_shift', 0.8)),
655
  'border_color': request.form.get('border_color', 'none'),
656
  'border_width': b_width,
657
  'text_overlay': request.form.get('text_overlay', ''),
658
+ 'subtitle_file_path': subtitle_file_path
659
  }
660
 
 
661
  raw_download_path = os.path.join(UPLOAD_FOLDER, f"{job_id}_raw.mp4")
662
 
663
  dl_success = False
 
802
  if os.path.exists(out_path):
803
  processed_files.append(out_filename)
804
 
805
+ # Cleanup raw downloaded file & sliced folder & subtitles
806
  if os.path.exists(raw_download_path):
807
  os.remove(raw_download_path)
808
 
 
810
  if os.path.exists(temp_clips_dir):
811
  shutil.rmtree(temp_clips_dir)
812
 
813
+ if subtitle_file_path and os.path.exists(subtitle_file_path):
814
+ try:
815
+ os.remove(subtitle_file_path)
816
+ except Exception:
817
+ pass
818
+
819
  return jsonify({
820
  'success': True,
821
  'message': f'YouTube video processed into {len(processed_files)} clips!',
 
823
  })
824
 
825
  except Exception as e:
826
+ import shutil
827
+ # Cleanup files in case of error
828
+ if 'raw_download_path' in locals() and os.path.exists(raw_download_path):
829
+ try: os.remove(raw_download_path)
830
+ except Exception: pass
831
+ if 'temp_clips_dir' in locals() and os.path.exists(temp_clips_dir):
832
+ try: shutil.rmtree(temp_clips_dir)
833
+ except Exception: pass
834
+ if 'subtitle_file_path' in locals() and subtitle_file_path and os.path.exists(subtitle_file_path):
835
+ try: os.remove(subtitle_file_path)
836
+ except Exception: pass
837
+
838
  import traceback
839
  traceback.print_exc()
840
  return jsonify({'success': False, 'error': str(e)}), 500
 
941
  return send_from_directory(OUTPUT_FOLDER, filename)
942
 
943
 
944
+ @app.route('/api/download-all', methods=['GET'])
945
+ def download_all():
946
+ """
947
+ ZIP all exported MP4 clips in the outputs directory and return as a single download.
948
+ """
949
+ import zipfile
950
+ import io
951
+ from flask import send_file
952
+ from datetime import datetime
953
+
954
+ try:
955
+ # Create in-memory zip
956
+ zip_buffer = io.BytesIO()
957
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
958
+ for f in os.listdir(OUTPUT_FOLDER):
959
+ # skip temp / partial files
960
+ if f.endswith('.mp4') and not any(pat in f for pat in ['.temp', 'TEMP_MPY', '.part', '.tmp']):
961
+ path = os.path.join(OUTPUT_FOLDER, f)
962
+ if os.path.exists(path) and os.path.getsize(path) > 0:
963
+ zip_file.write(path, f)
964
+
965
+ zip_buffer.seek(0)
966
+
967
+ zip_filename = f"exported_clips_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
968
+ return send_file(
969
+ zip_buffer,
970
+ mimetype='application/zip',
971
+ as_attachment=True,
972
+ download_name=zip_filename
973
+ )
974
+ except Exception as e:
975
+ import traceback
976
+ traceback.print_exc()
977
+ return jsonify({'success': False, 'error': f"Failed to zip files: {str(e)}"}), 500
978
+
979
+
980
  if __name__ == '__main__':
981
  import os
982
  port = int(os.environ.get('PORT', 5005))
static/css/style.css CHANGED
@@ -996,6 +996,12 @@ body.light-theme .library-action-btn {
996
  border-color: rgba(244, 63, 94, 0.3);
997
  }
998
 
 
 
 
 
 
 
999
  .refresh-btn:hover svg {
1000
  transform: rotate(180deg);
1001
  transition: transform 0.5s ease;
 
996
  border-color: rgba(244, 63, 94, 0.3);
997
  }
998
 
999
+ .zip-btn:hover {
1000
+ color: var(--success);
1001
+ background: var(--success-light);
1002
+ border-color: rgba(16, 185, 129, 0.3);
1003
+ }
1004
+
1005
  .refresh-btn:hover svg {
1006
  transform: rotate(180deg);
1007
  transition: transform 0.5s ease;
static/index.html CHANGED
@@ -267,24 +267,63 @@
267
 
268
  <div class="toggles-grid">
269
  <label class="toggle-control">
270
- <span class="toggle-label">↔ Flip</span>
271
- <input type="checkbox" name="mirror" value="true" checked>
272
  <span class="toggle-switch"></span>
273
  </label>
274
 
275
  <label class="toggle-control">
276
- <span class="toggle-label">πŸ” Zoom</span>
277
- <input type="checkbox" name="zoom" value="true" checked>
278
  <span class="toggle-switch"></span>
279
  </label>
280
 
281
  <label class="toggle-control">
282
- <span class="toggle-label">🎨 Color</span>
283
- <input type="checkbox" name="color_shift" value="true" checked>
284
  <span class="toggle-switch"></span>
285
  </label>
286
  </div>
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  <div class="form-grid">
289
  <div class="form-group">
290
  <label for="text_overlay">Text Overlay Watermark</label>
@@ -370,6 +409,11 @@
370
  <path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l.56-.56" stroke-linecap="round" stroke-linejoin="round"/>
371
  </svg>
372
  </button>
 
 
 
 
 
373
  <button class="library-action-btn clear-btn" id="clear-library-btn" type="button" title="Clear all files">
374
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
375
  <polyline points="3 6 5 6 21 6"/>
 
267
 
268
  <div class="toggles-grid">
269
  <label class="toggle-control">
270
+ <span class="toggle-label">πŸ” Zoom</span>
271
+ <input type="checkbox" name="zoom" value="true" checked>
272
  <span class="toggle-switch"></span>
273
  </label>
274
 
275
  <label class="toggle-control">
276
+ <span class="toggle-label">🎭 Vignette</span>
277
+ <input type="checkbox" name="vignette" value="true">
278
  <span class="toggle-switch"></span>
279
  </label>
280
 
281
  <label class="toggle-control">
282
+ <span class="toggle-label">πŸ“Ί Grain/Noise</span>
283
+ <input type="checkbox" name="noise_grain" value="true">
284
  <span class="toggle-switch"></span>
285
  </label>
286
  </div>
287
 
288
+ <div class="form-grid-2col">
289
+ <div class="form-group">
290
+ <label for="mirror_mode">↔ Mirror Flip Mode</label>
291
+ <select id="mirror_mode" name="mirror_mode">
292
+ <option value="none">No Flip</option>
293
+ <option value="horizontal" selected>Horizontal Flip</option>
294
+ <option value="vertical">Vertical Flip</option>
295
+ <option value="both">Both Flip</option>
296
+ </select>
297
+ </div>
298
+ <div class="form-group">
299
+ <label for="color_filter">🎨 Color Grading</label>
300
+ <select id="color_filter" name="color_filter">
301
+ <option value="none">No Filter</option>
302
+ <option value="beautify" selected>Beautiful Glow (Skin/Details)</option>
303
+ <option value="cinematic">Cinematic Contrast</option>
304
+ <option value="vibrant">Vibrant Saturation</option>
305
+ <option value="warm">Warm Sepia</option>
306
+ <option value="cool">Cool Blue</option>
307
+ <option value="vintage">Vintage Retro</option>
308
+ <option value="grayscale">Grayscale B&W</option>
309
+ <option value="dramatic">Dramatic Film</option>
310
+ <option value="faded">Faded Matte</option>
311
+ </select>
312
+ </div>
313
+ </div>
314
+
315
+ <div class="form-grid">
316
+ <div class="form-group">
317
+ <label for="caption_lang">πŸ’¬ Burn Subtitles / Captions (YouTube Only)</label>
318
+ <select id="caption_lang" name="caption_lang">
319
+ <option value="none" selected>No Captions</option>
320
+ <option value="en">English Captions</option>
321
+ <option value="hi">Hinglish / Hindi Captions</option>
322
+ <option value="ur">Urdu Captions</option>
323
+ </select>
324
+ </div>
325
+ </div>
326
+
327
  <div class="form-grid">
328
  <div class="form-group">
329
  <label for="text_overlay">Text Overlay Watermark</label>
 
409
  <path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l.56-.56" stroke-linecap="round" stroke-linejoin="round"/>
410
  </svg>
411
  </button>
412
+ <button class="library-action-btn zip-btn" id="download-all-btn" type="button" title="Download all clips as ZIP">
413
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
414
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4m-7-5v9m-4-4l4 4 4-4m-4-10v5a2 2 0 002 2h2" stroke-linecap="round" stroke-linejoin="round"/>
415
+ </svg>
416
+ </button>
417
  <button class="library-action-btn clear-btn" id="clear-library-btn" type="button" title="Clear all files">
418
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
419
  <polyline points="3 6 5 6 21 6"/>
static/js/app.js CHANGED
@@ -370,6 +370,9 @@ document.addEventListener('DOMContentLoaded', () => {
370
  });
371
 
372
  $('refresh-gallery')?.addEventListener('click', loadGallery);
 
 
 
373
  $('clear-library-btn')?.addEventListener('click', clearLibrary);
374
  $('library-search')?.addEventListener('input', loadGallery);
375
 
 
370
  });
371
 
372
  $('refresh-gallery')?.addEventListener('click', loadGallery);
373
+ $('download-all-btn')?.addEventListener('click', () => {
374
+ window.location.href = '/api/download-all';
375
+ });
376
  $('clear-library-btn')?.addEventListener('click', clearLibrary);
377
  $('library-search')?.addEventListener('input', loadGallery);
378
 
utils/video_effects.py CHANGED
@@ -2,6 +2,15 @@ import os
2
  import subprocess
3
  import tempfile
4
 
 
 
 
 
 
 
 
 
 
5
  def crop_to_aspect_ratio(clip, target_w, target_h):
6
  """
7
  Crops a video clip from its center to match the target aspect ratio,
@@ -137,29 +146,24 @@ def apply_copyright_filters(input_path, output_path, options):
137
  """
138
  Applies visual and audio transformations to bypass copyright filters.
139
  Uses pure FFmpeg subprocess for reliability β€” no moviepy silent failures.
140
-
141
- Filters applied:
142
- - Aspect Ratio (vertical 9:16, horizontal 16:9, or original)
143
- - Horizontal mirror (hflip)
144
- - 5% center zoom-in (crop + scale)
145
- - Speed adjustment (setpts + atempo)
146
- - Audio pitch shift (asetrate + atempo correction)
147
  """
148
- aspect = options.get("aspect_ratio", "original")
149
- do_mirror = options.get("mirror", True)
150
- do_zoom = options.get("zoom", True)
151
- speed_factor = float(options.get("speed", 1.04))
152
- pitch_semi = float(options.get("pitch_shift", 0.8))
153
-
154
- border_color = options.get("border_color", "none")
 
 
 
 
 
 
155
  try:
156
  border_width = int(options.get("border_width", 0))
157
  except ValueError:
158
  border_width = 0
159
- text_overlay = options.get("text_overlay", "")
160
- color_shift = options.get("color_shift", False)
161
- if isinstance(color_shift, str):
162
- color_shift = color_shift.lower() == "true"
163
 
164
  # ── Check if the source has an audio stream ──────────────────────
165
  probe = subprocess.run(
@@ -185,17 +189,52 @@ def apply_copyright_filters(input_path, output_path, options):
185
  # Pad with border color
186
  vf.append(f"pad=iw+2*{border_width}:ih+2*{border_width}:{border_width}:{border_width}:color={border_color}")
187
 
188
- if do_mirror:
 
189
  vf.append("hflip")
 
 
 
 
190
 
191
  if do_zoom:
192
  # Scale up 5%, then crop center back to original size
193
  vf.append("scale=iw*1.05:ih*1.05,crop=iw/1.05:ih/1.05")
194
 
195
- if color_shift:
196
- # Slightly shift color metrics
197
- vf.append("eq=contrast=1.03:brightness=0.02:saturation=1.08")
198
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  if text_overlay:
200
  # Check if drawtext filter is available in the host FFmpeg build
201
  drawtext_available = False
@@ -213,18 +252,27 @@ def apply_copyright_filters(input_path, output_path, options):
213
  clean_text = clean_text.replace("'", "'\\''").replace(":", "\\:")
214
 
215
  # Resolve standard font paths for macOS/Linux compatibility
216
- font_paths = [
217
- "/System/Library/Fonts/Helvetica.ttc",
218
- "/Library/Fonts/Arial.ttf",
219
- "/System/Library/Fonts/Supplemental/Arial.ttf",
220
- "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
221
- "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
222
- ]
223
  fontfile_param = ""
224
- for p in font_paths:
225
- if os.path.exists(p):
226
- fontfile_param = f":fontfile='{p}'"
227
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  vf.append(f"drawtext=text='{clean_text}'{fontfile_param}:x=(w-text_w)/2:y=100:fontsize=36:fontcolor=white:box=1:boxcolor=black@0.4:boxborderw=6")
229
  else:
230
  print("[video_effects] Warning: 'drawtext' filter is not supported by this FFmpeg build. Skipping text overlay.")
 
2
  import subprocess
3
  import tempfile
4
 
5
+ def _parse_bool(val, default=False):
6
+ if val is None:
7
+ return default
8
+ if isinstance(val, bool):
9
+ return val
10
+ if isinstance(val, str):
11
+ return val.lower() in ("true", "1", "yes", "on")
12
+ return bool(val)
13
+
14
  def crop_to_aspect_ratio(clip, target_w, target_h):
15
  """
16
  Crops a video clip from its center to match the target aspect ratio,
 
146
  """
147
  Applies visual and audio transformations to bypass copyright filters.
148
  Uses pure FFmpeg subprocess for reliability β€” no moviepy silent failures.
 
 
 
 
 
 
 
149
  """
150
+ aspect = options.get("aspect_ratio", "original")
151
+ mirror_mode = options.get("mirror_mode", "horizontal")
152
+ color_filter = options.get("color_filter", "cinematic")
153
+ do_zoom = _parse_bool(options.get("zoom"), True)
154
+ vignette = _parse_bool(options.get("vignette"), False)
155
+ noise_grain = _parse_bool(options.get("noise_grain"), False)
156
+ text_overlay = options.get("text_overlay", "")
157
+ subtitle_path = options.get("subtitle_file_path")
158
+
159
+ speed_factor = float(options.get("speed", 1.04))
160
+ pitch_semi = float(options.get("pitch_shift", 0.8))
161
+
162
+ border_color = options.get("border_color", "none")
163
  try:
164
  border_width = int(options.get("border_width", 0))
165
  except ValueError:
166
  border_width = 0
 
 
 
 
167
 
168
  # ── Check if the source has an audio stream ──────────────────────
169
  probe = subprocess.run(
 
189
  # Pad with border color
190
  vf.append(f"pad=iw+2*{border_width}:ih+2*{border_width}:{border_width}:{border_width}:color={border_color}")
191
 
192
+ # Flip Mode
193
+ if mirror_mode == "horizontal":
194
  vf.append("hflip")
195
+ elif mirror_mode == "vertical":
196
+ vf.append("vflip")
197
+ elif mirror_mode == "both":
198
+ vf.append("hflip,vflip")
199
 
200
  if do_zoom:
201
  # Scale up 5%, then crop center back to original size
202
  vf.append("scale=iw*1.05:ih*1.05,crop=iw/1.05:ih/1.05")
203
 
204
+ # Color Grading Filters
205
+ if color_filter == "cinematic":
206
+ vf.append("eq=contrast=1.15:brightness=0.03:saturation=1.2")
207
+ elif color_filter == "vibrant":
208
+ vf.append("eq=saturation=1.35:contrast=1.05")
209
+ elif color_filter == "beautify":
210
+ vf.append("eq=brightness=0.05:contrast=1.05:saturation=1.15,unsharp=3:3:0.5:3:3:0.5")
211
+ elif color_filter == "warm":
212
+ vf.append("colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131:0")
213
+ elif color_filter == "cool":
214
+ vf.append("colorchannelmixer=.3:.3:.3:0:.3:.3:.3:0:.8:.8:.8:0")
215
+ elif color_filter == "vintage":
216
+ vf.append("colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131:0")
217
+ elif color_filter == "grayscale":
218
+ vf.append("colorchannelmixer=.3:.59:.11:0:.3:.59:.11:0:.3:.59:.11:0")
219
+ elif color_filter == "dramatic":
220
+ vf.append("eq=contrast=1.3:brightness=-0.02:saturation=0.6")
221
+ elif color_filter == "faded":
222
+ vf.append("eq=contrast=0.85:brightness=0.08:saturation=0.9")
223
+
224
+ # Vignette
225
+ if vignette:
226
+ vf.append("vignette=pi/5")
227
+
228
+ # Noise/Grain
229
+ if noise_grain:
230
+ vf.append("noise=alls=3:allf=t")
231
+
232
+ # Burn subtitles if present
233
+ if subtitle_path and os.path.exists(subtitle_path):
234
+ escaped_sub_path = subtitle_path.replace("'", "'\\''").replace(":", "\\:")
235
+ vf.append(f"subtitles='{escaped_sub_path}':force_style='FontSize=20,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1'")
236
+
237
+ # Text Overlay
238
  if text_overlay:
239
  # Check if drawtext filter is available in the host FFmpeg build
240
  drawtext_available = False
 
252
  clean_text = clean_text.replace("'", "'\\''").replace(":", "\\:")
253
 
254
  # Resolve standard font paths for macOS/Linux compatibility
255
+ import glob
 
 
 
 
 
 
256
  fontfile_param = ""
257
+ # Search recursively in Linux standard font directory
258
+ linux_fonts = glob.glob("/usr/share/fonts/**/*.ttf", recursive=True) + \
259
+ glob.glob("/usr/share/fonts/**/*.ttc", recursive=True)
260
+ if linux_fonts:
261
+ escaped_p = linux_fonts[0].replace("'", "'\\''").replace(":", "\\:")
262
+ fontfile_param = f":fontfile='{escaped_p}'"
263
+ else:
264
+ font_paths = [
265
+ "/System/Library/Fonts/Helvetica.ttc",
266
+ "/Library/Fonts/Arial.ttf",
267
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
268
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
269
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
270
+ ]
271
+ for p in font_paths:
272
+ if os.path.exists(p):
273
+ escaped_p = p.replace("'", "'\\''").replace(":", "\\:")
274
+ fontfile_param = f":fontfile='{escaped_p}'"
275
+ break
276
  vf.append(f"drawtext=text='{clean_text}'{fontfile_param}:x=(w-text_w)/2:y=100:fontsize=36:fontcolor=white:box=1:boxcolor=black@0.4:boxborderw=6")
277
  else:
278
  print("[video_effects] Warning: 'drawtext' filter is not supported by this FFmpeg build. Skipping text overlay.")