import os import subprocess import shutil from pdf2image import convert_from_path from moviepy.editor import ImageSequenceClip, AudioFileClip def stitch_frames_to_video(frame_dir: str, audio_path: str | None, output_video_path: str, fps: int = 24): """ Stitches generated image frames into a video and attaches an audio track. Args: frame_dir: Directory containing the image frames. audio_path: Path to the master audio file (can be None for silent video). output_video_path: Full path for the output video file (e.g., "output.mp4"). fps: Frames per second for the output video. """ if not os.path.isdir(frame_dir): print(f"Error: Frame directory '{frame_dir}' not found. Cannot stitch video.") return frame_files = sorted([ os.path.join(frame_dir, f) for f in os.listdir(frame_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg')) ]) if not frame_files: print("No frames found to stitch. Video will not be created.") return print(f"Found {len(frame_files)} frames. Proceeding to create video...") video_clip = None audio_clip = None try: video_clip = ImageSequenceClip(frame_files, fps=fps) if audio_path and os.path.exists(audio_path): try: audio_clip = AudioFileClip(audio_path) video_clip = video_clip.set_audio(audio_clip) print("Audio attached successfully to the video clip.") except Exception as e: print(f"Error loading or attaching audio file '{audio_path}': {e}. Video will be silent.") else: print(f"Audio file '{audio_path}' not found or not provided. Skipping audio attachment.") video_clip.write_videofile( output_video_path, fps=fps, codec="libx264", audio_codec="aac", temp_audiofile="temp-audio.m4a", remove_temp=True, threads=4, logger=None # Suppress moviepy verbose output ) print(f"Video successfully saved to {output_video_path}") except Exception as e: print(f"AN ERROR OCCURRED during video writing: {e}") finally: # Ensure clips are closed to release resources if video_clip: video_clip.close() if audio_clip: audio_clip.close() # Clean up temporary frames directory try: shutil.rmtree(frame_dir) print(f"Removed temporary frame directory '{frame_dir}'.") except OSError as e: print(f"Error removing temporary frame directory '{frame_dir}': {e}") def get_audio_duration(audio_path): cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", audio_path] result = subprocess.run(cmd, capture_output=True, text=True) try: return float(result.stdout.strip()) except ValueError: print(f"Warning: Could not get duration for audio file {audio_path}, using default duration") return None def create_video_with_ffmpeg(image_paths, audio_mapping, output_video, output_dir, fps=24): temp_dir = os.path.join(output_dir, "temp") os.makedirs(temp_dir, exist_ok=True) slide_videos_dir = os.path.join(output_dir, "slide_videos") os.makedirs(slide_videos_dir, exist_ok=True) slide_videos_list_path = os.path.join(temp_dir, "slide_videos_list.txt") individual_slide_videos = [] try: with open(slide_videos_list_path, "w") as slide_list_file: for i, image_path in enumerate(image_paths, start=1): audio_file = audio_mapping.get(i) is_last_slide = (i == len(image_paths)) duration = 1 if is_last_slide else 3 if audio_file: audio_filename = os.path.basename(audio_file) original_audio_path = os.path.join(output_dir, "audio_check", f"slide_{i}_{audio_filename}") if os.path.exists(original_audio_path): fetched_duration = get_audio_duration(original_audio_path) if fetched_duration is not None and fetched_duration > 0: duration = fetched_duration slide_video_path = os.path.join(slide_videos_dir, f"slide_{i}.mp4") individual_slide_videos.append(slide_video_path) slide_list_file.write(f"file '{os.path.abspath(slide_video_path)}'\n") slide_ffmpeg_cmd = [ "ffmpeg", "-y", "-loop", "1", "-i", image_path, ] if audio_file and os.path.exists(original_audio_path): slide_ffmpeg_cmd.extend(["-i", original_audio_path]) slide_ffmpeg_cmd.extend([ "-map", "0:v", "-map", "1:a", "-c:a", "aac", "-b:a", "192k", ]) else: slide_ffmpeg_cmd.extend([ "-f", "lavfi", "-t", str(duration), "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-map", "0:v", "-map", "1:a", "-c:a", "aac", "-b:a", "192k", ]) slide_ffmpeg_cmd.extend([ "-c:v", "libx264", "-preset", "medium", "-profile:v", "high", "-level", "4.1", "-pix_fmt", "yuv420p", "-vf", f"scale=1920:1080:force_original_aspect_ratio=decrease:eval=frame,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black,fps={fps}", "-t", str(duration), "-shortest", slide_video_path ]) print(f"Creating slide {i} video: {' '.join(slide_ffmpeg_cmd)}") subprocess.run(slide_ffmpeg_cmd, check=True) print("Combining all slide videos...") temp_video_path = os.path.join(temp_dir, "temp_video.mp4") filter_cmd = [ 'ffmpeg', '-y' ] for video in individual_slide_videos: filter_cmd.extend(['-i', video]) input_segments = "".join(f"[{i}:v][{i}:a]" for i in range(len(individual_slide_videos))) filter_cmd.extend([ '-filter_complex', f'{input_segments}concat=n={len(individual_slide_videos)}:v=1:a=1[outv][outa]', '-map', '[outv]', '-map', '[outa]', '-c:v', 'libx264', '-preset', 'medium', '-crf', '22', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', temp_video_path ]) print(f"Running filter complex command: {' '.join(filter_cmd)}") subprocess.run(filter_cmd, check=True) shutil.copy2(temp_video_path, output_video) print(f"Video created successfully: {output_video}") return output_video except Exception as e: print(f"Error creating video: {e}") raise finally: try: shutil.rmtree(temp_dir) except Exception as e: print(f"Warning: Error cleaning up temporary files: {e}") def combine_slides_and_visualizations(slides_dir, vis_dir, output_path): try: slide_videos = sorted([f for f in os.listdir(slides_dir) if f.startswith('slide_') and f.endswith('.mp4')], key=lambda x: int(x.split('_')[1].split('.')[0])) vis_videos = sorted([f for f in os.listdir(vis_dir) if f.startswith('vis_') and f.endswith('.mp4')], key=lambda x: int(x.split('_')[1].split('.')[0])) if not slide_videos: print("No slide videos found.") return False temp_dir = "temp_processed_videos" os.makedirs(temp_dir, exist_ok=True) all_videos = [] if len(slide_videos) >= 1: all_videos.append(os.path.join(slides_dir, slide_videos[0])) if len(slide_videos) >= 2: all_videos.append(os.path.join(slides_dir, slide_videos[1])) vis_index = 0 slide_index = 2 while vis_index < len(vis_videos) or slide_index < len(slide_videos): if vis_index < len(vis_videos): all_videos.append(os.path.join(vis_dir, vis_videos[vis_index])) vis_index += 1 if slide_index < len(slide_videos): all_videos.append(os.path.join(slides_dir, slide_videos[slide_index])) slide_index += 1 processed_videos = [] for i, video_path in enumerate(all_videos): duration_cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{video_path}"' duration_output = subprocess.check_output(duration_cmd, shell=True).decode('utf-8').strip() duration = float(duration_output) output_temp = os.path.join(temp_dir, f"processed_{i}.mp4") cmd = (f'ffmpeg -i "{video_path}" -c:v libx264 -preset medium -crf 22 ' f'-c:a aac -b:a 192k -ar 48000 -to {duration} ' f'-vsync 1 -async 1 -copyts -avoid_negative_ts make_zero ' f'"{output_temp}"') print(f"Processing video {i+1}/{len(all_videos)}: {cmd}") subprocess.call(cmd, shell=True) processed_videos.append(output_temp) output_dir = os.path.dirname(output_path) if output_dir and not os.path.exists(output_dir): os.makedirs(output_dir) normalized_videos = [] for i, video_path in enumerate(processed_videos): normalized_path = os.path.join(temp_dir, f"normalized_{i}.mp4") normalize_cmd = (f'ffmpeg -y -i "{video_path}" -vf "setsar=1:1" -c:v libx264 -preset medium ' f'-crf 22 -c:a copy "{normalized_path}"') print(f"Normalizing video {i} SAR: {normalize_cmd}") subprocess.call(normalize_cmd, shell=True) normalized_videos.append(normalized_path) input_segments = "".join(f"[{i}:v][{i}:a]" for i in range(len(normalized_videos))) filter_cmd = ( 'ffmpeg -y ' + ' '.join([f'-i "{v}"' for v in normalized_videos]) + ' ' f'-filter_complex "{input_segments}concat=n={len(normalized_videos)}:v=1:a=1[outv][outa]" ' '-map "[outv]" -map "[outa]" -c:v libx264 -preset medium -crf 22 ' f'-c:a aac -b:a 192k -ar 48000 "{output_path}"' ) print(f"Creating video using filter complex: {filter_cmd}") subprocess.call(filter_cmd, shell=True) shutil.rmtree(temp_dir, ignore_errors=True) return os.path.exists(output_path) except Exception as e: print(f"Error combining videos: {e}") import traceback traceback.print_exc() return False def fix_video_compatibility(input_video, output_video=None): if output_video is None: base, ext = os.path.splitext(input_video) output_video = f"{base}_fixed.mp4" cmd = [ 'ffmpeg', '-i', input_video, '-c:v', 'libx264', '-preset', 'medium', '-profile:v', 'high', '-level', '4.1', '-pix_fmt', 'yuv420p', '-vf', 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2', '-r', '24', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', '-y', output_video ] print("Running FFmpeg command to fix video compatibility:") print(" ".join(cmd)) try: process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True ) while True: output = process.stderr.readline() if output == '' and process.poll() is not None: break if output: print(output.strip()) if process.returncode == 0: print(f"\nVideo successfully fixed and saved to: {output_video}") return True else: print("\nError: FFmpeg process failed") return False except Exception as e: print(f"\nError: {str(e)}") return False