quiz_generator / app.py
nazib61's picture
Update app.py
4b44949 verified
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()