Spaces:
Sleeping
Sleeping
File size: 15,122 Bytes
e5b621e 085d5e6 1b78077 e5b621e 1b78077 fef9da1 c5cfcb5 e5b621e 029da5b 377308b 8320d85 500f777 e5b621e 500f777 4717f77 029da5b c5cfcb5 4717f77 1bd9ab8 029da5b 1bd9ab8 7644c1e 1bd9ab8 5d20d42 029da5b 0ffcbef 029da5b 0ffcbef 029da5b 0ffcbef 029da5b c5cfcb5 029da5b 8320d85 0ffcbef 029da5b 0ffcbef de9f244 0ffcbef de9f244 5d20d42 1ce3011 0ffcbef 862347b 029da5b 5d20d42 029da5b 862347b 029da5b 862347b 0ffcbef 862347b 029da5b 5d20d42 029da5b 0ffcbef 029da5b 0ffcbef 029da5b 377308b 5d20d42 377308b 029da5b 377308b 029da5b 5d20d42 8d4c431 029da5b 5d20d42 8d4c431 5d20d42 8d4c431 029da5b 0ffcbef 8d4c431 029da5b 0ffcbef 029da5b 5d20d42 4717f77 8d4c431 0ffcbef 5d20d42 4717f77 8d4c431 862347b 1ce3011 5d20d42 862347b 4717f77 5d20d42 862347b 8320d85 4717f77 7644c1e ea1c088 c9a86ba 2a9840f 7644c1e ad4cab5 c5cfcb5 7644c1e 1ce3011 7644c1e 1ce3011 7644c1e 029da5b 7644c1e 0ffcbef 4717f77 fd9d93c 8320d85 7644c1e d24cfba 8320d85 7644c1e 1ce3011 7644c1e 77f3dab 7644c1e 029da5b 7644c1e 029da5b 0ffcbef 5d20d42 de9f244 0ffcbef 029da5b 7644c1e 1ce3011 7644c1e 1ce3011 7644c1e 029da5b 7644c1e 0ffcbef 7644c1e 029da5b 312fd62 c5cfcb5 7644c1e 1ce3011 312fd62 c5cfcb5 312fd62 1ce3011 312fd62 7644c1e 312fd62 7644c1e c5cfcb5 312fd62 c5cfcb5 312fd62 c5cfcb5 1ce3011 7644c1e 1ce3011 7644c1e 1ce3011 bbb5565 7644c1e ad4cab5 c5cfcb5 ad4cab5 c5cfcb5 ad4cab5 1ce3011 7644c1e 1ce3011 7644c1e 1ce3011 7644c1e c5cfcb5 ad4cab5 7644c1e 36bfe7e e5b621e c5cfcb5 36bfe7e c5cfcb5 5d20d42 b6a8e09 c5cfcb5 7644c1e 029da5b 7644c1e 8d4c431 4717f77 8d4c431 0ffcbef 8d4c431 0ffcbef 8d4c431 0ffcbef 4717f77 8d4c431 c5cfcb5 0b567d9 c5cfcb5 8320d85 c5cfcb5 1ce3011 8d4c431 4717f77 0ffcbef c5cfcb5 0b567d9 54f90d3 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 |
import gradio as gr
import tempfile
from pathlib import Path
import uuid
import subprocess
import shutil
import os
# Konstanten
WORD_FADE_DURATION = 0.2
FFMPEG_ESCAPE_CHAR = "\\"
# Erlaubte Dateiformate
allowed_medias = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"]
allowed_audios = [".mp3", ".wav", ".m4a", ".ogg"]
# Erweiterte Liste von Schriftpfaden, die in Hugging Face Spaces üblich sind
FONT_MAP = {
"System Default (FFmpeg)": None, # Kein fontfile-Parameter, FFmpeg wählt
"Noto Sans Bold": "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
"DejaVu Sans Bold": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"Liberation Sans Bold": "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"FreeSans Bold": "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
}
FONT_OPTIONS = list(FONT_MAP.keys())
def get_font_path(font_name):
"""
Gibt den tatsächlichen, existierenden Pfad für die ausgewählte Schriftart zurück.
"""
requested_path = FONT_MAP.get(font_name)
if requested_path is None or os.path.exists(requested_path):
return requested_path
for name, path in FONT_MAP.items():
if path and os.path.exists(path):
print(f"Warnung: Ausgewählte Schriftart '{font_name}' nicht gefunden. Verwende Fallback: '{name}'")
return path
print("Warnung: Keine bevorzugten Schriftarten gefunden. Verwende FFmpeg System Standard.")
return None
def save_temp_audio(audio_file_path):
"""Speichert die hochgeladene Audio-Datei in einem temporären Verzeichnis."""
if not audio_file_path:
return None, None
input_path = Path(audio_file_path)
ext = input_path.suffix
if ext.lower() not in allowed_audios:
ext = ".mp3"
temp_audio_dir = Path(tempfile.mkdtemp())
temp_audio = temp_audio_dir / f"input{ext}"
try:
shutil.copyfile(input_path, temp_audio)
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_sentence_base_filter(full_text, duration_clip, font_option, font_size, y_pos, style):
"""
Erstellt den FFmpeg drawtext Filter für die Basisschicht (den gesamten Satzabschnitt).
Dies ist entweder der gesamte Satz oder die Box für statischen Text.
"""
base_params = {
"fontcolor": "white",
"borderw": 2, # Standard Schatten/Rand
"bordercolor": "black",
"box": 1, "boxcolor": "[email protected]", # Semi-transparente schwarze Box
"fontsize": font_size
}
style_lower = style.lower()
if style_lower == "highlight":
# Hervorheben: Der gesamte Satz als Basis, aber nur mit leichtem Schatten
base_params["box"] = 0
base_params["borderw"] = 2
elif style_lower == "static":
# Statisch: Der gesamte Satz in einer Box, keine Animation, bleibt die ganze Zeit sichtbar
base_params["box"] = 1
base_params["borderw"] = 0
elif style_lower == "dynamic":
# Dynamisch: Große Schrift, leichte Box, wird später vom Highlight überlagert
base_params["box"] = 1
base_params["boxcolor"] = "[email protected]"
base_params["borderw"] = 0
base_params["fontsize"] = font_size * 1.2
escaped_text = full_text.replace(':', FFMPEG_ESCAPE_CHAR + ':')
# Filter für den gesamten Satz
drawtext_filter = (
f"drawtext=text='{escaped_text}':"
f"fontcolor={base_params['fontcolor']}:"
f"fontsize={base_params['fontsize']}:"
f"borderw={base_params['borderw']}:"
f"bordercolor={base_params['bordercolor']}:"
# boxborderw=10 fügt etwas Polsterung um die Box hinzu
+ (f"box={base_params['box']}:boxcolor={base_params['boxcolor']}:boxborderw=10:" if base_params["box"] else "") +
f"x=(w-text_w)/2:y=(h-text_h)*{y_pos}"
)
if font_option:
drawtext_filter += f":{font_option}"
# Der statische Stil wird sofort und für die gesamte Clip-Dauer eingeblendet
if style_lower == "static":
drawtext_filter += f":enable='between(t, 0, {duration_clip})'"
# Für Highlight und Dynamic brauchen wir die Basis als konstante Referenz
else:
# Bei "Highlight" und "Dynamic" ist dies der Basis-Text, der IMMER sichtbar ist.
drawtext_filter += f":enable='between(t, 0, {duration_clip})'"
return drawtext_filter
def create_highlight_word_filter(word, full_text, start_time, duration, font_option, font_size, y_pos, style):
"""
Erstellt den FFmpeg drawtext Filter für die Highlight-Schicht (nur das aktive Wort),
es sei denn, der Stil ist 'Static'.
"""
# Wenn statisch, wird kein Highlight benötigt
if style.lower() == "static":
return None
word_end_time = start_time + duration
# Alpha-Ausdruck für smooth Fade-In und Fade-Out der HIGHLIGHT-FARBE
highlight_alpha_expression = (
f"if(lt(t,{start_time}), 0, "
f"if(lt(t,{start_time + WORD_FADE_DURATION}), (t-{start_time})/{WORD_FADE_DURATION}, "
f"if(lt(t,{word_end_time - WORD_FADE_DURATION}), 1, "
f"if(lt(t,{word_end_time}), ({word_end_time}-t)/{WORD_FADE_DURATION}, 0))))"
)
# Styling Parameter
params = {
"fontcolor": "yellow",
"borderw": 0,
"bordercolor": "black",
"fontsize_override": font_size * 1.05 # Leicht vergrößert
}
style_lower = style.lower()
if style_lower == "dynamic":
# Dynamisch: Schrift deutlich größer und mit Rand, zentriert.
params["fontcolor"] = "yellow"
params["borderw"] = 4
params["fontsize_override"] = font_size * 1.5
else: # Highlight
# Highlight: Gelbe Schrift, kein Rand
params["fontcolor"] = "yellow"
params["borderw"] = 0
escaped_word = word.replace(':', FFMPEG_ESCAPE_CHAR + ':')
# Filter für das einzelne, hervorgehobene Wort (Das gesamte Wort wird gezeichnet)
drawtext_filter = (
f"drawtext=text='{escaped_word}':"
f"fontcolor={params['fontcolor']}:"
f"fontsize={params['fontsize_override']}:"
f"borderw={params['borderw']}:"
f"bordercolor={params['bordercolor']}:"
f"x=(w-text_w)/2:y=(h-text_h)*{y_pos}"
)
if font_option:
drawtext_filter += f":{font_option}"
# Der Highlight-Filter ist nur aktiv, wenn das Wort aktiv ist (via Alpha-Expression).
drawtext_filter += f":alpha='{highlight_alpha_expression}'"
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, subtitle_style, selected_font):
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 = []
# Schriftart finden basierend auf der Auswahl
font_path = get_font_path(selected_font)
# Pfad für FFmpeg vorbereiten und maskieren.
font_option = ""
if font_path:
escaped_font_path = str(font_path).replace(FFMPEG_ESCAPE_CHAR, FFMPEG_ESCAPE_CHAR + FFMPEG_ESCAPE_CHAR)
escaped_font_path = escaped_font_path.replace(':', FFMPEG_ESCAPE_CHAR + ':')
font_option = f"fontfile='{escaped_font_path}'"
# Audio verarbeiten
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)
word_segment = words[current_word_index : current_word_index + words_on_this_clip]
current_word_index += len(word_segment)
full_text = " ".join(word_segment)
# 2. Berechne die Clip-Dauer
text_duration = len(word_segment) * duration_per_word
duration_clip = max(duration_per_image, text_duration)
drawtext_filters = []
if full_text:
# ERSTE SCHICHT: Der gesamte Satz (als STABILE BASIS oder STATISCHE BOX)
base_filter = create_sentence_base_filter(full_text, duration_clip, font_option, font_size, y_pos, subtitle_style)
drawtext_filters.append(base_filter)
# ZWEITE SCHICHT: Highlight-Layer (nur wenn nicht "Static")
if subtitle_style.lower() != "static":
word_start_time = 0.0
for word in word_segment:
highlight_filter = create_highlight_word_filter(
word,
full_text,
word_start_time,
duration_per_word,
font_option,
font_size,
y_pos,
subtitle_style
)
if highlight_filter:
drawtext_filters.append(highlight_filter)
word_start_time += duration_per_word
# 3. 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}"
# 4. Kombiniere alle Filter
if drawtext_filters:
# Die Reihenfolge ist wichtig: Basis zuerst, Highlights zuletzt
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}"
# 5. 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:
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_input = gr.Textbox(label="Text (Wörter werden gleichmäßig auf alle Bilder verteilt)", lines=5, placeholder="Der Basissatz wird konstant angezeigt. Das aktive Wort wird hervorgehoben.")
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 Hervorhebung]")
fade_input = gr.Number(value=0.5, label="Bild-Fade Dauer (s)")
with gr.Row():
font_select_input = gr.Dropdown(
FONT_OPTIONS,
label="Schriftart",
value="DejaVu Sans Bold" if "DejaVu Sans Bold" in FONT_OPTIONS else FONT_OPTIONS[0],
interactive=True,
scale=1
)
font_size_input = gr.Number(value=80, label="Schriftgröße (px)", scale=1)
ypos_input = gr.Slider(0.0, 1.0, value=0.9, label="Y-Position (0=Oben, 1=Unten)", scale=2)
# NEU: Reduzierte Untertitel-Stile
subtitle_style_input = gr.Dropdown(
["Highlight", "Dynamic", "Static"],
label="Untertitel-Stil",
value="Highlight",
interactive=True,
scale=1
)
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")
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,
subtitle_style_input,
font_select_input
],
outputs=[out_video, status]
)
demo.launch() |