Created
December 8, 2025 00:24
-
-
Save JupyterJones/b60bbed38ffd1c23dfc3f2f33066848a to your computer and use it in GitHub Desktop.
With this flask app you may create a video or join two existing videos
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import os | |
| import uuid | |
| import subprocess | |
| import requests | |
| from flask import Flask, request, send_from_directory | |
| from werkzeug.utils import secure_filename | |
| from icecream import ic | |
| from flask import Flask, request, render_template_string | |
| from flask import send_from_directory, redirect | |
| import os | |
| import uuid | |
| import subprocess | |
| from icecream import ic | |
| app = Flask(__name__) | |
| PROJECT_DIR = "static/ADS" | |
| os.makedirs(PROJECT_DIR, exist_ok=True) | |
| ''' | |
| https://gist.github.com/JupyterJones/0aea5806a684a661d50922d46c7452f4 | |
| This is a Flask Application That can join multiplt video formats: | |
| What formats this FFmpeg script can decode from in practice | |
| Even though this UI only lists a few extensions, FFmpeg can decode: | |
| Many containers it can handle | |
| MP4, MKV, MOV, AVI, WEBM, OGG, M4V, FLV, TS, MPEG, many more\n, Video | |
| codecs it can decode | |
| H.264 / AVC, HEVC / H.265, VP8, VP9, AV1, MPEG-2, MPEG-4 ASP (DivX, XviD), | |
| WMV, Theora, ProRes, DNxHD/DNxHR, MJPEG, | |
| Many others (FFmpeg supports hundreds) | |
| Audio codecs it can decode, AAC, MP3, Opus, Vorbis, AC-3, PCM, FLAC, ALAC, | |
| DTS, WMA, Many more | |
| ''' | |
| def run(cmd): | |
| ic("Running command:", " ".join(cmd)) | |
| result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
| ic("Return code:", result.returncode) | |
| if result.stdout: | |
| ic(result.stdout.decode(errors="ignore")) | |
| if result.stderr: | |
| ic(result.stderr.decode(errors="ignore")) | |
| return result.returncode == 0 | |
| def normalize_video(src, dst): | |
| cmd = [ | |
| "ffmpeg", "-y", | |
| "-i", src, | |
| "-c:v", "libx264", | |
| "-preset", "fast", | |
| "-crf", "18", | |
| "-c:a", "aac", | |
| "-ar", "48000", | |
| dst | |
| ] | |
| return run(cmd) | |
| def concat_videos(v1, v2, output_file): | |
| list_file = f"concat_{uuid.uuid4()}.txt" | |
| with open(list_file, "w") as f: | |
| f.write(f"file '{v1}'\n") | |
| f.write(f"file '{v2}'\n") | |
| cmd = [ | |
| "ffmpeg", "-y", | |
| "-f", "concat", | |
| "-safe", "0", | |
| "-i", list_file, | |
| "-c", "copy", | |
| output_file | |
| ] | |
| ok = run(cmd) | |
| os.remove(list_file) | |
| return ok | |
| @app.route("/jointwo", methods=["GET"]) | |
| def jointwo(): | |
| videos = [] | |
| # Ensure project directory exists | |
| if not os.path.isdir(PROJECT_DIR): | |
| ic(f"Directory not found: {PROJECT_DIR}") | |
| return "<h2>Project directory does not exist.</h2>" | |
| for f in os.listdir(PROJECT_DIR): | |
| if f.lower().endswith((".mp4", ".mkv", ".ogg", ".webm", ".avi", ".mov")): | |
| full_path = os.path.join(PROJECT_DIR, f) | |
| try: | |
| mtime = os.path.getmtime(full_path) | |
| videos.append((f, mtime)) | |
| ic(f"Found video: {f} | mtime: {mtime}") | |
| except Exception as e: | |
| ic(f"Error reading file time for {f}: {e}") | |
| videos.sort(key=lambda x: x[1], reverse=True) | |
| videos = [v[0] for v in videos] | |
| ic("Sorted videos newest-first:") | |
| ic(videos) | |
| html = ''' | |
| <h2 style="font-family:Arial; color:white;">Video Joiner — Click to | |
| Select Boxes ❤️</h2> | |
| <a style = "font-size:2vw;" href = "/">Create a Video</a><br /><br /> | |
| <p style="color:white;">Click a video to assign it to Box 1 or Box 2. | |
| Selected videos are highlighted in blue.</p> | |
| <style> | |
| html, body { background-color: slategray; font-family: Arial; } | |
| .video-box { | |
| margin: 10px; | |
| padding: 5px; | |
| border: 2px solid #ccc; | |
| cursor: pointer; | |
| width: 240px; | |
| word-wrap: break-word; | |
| text-align: center; | |
| display: inline-block; | |
| vertical-align: top; | |
| } | |
| .video-name { | |
| margin-top: 5px; | |
| font-size: 0.9em; | |
| word-wrap: break-word; | |
| color: white; | |
| } | |
| .box-label { | |
| color: white; | |
| background-color: darkblue; | |
| display: block; | |
| padding: 3px 6px; | |
| border-radius: 4px; | |
| margin-top: 5px; | |
| text-align: center; | |
| font-weight: bold; | |
| visibility: hidden; | |
| } | |
| .video-box.selected .box-label { | |
| visibility: visible; | |
| } | |
| .video-box:hover { border-color: green; } | |
| button { margin-top: 20px; padding: 8px 16px; font-size: 1em; } | |
| </style> | |
| <form method="POST" action="/join" id="joinForm"> | |
| <input type="hidden" name="video1" id="video1"> | |
| <input type="hidden" name="video2" id="video2"> | |
| <button type="submit">Join Videos</button> | |
| </form> | |
| <div> | |
| {% for v in videos %} | |
| <div class="video-box" data-filename="{{v}}"> | |
| <video width="240" controls> | |
| <source src="/video/{{v}}" type="video/mp4"> | |
| <source src="/video/{{v}}" type="video/webm"> | |
| <source src="/video/{{v}}" type="video/mkv"> | |
| Your browser does not support HTML5 video. | |
| </video> | |
| <div class="video-name">{{v}}</div> | |
| <div class="box-label"></div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| <script> | |
| let box1 = null; | |
| let box2 = null; | |
| document.querySelectorAll(".video-box").forEach(function(el){ | |
| el.addEventListener("click", function(){ | |
| const filename = el.getAttribute("data-filename"); | |
| const label = el.querySelector(".box-label"); | |
| if (!box1) { | |
| box1 = el; | |
| document.getElementById("video1").value = filename; | |
| el.classList.add("selected"); | |
| label.textContent = "Box 1"; | |
| el.style.border = "4px solid blue"; | |
| } else if (!box2 && el !== box1) { | |
| box2 = el; | |
| document.getElementById("video2").value = filename; | |
| el.classList.add("selected"); | |
| label.textContent = "Box 2"; | |
| el.style.border = "4px solid blue"; | |
| } else { | |
| if (el === box1) { | |
| box1 = null; | |
| document.getElementById("video1").value = ""; | |
| el.classList.remove("selected"); | |
| label.textContent = ""; | |
| el.style.border = "2px solid #ccc"; | |
| } else if (el === box2) { | |
| box2 = null; | |
| document.getElementById("video2").value = ""; | |
| el.classList.remove("selected"); | |
| label.textContent = ""; | |
| el.style.border = "2px solid #ccc"; | |
| } | |
| } | |
| }); | |
| }); | |
| </script> | |
| ''' | |
| return render_template_string(html, videos=videos) | |
| ''' | |
| @app.route("/video/<path:filename>") | |
| def serve_video(filename): | |
| return send_from_directory(PROJECT_DIR, filename) | |
| ''' | |
| @app.route("/join", methods=["POST"]) | |
| def join_videos(): | |
| v1 = request.form.get("video1") | |
| v2 = request.form.get("video2") | |
| if not v1 or not v2: | |
| return "Please select both Box 1 and Box 2", 400 | |
| src1 = os.path.join(PROJECT_DIR, v1) | |
| src2 = os.path.join(PROJECT_DIR, v2) | |
| ic("Selected video 1:", src1) | |
| ic("Selected video 2:", src2) | |
| uid = str(uuid.uuid4()) | |
| norm1 = os.path.join(PROJECT_DIR, f"norm1_{uid}.mkv") | |
| norm2 = os.path.join(PROJECT_DIR, f"norm2_{uid}.mkv") | |
| if not normalize_video(src1, norm1): | |
| return "Failed to normalize video 1" | |
| if not normalize_video(src2, norm2): | |
| return "Failed to normalize video 2" | |
| out_file = f"Publish_{uid}.mkv" | |
| out_path = os.path.join(PROJECT_DIR, out_file) | |
| if not concat_videos(norm1, norm2, out_path): | |
| return "Concat failed" | |
| os.remove(norm1) | |
| os.remove(norm2) | |
| return redirect(f"/video/{out_file}") | |
| app.config['UPLOAD_FOLDER'] = 'static/ADS' | |
| os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) | |
| TTS_API_URL = "http://localhost:8880/v1/audio/speech" | |
| ASSISTANT_VOICE = "am_michael" | |
| HEADERS = {"Content-Type": "application/json"} | |
| HTML_PAGE = """ | |
| <!doctype html> | |
| <title>Image to Video</title> | |
| <h2>Upload Image and Enter Text</h2> | |
| <a style = "font-size:2vw;" href = "/jointwo">Join Two Videos</a><br /><br /> | |
| <form method=post enctype=multipart/form-data> | |
| <input type=file name=image><br><br> | |
| <textarea name=text rows=10 cols=60 placeholder="Enter your image here..."></textarea><br><br> | |
| <input type=submit value=CREATE> | |
| </form> | |
| {preview} | |
| """ | |
| def generate_tts(text, output_mp3_path): | |
| payload = { | |
| "input": text, | |
| "voice": ASSISTANT_VOICE | |
| } | |
| try: | |
| response = requests.post(TTS_API_URL, json=payload, headers=HEADERS, timeout=180) | |
| if response.status_code == 200: | |
| with open(output_mp3_path, 'wb') as f: | |
| f.write(response.content) | |
| ic(f"TTS audio saved: {output_mp3_path}") | |
| return True | |
| else: | |
| ic(f"TTS error {response.status_code}: {response.text}") | |
| return False | |
| except Exception as e: | |
| ic(f"TTS request failed: {e}") | |
| return False | |
| def run_ffmpeg(image_path, audio_path, output_path): | |
| try: | |
| ffprobe_cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', audio_path] | |
| result = subprocess.run(ffprobe_cmd, check=True, capture_output=True, text=True) | |
| duration = float(eval(result.stdout)['format']['duration']) | |
| ic(f"Audio duration: {duration}") | |
| image_w, image_h = 512, 768 # You can extract this with Pillow if needed | |
| padded_w, padded_h = image_w + 30, image_h + 30 | |
| fade_duration = 2.5 | |
| fade_start = duration + 1.0 + 2 - fade_duration # match your formula | |
| title_path = "static/assets/talks.png" | |
| filter_graph = ( | |
| f"color=c=#A52A2A:s={padded_w}x{padded_h}:d={duration + 3}[bg];" | |
| f"[1:v]scale={padded_w}:{padded_h}[title];" | |
| f"[bg][0:v]overlay=(W-w)/2:(H-h)/2[framed];" | |
| f"[framed][title]overlay[prefade];" | |
| f"[prefade]fade=type=out:start_time={fade_start}:duration={fade_duration}[v];" | |
| f"[2:a]apad=pad_dur=1.0[a]" | |
| ) | |
| ffmpeg_cmd = [ | |
| 'ffmpeg', | |
| '-loop', '1', '-i', image_path, | |
| '-loop', '1', '-i', title_path, | |
| '-i', audio_path, | |
| '-filter_complex', filter_graph, | |
| '-map', '[v]', | |
| '-map', '[a]', | |
| '-c:v', 'libx264', | |
| '-pix_fmt', 'yuv420p', | |
| '-c:a', 'aac', | |
| '-b:a', '192k', | |
| '-t', str(duration + 3), | |
| '-y', | |
| output_path | |
| ] | |
| ic("Running FFMPEG command...") | |
| subprocess.run(ffmpeg_cmd, check=True) | |
| ic(f"Video created: {output_path}") | |
| return True | |
| except Exception as e: | |
| ic(f"FFMPEG failed: {e}") | |
| return False | |
| @app.route('/', methods=['GET', 'POST']) | |
| def upload(): | |
| preview_html = "" | |
| if request.method == 'POST': | |
| image = request.files['image'] | |
| text = request.form.get('text', '').strip() | |
| if image and text: | |
| filename = secure_filename(image.filename) | |
| base = str(uuid.uuid4()) | |
| img_ext = os.path.splitext(filename)[1].lower() | |
| image_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{base}{img_ext}") | |
| audio_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{base}.mp3") | |
| video_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{base}.mp4") | |
| image.save(image_path) | |
| ic(f"Image saved: {image_path}") | |
| ic(f"Entered text: {text[:80]}...") | |
| if generate_tts(text, audio_path): | |
| if run_ffmpeg(image_path, audio_path, video_path): | |
| preview_html = f"<h3>✅ Video Ready</h3><video width='512' height='768' controls><source src='/video/{os.path.basename(video_path)}' type='video/mp4'></video>" | |
| return HTML_PAGE.format(preview=preview_html) | |
| @app.route('/video/<filename>') | |
| def serve_video(filename): | |
| return send_from_directory(app.config['UPLOAD_FOLDER'], filename) | |
| if __name__ == '__main__': | |
| app.run(debug=True, host='0.0.0.0', port=5100) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment