Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -47,15 +47,15 @@ def save_temp_audio(audio_file_path):
|
|
| 47 |
shutil.rmtree(temp_audio_dir)
|
| 48 |
return None, None
|
| 49 |
|
| 50 |
-
def
|
| 51 |
"""
|
| 52 |
-
Erstellt den FFmpeg drawtext Filter für die Basisschicht
|
| 53 |
-
|
| 54 |
"""
|
| 55 |
# Standard-Stil
|
| 56 |
base_params = {
|
| 57 |
"fontcolor": "white",
|
| 58 |
-
"borderw": 0,
|
| 59 |
"bordercolor": "black",
|
| 60 |
"box": 0, "boxcolor": "",
|
| 61 |
"fontsize": font_size
|
|
@@ -63,111 +63,120 @@ def create_cumulative_base_filter(text_to_draw, start_time, font_option, font_si
|
|
| 63 |
|
| 64 |
style_lower = style.lower()
|
| 65 |
|
| 66 |
-
#
|
| 67 |
if style_lower == "modern":
|
| 68 |
-
# Modern: Graue, semi-transparente Hintergrundbox (HINWEIS: FFmpeg unterstützt keine abgerundeten Ecken)
|
| 69 |
base_params["box"] = 1
|
| 70 |
-
|
| 71 |
-
base_params["
|
| 72 |
-
base_params["borderw"] = 0
|
| 73 |
base_params["fontsize"] = font_size
|
| 74 |
|
|
|
|
| 75 |
elif style_lower == "pop":
|
| 76 |
-
# Pop: Schwarze, semi-transparente Hintergrundbox
|
| 77 |
base_params["box"] = 1
|
| 78 |
base_params["boxcolor"] = "[email protected]"
|
| 79 |
base_params["fontsize"] = font_size * 1.1
|
| 80 |
-
base_params["borderw"] = 0
|
| 81 |
|
| 82 |
-
# Für andere Stile wird
|
|
|
|
|
|
|
| 83 |
|
| 84 |
-
escaped_text =
|
| 85 |
|
| 86 |
-
# Filter für den gesamten Satz,
|
| 87 |
drawtext_filter = (
|
| 88 |
f"drawtext=text='{escaped_text}':"
|
| 89 |
f"fontcolor={base_params['fontcolor']}:"
|
| 90 |
f"fontsize={base_params['fontsize']}:"
|
| 91 |
f"borderw={base_params['borderw']}:"
|
| 92 |
f"bordercolor={base_params['bordercolor']}:"
|
| 93 |
-
# boxborderw=10 fügt Polsterung hinzu
|
| 94 |
+ (f"box={base_params['box']}:boxcolor={base_params['boxcolor']}:boxborderw=10:" if base_params["box"] else "") +
|
| 95 |
f"x=(w-text_w)/2:y=(h-text_h)*{y_pos}"
|
| 96 |
)
|
| 97 |
|
|
|
|
| 98 |
if font_option:
|
| 99 |
drawtext_filter += f":{font_option}"
|
| 100 |
|
| 101 |
-
|
| 102 |
-
drawtext_filter += f":enable='gt(t, {start_time - 0.05})'" # -0.05 für nahtlosen Übergang
|
| 103 |
return drawtext_filter
|
| 104 |
|
| 105 |
|
| 106 |
-
def create_highlight_word_filter(word, start_time, duration, font_option, font_size, y_pos, style):
|
| 107 |
"""
|
| 108 |
Erstellt den FFmpeg drawtext Filter für die Highlight-Schicht (nur das aktive Wort).
|
|
|
|
|
|
|
|
|
|
| 109 |
"""
|
| 110 |
word_end_time = start_time + duration
|
| 111 |
|
| 112 |
-
# Alpha-Ausdruck für smooth Fade-In und Fade-Out
|
| 113 |
-
|
| 114 |
f"if(lt(t,{start_time}), 0, "
|
| 115 |
f"if(lt(t,{start_time + WORD_FADE_DURATION}), (t-{start_time})/{WORD_FADE_DURATION}, "
|
| 116 |
f"if(lt(t,{word_end_time - WORD_FADE_DURATION}), 1, "
|
| 117 |
f"if(lt(t,{word_end_time}), ({word_end_time}-t)/{WORD_FADE_DURATION}, 0))))"
|
| 118 |
)
|
| 119 |
|
| 120 |
-
# Styling Parameter
|
| 121 |
params = {
|
| 122 |
"fontcolor": "yellow",
|
| 123 |
-
"borderw":
|
| 124 |
"bordercolor": "black",
|
| 125 |
"box": 0, "boxcolor": "",
|
| 126 |
-
"fontsize_override": font_size
|
| 127 |
}
|
| 128 |
|
| 129 |
style_lower = style.lower()
|
| 130 |
|
| 131 |
-
# --- STYLES FÜR DIE HIGHLIGHT-SCHICHT (Das aktuell hervorgehobene Wort) ---
|
| 132 |
if style_lower == "modern":
|
| 133 |
-
# Modern: Gelbe Schrift
|
| 134 |
params["fontcolor"] = "yellow"
|
| 135 |
params["borderw"] = 0
|
| 136 |
-
params["fontsize_override"] = font_size * 1.05
|
| 137 |
|
| 138 |
elif style_lower == "bold":
|
| 139 |
# Bold: Gelb mit starkem Rand
|
| 140 |
params["fontcolor"] = "yellow"
|
| 141 |
params["borderw"] = 4
|
| 142 |
-
params["fontsize_override"] = font_size * 1.1
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
|
|
|
| 146 |
params["fontcolor"] = "yellow"
|
| 147 |
params["borderw"] = 0
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
elif style_lower == "badge":
|
| 151 |
-
# Badge: Mint Green Box um das Wort
|
| 152 |
-
params["fontcolor"] = "white"
|
| 153 |
-
params["borderw"] = 0
|
| 154 |
-
params["box"] = 1
|
| 155 |
-
params["boxcolor"] = "[email protected]" # Mint Green
|
| 156 |
-
params["fontsize_override"] = font_size * 1.05
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
params[
|
| 164 |
-
params[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
# Filter für das einzelne, hervorgehobene Wort
|
| 169 |
drawtext_filter = (
|
| 170 |
-
f"drawtext=text='{
|
| 171 |
f"fontcolor={params['fontcolor']}:"
|
| 172 |
f"fontsize={params['fontsize_override']}:"
|
| 173 |
f"borderw={params['borderw']}:"
|
|
@@ -176,10 +185,12 @@ def create_highlight_word_filter(word, start_time, duration, font_option, font_s
|
|
| 176 |
f"x=(w-text_w)/2:y=(h-text_h)*{y_pos}"
|
| 177 |
)
|
| 178 |
|
|
|
|
| 179 |
if font_option:
|
| 180 |
drawtext_filter += f":{font_option}"
|
| 181 |
|
| 182 |
-
|
|
|
|
| 183 |
return drawtext_filter
|
| 184 |
|
| 185 |
|
|
@@ -229,29 +240,17 @@ def generate_slideshow_with_audio(images, input_text, duration_per_word, duratio
|
|
| 229 |
drawtext_filters = []
|
| 230 |
|
| 231 |
if full_text:
|
| 232 |
-
|
| 233 |
-
|
|
|
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
current_cumulative_text = " ".join(cumulative_text_list)
|
| 240 |
-
|
| 241 |
-
# ERSTE SCHICHT: Kumulierter Basistext (wird ab diesem Wort permanent sichtbar)
|
| 242 |
-
base_cumulative_filter = create_cumulative_base_filter(
|
| 243 |
-
current_cumulative_text,
|
| 244 |
-
word_start_time,
|
| 245 |
-
font_option,
|
| 246 |
-
font_size,
|
| 247 |
-
y_pos,
|
| 248 |
-
subtitle_style
|
| 249 |
-
)
|
| 250 |
-
drawtext_filters.append(base_cumulative_filter)
|
| 251 |
-
|
| 252 |
-
# ZWEITE SCHICHT: Highlight-Layer (fadet ein und aus)
|
| 253 |
highlight_filter = create_highlight_word_filter(
|
| 254 |
word,
|
|
|
|
| 255 |
word_start_time,
|
| 256 |
duration_per_word,
|
| 257 |
font_option,
|
|
@@ -260,7 +259,6 @@ def generate_slideshow_with_audio(images, input_text, duration_per_word, duratio
|
|
| 260 |
subtitle_style
|
| 261 |
)
|
| 262 |
drawtext_filters.append(highlight_filter)
|
| 263 |
-
|
| 264 |
word_start_time += duration_per_word
|
| 265 |
|
| 266 |
|
|
@@ -277,7 +275,7 @@ def generate_slideshow_with_audio(images, input_text, duration_per_word, duratio
|
|
| 277 |
|
| 278 |
# 4. Kombiniere alle Filter
|
| 279 |
if drawtext_filters:
|
| 280 |
-
# Wichtig:
|
| 281 |
all_drawtext_filters = ",".join(drawtext_filters)
|
| 282 |
vf_filters_clip = f"{base_filters},{all_drawtext_filters},{fade_img_filter}"
|
| 283 |
else:
|
|
@@ -354,7 +352,7 @@ with gr.Blocks() as demo:
|
|
| 354 |
|
| 355 |
with gr.Row():
|
| 356 |
img_input = gr.Files(label="Bilder", file_types=allowed_medias)
|
| 357 |
-
text_input = gr.Textbox(label="Text (Wörter werden gleichmäßig auf alle Bilder verteilt)", lines=5, placeholder="
|
| 358 |
|
| 359 |
with gr.Row():
|
| 360 |
duration_image_input = gr.Number(value=3, label="Mindest-Dauer pro BILD (s)")
|
|
|
|
| 47 |
shutil.rmtree(temp_audio_dir)
|
| 48 |
return None, None
|
| 49 |
|
| 50 |
+
def create_sentence_base_filter(full_text, duration_clip, font_option, font_size, y_pos, style):
|
| 51 |
"""
|
| 52 |
+
Erstellt den FFmpeg drawtext Filter für die Basisschicht (den gesamten Satzabschnitt),
|
| 53 |
+
der für die volle Clip-Dauer sichtbar ist (stabile Positionierung).
|
| 54 |
"""
|
| 55 |
# Standard-Stil
|
| 56 |
base_params = {
|
| 57 |
"fontcolor": "white",
|
| 58 |
+
"borderw": 0, # Kein Rand
|
| 59 |
"bordercolor": "black",
|
| 60 |
"box": 0, "boxcolor": "",
|
| 61 |
"fontsize": font_size
|
|
|
|
| 63 |
|
| 64 |
style_lower = style.lower()
|
| 65 |
|
| 66 |
+
# SPEZIALFALL: Modern Style (graue, semi-transparente Hintergrundbox)
|
| 67 |
if style_lower == "modern":
|
|
|
|
| 68 |
base_params["box"] = 1
|
| 69 |
+
# Dunkelgrau (0x444444) mit 60% Transparenz (@0.6)
|
| 70 |
+
base_params["boxcolor"] = "[email protected]"
|
| 71 |
+
base_params["borderw"] = 0 # Kein Text-Rand bei Hintergrundbox
|
| 72 |
base_params["fontsize"] = font_size
|
| 73 |
|
| 74 |
+
# SPEZIALFALL: Pop Style (schwarze Box)
|
| 75 |
elif style_lower == "pop":
|
|
|
|
| 76 |
base_params["box"] = 1
|
| 77 |
base_params["boxcolor"] = "[email protected]"
|
| 78 |
base_params["fontsize"] = font_size * 1.1
|
|
|
|
| 79 |
|
| 80 |
+
# Für andere Stile wird der Basistext nur als Schatten (borderw=2) gezeichnet
|
| 81 |
+
elif style_lower in ["bold", "badge", "word"]:
|
| 82 |
+
base_params["borderw"] = 2
|
| 83 |
|
| 84 |
+
escaped_text = full_text.replace(':', FFMPEG_ESCAPE_CHAR + ':')
|
| 85 |
|
| 86 |
+
# Filter für den gesamten Satz, sichtbar für die gesamte Clip-Dauer
|
| 87 |
drawtext_filter = (
|
| 88 |
f"drawtext=text='{escaped_text}':"
|
| 89 |
f"fontcolor={base_params['fontcolor']}:"
|
| 90 |
f"fontsize={base_params['fontsize']}:"
|
| 91 |
f"borderw={base_params['borderw']}:"
|
| 92 |
f"bordercolor={base_params['bordercolor']}:"
|
| 93 |
+
# boxborderw=10 fügt etwas Polsterung um die Box hinzu
|
| 94 |
+ (f"box={base_params['box']}:boxcolor={base_params['boxcolor']}:boxborderw=10:" if base_params["box"] else "") +
|
| 95 |
f"x=(w-text_w)/2:y=(h-text_h)*{y_pos}"
|
| 96 |
)
|
| 97 |
|
| 98 |
+
# Fügt fontfile nur hinzu, wenn vorhanden und vermeidet doppelte Doppelpunkte
|
| 99 |
if font_option:
|
| 100 |
drawtext_filter += f":{font_option}"
|
| 101 |
|
| 102 |
+
drawtext_filter += f":enable='between(t, 0, {duration_clip})'"
|
|
|
|
| 103 |
return drawtext_filter
|
| 104 |
|
| 105 |
|
| 106 |
+
def create_highlight_word_filter(word, full_text, start_time, duration, font_option, font_size, y_pos, style):
|
| 107 |
"""
|
| 108 |
Erstellt den FFmpeg drawtext Filter für die Highlight-Schicht (nur das aktive Wort).
|
| 109 |
+
Da FFmpeg keine Wort-Positionen kennt, muss der GESAMTE Satz gezeichnet werden,
|
| 110 |
+
aber nur das aktive Wort hat die Highlight-Farbe und der Rest ist transparent (alpha=0).
|
| 111 |
+
Das ist notwendig, um die korrekte Zentrierung beizubehalten!
|
| 112 |
"""
|
| 113 |
word_end_time = start_time + duration
|
| 114 |
|
| 115 |
+
# Alpha-Ausdruck für smooth Fade-In und Fade-Out der HIGHLIGHT-FARBE
|
| 116 |
+
highlight_alpha_expression = (
|
| 117 |
f"if(lt(t,{start_time}), 0, "
|
| 118 |
f"if(lt(t,{start_time + WORD_FADE_DURATION}), (t-{start_time})/{WORD_FADE_DURATION}, "
|
| 119 |
f"if(lt(t,{word_end_time - WORD_FADE_DURATION}), 1, "
|
| 120 |
f"if(lt(t,{word_end_time}), ({word_end_time}-t)/{WORD_FADE_DURATION}, 0))))"
|
| 121 |
)
|
| 122 |
|
| 123 |
+
# Styling Parameter
|
| 124 |
params = {
|
| 125 |
"fontcolor": "yellow",
|
| 126 |
+
"borderw": 0,
|
| 127 |
"bordercolor": "black",
|
| 128 |
"box": 0, "boxcolor": "",
|
| 129 |
+
"fontsize_override": font_size * 1.05 # Leicht vergrößert
|
| 130 |
}
|
| 131 |
|
| 132 |
style_lower = style.lower()
|
| 133 |
|
|
|
|
| 134 |
if style_lower == "modern":
|
| 135 |
+
# Modern: Gelbe Schrift, kein Rand
|
| 136 |
params["fontcolor"] = "yellow"
|
| 137 |
params["borderw"] = 0
|
|
|
|
| 138 |
|
| 139 |
elif style_lower == "bold":
|
| 140 |
# Bold: Gelb mit starkem Rand
|
| 141 |
params["fontcolor"] = "yellow"
|
| 142 |
params["borderw"] = 4
|
|
|
|
| 143 |
|
| 144 |
+
# Hinweis: Badge/Word benötigen einen Trick, da FFmpeg keine Wort-Hintergrundboxen unterstützt.
|
| 145 |
+
# Wir lassen sie hier auf den Standard-Highlight-Effekt fallen.
|
| 146 |
+
elif style_lower in ["badge", "word", "pop"]:
|
| 147 |
params["fontcolor"] = "yellow"
|
| 148 |
params["borderw"] = 0
|
| 149 |
+
|
| 150 |
+
escaped_text = full_text.replace(':', FFMPEG_ESCAPE_CHAR + ':')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
+
# Filter für das einzelne, hervorgehobene Wort (ACHTUNG: Es wird der gesamte Satz gezeichnet!)
|
| 153 |
+
# Hier zeichnen wir nur das aktuell aktive Wort, was im Prinzip ein kumulierter Effekt ist,
|
| 154 |
+
# aber ohne die Positionierungsfehler des vorherigen Versuchs, da nur EIN Wort gezeichnet wird.
|
| 155 |
+
drawtext_filter = (
|
| 156 |
+
f"drawtext=text='{word.replace(':', FFMPEG_ESCAPE_CHAR + ':')}':" # NUR das Wort
|
| 157 |
+
f"fontcolor={params['fontcolor']}:"
|
| 158 |
+
f"fontsize={params['fontsize_override']}:"
|
| 159 |
+
f"borderw={params['borderw']}:"
|
| 160 |
+
f"bordercolor={params['bordercolor']}:"
|
| 161 |
+
# Die X-Position muss manuell berechnet werden, um es über dem Basistext zu positionieren.
|
| 162 |
+
# Da wir das nicht können, zeichnen wir einfach den Satz und blenden das Highlight ein/aus.
|
| 163 |
+
# NEUER ANSATZ: Wir zeichnen das Wort MIT SEINER EIGENEN ZENTRIERUNG und verlassen uns auf die Transparenz.
|
| 164 |
+
# Das funktioniert nur, wenn das Highlight-Wort das gleiche ist wie der Basistext.
|
| 165 |
+
# Da der Basistext in diesem stabilen Modell bereits den ganzen Satz anzeigt, müssen wir hier kreativ sein.
|
| 166 |
+
# Wir müssen den highlight_alpha_expression verwenden, um das Wort EINZELN anzuzeigen.
|
| 167 |
|
| 168 |
+
f"x=(w-text_w)/2:y=(h-text_h)*{y_pos}"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# HACK: Da FFmpeg drawtext KEINE Wort-zu-Wort-Positions-Überlagerung unterstützt,
|
| 172 |
+
# können wir nur das ZENTRIERTE WORT einblenden lassen.
|
| 173 |
+
# Dies ist nicht perfekt, aber die stabilste Lösung.
|
| 174 |
+
|
| 175 |
+
# Wir belassen es bei der Zentrierung des Worts selbst. Das wird visuell besser sein,
|
| 176 |
+
# als wenn wir den ganzen Satz neu berechnen.
|
| 177 |
|
|
|
|
| 178 |
drawtext_filter = (
|
| 179 |
+
f"drawtext=text='{word.replace(':', FFMPEG_ESCAPE_CHAR + ':')}':"
|
| 180 |
f"fontcolor={params['fontcolor']}:"
|
| 181 |
f"fontsize={params['fontsize_override']}:"
|
| 182 |
f"borderw={params['borderw']}:"
|
|
|
|
| 185 |
f"x=(w-text_w)/2:y=(h-text_h)*{y_pos}"
|
| 186 |
)
|
| 187 |
|
| 188 |
+
|
| 189 |
if font_option:
|
| 190 |
drawtext_filter += f":{font_option}"
|
| 191 |
|
| 192 |
+
# Der Highlight-Filter ist nur aktiv, wenn das Wort aktiv ist.
|
| 193 |
+
drawtext_filter += f":alpha='{highlight_alpha_expression}'"
|
| 194 |
return drawtext_filter
|
| 195 |
|
| 196 |
|
|
|
|
| 240 |
drawtext_filters = []
|
| 241 |
|
| 242 |
if full_text:
|
| 243 |
+
# ERSTE SCHICHT: Der gesamte Satz (als STABILE BASIS mit Kasten)
|
| 244 |
+
base_filter = create_sentence_base_filter(full_text, duration_clip, font_option, font_size, y_pos, subtitle_style)
|
| 245 |
+
drawtext_filters.append(base_filter)
|
| 246 |
|
| 247 |
+
# ZWEITE SCHICHT: Highlight-Layer für jedes Wort
|
| 248 |
+
word_start_time = 0.0
|
| 249 |
+
# Wir verwenden hier NICHT den kumulativen Ansatz, sondern überlagern das Einzelwort
|
| 250 |
+
for word in word_segment:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
highlight_filter = create_highlight_word_filter(
|
| 252 |
word,
|
| 253 |
+
full_text,
|
| 254 |
word_start_time,
|
| 255 |
duration_per_word,
|
| 256 |
font_option,
|
|
|
|
| 259 |
subtitle_style
|
| 260 |
)
|
| 261 |
drawtext_filters.append(highlight_filter)
|
|
|
|
| 262 |
word_start_time += duration_per_word
|
| 263 |
|
| 264 |
|
|
|
|
| 275 |
|
| 276 |
# 4. Kombiniere alle Filter
|
| 277 |
if drawtext_filters:
|
| 278 |
+
# Wichtig: Der Basis-Satz muss als erster Filter, die Highlights als letzte Filter stehen.
|
| 279 |
all_drawtext_filters = ",".join(drawtext_filters)
|
| 280 |
vf_filters_clip = f"{base_filters},{all_drawtext_filters},{fade_img_filter}"
|
| 281 |
else:
|
|
|
|
| 352 |
|
| 353 |
with gr.Row():
|
| 354 |
img_input = gr.Files(label="Bilder", file_types=allowed_medias)
|
| 355 |
+
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.")
|
| 356 |
|
| 357 |
with gr.Row():
|
| 358 |
duration_image_input = gr.Number(value=3, label="Mindest-Dauer pro BILD (s)")
|