Videoeditor / src /streamlit_app.py
Benu82mohanty's picture
Update src/streamlit_app.py
7cafdde verified
import streamlit as st
import base64
import tempfile
import os
import math
# --- FORCED FONT DOWNLOADER ---
font_path = "/tmp/Hind-Bold.ttf"
if not os.path.exists(font_path) or os.path.getsize(font_path) < 10000:
os.system("wget -qO /tmp/Hind-Bold.ttf https://github.com/google/fonts/raw/main/ofl/hind/Hind-Bold.ttf")
try:
from PIL import Image, ImageDraw, ImageFont
import numpy as np
if not hasattr(Image, 'ANTIALIAS'):
Image.ANTIALIAS = Image.Resampling.LANCZOS
except ImportError:
pass
def hex_to_rgb(hex_color):
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
# --- MASTER TEXT GENERATOR (Standard) ---
def get_text_clip(text, fontsize, color_hex, duration, anim_type=""):
import moviepy.editor as mpy
if not text.strip():
text = " "
if anim_type in ["Scroll Left", "Scroll Right"]:
text = text.replace('\n', ' ')
text = (text.rstrip() + " ") * 5
elif anim_type in ["Scroll Up", "Scroll Down"]:
text = (text.strip() + "\n\n\n\n\n") * 15
else:
text = text.strip()
actual_font_size = int(fontsize)
font = None
try: font = ImageFont.truetype(font_path, actual_font_size)
except: font = ImageFont.load_default()
dummy_img = Image.new('RGBA', (1, 1), (0, 0, 0, 0))
draw = ImageDraw.Draw(dummy_img)
lines = text.split('\n')
max_w, total_h = 0, 0
line_heights, line_bboxes = [], []
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
line_bboxes.append(bbox)
lw, lh = bbox[2] - bbox[0], bbox[3] - bbox[1]
if lw > max_w: max_w = lw
total_h += lh
line_heights.append(lh)
total_h += max(0, len(lines) - 1) * 15
img_w = max(1, int(max_w) + 40)
img_h = max(1, int(total_h) + 40)
img = Image.new('RGBA', (img_w, img_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
txt_rgb = hex_to_rgb(color_hex)
y_offset = 20
try:
from pilmoji import Pilmoji
with Pilmoji(img) as pilmoji:
for i, line in enumerate(lines):
bbox = line_bboxes[i]
x_pos = (img_w - (bbox[2] - bbox[0])) / 2
pilmoji.text((x_pos, y_offset - bbox[1]), line, font=font, fill=txt_rgb + (255,))
y_offset += line_heights[i] + 15
except ImportError:
for i, line in enumerate(lines):
bbox = line_bboxes[i]
x_pos = (img_w - (bbox[2] - bbox[0])) / 2
draw.text((x_pos, y_offset - bbox[1]), line, font=font, fill=txt_rgb + (255,))
y_offset += line_heights[i] + 15
arr = np.array(img)
clip = mpy.ImageClip(arr[:, :, :3]).set_duration(duration)
mask = mpy.ImageClip(arr[:, :, 3] / 255.0, ismask=True).set_duration(duration)
return clip.set_mask(mask)
# --- SINGLE CHAR GENERATOR (Fixes Beka-Dheka Text / Perfect Baseline) ---
def get_char_clip(char, fontsize, color_hex, duration):
import moviepy.editor as mpy
font = ImageFont.truetype(font_path, int(fontsize))
dummy = Image.new('RGBA', (1, 1), (0, 0, 0, 0))
draw = ImageDraw.Draw(dummy)
ref_bbox = draw.textbbox((0, 0), "Ay", font=font)
char_bbox = draw.textbbox((0, 0), char, font=font)
lw = char_bbox[2] - char_bbox[0]
lh = ref_bbox[3] - ref_bbox[1]
img_w = max(1, int(lw) + 4)
img_h = max(1, int(lh) + 20)
img = Image.new('RGBA', (img_w, img_h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
txt_rgb = hex_to_rgb(color_hex)
x_pos = 2
y_pos = 10 - ref_bbox[1]
try:
from pilmoji import Pilmoji
with Pilmoji(img) as pilmoji:
pilmoji.text((x_pos, y_pos), char, font=font, fill=txt_rgb + (255,))
except ImportError:
d.text((x_pos, y_pos), char, font=font, fill=txt_rgb + (255,))
arr = np.array(img)
clip = mpy.ImageClip(arr[:, :, :3]).set_duration(duration)
mask = mpy.ImageClip(arr[:, :, 3] / 255.0, ismask=True).set_duration(duration)
return clip.set_mask(mask), img_w
st.set_page_config(page_title="NeuzX - Broadcast Pro 720p 60FPS", layout="wide")
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Mukta:wght@800&display=swap');
.main { background-color: #0d1117; color: #c9d1d9; font-family: 'Mukta', sans-serif; }
div[data-testid="stExpander"] { background-color: #161b22 !important; border: 1px solid #30363d !important; border-radius: 8px; }
.stButton>button { background-color: #238636 !important; color: white !important; font-weight: bold; width: 100%; border-radius: 6px; border: none; height: 45px; }
.green_screen_bg { background-color: #00FF00; }
@keyframes brightScan { 0% { left: -100%; opacity: 0; } 10% { opacity: 0.9; } 30% { left: 100%; opacity: 0; } 100% { left: 100%; opacity: 0; } }
.ticker_gfx_sweep { overflow: hidden; position: relative; }
.ticker_gfx_sweep::before { content: ""; position: absolute; top: 0; left: -100%; width: 80%; height: 100%; background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.9) 50%, transparent 100%); z-index: 2; pointer-events: none; animation: brightScan 5s infinite linear; }
@keyframes flashGlow { 0% { opacity: 0.5; filter: brightness(1); text-shadow: none; } 50% { opacity: 1.0; filter: brightness(1.5); text-shadow: 0 0 15px rgba(255,255,255,1); } 100% { opacity: 0.5; filter: brightness(1); text-shadow: none; } }
@keyframes slideHold { 0% { transform: translateX(100%); } 15% { transform: translateX(0%); } 85% { transform: translateX(0%); } 100% { transform: translateX(-100%); } }
/* PREVIEW LOGO ANIMATION */
@keyframes logoEntryLoop { 0% { transform: translateX(-30px); opacity: 0; text-shadow: none; } 15% { transform: translateX(0); opacity: 1; text-shadow: none; } 50% { text-shadow: 0 0 12px rgba(255,255,255,0.9); } 85% { transform: translateX(0); opacity: 1; text-shadow: none; } 100% { transform: translateX(30px); opacity: 0; text-shadow: none; } }
.preview-logo-box span { display: inline-block; animation: logoEntryLoop 10s infinite both; }
@keyframes pulse3D { 0% { transform: scale(1); } 100% { transform: scale(1.15); } }
@keyframes float3D { 0% { transform: translateY(-3px) translateX(-3px); } 100% { transform: translateY(3px) translateX(3px); } }
@keyframes shake3D { 0% { transform: translate(1px, 1px) rotate(0deg); } 25% { transform: translate(-1px, -1px) rotate(-2deg); } 50% { transform: translate(1px, -1px) rotate(2deg); } 75% { transform: translate(-1px, 1px) rotate(0deg); } 100% { transform: translate(1px, 1px) rotate(0deg); } }
@keyframes flip3D { 0% { transform: perspective(400px) rotateY(0deg); } 100% { transform: perspective(400px) rotateY(360deg); } }
</style>
""", unsafe_allow_html=True)
st.title("🎬 NeuzX - Broadcast Pro 720p 60FPS")
col_workspace, col_property_panel = st.columns([6, 5])
anim_options = ["Static", "Scroll Left", "Scroll Right", "Scroll Up", "Scroll Down", "Bounce", "Slide", "Flash/Glow", "Slide & Hold"]
logo_anim_options = ["Static", "3D Pulse (Zoom)", "3D Float", "3D Shake", "3D Flip", "Reverse Letter Slide"]
def get_marquee(text, anim, speed):
if anim in ["Scroll Up", "Scroll Down"]: return text.replace('\n', '<br>')
formatted_text = text.replace('\n', ' &nbsp;&nbsp;&nbsp;&nbsp; ').replace(' ', ' &nbsp;')
if anim == "Static": return formatted_text
elif anim == "Flash/Glow": return f'<div style="animation: flashGlow 3s infinite; width: 100%; text-align: center;">{formatted_text}</div>'
elif anim == "Slide & Hold": return f'<div style="animation: slideHold 10s infinite; width: 100%; text-align: center;">{formatted_text}</div>'
elif anim == "Scroll Left": return f'<marquee scrollamount="{speed}" direction="left" style="white-space: nowrap; width: 100%;">{formatted_text}</marquee>'
elif anim == "Scroll Right": return f'<marquee scrollamount="{speed}" direction="right" style="white-space: nowrap; width: 100%;">{formatted_text}</marquee>'
elif anim == "Scroll Up": return f'<marquee scrollamount="{speed}" direction="up" style="text-align: center; width: 100%; height: 100%; display: block;">{formatted_text}</marquee>'
elif anim == "Scroll Down": return f'<marquee scrollamount="{speed}" direction="down" style="text-align: center; width: 100%; height: 100%; display: block;">{formatted_text}</marquee>'
elif anim == "Bounce": return f'<marquee scrollamount="{speed}" behavior="alternate" style="text-align: center; width: 100%;">{formatted_text}</marquee>'
elif anim == "Slide": return f'<marquee scrollamount="{speed}" behavior="slide" style="white-space: nowrap; text-align: center; width: 100%;">{formatted_text}</marquee>'
return formatted_text
with col_property_panel:
sliders_locked = st.toggle("🔒 Lock Sliders & Sizes (Prevent Accidental Changes)", value=False)
st.subheader("🛠️ Customization (720p)")
with st.expander("⬆️ Upper Ticker Settings", expanded=True):
top_speed = st.slider("Upper Speed", 1, 20, 6, disabled=sliders_locked)
col1, col2 = st.columns(2)
top_left_bg = col1.color_picker("Top Left BG", "#FF0000")
top_left_color = col1.color_picker("Top Left Text Color", "#FFFF00")
top_left_size = col1.slider("Top Left Font Size", 10, 250, 45, disabled=sliders_locked)
top_left_text = col1.text_area("Top Left Text", "आज की 20 बड़ी खबरें")
top_left_anim = col1.selectbox("Left Animation", anim_options, index=0)
top_right_bg = col2.color_picker("Top Right BG", "#0000FF")
top_right_color = col2.color_picker("Top Right Text Color", "#FFFFFF")
top_right_size = col2.slider("Top Right Font Size", 10, 250, 45, disabled=sliders_locked)
top_right_text = col2.text_area("Top Right Text", "FAST NEWS | 20 बड़ी खबरें")
top_right_anim = col2.selectbox("Right Animation", anim_options, index=8) # Set Slide & Hold as default
with st.expander("⬇️ Lower Ticker Settings", expanded=True):
col7, col8 = st.columns(2)
logo_bg = col7.color_picker("Logo Background", "#FFFF00")
logo_color = col7.color_picker("Logo Text Color", "#000000")
logo_size = col7.slider("Logo Font Size", 10, 250, 60, disabled=sliders_locked)
logo_text = col7.text_input("Logo Text", "NeuzX")
logo_anim = col7.selectbox("Logo Animation", logo_anim_options, index=5)
logo_anim_speed = col7.slider("Logo Anim Speed", 1, 20, 3, disabled=sliders_locked)
bottom_bg = col8.color_picker("Bottom Bar BG", "#0000FF")
bottom_color = col8.color_picker("Bottom Text Color", "#FFFFFF")
bottom_size = col8.slider("Bottom Font Size", 10, 250, 50, disabled=sliders_locked)
default_bottom_txt = "🌐 देश-विदेश की तमाम बड़ी खबरों के लिए हमारे साथ जुड़े रहें... 🎯 सच्ची, सटीक और निष्पक्ष खबरों का एकमात्र ठिकाना - Neuz X 📢 देशभर की सुबह की 20 सबसे बड़ी खबरें सीधे आप... 👍 अगर जानकारी अच्छी लगी तो वीडियो को एक लाइक जरूर करें..."
bottom_text = col8.text_input("Bottom Ticker", default_bottom_txt)
bottom_speed = st.slider("Lower Speed", 1, 20, 3, disabled=sliders_locked)
tl_content = get_marquee(top_left_text, top_left_anim, top_speed)
tr_content = get_marquee(top_right_text, top_right_anim, top_speed)
logo_css = ""
css_speed = max(0.2, 5.0 / max(1, logo_anim_speed))
if logo_anim == "3D Pulse (Zoom)": logo_css = f"animation: pulse3D {css_speed}s infinite alternate ease-in-out;"
elif logo_anim == "3D Float": logo_css = f"animation: float3D {css_speed}s infinite alternate ease-in-out;"
elif logo_anim == "3D Shake": logo_css = f"animation: shake3D {css_speed/2}s infinite linear;"
elif logo_anim == "3D Flip": logo_css = f"animation: flip3D {css_speed * 1.5}s infinite linear;"
if logo_anim == "Reverse Letter Slide":
span_html = ""
word_len = len(logo_text)
for i, char in enumerate(logo_text):
delay = (word_len - 1 - i) * 0.3
span_html += f'<span style="animation-delay: {delay}s;">{char}</span>'
logo_preview_html = f'<div class="preview-logo-box" style="font-size: {logo_size * 0.078125:.3f}cqw; line-height: 1; display: flex; align-items: center; justify-content: center;">{span_html}</div>'
else:
logo_preview_html = f'<div style="font-size: {logo_size * 0.078125:.3f}cqw; line-height: 1; display: flex; align-items: center; justify-content: center; {logo_css}">{logo_text}</div>'
cqw_factor = 0.078125
tl_fz = f"{top_left_size * cqw_factor:.3f}cqw"
tr_fz = f"{top_right_size * cqw_factor:.3f}cqw"
bot_fz = f"{bottom_size * cqw_factor:.3f}cqw"
with col_workspace:
st.subheader("📺 1:1 Pixel-Perfect Monitor")
html_preview = f"""<div class="green_screen_bg" style="container-type: inline-size; width: 100%; aspect-ratio: 16/9; position: relative; overflow: hidden; border: 2px solid white;"><div style="position: absolute; top: 0; left: 0; width: 100%; height: 8%; display: flex; font-weight: bold; z-index: 10;"><div class="ticker_gfx_sweep" style="width: 50%; height: 100%; background: {top_left_bg}; color: {top_left_color}; font-size: {tl_fz}; line-height: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; padding: 0 5px;">{tl_content}</div><div class="ticker_gfx_sweep" style="width: 50%; height: 100%; background: {top_right_bg}; color: {top_right_color}; font-size: {tr_fz}; line-height: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; padding: 0 5px;">{tr_content}</div></div><div style="position: absolute; bottom: 0; left: 0; width: 100%; height: 10%; display: flex; font-weight: bold; z-index: 10;"><div style="width: 20%; background: {logo_bg}; color: {logo_color}; display: flex; align-items: center; justify-content: center; overflow: hidden;">{logo_preview_html}</div><div class="ticker_gfx_sweep" style="width: 80%; background: {bottom_bg}; color: {bottom_color}; font-size: {bot_fz}; line-height: 1; display: flex; align-items: center; overflow: hidden;"><marquee scrollamount="{bottom_speed}">{bottom_text}</marquee></div></div></div>"""
st.markdown(html_preview, unsafe_allow_html=True)
st.markdown("---")
st.info("💡 Render: 720p HD | 60 FPS. (Slide & Hold 10-Second Loop Fix Applied!)")
def get_light_sweep_clip(w, h, duration):
import moviepy.editor as mpy
import numpy as np
sweep_w = max(100, int(w * 0.6))
arr = np.ones((h, sweep_w, 3), dtype=np.uint8) * 255
alpha = np.zeros((h, sweep_w), dtype=np.float32)
for i in range(sweep_w):
dist = abs(i - sweep_w/2.0)/(sweep_w/2.0)
alpha[:, i] = 0.75 * (1.0 - (dist ** 2))
sweep = mpy.ImageClip(arr).set_mask(mpy.ImageClip(alpha, ismask=True)).set_duration(duration)
def sweep_pos(t):
t_c = t % 5.0
if t_c < 1.5: return (-sweep_w + (w + sweep_w) * (t_c / 1.5), 0)
return (-w*2, 0)
return sweep.set_position(sweep_pos)
def apply_animation(txt_clip, anim_name, speed, box_w, box_h):
txt_w, txt_h = txt_clip.size
if anim_name == "Flash/Glow":
import numpy as np
def change_opacity(get_frame, t):
frame = get_frame(t)
op = 0.5 + 0.5 * np.abs(np.sin(speed * 0.8 * t))
return frame * op
txt_clip = txt_clip.set_position(('center', 'center'))
if txt_clip.mask is not None:
txt_clip.mask = txt_clip.mask.fl(change_opacity)
return txt_clip
elif anim_name == "Scroll Left": return txt_clip.set_position(lambda t: (box_w - (speed*80.0)*t, 'center'))
elif anim_name == "Scroll Right": return txt_clip.set_position(lambda t: (-txt_w + (speed*80.0)*t, 'center'))
elif anim_name == "Scroll Up": return txt_clip.set_position(lambda t: ('center', box_h - (speed*60.0)*t))
elif anim_name == "Scroll Down": return txt_clip.set_position(lambda t: ('center', -txt_h + (speed*60.0)*t))
elif anim_name == "Bounce":
import numpy as np
mid_x = (box_w - txt_w) / 2.0
return txt_clip.set_position(lambda t: (mid_x - mid_x * np.cos(speed * 0.8 * t), 'center'))
elif anim_name == "Slide": return txt_clip.set_position(lambda t: (min((box_w - txt_w)/2, -txt_w + (speed*80.0)*t), 'center'))
else: return txt_clip.set_position(('center', 'center'))
def process_text_box(text, size, color, bg_color, anim_name, speed, box_w, box_h, duration):
import moviepy.editor as mpy
bg = mpy.ColorClip(size=(box_w, box_h), color=hex_to_rgb(bg_color)).set_duration(duration)
sweep = get_light_sweep_clip(box_w, box_h, duration)
if anim_name == "Slide & Hold":
lines = [l.strip() for l in text.split('\n') if l.strip()]
if not lines: lines = [" "]
# --- FIX: Single Line Repeater for 10-second looping ---
if len(lines) == 1:
lines = [lines[0]] * max(1, int(duration / 10.0))
line_dur = duration / len(lines)
clips_to_composite = [bg]
for i, line in enumerate(lines):
s_txt = get_text_clip(line, size, color, line_dur, anim_type="Static")
txt_w, txt_h = s_txt.size
mid_x = (box_w - txt_w) / 2.0
def make_pos_func(w_box, w_txt, m_x, dur, spd):
def pos(t):
slide_time = max(0.2, 3.0 / max(1, spd))
if slide_time * 2 > dur: slide_time = dur / 2.0
if t < slide_time: return (w_box - (w_box - m_x) * (t / slide_time), 'center')
elif t > dur - slide_time:
out_t = t - (dur - slide_time)
return (m_x - (m_x + w_txt) * (out_t / slide_time), 'center')
else: return (m_x, 'center')
return pos
s_txt = s_txt.set_position(make_pos_func(box_w, txt_w, mid_x, line_dur, speed)).set_start(i * line_dur)
clips_to_composite.append(s_txt)
clips_to_composite.append(sweep)
return mpy.CompositeVideoClip(clips_to_composite, size=(box_w, box_h)).set_duration(duration)
else:
txt_final = get_text_clip(text, size, color, duration, anim_type=anim_name)
txt_final = apply_animation(txt_final, anim_name, speed, box_w, box_h)
return mpy.CompositeVideoClip([bg, txt_final, sweep], size=(box_w, box_h))
if st.button("🚀 COMPILE BROADCAST VIDEO (720p 60FPS)", use_container_width=True):
with st.spinner("Rendering Slide & Hold Loop..."):
try:
import moviepy.editor as mpy
import numpy as np
W, H = 1280, 720
duration = 30.0
bg = mpy.ColorClip(size=(W, H), color=(0, 255, 0)).set_duration(duration)
clips = [bg]
def tl_pos(t):
if t < 1.0: return (-W//2 + (W//2) * t, 0)
if t > 29.0: return (-W//2 + (W//2) * (30.0 - t), 0)
return (0, 0)
def tr_pos(t):
if t < 1.0: return (W - (W//2) * t, 0)
if t > 29.0: return (W//2 + (W//2) * (t - 29.0), 0)
return (W//2, 0)
def logo_pos(t):
if t < 1.0: return (0, H - int(H*0.1) * t)
if t > 29.0: return (0, int(H*0.9) + int(H*0.1) * (t - 29.0))
return (0, int(H*0.9))
def bot_pos(t):
if t < 1.0: return (W - int(W*0.8) * t, int(H*0.9))
if t > 29.0: return (int(W*0.2) + int(W*0.8) * (t - 29.0), int(H*0.9))
return (int(W*0.2), int(H*0.9))
tl_box_w, tl_box_h = W//2, int(H*0.08)
tl_final = process_text_box(top_left_text, top_left_size, top_left_color, top_left_bg, top_left_anim, top_speed, tl_box_w, tl_box_h, duration)
clips.append(tl_final.set_position(tl_pos))
tr_box_w, tr_box_h = W//2, int(H*0.08)
tr_final = process_text_box(top_right_text, top_right_size, top_right_color, top_right_bg, top_right_anim, top_speed, tr_box_w, tr_box_h, duration)
clips.append(tr_final.set_position(tr_pos))
logo_box_w, logo_box_h = int(W*0.2), int(H*0.1)
l_bg = mpy.ColorClip(size=(logo_box_w, logo_box_h), color=hex_to_rgb(logo_bg)).set_duration(duration)
if logo_anim == "Reverse Letter Slide":
word = logo_text if logo_text.strip() else " "
letter_clips = []
char_data = []
total_w = 0
for char in word:
c_clip, c_w = get_char_clip(char, logo_size, logo_color, duration)
char_data.append((char, c_clip, c_w))
total_w += c_w
start_x = (logo_box_w - total_w) / 2
current_x = start_x
for i, (char, c_clip, c_w) in enumerate(char_data):
target_x = current_x
current_x += c_w
delay = (len(word) - 1 - i) * 0.25
def make_char_pos(tx, dly, index):
def cpos(t):
tc = t % 10.0
if tc < dly: return (-200, 0)
if tc < dly + 0.6:
progress = (tc - dly) / 0.6
return (tx - 100 + 100 * progress, 'center')
if tc > 8.0 + (index*0.1):
p_out = (tc - (8.0 + (index*0.1))) / 0.5
return (tx + 150 * p_out, 'center')
return (tx, 'center')
return cpos
def glow_fl(get_frame, t):
frame = get_frame(t)
tc = t % 10.0
if 2.0 < tc < 8.0:
pulse = 1.0 + 0.4 * abs(math.sin(tc * (logo_anim_speed/2.0)))
return (frame * pulse).clip(0, 255)
return frame
c_clip = c_clip.set_position(make_char_pos(target_x, delay, i)).fl(glow_fl)
letter_clips.append(c_clip)
logo_final = mpy.CompositeVideoClip([l_bg] + letter_clips, size=(logo_box_w, logo_box_h))
else:
logo_txt_clip = get_text_clip(logo_text, logo_size, logo_color, duration, anim_type="Static")
if logo_anim == "3D Pulse (Zoom)": logo_txt_clip = logo_txt_clip.resize(lambda t: 1.0 + 0.1 * np.sin(logo_anim_speed * 1.5 * t)).set_position(('center', 'center'))
elif logo_anim == "3D Float": logo_txt_clip = logo_txt_clip.set_position(lambda t: ((logo_box_w - logo_txt_clip.w)/2 + 10 * np.sin(logo_anim_speed * 1.5 * t), (logo_box_h - logo_txt_clip.h)/2 + 5 * np.cos(logo_anim_speed * 1.5 * t)))
elif logo_anim == "3D Shake": logo_txt_clip = logo_txt_clip.set_position(lambda t: ((logo_box_w - logo_txt_clip.w)/2 + 5 * np.sin(logo_anim_speed * 5 * t), (logo_box_h - logo_txt_clip.h)/2 + 3 * np.cos(logo_anim_speed * 8 * t)))
elif logo_anim == "3D Flip":
orig_w, orig_h = logo_txt_clip.size
def flip_size(t): return (max(1, int(orig_w * abs(np.cos(logo_anim_speed * 1.2 * t)))), orig_h)
logo_txt_clip = logo_txt_clip.resize(flip_size).set_position(('center', 'center'))
else: logo_txt_clip = logo_txt_clip.set_position(('center', 'center'))
logo_final = mpy.CompositeVideoClip([l_bg, logo_txt_clip], size=(logo_box_w, logo_box_h))
clips.append(logo_final.set_position(logo_pos))
bot_box_w, bot_box_h = int(W*0.8), int(H*0.1)
bot_final = process_text_box(bottom_text, bottom_size, bottom_color, bottom_bg, "Scroll Left", bottom_speed, bot_box_w, bot_box_h, duration)
clips.append(bot_final.set_position(bot_pos))
final = mpy.CompositeVideoClip(clips)
out = "NeuzX_Broadcast_Pro_720p_60FPS.mp4"
final.write_videofile(out, fps=60, codec="libx264", preset="ultrafast", threads=2, audio=False, logger=None)
st.success("🎉 Single Line Auto-Loop Fix Applied! Video is Ready.")
with open(out, "rb") as f:
st.download_button("⬇️ DOWNLOAD VIDEO", f, out, "video/mp4")
except Exception as e:
st.error(f"Render Error: {e}")