Spaces:
Running
Running
Commit Β·
457f453
1
Parent(s): f7d5742
Fix video filters, text overlay, burn subtitles (en, hi, ur), add ZIP download and Beautify preset
Browse files- app.py +125 -4
- static/css/style.css +6 -0
- static/index.html +50 -6
- static/js/app.js +3 -0
- 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 |
-
'
|
| 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 |
-
'
|
| 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">
|
| 271 |
-
<input type="checkbox" name="
|
| 272 |
<span class="toggle-switch"></span>
|
| 273 |
</label>
|
| 274 |
|
| 275 |
<label class="toggle-control">
|
| 276 |
-
<span class="toggle-label">
|
| 277 |
-
<input type="checkbox" name="
|
| 278 |
<span class="toggle-switch"></span>
|
| 279 |
</label>
|
| 280 |
|
| 281 |
<label class="toggle-control">
|
| 282 |
-
<span class="toggle-label">
|
| 283 |
-
<input type="checkbox" name="
|
| 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
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 196 |
-
|
| 197 |
-
vf.append("eq=contrast=1.
|
| 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 |
-
|
| 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 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.")
|