video-ffmpeg / app.py
Tim13ekd's picture
Update app.py
1bd9ab8 verified
raw
history blame
10.4 kB
import gradio as gr
import tempfile
from pathlib import Path
import uuid
import subprocess
import shutil
import os
# NEU: Dauer des Fade-In/Out für jedes einzelne Wort (z.B. 0.2 Sekunden)
WORD_FADE_DURATION = 0.2
FFMPEG_ESCAPE_CHAR = "\\"
# Erlaubte Dateiformate
allowed_medias = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"]
allowed_audios = [".mp3", ".wav", ".m4a", ".ogg"]
def get_font_path():
"""Versucht, eine Standard-Schriftart im Linux-System zu finden."""
possible_fonts = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf"
]
for font in possible_fonts:
if os.path.exists(font):
return font
return None # Fallback: FFmpeg soll selbst suchen (klappt manchmal nicht)
def save_temp_audio(audio_file_path):
"""
Speichert die hochgeladene Audio-Datei in einem temporären Verzeichnis.
Erwartet einen Dateipfad-String von Gradio.
"""
if not audio_file_path:
return None, None
# Gradio liefert einen String-Pfad zum temporären Speicherort
input_path = Path(audio_file_path)
# Bestimme die Erweiterung
ext = input_path.suffix
if ext.lower() not in allowed_audios:
ext = ".mp3"
# Erstelle das Zielverzeichnis und den Zielpfad
temp_audio_dir = Path(tempfile.mkdtemp())
temp_audio = temp_audio_dir / f"input{ext}"
# Kopiere die Datei vom Gradio-Temp-Pfad in unseren eigenen Temp-Pfad
try:
shutil.copyfile(input_path, temp_audio)
# Rückgabe des Verzeichnisses, das später gelöscht werden kann, und des Dateipfads
return temp_audio_dir, temp_audio
except Exception as e:
print(f"Fehler beim Kopieren der Audiodatei: {e}")
if temp_audio_dir.exists():
shutil.rmtree(temp_audio_dir)
return None, None
def create_timed_drawtext(word, start_time, duration, font_option, font_size, y_pos):
"""Erstellt einen FFmpeg drawtext Filter, der ein Wort mit weichen Übergängen (Alpha-Kanal) einblendet."""
global FFMPEG_ESCAPE_CHAR
global WORD_FADE_DURATION
# 1. Escaping: Ersetze alle ":" durch "\:" für FFmpeg
escaped_word = word.replace(':', f"{FFMPEG_ESCAPE_CHAR}:")
# Definiere die Start- und Endzeit des WORTES
end_time = start_time + duration
# Zeitpunkte für den Fade
fade_in_end = start_time + WORD_FADE_DURATION
fade_out_start = end_time - WORD_FADE_DURATION
# Alpha-Ausdruck für smooth Fade-In und Fade-Out
# Steuert die Deckkraft basierend auf der Zeit t (relativ zum Clip-Start)
alpha_expression = (
f"if(lt(t,{start_time}), 0, "
f"if(lt(t,{fade_in_end}), (t-{start_time})/{WORD_FADE_DURATION}, "
f"if(lt(t,{fade_out_start}), 1, "
f"if(lt(t,{end_time}), ({end_time}-t)/{WORD_FADE_DURATION}, 0))))"
)
# Erstelle den Filterstring
drawtext_filter = (
f"drawtext=text='{escaped_word}'{font_option}:fontcolor=white:fontsize={font_size}:borderw=2:bordercolor=black:"
f"x=(w-text_w)/2:y=(h-text_h)*{y_pos}:"
f"alpha='{alpha_expression}'" # Steuert die Deckkraft (Smoothness)
)
return drawtext_filter
def generate_slideshow_with_audio(images, input_text, duration_per_word, duration_per_image, fade_duration, font_size, y_pos, audio_file):
if not images:
return None, "❌ Keine Bilder ausgewählt"
temp_dir = tempfile.mkdtemp()
# Text in Wörter aufteilen
words = input_text.split() if input_text else []
total_words = len(words)
num_images = len(images)
# Berechnung der gleichmäßigen Verteilung der Wörter auf die Bilder
base_words_per_clip = total_words // num_images
remainder = total_words % num_images
current_word_index = 0
clips_with_text = [] # Paths der generierten MP4-Clips
# Schriftart finden
font_path = get_font_path()
font_option = f":fontfile='{font_path}'" if font_path else ""
# Audio verarbeiten
# audio_file ist der Pfad-String von Gradio
audio_temp_dir, temp_audio_file = save_temp_audio(audio_file) if audio_file else (None, None)
# --- 1. SCHLEIFE: Erstelle jeden Clip mit seinem Textsegment ---
for i in range(num_images):
img_path = Path(images[i].name)
clip_path = Path(temp_dir) / f"clip_with_text_{i}.mp4"
# 1. Bestimme das Wortsegment für diesen Clip
words_on_this_clip = base_words_per_clip + (1 if i < remainder else 0)
# Extrahieren des Segments aus der Gesamtliste der Wörter
word_segment = words[current_word_index : current_word_index + words_on_this_clip]
current_word_index += len(word_segment)
# 2. Berechne die Clip-Dauer
text_duration = len(word_segment) * duration_per_word
# Die Dauer ist das Maximum aus der gewünschten Bilddauer und der benötigten Textdauer
duration_clip = max(duration_per_image, text_duration)
# 3. Generiere Drawtext Filter (Startzeit ist relativ zum Clip-Start, also 0)
drawtext_filters = []
word_start_time = 0.0
for word in word_segment:
filter_str = create_timed_drawtext(word, word_start_time, duration_per_word, font_option, font_size, y_pos)
drawtext_filters.append(filter_str)
word_start_time += duration_per_word
# 4. Basis- und Fade-Filter
base_filters = (
"scale=w=1280:h=720:force_original_aspect_ratio=decrease,"
"pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,"
"fps=25,format=yuv420p"
)
fade_out_start = duration_clip - fade_duration
if fade_out_start < 0: fade_out_start = 0
fade_img_filter = f"fade=t=in:st=0:d={fade_duration},fade=t=out:st={fade_out_start}:d={fade_duration}"
# 5. Kombiniere alle Filter
if drawtext_filters:
all_drawtext_filters = ",".join(drawtext_filters)
vf_filters_clip = f"{base_filters},{all_drawtext_filters},{fade_img_filter}"
else:
# Kein Text mehr: Nur Bild mit Fade
vf_filters_clip = f"{base_filters},{fade_img_filter}"
# 6. FFmpeg Command zum Erstellen des Clips
cmd = [
"ffmpeg", "-y", "-loop", "1", "-i", str(img_path),
"-t", str(duration_clip),
"-vf", vf_filters_clip,
str(clip_path)
]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
clips_with_text.append(clip_path)
except subprocess.CalledProcessError as e:
# Bereinigung bei Fehler
shutil.rmtree(temp_dir)
if audio_temp_dir: shutil.rmtree(audio_temp_dir)
return None, f"❌ FFmpeg Fehler bei Bild {i+1}:\n{e.stderr}"
# --- 2. ZUSAMMENFÜGEN ---
filelist_path = Path(temp_dir) / "filelist.txt"
with open(filelist_path, "w") as f:
for clip in clips_with_text:
f.write(f"file '{clip}'\n")
output_video = Path(temp_dir) / f"slideshow_{uuid.uuid4().hex}.mp4"
cmd_concat = [
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
"-i", str(filelist_path),
"-c:v", "libx264", "-pix_fmt", "yuv420p",
str(output_video)
]
try:
subprocess.run(cmd_concat, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
shutil.rmtree(temp_dir)
if audio_temp_dir: shutil.rmtree(audio_temp_dir)
return None, f"❌ FFmpeg Fehler beim Zusammenfügen:\n{e.stderr}"
# --- 3. AUDIO HINZUFÜGEN (falls vorhanden) ---
final_output = output_video
if temp_audio_file:
final_output = Path(temp_dir) / f"final_{uuid.uuid4().hex}.mp4"
cmd_audio = [
"ffmpeg", "-y", "-i", str(output_video), "-i", str(temp_audio_file),
"-c:v", "copy", "-c:a", "aac", "-shortest",
str(final_output)
]
try:
subprocess.run(cmd_audio, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
shutil.rmtree(temp_dir)
if audio_temp_dir: shutil.rmtree(audio_temp_dir)
return None, f"❌ FFmpeg Fehler beim Hinzufügen von Audio:\n{e.stderr}"
# Bereinige das separate Audio-Temp-Verzeichnis
if audio_temp_dir: shutil.rmtree(audio_temp_dir)
return str(final_output), "✅ Video mit Audio erstellt!"
# Nur Video-Pfad zurückgeben
return str(final_output), "✅ Video erstellt (ohne Audio)"
# Gradio UI
with gr.Blocks() as demo:
gr.Markdown("# Slideshow Generator")
with gr.Row():
img_input = gr.Files(label="Bilder", file_types=allowed_medias)
# TEXT WURDE GEÄNDERT: Neue Beschreibung für Textverteilung
text_input = gr.Textbox(label="Text (Wörter werden gleichmäßig auf alle Bilder verteilt)", lines=5, placeholder="Jedes Wort wird für 'Dauer pro Wort' angezeigt.")
with gr.Row():
duration_image_input = gr.Number(value=3, label="Mindest-Dauer pro BILD (s)")
duration_word_input = gr.Number(value=1.0, label="Dauer pro WORT (s) [bestimmt Geschwindigkeit der Text-Anzeige]")
fade_input = gr.Number(value=0.5, label="Bild-Fade Dauer (s)")
font_size_input = gr.Number(value=80, label="Schriftgröße (px)")
ypos_input = gr.Slider(0.0, 1.0, value=0.9, label="Y-Position (0=Oben, 1=Unten)")
audio_input = gr.File(label="Audio (optional)", file_types=allowed_audios)
btn = gr.Button("Erstellen", variant="primary")
out_video = gr.Video(label="Ergebnis")
status = gr.Textbox(label="Status")
# KORREKTE REIHENFOLGE DER INPUTS:
# (images, input_text, duration_per_word, duration_per_image, fade_duration, font_size, y_pos, audio_file)
btn.click(
fn=generate_slideshow_with_audio,
inputs=[
img_input,
text_input,
duration_word_input,
duration_image_input,
fade_input,
font_size_input,
ypos_input,
audio_input
],
outputs=[out_video, status]
)
demo.launch()