import os import uuid import shutil import subprocess from flask import Flask, request, send_file, render_template, jsonify # Config UPLOAD_DIR = "uploads" OUTPUT_DIR = "outputs" CLIP_DURATION = 5 # seconds per image clip BEFORE transitions TRANS_DURATION = 1 # seconds of crossfade between clips RESOLUTION = (1280, 720) # output resolution (width, height) FPS = 25 os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(OUTPUT_DIR, exist_ok=True) app = Flask(__name__) def run(cmd): """Run subprocess and raise if fails.""" print("RUN:", " ".join(cmd)) proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if proc.returncode != 0: print("ERROR STDOUT:", proc.stdout) print("ERROR STDERR:", proc.stderr) raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{proc.stderr}") return proc.stdout def create_zoompan_clip(image_path, out_path, duration=CLIP_DURATION, res=RESOLUTION, fps=FPS, zoom_amount=1.15): """ Create a video clip from a single image that slowly zooms (Ken Burns). Produces a file with same codec/params so concatenation later is easier. """ w, h = res # compute zoompan parameters: zoom increases from 1 to zoom_amount across duration*fps frames frames = duration * fps # ffmpeg zoompan expression: zoom='1+((zoom_amount-1)/frames)*in', but we will compute relative frame-based expression zoom_expr = f"zoom='if(eq(in,0),1,zoom+({zoom_amount}-1)/{frames})'" # simpler approach: use scale and crop with zoom parameter using z=1+0.0... incremental # We'll use simple zoompan filter template: zoompan_filter = ( f"zoompan=d={frames}:s={w}x{h}:z='zoom+({zoom_amount}-1)/{frames}':x='iw/2-(iw/{ { 'z' } }/2)':y='ih/2-(ih/{{z}}/2)'" ) # The above is quite complex to generate safely across shells; instead we'll use a more robust, widely-compatible command: # Use "-vf scale=...,zoompan=..." pattern (some ffmpeg builds require escaping; keep it simple). cmd = [ "ffmpeg", "-y", "-loop", "1", "-t", str(duration), "-i", image_path, "-vf", f"scale={w}:{h},zoompan=z='if(lte(zoom,1.0),1,zoom)+({zoom_amount}-1)/{frames}':d={frames}:s={w}x{h}", "-r", str(fps), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out_path ] # In practice the zoompan expression above can be brittle depending on ffmpeg version/shell escaping. # We'll use a two-step reliable approach below instead. # fallback to simpler implementation: try: run(cmd) except Exception: # fallback: create a still image video with a single slow zoom using scale and crop via 'scale' + 'crop' animated by 'zoompan' simpler formula # Simpler: use zoompan with 'zoom=1.0+0.01*in' style cmd2 = [ "ffmpeg", "-y", "-loop", "1", "-t", str(duration), "-i", image_path, "-vf", f"scale={w}:{h},zoompan=z='1+({zoom_amount}-1)*in/{frames}':d={frames}:s={w}x{h}", "-r", str(fps), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out_path ] run(cmd2) def build_xfade_chain(input_clips, output_path, clip_duration=CLIP_DURATION, trans_duration=TRANS_DURATION, fps=FPS): """ Use ffmpeg xfade filter to chain multiple clips with crossfades. Pattern for multiple inputs: -i clip0 -i clip1 -i clip2 ... filter_complex: [0:v]setpts=PTS-STARTPTS[v0]; [1:v]setpts=PTS-STARTPTS[v1]; ... [v0][v1]xfade=transition=fade:duration=TRANS:offset=OFFSET0[v01]; [v01][v2]xfade=transition=fade:duration=TRANS:offset=OFFSET1[v012]; ... final mapping: -map [vFINAL] """ if len(input_clips) == 0: raise ValueError("No clips to join") if len(input_clips) == 1: # just move single clip to output shutil.copyfile(input_clips[0], output_path) return cmd = ["ffmpeg", "-y"] for clip in input_clips: cmd += ["-i", clip] # Build filter_complex filter_lines = [] # setpts lines for i in range(len(input_clips)): filter_lines.append(f"[{i}:v]setpts=PTS-STARTPTS[v{i}]") # chain xfade filters # offsets accumulate: offset_k = (k+1) * (clip_duration - trans_duration) prev_label = "v0" cur_index = 1 for k in range(len(input_clips)-1): a = prev_label b = f"v{cur_index}" offset = (k+1) * (clip_duration - trans_duration) out_label = f"vx_{k+1}" # Use a popular xfade transition (fade) — you can change name to e.g. slideleft, luma, etc. filter_lines.append(f"[{a}][{b}]xfade=transition=fade:duration={trans_duration}:offset={offset}[{out_label}]") prev_label = out_label cur_index += 1 filter_complex = ";".join(filter_lines) cmd += ["-filter_complex", filter_complex] # map final video stream cmd += ["-map", f"[{prev_label}]", "-c:v", "libx264", "-r", str(fps), "-pix_fmt", "yuv420p", output_path] run(cmd) @app.route("/") def index(): return render_template("index.html") @app.route("/generate", methods=["POST"]) def generate(): """ Expect multipart form data: - files[]: multiple image files - music: optional background music file - title_text[]: optional text captions aligned with each image (same order) - clip_duration, trans_duration, resolution_w, resolution_h (optional) """ # Create working dirs job_id = str(uuid.uuid4())[:8] work = os.path.join(UPLOAD_DIR, job_id) os.makedirs(work, exist_ok=True) out_job = os.path.join(OUTPUT_DIR, job_id) os.makedirs(out_job, exist_ok=True) # Params from form try: clip_duration = float(request.form.get("clip_duration", CLIP_DURATION)) trans_duration = float(request.form.get("trans_duration", TRANS_DURATION)) res_w = int(request.form.get("resolution_w", RESOLUTION[0])) res_h = int(request.form.get("resolution_h", RESOLUTION[1])) except Exception: return jsonify({"error": "invalid numeric params"}), 400 files = request.files.getlist("files[]") if not files: return jsonify({"error": "no files uploaded"}), 400 # Save images image_paths = [] for i, f in enumerate(files): fname = f"{i:03d}_{secure_filename := (f.filename or f'image_{i}.jpg').replace(' ', '_')}" path = os.path.join(work, fname) f.save(path) image_paths.append(path) # Optional music music_file = request.files.get("music") music_path = None if music_file and music_file.filename: music_path = os.path.join(work, f"music_{secure_filename}") music_file.save(music_path) # Create per-image zoompan clips clip_paths = [] for idx, img in enumerate(image_paths): clip_out = os.path.join(work, f"clip_{idx:03d}.mp4") # call ffmpeg to produce zoom-animated clip try: create_zoompan_clip(img, clip_out, duration=int(clip_duration), res=(res_w, res_h)) except Exception as e: # If zoompan generation fails, fallback to simple still-image to video using loop print(f"zoompan failed for {img}: {e}, using fallback.") cmd = [ "ffmpeg", "-y", "-loop", "1", "-t", str(int(clip_duration)), "-i", img, "-vf", f"scale={res_w}:{res_h}", "-c:v", "libx264", "-pix_fmt", "yuv420p", clip_out ] run(cmd) clip_paths.append(clip_out) # Build final video with xfade chain final_out = os.path.join(out_job, f"{job_id}_final.mp4") try: build_xfade_chain(clip_paths, final_out, clip_duration=int(clip_duration), trans_duration=float(trans_duration)) except Exception as e: return jsonify({"error": f"Failed to build final video: {e}"}), 500 # If music provided, merge with audio (loop/resample) and set shortest or fade if music_path: merged = os.path.join(out_job, f"{job_id}_with_music.mp4") # Add music, keep video, use -shortest so output ends when shorter stream ends (video) cmd = [ "ffmpeg", "-y", "-i", final_out, "-stream_loop", "-1", "-i", music_path, "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", merged ] try: run(cmd) final_out = merged except Exception as e: print("Failed to add music:", e) # Return file return send_file(final_out, as_attachment=True, download_name=f"photo_video_{job_id}.mp4") if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=7860)

Comments

Popular posts from this blog

අම්මගෙ වැල් වීඩියෝ එක