Spaces:
Sleeping
Sleeping
| import os | |
| import subprocess | |
| import requests | |
| import textwrap | |
| import wave | |
| import math | |
| import struct | |
| import shutil | |
| import random | |
| import base64 | |
| from io import BytesIO | |
| from PIL import Image, ImageDraw, ImageFont | |
| import gradio as gr | |
| # --- NEW LIBRARY FOR KOKORO TTS --- | |
| from gradio_client import Client | |
| # --- LIBRARIES --- | |
| import pyttsx3 | |
| # --- 1. AUTOMATIC DIRECTORY DETECTION --- | |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| def get_path(filename): | |
| return os.path.join(BASE_DIR, filename) | |
| try: | |
| import imageio_ffmpeg | |
| FFMPEG_BINARY = imageio_ffmpeg.get_ffmpeg_exe() | |
| except ImportError: | |
| FFMPEG_BINARY = "ffmpeg" | |
| # --- CONFIGURATION --- | |
| SCREEN_WIDTH = 1080 | |
| SCREEN_HEIGHT = 1920 | |
| def get_best_font(): | |
| system_fonts = [ | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", | |
| "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", | |
| "arial.ttf" | |
| ] | |
| for font_path in system_fonts: | |
| if os.path.exists(font_path): | |
| return font_path | |
| download_url = "https://github.com/google/fonts/raw/main/apache/roboto/Roboto-Bold.ttf" | |
| tmp_path = "/tmp/Roboto-Bold.ttf" | |
| if not os.path.exists(tmp_path): | |
| try: | |
| r = requests.get(download_url, timeout=10) | |
| if r.status_code == 200: | |
| with open(tmp_path, "wb") as f: | |
| f.write(r.content) | |
| return tmp_path | |
| except Exception as e: | |
| print(f"Font download failed: {e}") | |
| elif os.path.exists(tmp_path): | |
| return tmp_path | |
| return None | |
| FONT_PATH_GLOBAL = get_best_font() | |
| # --- COLORS --- | |
| # Standard Theme | |
| COLOR_BG_DARK = (20, 20, 20) | |
| COLOR_ACCENT_YELLOW = (255, 215, 0) | |
| COLOR_ACCENT_GREEN = (40, 167, 69) | |
| COLOR_PANEL_WHITE = (245, 245, 245) | |
| # Classic Theme | |
| CLASSIC_BG = (255, 255, 255) | |
| CLASSIC_RED = (230, 0, 0) | |
| CLASSIC_GREEN = (144, 238, 144) | |
| CLASSIC_TEXT = (0, 0, 0) | |
| # --- HELPER: SAVE/LOAD LAYOUT --- | |
| def save_layout_config(timer_y): | |
| with open(get_path("layout_config.txt"), "w") as f: | |
| f.write(str(int(timer_y))) | |
| def get_timer_y_pos(): | |
| try: | |
| with open(get_path("layout_config.txt"), "r") as f: | |
| return int(f.read().strip()) | |
| except: | |
| return 1500 | |
| # --- TEXT & ASSET HELPERS --- | |
| def load_font(size): | |
| if FONT_PATH_GLOBAL and os.path.exists(FONT_PATH_GLOBAL): | |
| try: | |
| return ImageFont.truetype(FONT_PATH_GLOBAL, size) | |
| except: pass | |
| return ImageFont.load_default() | |
| def create_bordered_panel(width, height, radius, bg_color, border_color, border_width=4): | |
| img = Image.new('RGBA', (width, height), (0,0,0,0)) | |
| draw = ImageDraw.Draw(img) | |
| draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=bg_color) | |
| draw.rounded_rectangle((1, 1, width-2, height-2), radius=radius, outline=border_color, width=border_width) | |
| return img | |
| def create_wrapped_text_img(text, max_width, font_size=60, color="white", align="center"): | |
| font = load_font(font_size) | |
| avg_char_width = font_size * 0.6 | |
| max_chars = int(max_width / avg_char_width) if avg_char_width > 0 else 20 | |
| lines = textwrap.wrap(text, width=max_chars) | |
| line_height = int(font_size * 1.3) | |
| total_text_height = len(lines) * line_height | |
| img = Image.new('RGBA', (max_width, total_text_height + 10), (0,0,0,0)) | |
| draw = ImageDraw.Draw(img) | |
| y = 0 | |
| for line in lines: | |
| try: | |
| bbox = draw.textbbox((0, 0), line, font=font) | |
| line_w = bbox[2] - bbox[0] | |
| if align == "center": | |
| x = (max_width - line_w) // 2 | |
| else: | |
| x = 0 | |
| draw.text((x, y), line, font=font, fill=color) | |
| except TypeError: | |
| draw.text((10, y), line, font=font, fill=color) | |
| y += line_height | |
| return img, total_text_height | |
| # --- THEME 1: STANDARD BUTTONS --- | |
| def create_dynamic_button_standard(width, label, text, state="normal"): | |
| label_w = 120 | |
| text_max_w = width - label_w - 60 | |
| f_txt_size = 45 | |
| txt_img, txt_h = create_wrapped_text_img(text, text_max_w, font_size=f_txt_size, color="black") | |
| min_height = 115 | |
| padding_vertical = 50 | |
| final_height = max(min_height, txt_h + padding_vertical) | |
| border_color = COLOR_ACCENT_YELLOW | |
| if state == "correct": | |
| fill = COLOR_ACCENT_GREEN; bg_label = (0, 80, 0); txt_col = "white"; lbl_col = "white" | |
| border_color = COLOR_ACCENT_GREEN | |
| elif state == "dimmed": | |
| fill = (60, 60, 60); bg_label = (30, 30, 30); txt_col = (150, 150, 150); lbl_col = "white" | |
| border_color = (80, 80, 80) | |
| else: | |
| fill = COLOR_PANEL_WHITE; bg_label = COLOR_ACCENT_YELLOW; txt_col = "black"; lbl_col = "black" | |
| img = Image.new('RGBA', (width, final_height), (0,0,0,0)) | |
| draw = ImageDraw.Draw(img) | |
| radius = 25 | |
| draw.rounded_rectangle((0, 0, width, final_height), radius=radius, fill=fill) | |
| draw.rounded_rectangle((0, 0, label_w, final_height), radius=radius, fill=bg_label) | |
| draw.rectangle((label_w/2, 0, label_w, final_height), fill=bg_label) | |
| draw.rounded_rectangle((1, 1, width-2, final_height-2), radius=radius, outline=border_color, width=4) | |
| draw.line((label_w, 0, label_w, final_height), fill=border_color, width=4) | |
| f_lbl = load_font(55) | |
| draw.text((label_w//2, final_height//2), label, font=f_lbl, fill=lbl_col, anchor="mm") | |
| if txt_col != "black": | |
| txt_img, _ = create_wrapped_text_img(text, text_max_w, font_size=f_txt_size, color=txt_col) | |
| text_y = (final_height - txt_img.height) // 2 | |
| img.paste(txt_img, (label_w + 30, text_y), txt_img) | |
| return img | |
| # --- THEME 2: CLASSIC BUTTONS --- | |
| def create_dynamic_button_classic(width, label, text, state="normal"): | |
| text_max_w = width - 80 | |
| f_txt_size = 45 | |
| txt_img, txt_h = create_wrapped_text_img(text, text_max_w, font_size=f_txt_size, color="black", align="left") | |
| min_height = 100 | |
| padding_vertical = 40 | |
| final_height = max(min_height, txt_h + padding_vertical) | |
| border_color = (180, 180, 180) | |
| if state == "correct": | |
| fill = CLASSIC_GREEN | |
| elif state == "dimmed": | |
| fill = (240, 240, 240) | |
| else: | |
| fill = (255, 255, 255) | |
| img = Image.new('RGBA', (width, final_height), (0,0,0,0)) | |
| draw = ImageDraw.Draw(img) | |
| draw.rectangle((0, 0, width, final_height), fill=fill, outline=border_color, width=2) | |
| full_text_label = f"{label}. " | |
| f_lbl = load_font(45) | |
| draw.text((30, final_height//2), full_text_label, font=f_lbl, fill="black", anchor="lm") | |
| label_pixel_w = 60 | |
| text_y = (final_height - txt_img.height) // 2 | |
| img.paste(txt_img, (30 + label_pixel_w, text_y), txt_img) | |
| return img | |
| # ========================================== | |
| # === SCENE GENERATOR --- | |
| # ========================================== | |
| def draw_standard_theme(base, question, options, correct_idx, footer_text): | |
| current_y_cursor = 150 | |
| scene1_container = base.copy() | |
| scene2 = base.copy() | |
| # 1. Question Panel | |
| q_width = 1000 | |
| q_padding = 80 | |
| q_txt_img, q_txt_h = create_wrapped_text_img(question, q_width - 100, font_size=55, color="black") | |
| q_panel_h = max(340, q_txt_h + q_padding) | |
| q_panel = create_bordered_panel(q_width, q_panel_h, radius=40, bg_color=COLOR_PANEL_WHITE, border_color=COLOR_ACCENT_YELLOW) | |
| q_y_offset = (q_panel_h - q_txt_img.height) // 2 | |
| q_panel.paste(q_txt_img, (50, q_y_offset), q_txt_img) | |
| panel_x = (SCREEN_WIDTH - q_width) // 2 | |
| base.paste(q_panel, (panel_x, current_y_cursor), q_panel) | |
| scene1_container.paste(q_panel, (panel_x, current_y_cursor), q_panel) | |
| scene2.paste(q_panel, (panel_x, current_y_cursor), q_panel) | |
| current_y_cursor += q_panel_h + 60 | |
| # 2. Options | |
| labels = ["A", "B", "C", "D"] | |
| btn_width = 900 | |
| btn_x = (SCREEN_WIDTH - btn_width) // 2 | |
| for i, opt_text in enumerate(options): | |
| lbl = labels[i] if i < len(labels) else "?" | |
| btn_norm = create_dynamic_button_standard(btn_width, lbl, opt_text, "normal") | |
| btn_res = create_dynamic_button_standard(btn_width, lbl, opt_text, "correct" if i==correct_idx else "dimmed") | |
| scene1_container.paste(btn_norm, (btn_x, current_y_cursor), btn_norm) | |
| scene2.paste(btn_res, (btn_x, current_y_cursor), btn_res) | |
| current_y_cursor += btn_norm.height + 30 | |
| # 3. Timer | |
| current_y_cursor += 30 | |
| timer_w = 800; timer_h = 60 | |
| timer_x = (SCREEN_WIDTH - timer_w) // 2 | |
| timer_y = current_y_cursor | |
| draw = ImageDraw.Draw(scene1_container) | |
| draw.rounded_rectangle((timer_x, timer_y, timer_x+timer_w, timer_y+timer_h), radius=timer_h//2, outline="white", width=6) | |
| save_layout_config(timer_y) | |
| current_y_cursor += timer_h + 50 | |
| # 4. Footer | |
| if footer_text and footer_text.strip(): | |
| f_width = 950 | |
| f_txt_img, f_txt_h = create_wrapped_text_img(footer_text, f_width - 70, font_size=38, color="black") | |
| f_panel_h = max(200, f_txt_h + 60) | |
| f_panel = create_bordered_panel(f_width, f_panel_h, radius=30, bg_color=COLOR_PANEL_WHITE, border_color=COLOR_ACCENT_YELLOW) | |
| f_y_offset = (f_panel_h - f_txt_img.height) // 2 | |
| f_panel.paste(f_txt_img, (35, f_y_offset), f_txt_img) | |
| f_x = (SCREEN_WIDTH - f_width) // 2 | |
| scene2.paste(f_panel, (f_x, current_y_cursor), f_panel) | |
| return scene1_container, scene2 | |
| def draw_classic_theme(base, question, options, correct_idx, footer_text): | |
| draw_base = ImageDraw.Draw(base) | |
| # 1. Top Red Bar | |
| header_h = 120 | |
| draw_base.rectangle((0, 0, SCREEN_WIDTH, header_h), fill=CLASSIC_RED) | |
| # 2. Header Text | |
| header_txt = "Visit our Website Link in Bio" | |
| f_head = load_font(45) | |
| bbox = draw_base.textbbox((0,0), header_txt, font=f_head) | |
| w_head = bbox[2]-bbox[0] | |
| draw_base.text(((SCREEN_WIDTH-w_head)//2, (header_h-60)//2 + 10), header_txt, font=f_head, fill="white") | |
| current_y_cursor = header_h + 80 | |
| # 3. Main Title | |
| title_txt = "Driving Test Preparation" | |
| f_title = load_font(65) | |
| bbox = draw_base.textbbox((0,0), title_txt, font=f_title) | |
| w_title = bbox[2]-bbox[0] | |
| draw_base.text(((SCREEN_WIDTH-w_title)//2, current_y_cursor), title_txt, font=f_title, fill="black") | |
| current_y_cursor += 120 | |
| # 4. Question | |
| q_width = 950 | |
| q_txt_img, q_txt_h = create_wrapped_text_img(question, q_width, font_size=50, color="black", align="left") | |
| q_x = (SCREEN_WIDTH - q_width) // 2 | |
| base.paste(q_txt_img, (q_x, current_y_cursor), q_txt_img) | |
| current_y_cursor += q_txt_h + 80 | |
| scene1_container = base.copy() | |
| scene2 = base.copy() | |
| # 5. Options | |
| labels = ["A", "B", "C", "D"] | |
| btn_width = 950 | |
| btn_x = (SCREEN_WIDTH - btn_width) // 2 | |
| for i, opt_text in enumerate(options): | |
| lbl = labels[i] if i < len(labels) else "?" | |
| btn_norm = create_dynamic_button_classic(btn_width, lbl, opt_text, "normal") | |
| btn_res = create_dynamic_button_classic(btn_width, lbl, opt_text, "correct" if i==correct_idx else "dimmed") | |
| scene1_container.paste(btn_norm, (btn_x, current_y_cursor), btn_norm) | |
| scene2.paste(btn_res, (btn_x, current_y_cursor), btn_res) | |
| current_y_cursor += btn_norm.height + 25 | |
| # 6. Timer | |
| current_y_cursor += 50 | |
| timer_w = 800; timer_h = 40 | |
| timer_x = (SCREEN_WIDTH - timer_w) // 2 | |
| timer_y = current_y_cursor | |
| draw1 = ImageDraw.Draw(scene1_container) | |
| draw1.rectangle((timer_x, timer_y, timer_x+timer_w, timer_y+timer_h), fill=(200,200,200)) | |
| save_layout_config(timer_y) | |
| # 7. Footer | |
| if footer_text and footer_text.strip(): | |
| current_y_cursor += 100 | |
| f_txt_img, _ = create_wrapped_text_img(footer_text, 900, font_size=35, color="black") | |
| f_x = (SCREEN_WIDTH - 900) // 2 | |
| scene2.paste(f_txt_img, (f_x, current_y_cursor), f_txt_img) | |
| return scene1_container, scene2 | |
| def generate_static_scenes_manager(question, options, correct_idx, bg_path, footer_text, theme): | |
| # Setup Base Background | |
| if theme == "Classic": | |
| base = Image.new('RGB', (SCREEN_WIDTH, SCREEN_HEIGHT), CLASSIC_BG) | |
| else: | |
| base = Image.new('RGB', (SCREEN_WIDTH, SCREEN_HEIGHT), COLOR_BG_DARK) | |
| # --- Background Image Handling (FIT to screen) --- | |
| if bg_path: | |
| try: | |
| p = None | |
| if bg_path.startswith('data:image'): | |
| header, base64_str = bg_path.split(",", 1) | |
| image_data = base64.b64decode(base64_str) | |
| p = Image.open(BytesIO(image_data)) | |
| elif bg_path.startswith('http'): | |
| headers = {'User-Agent': 'Mozilla/5.0'} | |
| r = requests.get(bg_path, headers=headers, timeout=10) | |
| p = Image.open(BytesIO(r.content)) | |
| elif os.path.exists(bg_path): | |
| p = Image.open(bg_path) | |
| if p: | |
| img_w, img_h = p.size | |
| scale = min(SCREEN_WIDTH/img_w, SCREEN_HEIGHT/img_h) | |
| nw, nh = int(img_w*scale), int(img_h*scale) | |
| p = p.resize((nw, nh), Image.LANCZOS) | |
| # Center the image | |
| x_pos = (SCREEN_WIDTH - nw) // 2 | |
| y_pos = (SCREEN_HEIGHT - nh) // 2 | |
| base.paste(p, (x_pos, y_pos)) | |
| if theme == "Standard": | |
| dark_overlay = Image.new('RGBA', base.size, (0,0,0,100)) | |
| base.paste(dark_overlay, (0,0), dark_overlay) | |
| else: | |
| white_overlay = Image.new('RGBA', base.size, (255,255,255,200)) | |
| base.paste(white_overlay, (0,0), white_overlay) | |
| except Exception as e: | |
| print(f"Bg Error: {e}") | |
| if theme == "Classic": | |
| s1, s2 = draw_classic_theme(base, question, options, correct_idx, footer_text) | |
| else: | |
| s1, s2 = draw_standard_theme(base, question, options, correct_idx, footer_text) | |
| s1.save(get_path("temp_scene1_container.png")) | |
| s2.save(get_path("temp_scene2.png")) | |
| def generate_timer_animation_frames(duration=5.0, fps=30, theme="Standard"): | |
| frames_dir = get_path("temp_frames") | |
| if os.path.exists(frames_dir): shutil.rmtree(frames_dir) | |
| os.makedirs(frames_dir) | |
| base_img = Image.open(get_path("temp_scene1_container.png")) | |
| timer_y = get_timer_y_pos() | |
| total_frames = int(duration * fps) | |
| w_full = 800 | |
| if theme == "Classic": | |
| h = 40 | |
| else: | |
| h = 60 | |
| x_start = (SCREEN_WIDTH - w_full) // 2 | |
| for i in range(total_frames): | |
| frame_img = base_img.copy() | |
| draw = ImageDraw.Draw(frame_img) | |
| progress = i / (total_frames - 1) | |
| if theme == "Classic": | |
| # Dark Gray filling up a rectangle | |
| current_w = progress * w_full | |
| draw.rectangle((x_start, timer_y, x_start + current_w, timer_y + h), fill=(100, 100, 100)) | |
| else: | |
| # Standard Yellow Bar inside border | |
| w_max = w_full - 12 | |
| x_bar = x_start + 6 | |
| y_bar = timer_y + 6 | |
| h_bar = h - 12 | |
| current_w = progress * w_max | |
| if current_w >= 1: | |
| draw.rounded_rectangle((x_bar, y_bar, x_bar + current_w, y_bar + h_bar), radius=h_bar//2, fill=COLOR_ACCENT_YELLOW) | |
| frame_img.save(os.path.join(frames_dir, f"frame_{i:04d}.png")) | |
| # --- AUDIO & KOKORO TTS --- | |
| def generate_tick_sound(filename, duration=5.0, ticks_per_second=4): | |
| sample_rate = 44100; n_frames = int(sample_rate * duration); data = [] | |
| interval = 1.0 / ticks_per_second | |
| for i in range(n_frames): | |
| t = i / sample_rate; local_t = t % interval | |
| if local_t < 0.04: | |
| freq = 3500 if int(t / interval) % 2 == 0 else 2800 | |
| tone = math.sin(2 * math.pi * freq * local_t) | |
| noise = random.uniform(-1, 1) | |
| decay = math.exp(-200 * local_t) | |
| val = (tone * 0.4 + noise * 0.6) * decay * 0.3 | |
| else: val = 0.0 | |
| data.append(int(val * 32767)) | |
| with wave.open(filename, 'w') as f: | |
| f.setnchannels(1); f.setsampwidth(2); f.setframerate(sample_rate); f.writeframes(struct.pack('<' + 'h'*len(data), *data)) | |
| # --- NEW KOKORO TTS FUNCTION --- | |
| def generate_kokoro_audio(text, output_filename): | |
| print(f"Calling Kokoro API for: {text[:20]}...") | |
| try: | |
| # Client connects to the specific Space you provided | |
| client = Client("ysharma/Make_Custom_Voices_With_KokoroTTS") | |
| # We use the specific formula for Adam's Voice as requested | |
| # You can change the formula string here to mix other voices | |
| voice_formula = "1.000 * am_adam" | |
| # Use the predict method on the text_to_speech endpoint | |
| result_path = client.predict( | |
| text=text, | |
| formula=voice_formula, | |
| api_name="/text_to_speech" | |
| ) | |
| # Move the downloaded file to our desired path | |
| if os.path.exists(output_filename): | |
| os.remove(output_filename) | |
| shutil.move(result_path, output_filename) | |
| print("Kokoro generation successful.") | |
| return True | |
| except Exception as e: | |
| print(f"Kokoro API Failed: {e}") | |
| return False | |
| def gen_voice_manager(q_text, options, ans_text): | |
| print("--- Starting Voice Generation ---") | |
| full_intro_text = f"{q_text}. " | |
| labels = ["A", "B", "C", "D"] | |
| for i, opt in enumerate(options): | |
| full_intro_text += f"Option {labels[i]}: {opt}. " | |
| # 1. Try Kokoro TTS First | |
| success_intro = generate_kokoro_audio(full_intro_text, get_path("temp_intro.mp3")) | |
| success_ans = generate_kokoro_audio(ans_text, get_path("temp_a.mp3")) | |
| # 2. If Kokoro fails, Fallback to Offline TTS (pyttsx3) | |
| if not (success_intro and success_ans): | |
| print("Falling back to offline TTS...") | |
| generate_offline_male_voice(full_intro_text, ans_text) | |
| def generate_offline_male_voice(intro_text, ans_text): | |
| try: | |
| engine = pyttsx3.init() | |
| engine.save_to_file(intro_text, get_path("temp_intro.mp3")) | |
| engine.save_to_file(ans_text, get_path("temp_a.mp3")) | |
| engine.runAndWait() | |
| except Exception as e: | |
| print(f"Offline TTS failed: {e}") | |
| # Create silent files to prevent crash | |
| open(get_path("temp_intro.mp3"), 'a').close() | |
| open(get_path("temp_a.mp3"), 'a').close() | |
| def get_audio_duration(filename): | |
| if not os.path.exists(filename) or os.path.getsize(filename) == 0: | |
| return 3.0 | |
| try: | |
| cmd = [FFMPEG_BINARY, "-i", filename, "-f", "null", "-"] | |
| result = subprocess.run(cmd, stderr=subprocess.PIPE, text=True) | |
| for line in result.stderr.split('\n'): | |
| if "Duration" in line: | |
| time_str = line.split("Duration:")[1].split(",")[0].strip(); h,m,s = time_str.split(':') | |
| return float(h)*3600 + float(m)*60 + float(s) | |
| except: pass | |
| return 3.0 | |
| def render_ffmpeg(intro_dur, timer_dur, a_dur, end_buffer, output_file, fps=30): | |
| start_timer = intro_dur + 0.5 | |
| reveal_time = start_timer + timer_dur | |
| intro_audio_idx, a_audio_idx, tick_audio_idx = 3, 4, 5 | |
| cmd = [ | |
| FFMPEG_BINARY, "-y", "-hide_banner", "-loglevel", "error", | |
| "-loop", "1", "-t", str(start_timer), "-i", get_path("temp_scene1_container.png"), | |
| "-framerate", str(fps), "-i", get_path("temp_frames/frame_%04d.png"), | |
| "-loop", "1", "-t", str(a_dur + end_buffer), "-i", get_path("temp_scene2.png"), | |
| "-i", get_path("temp_intro.mp3"), | |
| "-i", get_path("temp_a.mp3"), | |
| "-i", get_path("temp_tick.wav"), | |
| "-filter_complex", | |
| "[0:v][1:v][2:v]concat=n=3:v=1:a=0[v_final];" | |
| f"[{tick_audio_idx}:a]volume=0.4[tick_vol];" | |
| f"[tick_vol]adelay={int(start_timer*1000)}|{int(start_timer*1000)}[tick_delayed];" | |
| f"[{a_audio_idx}:a]adelay={int(reveal_time*1000)}|{int(reveal_time*1000)}[ans_delayed];" | |
| f"[{intro_audio_idx}:a][tick_delayed][ans_delayed]amix=inputs=3:duration=longest[mixed];" | |
| "[mixed]highpass=f=200,loudnorm[a_final]", | |
| "-map", "[v_final]", "-map", "[a_final]", | |
| "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", | |
| "-c:a", "aac", "-b:a", "128k", "-shortest", output_file | |
| ] | |
| subprocess.run(cmd, check=True) | |
| # --- GRADIO WRAPPER --- | |
| def generate_video(theme, question, opt1, opt2, opt3, opt4, correct_option, background_input, footer_text): | |
| options = [opt1, opt2, opt3, opt4] | |
| try: | |
| clean_opts = [o.strip() for o in options] | |
| clean_cor = correct_option.strip() | |
| c_idx = clean_opts.index(clean_cor) if clean_cor in clean_opts else 0 | |
| except ValueError: c_idx = 0 | |
| try: | |
| generate_static_scenes_manager(question, options, c_idx, background_input, footer_text, theme) | |
| generate_timer_animation_frames(duration=5.0, fps=30, theme=theme) | |
| letter = ["A", "B", "C", "D"][c_idx] | |
| gen_voice_manager(question, clean_opts, f"Answer {letter}. {clean_opts[c_idx]}") | |
| generate_tick_sound(get_path("temp_tick.wav"), duration=5.0) | |
| intro_len = get_audio_duration(get_path("temp_intro.mp3")) | |
| a_len = get_audio_duration(get_path("temp_a.mp3")) | |
| output_filename = get_path(f"output_{random.randint(1000,9999)}.mp4") | |
| render_ffmpeg(intro_len, 5.0, a_len, 2.0, output_filename) | |
| files_to_clean = ["temp_scene1_container.png", "temp_scene2.png", "temp_intro.mp3", "temp_a.mp3", "temp_tick.wav", "layout_config.txt"] | |
| for f in files_to_clean: | |
| if os.path.exists(get_path(f)): os.remove(get_path(f)) | |
| if os.path.exists(get_path("temp_frames")): shutil.rmtree(get_path("temp_frames")) | |
| return output_filename | |
| except Exception as e: | |
| raise gr.Error(f"Error during video generation: {e}") | |
| # --- GRADIO INTERFACE --- | |
| with gr.Blocks(title="Trivia Video Generator") as app: | |
| gr.Markdown("# Instant Trivia Video Generator") | |
| with gr.Row(): | |
| with gr.Column(): | |
| theme_selector = gr.Dropdown(label="Select Theme", choices=["Standard", "Classic"], value="Standard") | |
| q_input = gr.Textbox(label="Question", placeholder="What is the capital of France?") | |
| opt1 = gr.Textbox(label="Option A", placeholder="Berlin") | |
| opt2 = gr.Textbox(label="Option B", placeholder="Paris") | |
| opt3 = gr.Textbox(label="Option C", placeholder="Madrid") | |
| opt4 = gr.Textbox(label="Option D", placeholder="Lisbon") | |
| correct_input = gr.Dropdown(label="Correct Answer", choices=[], allow_custom_value=True) | |
| def update_dropdown(o1, o2, o3, o4): | |
| return gr.Dropdown(choices=[o1, o2, o3, o4]) | |
| for opt in [opt1, opt2, opt3, opt4]: | |
| opt.change(update_dropdown, inputs=[opt1, opt2, opt3, opt4], outputs=correct_input) | |
| bg_input = gr.Textbox(label="Background (URL or Base64 String)", placeholder="https://... or data:image/...") | |
| footer_input = gr.Textbox(label="Footer Text (Optional)", placeholder="Did you know?") | |
| btn = gr.Button("Generate Video", variant="primary") | |
| with gr.Column(): | |
| video_output = gr.Video(label="Generated Video") | |
| btn.click( | |
| fn=generate_video, | |
| inputs=[theme_selector, q_input, opt1, opt2, opt3, opt4, correct_input, bg_input, footer_input], | |
| outputs=video_output | |
| ) | |
| if __name__ == "__main__": | |
| app.launch() |