scn-consultation-api / src /tools /video_utils.py
SS-2005's picture
Initial HF Space deployment
59ec65a
Raw
History Blame Contribute Delete
12.4 kB
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