Mrsadeeqnextgenhub's picture
Rename app-12.py to app.py
c9ad820 verified
import streamlit as st
import requests
# edge-tts replaced with gTTS for reliability
try:
from gtts import gTTS
GTTS_AVAILABLE = True
except ImportError:
GTTS_AVAILABLE = False
import asyncio
import os
import subprocess
import re
import nest_asyncio
import json
import shlex
import time
import html
from typing import List, Optional
nest_asyncio.apply()
# CONFIG
st.set_page_config(
page_title="Islamic Shorts Creator",
page_icon="🕌",
layout="centered",
initial_sidebar_state="collapsed"
)
CHANNEL_NAME = "Abubakar Daily Islamic Shorts"
LOGO_FILE = "logo.png"
FREEMODEL_KEY = os.getenv("FREEMODEL_API_KEY", "").strip()
PEXELS_KEY = os.getenv("PEXELS_API_KEY", "").strip()
# freemodel.dev config
FREEMODEL_BASE = "https://api.freemodel.dev/v1"
FREEMODEL_MODEL = "gpt-5.5"
if "final_video_path" not in st.session_state:
st.session_state.final_video_path = None
if "script_text" not in st.session_state:
st.session_state.script_text = ""
if "metadata" not in st.session_state:
st.session_state.metadata = {}
if "last_metadata_file" not in st.session_state:
st.session_state.last_metadata_file = ""
if "thumbnail_path" not in st.session_state:
st.session_state.thumbnail_path = None
if "topic_suggestions" not in st.session_state:
st.session_state.topic_suggestions = []
if "nasheed_path" not in st.session_state:
st.session_state.nasheed_path = None
if "video_history" not in st.session_state:
# load_history defined later — safe inline load here
try:
import json as _json
_hf = "/tmp/video_history.json"
if os.path.exists(_hf):
with open(_hf, "r", encoding="utf-8") as _f:
st.session_state.video_history = _json.load(_f)
else:
st.session_state.video_history = []
except:
st.session_state.video_history = []
if "export_paths" not in st.session_state:
st.session_state.export_paths = {}
if "batch_results" not in st.session_state:
st.session_state.batch_results = []
if "yt_status" not in st.session_state:
st.session_state.yt_status = ""
# UI styling
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;900&family=Crimson+Pro:ital,wght@0,300;0,400,1,300&family=Amiri:wght@400;700&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.stApp { background: radial-gradient(ellipse at top, #0d1b2a 0%, #050e18 60%, #000 100%); min-height: 100vh; font-family: 'Crimson Pro', serif; }
.stApp::before { content: ''; position: fixed; inset: 0; background-image: radial-gradient(1px 1px at 15% 20%, rgba(212,175,55,0.4) 0%, transparent 100%), radial-gradient(1px 1px at 80% 10%, rgba(255,255,255,0.3) 0%, transparent 100%), radial-gradient(1px 1px at 45% 70%, rgba(212,175,55,0.2) 0%, transparent 100%), radial-gradient(2px 2px at 90% 80%, rgba(255,255,255,0.15) 0%, transparent 100%), radial-gradient(1px 1px at 5% 90%, rgba(212,175,55,0.3) 0%, transparent 100%); pointer-events: none; z-index: 0; }
.hero-header { text-align: center; padding: 2.5rem 1rem 1.5rem; position: relative; }
.hero-bismillah { font-family: 'Amiri', serif; font-size: 2.2rem; color: #d4af37; letter-spacing: 0.05em; text-shadow: 0 0 30px rgba(212,175,55,0.5); display: block; margin-bottom: 0.5rem; }
.hero-title { font-family: 'Cinzel', serif; font-size: clamp(1.5rem, 4vw, 2.4rem); font-weight: 900; background: linear-gradient(135deg, #d4af37 0%, #f5e07a 50%, #b8942c 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-transform: uppercase; letter-spacing: 0.15em; line-height: 1.2; }
.hero-sub { font-family: 'Crimson Pro', serif; font-size: 1rem; color: rgba(212,175,55,0.6); letter-spacing: 0.3em; text-transform: uppercase; margin-top: 0.4rem; }
.hero-divider { width: 200px; height: 1px; background: linear-gradient(90deg, transparent, #d4af37, transparent); margin: 1rem auto 0; }
.panel { background: linear-gradient(135deg, rgba(212,175,55,0.06) 0%, rgba(13,27,42,0.9) 40%, rgba(5,14,24,0.95) 100%); border: 1px solid rgba(212,175,55,0.18); border-radius: 16px; padding: 1.8rem 2rem; margin: 1rem 0; backdrop-filter: blur(10px); box-shadow: 0 8px 32px rgba(0,0,0,0.6), inset 0 1px 0 rgba(212,175,55,0.1); position: relative; }
.panel::before { content: ''; position: absolute; top: -1px; left: 20%; right: 20%; height: 2px; background: linear-gradient(90deg, transparent, #d4af37, transparent); border-radius: 2px; }
.section-label { font-family: 'Cinzel', serif; font-size: 0.7rem; color: #d4af37; letter-spacing: 0.3em; text-transform: uppercase; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.6rem; }
.section-label::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, rgba(212,175,55,0.3), transparent); }
div[data-testid="stSelectbox"] label, div[data-testid="stTextInput"] label, div[data-testid="stTextArea"] label { font-family: 'Cinzel', serif !important; font-size: 0.75rem !important; color: rgba(212,175,55,0.8) !important; letter-spacing: 0.2em !important; text-transform: uppercase !important; }
div[data-testid="stSelectbox"] > div > div, div[data-testid="stTextInput"] > div > div > input, div[data-testid="stTextArea"] > div > div > textarea { background: rgba(5,14,24,0.8) !important; border: 1px solid rgba(212,175,55,0.25) !important; border-radius: 8px !important; color: #f0e8d0 !important; font-family: 'Crimson Pro', serif !important; font-size: 1.05rem !important; }
div[data-testid="stSelectbox"] > div > div:focus-within, div[data-testid="stTextInput"] > div > div > input:focus, div[data-testid="stTextArea"] > div > div > textarea:focus { border-color: rgba(212,175,55,0.6) !important; box-shadow: 0 0 15px rgba(212,175,55,0.12) !important; }
div[data-testid="stButton"] > button[kind="primary"], div[data-testid="stButton"] > button { width: 100% !important; background: linear-gradient(135deg, #b8942c 0%, #d4af37 50%, #f0c840 100%) !important; color: #050e18 !important; font-family: 'Cinzel', serif !important; font-size: 0.85rem !important; font-weight: 700 !important; letter-spacing: 0.25em !important; text-transform: uppercase !important; border: none !important; border-radius: 10px !important; padding: 0.85rem 2rem !important; cursor: pointer !important; transition: all 0.3s ease !important; box-shadow: 0 4px 20px rgba(212,175,55,0.3), inset 0 1px 0 rgba(255,255,255,0.2) !important; }
div[data-testid="stButton"] > button:hover { transform: translateY(-2px) !important; box-shadow: 0 8px 30px rgba(212,175,55,0.45) !important; }
.script-box { background: rgba(0,0,0,0.4); border: 1px solid rgba(212,175,55,0.15); border-left: 3px solid #d4af37; border-radius: 8px; padding: 1.2rem 1.5rem; font-family: 'Crimson Pro', serif; font-size: 1.05rem; color: #e8dfc0; line-height: 1.8; white-space: pre-wrap; margin: 0.5rem 0 1rem; }
div[data-testid="stDownloadButton"] > button { background: transparent !important; border: 1px solid rgba(212,175,55,0.4) !important; color: #d4af37 !important; font-family: 'Cinzel', serif !important; font-size: 0.75rem !important; letter-spacing: 0.2em !important; border-radius: 8px !important; width: 100% !important; padding: 0.7rem !important; transition: all 0.3s !important; }
div[data-testid="stDownloadButton"] > button:hover { background: rgba(212,175,55,0.1) !important; border-color: #d4af37 !important; box-shadow: 0 0 20px rgba(212,175,55,0.2) !important; }
.footer { text-align: center; padding: 2rem 1rem; font-family: 'Amiri', serif; color: rgba(212,175,55,0.35); font-size: 1rem; letter-spacing: 0.1em; }
</style>""", unsafe_allow_html=True)
# HERO
st.markdown("""
<div class="hero-header">
<span class="hero-bismillah">بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ</span>
<div class="hero-title">Islamic Shorts Creator</div>
<div class="hero-sub">Abubakar Daily Islamic Channel</div>
<div class="hero-divider"></div>
</div>
""", unsafe_allow_html=True)
# CORE FUNCTIONS
def call_freemodel(topic, lang, duration_sec: int = 35):
lang_instruction = {
"Hausa": "Write ONLY in Hausa language.",
"Larabci": "اكتب النص باللغة العربية الفصحى فقط. لا تستخدم أي لغة أخرى.",
"Arabic": "اكتب النص باللغة العربية الفصحى فقط. لا تستخدم أي لغة أخرى.",
"English": "Write ONLY in English language.",
}.get(lang, f"Write ONLY in {lang} language.")
prompt = (
f"{lang_instruction} "
f"Write a {duration_sec}-second spiritual Islamic script about '{topic}'. "
"Write like an imam giving a heartfelt short reminder. "
"Use vivid imagery and short powerful sentences. "
"Pause naturally between ideas. "
"Output ONLY the spoken text. No titles, no hashtags, no stage directions, no translation. "
"End with a short thought-provoking question to encourage comments."
)
try:
r = requests.post(
f"{FREEMODEL_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {FREEMODEL_KEY}",
"Content-Type": "application/json"
},
json={
"model": FREEMODEL_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1024
},
timeout=60
)
return r.json()['choices'][0]['message']['content'].strip()
except Exception as e:
return f"Error: {e}"
def call_freemodel_metadata(topic, lang, script_text):
prompt = (
"You are a metadata assistant. Given the following short Islamic spoken script, "
"produce a JSON object with keys: title (max 60 chars), description (50-150 words), "
"hashtags (an array of 5 trending hashtags, include the # symbol). "
"Output ONLY valid JSON and nothing else.\n\n"
f"Language: {lang}\nTopic: {topic}\n\nScript:\n{script_text}\n"
)
try:
r = requests.post(
f"{FREEMODEL_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {FREEMODEL_KEY}",
"Content-Type": "application/json"
},
json={
"model": FREEMODEL_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1024
},
timeout=60
)
raw = r.json()['choices'][0]['message']['content'].strip()
try:
return json.loads(raw)
except Exception:
jstart = raw.find("{")
jend = raw.rfind("}")
if jstart != -1 and jend != -1:
try:
return json.loads(raw[jstart:jend+1])
except Exception:
return {"title": "", "description": raw, "hashtags": []}
return {"title": "", "description": raw, "hashtags": []}
except Exception as e:
return {"title": "", "description": f"Error: {e}", "hashtags": []}
# ════════════════════════════════════════════
# FEATURE 1: AI Topic Suggestions
# ════════════════════════════════════════════
def get_topic_suggestions(lang: str) -> list:
prompt = (
f"Give me 10 trending Islamic YouTube Shorts topic ideas in {lang}. "
"Each topic should be short (3-6 words), spiritually engaging, and suitable for a 30-40 second video. "
"Output ONLY a JSON array of 10 strings. No explanation, no numbering, no extra text."
)
try:
r = requests.post(
f"{FREEMODEL_BASE}/chat/completions",
headers={"Authorization": f"Bearer {FREEMODEL_KEY}", "Content-Type": "application/json"},
json={"model": FREEMODEL_MODEL, "messages": [{"role": "user", "content": prompt}], "max_tokens": 512},
timeout=30
)
raw = r.json()['choices'][0]['message']['content'].strip()
raw = re.sub(r'^```json|^```|```$', '', raw, flags=re.MULTILINE).strip()
return json.loads(raw)
except Exception as e:
print(f"Topic suggestion error: {e}")
return []
# ════════════════════════════════════════════
# FEATURE 2: Thumbnail Generator
# ════════════════════════════════════════════
def generate_thumbnail(title: str, aspect: str, out_path: str = "/tmp/thumbnail.jpg") -> bool:
try:
from PIL import Image, ImageDraw, ImageFont
import textwrap
if aspect == "9:16":
w, h = 720, 1280
else:
w, h = 1280, 720
# Background gradient (dark blue → black)
img = Image.new("RGB", (w, h), (5, 14, 24))
draw = ImageDraw.Draw(img)
# Gold gradient overlay at top
for y in range(h // 3):
alpha = int(80 * (1 - y / (h / 3)))
draw.line([(0, y), (w, y)], fill=(212, 175, 55, alpha))
# Decorative border
border = 18
draw.rectangle([border, border, w - border, h - border],
outline=(212, 175, 55), width=3)
draw.rectangle([border + 8, border + 8, w - border - 8, h - border - 8],
outline=(212, 175, 55, 80), width=1)
# Bismillah Arabic text at top
try:
font_ar = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 48)
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 64 if aspect == "9:16" else 54)
font_sub = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
except:
font_ar = ImageFont.load_default()
font_title = font_ar
font_sub = font_ar
# Bismillah
bism = "Abubakar Daily Islamic Shorts"
bbox = draw.textbbox((0, 0), bism, font=font_sub)
bw = bbox[2] - bbox[0]
draw.text(((w - bw) // 2, border + 30), bism, font=font_sub, fill=(212, 175, 55))
# Gold divider line
draw.line([(w // 4, border + 80), (3 * w // 4, border + 80)], fill=(212, 175, 55), width=2)
# Main title — wrapped
max_chars = 20 if aspect == "9:16" else 35
lines = textwrap.wrap(title.upper(), width=max_chars)
total_h = len(lines) * 80
y_start = (h - total_h) // 2 - 40
for i, line in enumerate(lines):
bbox = draw.textbbox((0, 0), line, font=font_title)
lw = bbox[2] - bbox[0]
x = (w - lw) // 2
y = y_start + i * 80
# Shadow
draw.text((x + 3, y + 3), line, font=font_title, fill=(0, 0, 0))
# Gold text
draw.text((x, y), line, font=font_title, fill=(212, 175, 55))
# Bottom gold bar
draw.rectangle([0, h - 80, w, h], fill=(212, 175, 55, 40))
channel = "🕌 Islamic Shorts"
bbox = draw.textbbox((0, 0), channel, font=font_sub)
cw = bbox[2] - bbox[0]
draw.text(((w - cw) // 2, h - 58), channel, font=font_sub, fill=(255, 255, 255))
img.save(out_path, "JPEG", quality=95)
return os.path.exists(out_path)
except Exception as e:
print(f"Thumbnail error: {e}")
return False
# ════════════════════════════════════════════
# SOUND LIBRARY — Nasheeds + Quran (free, archive.org)
# ════════════════════════════════════════════
# ══════════════════════════════════════════════════════
# ISLAMIC INSTRUMENTAL SOUND LIBRARY
# All sources: freemusicarchive.org / pixabay / archive.org
# 100% Copyright-Free / CC0 / Public Domain
# ══════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════
# ISLAMIC SOUND LIBRARY — 100% FFmpeg Generated
# No downloads needed — always works guaranteed
# Frequencies used in Islamic/meditation audio
# ══════════════════════════════════════════════════════
SOUND_LIBRARY = {
"🎵 Oud Effect — 432Hz + Echo (Warm)": "__432_OUD__",
"🎵 Ney Flute Effect — 528Hz + Reverb": "__528_NEY__",
"🎵 Deep Calm — 396Hz + Long Echo": "__396_DEEP__",
"🎵 Strings Effect — 639Hz + Chorus": "__639_STR__",
"🎵 Meditation Bell — 741Hz + Fade": "__741_BELL__",
"🎵 Soft Drone — 174Hz Bass Ambient": "__174_DRONE__",
"🎵 Crystal Bowl — 852Hz Pure Tone": "__852_BOWL__",
"🔇 Babu Music (murya kawai)": "",
}
FALLBACK_URLS = []
def generate_islamic_tone(preset: str, out_path: str, duration: int = 120) -> str:
"""
Generate Islamic-mood audio using ffmpeg only — no downloads, always works.
Each preset uses different frequency + effects to mimic real instruments.
"""
try:
# Build ffmpeg filter based on preset
presets = {
# 432Hz sine + echo + low pass → warm oud-like tone
"__432_OUD__": (
432,
"volume=0.07,"
"aecho=0.8:0.7:60:0.4,"
"aecho=0.5:0.5:120:0.2,"
"lowpass=f=900,"
"treble=g=-6"
),
# 528Hz + chorus + reverb → breathy ney flute feel
"__528_NEY__": (
528,
"volume=0.07,"
"aecho=0.9:0.8:80:0.5,"
"chorus=0.5:0.9:50:0.4:0.25:2,"
"lowpass=f=1200,"
"highpass=f=200"
),
# 396Hz deep + long echo → cave/mosque reverb
"__396_DEEP__": (
396,
"volume=0.08,"
"aecho=0.9:0.9:200:0.6,"
"aecho=0.7:0.7:400:0.3,"
"lowpass=f=600,"
"bass=g=4"
),
# 639Hz + phaser → string-like shimmer
"__639_STR__": (
639,
"volume=0.06,"
"aphaser=in_gain=0.4:out_gain=0.7:delay=3:decay=0.4:speed=0.5,"
"aecho=0.6:0.6:50:0.3,"
"lowpass=f=1500"
),
# 741Hz + bell-like decay
"__741_BELL__": (
741,
"volume=0.06,"
"aecho=0.8:0.88:300:0.7,"
"aecho=0.5:0.5:600:0.3,"
"highpass=f=400,"
"lowpass=f=2000"
),
# 174Hz bass drone — deep meditation
"__174_DRONE__": (
174,
"volume=0.09,"
"aecho=0.7:0.7:100:0.5,"
"lowpass=f=400,"
"bass=g=6"
),
# 852Hz crystal — bright pure tone
"__852_BOWL__": (
852,
"volume=0.05,"
"aecho=0.9:0.9:150:0.6,"
"highpass=f=600,"
"lowpass=f=3000"
),
}
freq, af = presets.get(preset, presets["__432_OUD__"])
subprocess.run([
"ffmpeg", "-y",
"-f", "lavfi",
"-i", f"sine=frequency={freq}:duration={duration}",
"-af", af,
"-ar", "44100",
out_path
], capture_output=True, text=True)
return out_path if os.path.exists(out_path) and os.path.getsize(out_path) > 100 else ""
except Exception as e:
print(f"Tone generation error: {e}")
return ""
def download_sound(url: str, out_path: str) -> str:
"""Generate Islamic tone from preset — guaranteed, no internet needed."""
if not url:
return ""
# All presets are ffmpeg-generated
if url.startswith("__") and url.endswith("__"):
return generate_islamic_tone(url, out_path)
# Legacy: if real URL passed, try download then fallback
try:
r = requests.get(url, timeout=20, stream=True,
headers={"User-Agent": "Mozilla/5.0"})
if r.status_code == 200:
with open(out_path, "wb") as f:
for chunk in r.iter_content(8192):
if chunk: f.write(chunk)
if os.path.exists(out_path) and os.path.getsize(out_path) > 10000:
return out_path
except Exception as e:
print(f"Download failed: {e}")
# Fallback to warm oud tone
return generate_islamic_tone("__432_OUD__", out_path)
def download_nasheed(out_path: str = "/tmp/nasheed.mp3") -> str:
"""Legacy wrapper — download default Quran recitation."""
return download_sound(list(SOUND_LIBRARY.values())[0], out_path)
def mix_background_nasheed(voice_path: str, nasheed_path: str, out_path: str = "/tmp/mixed_nasheed.mp3") -> str:
"""Mix voice with nasheed at low volume, loop nasheed to match voice length."""
try:
dur = get_tts_duration(voice_path)
if not nasheed_path or not os.path.exists(nasheed_path):
# Fallback: soft sine ambient
return mix_background_music(voice_path, out_path)
proc = subprocess.run([
"ffmpeg", "-y",
"-i", voice_path,
"-stream_loop", "-1", "-i", nasheed_path,
"-filter_complex",
"[0:a]volume=1.0[voice];[1:a]volume=0.12,atrim=0:{dur}[bg];[voice][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]".format(dur=f"{dur:.3f}"),
"-map", "[aout]",
"-ar", "44100",
"-t", f"{dur:.3f}",
out_path
], capture_output=True, text=True)
if proc.returncode == 0 and os.path.exists(out_path) and os.path.getsize(out_path) > 1000:
return out_path
else:
print("Nasheed mix stderr:", proc.stderr[-500:])
return mix_background_music(voice_path, out_path)
except Exception as e:
print(f"Nasheed mix error: {e}")
return voice_path
# ════════════════════════════════════════════
# FEATURE: Video Preview Card
# ════════════════════════════════════════════
def render_preview_card(script: str, metadata: dict, aspect: str):
"""Show script + metadata preview before generating video."""
title = metadata.get("title", "") if metadata else ""
desc = metadata.get("description", "") if metadata else ""
tags = metadata.get("hashtags", []) if metadata else []
tags_str = " ".join(tags) if isinstance(tags, list) else str(tags)
aspect_icon = "📱" if aspect == "9:16" else "🖥️"
st.markdown(f"""
<div style="background:rgba(212,175,55,0.05);border:1px solid rgba(212,175,55,0.3);
border-radius:12px;padding:1.2rem;margin:0.5rem 0;">
<div style="color:#d4af37;font-size:0.75rem;letter-spacing:0.2em;margin-bottom:0.5rem;">
{aspect_icon} VIDEO PREVIEW — {aspect}
</div>
<div style="color:#f5e07a;font-size:1.1rem;font-weight:700;margin-bottom:0.4rem;">
{html.escape(title)}
</div>
<div style="color:#e8dfc0;font-size:0.9rem;line-height:1.6;margin-bottom:0.6rem;
border-left:2px solid #d4af37;padding-left:0.8rem;">
{html.escape(script[:200])}{'...' if len(script) > 200 else ''}
</div>
<div style="color:rgba(212,175,55,0.6);font-size:0.8rem;">{html.escape(tags_str)}</div>
</div>
""", unsafe_allow_html=True)
# ════════════════════════════════════════════
# FEATURE: History / Archive
# ════════════════════════════════════════════
HISTORY_FILE = "/tmp/video_history.json"
def load_history() -> list:
try:
if os.path.exists(HISTORY_FILE):
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except:
pass
return []
def save_to_history(topic: str, lang: str, metadata: dict, video_path: str, thumb_path: str):
history = load_history()
entry = {
"id": int(time.time()),
"date": time.strftime("%Y-%m-%d %H:%M"),
"topic": topic,
"lang": lang,
"title": metadata.get("title", topic) if metadata else topic,
"video_path": video_path,
"thumb_path": thumb_path,
}
history.insert(0, entry)
history = history[:20] # keep last 20 only
try:
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(history, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"History save error: {e}")
# ════════════════════════════════════════════
# FEATURE: Multi-Platform Export
# ════════════════════════════════════════════
EXPORT_FORMATS = {
"YouTube Shorts / TikTok / Reels (9:16 720x1280)": ("720", "1280", "9x16"),
"Instagram Square (1:1 1080x1080)": ("1080", "1080", "1x1"),
"Facebook / YouTube (16:9 1280x720)": ("1280", "720", "16x9"),
"Instagram Story (9:16 1080x1920)": ("1080", "1920", "9x16_hd"),
}
def export_video_format(src_path: str, width: str, height: str, suffix: str) -> str:
out = f"/tmp/export_{suffix}.mp4"
subprocess.run([
"ffmpeg", "-y", "-i", src_path,
"-vf", f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", "libx264", "-preset", "ultrafast",
"-c:a", "copy", out
], capture_output=True, text=True)
return out if os.path.exists(out) else ""
def make_tts(text: str, voice: str, path: str):
"""
TTS with multiple fallbacks:
1. gTTS (Google) — most reliable
2. edge-tts — fallback
3. Silent audio — last resort
"""
clean = re.sub(r'[#*()<>🕌🎵📝✅❌⬇️💛]', '', text).strip()
# Detect language from voice name
if "ar-SA" in voice or "ar-" in voice:
lang_code = "ar"
elif "ha-NG" in voice or "ha-" in voice:
lang_code = "en" # gTTS has no Hausa — use English fallback
else:
lang_code = "en"
# Try gTTS first
if GTTS_AVAILABLE:
try:
tts_obj = gTTS(text=clean, lang=lang_code, slow=False)
tts_obj.save(path)
if os.path.exists(path) and os.path.getsize(path) > 1000:
return True
except Exception as e:
print(f"gTTS error: {e}")
# Fallback: edge-tts
try:
import edge_tts
async def _edge():
comm = edge_tts.Communicate(clean, voice, rate="-10%", pitch="-5Hz")
await comm.save(path)
asyncio.run(_edge())
if os.path.exists(path) and os.path.getsize(path) > 1000:
return True
except Exception as e:
print(f"edge-tts error: {e}")
# Last resort: generate silent audio placeholder
try:
subprocess.run([
"ffmpeg", "-y", "-f", "lavfi",
"-i", "sine=frequency=1:duration=35",
"-af", "volume=0",
path
], capture_output=True, text=True)
return os.path.exists(path)
except:
return False
async def tts(text, voice, path):
"""Async wrapper for compatibility."""
make_tts(text, voice, path)
_tts = tts
def mix_background_music(voice_path: str, out_path: str, music_volume: float = 0.18) -> str:
try:
dur = get_tts_duration(voice_path)
bg_audio = "/tmp/bg_ambient.mp3"
# Soft 432Hz ambient tone with echo — calming Islamic atmosphere
subprocess.run([
"ffmpeg", "-y",
"-f", "lavfi",
"-i", f"sine=frequency=432:duration={dur:.3f}",
"-af", "volume=0.06,aecho=0.8:0.88:60:0.4",
bg_audio
], capture_output=True, text=True)
if not os.path.exists(bg_audio):
return voice_path
mixed = "/tmp/mixed_audio.mp3"
subprocess.run([
"ffmpeg", "-y",
"-i", voice_path,
"-i", bg_audio,
"-filter_complex",
f"[1:a]volume={music_volume}[bg];[0:a][bg]amix=inputs=2:duration=first[aout]",
"-map", "[aout]",
"-t", f"{dur:.3f}",
mixed
], capture_output=True, text=True)
return mixed if os.path.exists(mixed) else voice_path
except Exception as e:
print(f"Music mix error: {e}")
return voice_path
def get_tts_duration(path: str) -> float:
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path],
capture_output=True, text=True
)
return float(result.stdout.strip())
except:
return 35.0
def escape_ffmpeg_text(text: str) -> str:
"""Escape text safely for ffmpeg drawtext filter"""
# Remove characters that break ffmpeg filter syntax
text = re.sub(r"[':=\\,\[\]@{}()]", " ", text)
# Collapse multiple spaces
text = re.sub(r" +", " ", text).strip()
return text
def build_professional_captions(words, audio_dur, tw, th, aspect, style="Netflix (Outline, Bottom)"):
"""
Multi-style caption builder.
Styles: Netflix (Outline, Bottom) | TikTok (Box, Center) | Minimal (Small, Bottom)
"""
if aspect == "9:16":
chunk_size = 4
if style == "Netflix (Outline, Bottom)":
fontsize, y_pos, box, borderw, bcolor, fc = 62, int(th*0.72), 0, 4, "black@0.95", "white"
elif style == "TikTok (Box, Center)":
fontsize, y_pos, box, borderw, bcolor, fc = 58, int(th*0.50), 1, 0, "black@0.65", "white"
else: # Minimal
fontsize, y_pos, box, borderw, bcolor, fc = 42, int(th*0.80), 0, 2, "black@0.8", "white@0.9"
else:
chunk_size = 5
if style == "Netflix (Outline, Bottom)":
fontsize, y_pos, box, borderw, bcolor, fc = 52, int(th*0.78), 0, 4, "black@0.95", "white"
elif style == "TikTok (Box, Center)":
fontsize, y_pos, box, borderw, bcolor, fc = 48, int(th*0.50), 1, 0, "black@0.65", "white"
else:
fontsize, y_pos, box, borderw, bcolor, fc = 36, int(th*0.82), 0, 2, "black@0.8", "white@0.9"
total_words = len(words)
sec_per_word = audio_dur / max(total_words, 1)
drawtext_filters = []
for i in range(0, total_words, chunk_size):
chunk = words[i: i + chunk_size]
half = (len(chunk) + 1) // 2
line1 = escape_ffmpeg_text(" ".join(chunk[:half]))
line2 = escape_ffmpeg_text(" ".join(chunk[half:])) if len(chunk) > half else ""
t_start = i * sec_per_word
t_end = t_start + (len(chunk) * sec_per_word)
enable = f"between(t,{t_start:.3f},{t_end:.3f})"
box_str = f"box={box}:boxcolor={bcolor}:boxborderw=16:" if box else ""
df1 = (
f"drawtext=text='{line1}':"
f"fontcolor={fc}:fontsize={fontsize}:font=Arial:"
f"borderw={borderw}:bordercolor={bcolor}:"
f"{box_str}"
f"x=(w-text_w)/2:y={y_pos - fontsize - 10}:"
f"enable='{enable}'"
)
drawtext_filters.append(df1)
if line2:
df2 = (
f"drawtext=text='{line2}':"
f"fontcolor={fc}:fontsize={fontsize}:font=Arial:"
f"borderw={borderw}:bordercolor={bcolor}:"
f"{box_str}"
f"x=(w-text_w)/2:y={y_pos}:"
f"enable='{enable}'"
)
drawtext_filters.append(df2)
return drawtext_filters
def apply_zoom_to_clip(src: str, dst: str, tw: int, th: int, zoom_dir: str, duration: float) -> bool:
"""
Reliable Ken Burns effect using scale expression.
zoom_dir: 'in' = slowly zoom in, 'out' = slowly zoom out
"""
fps = 25
n_frames = int(duration * fps)
# Scale slightly larger than target so zoom has room
sw = int(tw * 1.3)
sh = int(th * 1.3)
cx = sw // 2
cy = sh // 2
if zoom_dir == "in":
# Pan from slightly zoomed-out to center — zooming in
vf = (
f"scale={sw}:{sh}:force_original_aspect_ratio=increase,"
f"crop={sw}:{sh},"
f"crop=w='if(lte(t,0),{tw},{tw}+({sw}-{tw})*max(0,1-t/{duration:.3f}))'"
f":h='if(lte(t,0),{th},{th}+({sh}-{th})*max(0,1-t/{duration:.3f}))'"
f":x='({sw}-out_w)/2':y='({sh}-out_h)/2',"
f"scale={tw}:{th}"
)
else:
# Zoom out: start tight, slowly reveal more
vf = (
f"scale={sw}:{sh}:force_original_aspect_ratio=increase,"
f"crop={sw}:{sh},"
f"crop=w='if(lte(t,0),{tw},{tw}+({sw}-{tw})*min(1,t/{duration:.3f}))'"
f":h='if(lte(t,0),{th},{th}+({sh}-{th})*min(1,t/{duration:.3f}))'"
f":x='({sw}-out_w)/2':y='({sh}-out_h)/2',"
f"scale={tw}:{th}"
)
cmd = [
"ffmpeg", "-y", "-i", src,
"-vf", vf,
"-t", f"{duration:.3f}",
"-r", str(fps),
"-c:v", "libx264",
"-preset", "ultrafast",
"-an", dst
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if not (os.path.exists(dst) and os.path.getsize(dst) > 0):
# Fallback: simple scale crop no zoom
fb_cmd = [
"ffmpeg", "-y", "-i", src,
"-vf", f"scale={tw}:{th}:force_original_aspect_ratio=increase,crop={tw}:{th}",
"-t", f"{duration:.3f}",
"-r", str(fps),
"-c:v", "libx264", "-preset", "ultrafast", "-an", dst
]
subprocess.run(fb_cmd, capture_output=True, text=True)
return os.path.exists(dst) and os.path.getsize(dst) > 0
def create_video_centered(clip_paths: List[str], voice_path: str, out_path: str, script: str, aspect: str, caption_style: str = 'Netflix (Outline, Bottom)', use_fades: bool = True) -> bool:
try:
audio_dur = get_tts_duration(voice_path)
if aspect == "9:16":
tw, th = 720, 1280
watermark_fs = 30
else:
tw, th = 1280, 720
watermark_fs = 24
n_clips = len(clip_paths)
# Each clip gets equal share of audio duration
dur_per_clip = audio_dur / n_clips
# ── Step 1: Apply zoom effect to each clip individually, trim to equal duration
zoom_dirs = ["in", "out", "in", "out"] # alternate zoom direction
processed = []
for i, src in enumerate(clip_paths):
dst = f"/tmp/zoom_{i}.mp4"
zdir = zoom_dirs[i % len(zoom_dirs)]
ok = apply_zoom_to_clip(src, dst, tw, th, zdir, dur_per_clip)
if not ok:
# fallback: simple scale+crop
fb = f"/tmp/fb_{i}.mp4"
subprocess.run([
"ffmpeg", "-y", "-i", src,
"-vf", f"scale={tw}:{th}:force_original_aspect_ratio=increase,crop={tw}:{th}",
"-t", f"{dur_per_clip:.3f}",
"-preset", "ultrafast", "-an", fb
], capture_output=True, text=True)
dst = fb
# Apply fade transitions if enabled
if use_fades and os.path.exists(dst):
faded = f"/tmp/faded_{i}.mp4"
fade_ok = apply_fade_to_clip(dst, faded, dur_per_clip, fade_dur=0.4)
if fade_ok:
dst = faded
if os.path.exists(dst) and os.path.getsize(dst) > 0:
processed.append(dst)
if not processed:
return False
# ── Step 2: Concatenate all processed clips into one background
list_file = "/tmp/concat_list.txt"
with open(list_file, "w") as lf:
for p in processed:
lf.write(f"file '{p}'\n")
joined = "/tmp/joined_raw.mp4"
subprocess.run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
"-i", list_file, "-c", "copy", joined
], capture_output=True, text=True)
if not os.path.exists(joined):
return False
# ── Step 3: Trim/extend to exactly audio_dur
looped = "/tmp/looped_bg.mp4"
subprocess.run([
"ffmpeg", "-y", "-stream_loop", "-1", "-i", joined,
"-t", f"{audio_dur:.3f}", "-c", "copy", looped
], capture_output=True, text=True)
bg = looped if os.path.exists(looped) else joined
# ── Step 4: Build captions based on selected style
clean_script = re.sub(r'[^\w\s]', '', script)
words = clean_script.split()
caption_filters = build_professional_captions(words, audio_dur, tw, th, aspect, style=caption_style)
# ── Step 5: Watermark at bottom (smaller, elegant)
watermark_text = "Abubakar Daily Islamic Shorts"
wm_y = th - 36
watermark_draw = (
f"drawtext=text='{watermark_text}':"
f"fontcolor=white@0.7:fontsize={watermark_fs}:font=Arial:"
f"borderw=2:bordercolor=black@0.8:"
f"x=(w-text_w)/2:y={wm_y}:"
f"enable='between(t,0,{audio_dur:.3f})'"
)
# Combine all drawtext filters
all_text_filters = caption_filters + [watermark_draw]
combined_text = ",".join(all_text_filters)
# ── Step 6: Build final ffmpeg command with captions + watermark + logo
if os.path.exists(LOGO_FILE):
# Circle logo: scale → geq mask → alphaextract → overlay
filter_complex = (
f"[2:v]scale=110:110[logo_sq];"
# Create circle mask via geq
f"[logo_sq]format=rgba,"
f"geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)'"
f":a='if(lte(pow(X-55,2)+pow(Y-55,2),pow(54,2)),255,0)'[logo_circ];"
f"[0:v][logo_circ]overlay=W-w-16:16[tmp];"
f"[tmp]{combined_text}[vout]"
)
ff_cmd = [
"ffmpeg", "-y",
"-i", bg,
"-i", voice_path,
"-i", LOGO_FILE,
"-filter_complex", filter_complex,
"-map", "[vout]",
"-map", "1:a",
"-c:v", "libx264", "-preset", "ultrafast",
"-t", f"{audio_dur:.3f}",
out_path
]
else:
filter_complex = f"[0:v]{combined_text}[vout]"
ff_cmd = [
"ffmpeg", "-y",
"-i", bg,
"-i", voice_path,
"-filter_complex", filter_complex,
"-map", "[vout]",
"-map", "1:a",
"-c:v", "libx264", "-preset", "ultrafast",
"-t", f"{audio_dur:.3f}",
out_path
]
proc = subprocess.run(ff_cmd, capture_output=True, text=True)
if proc.returncode != 0:
print("ffmpeg stderr:", proc.stderr[-3000:])
return False
# Trim to exact duration if needed
if os.path.exists(out_path):
try:
pr = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", out_path],
capture_output=True, text=True
)
vdur = float(pr.stdout.strip())
except:
vdur = None
if vdur and abs(vdur - audio_dur) > 0.05:
trimmed = out_path + ".trim.mp4"
subprocess.run(["ffmpeg", "-y", "-i", out_path,
"-t", f"{audio_dur:.3f}", "-c", "copy", trimmed],
capture_output=True, text=True)
if os.path.exists(trimmed):
os.replace(trimmed, out_path)
return os.path.exists(out_path)
except Exception as e:
print(f"Error in create_video_centered: {e}")
return False
# ════════════════════════════════════════════
# FEATURE: YouTube Upload (via YouTube Data API v3)
# ════════════════════════════════════════════
YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY", "").strip()
def upload_to_youtube(video_path: str, title: str, description: str, tags: list) -> dict:
"""
Upload video to YouTube using resumable upload.
Requires YOUTUBE_API_KEY (OAuth token) in secrets.
Returns {"success": True/False, "url": "...", "error": "..."}
"""
try:
if not YOUTUBE_API_KEY:
return {"success": False, "error": "YOUTUBE_API_KEY ba a saka ba a Secrets"}
# Step 1: Initialize resumable upload
metadata = {
"snippet": {
"title": title[:100],
"description": description,
"tags": tags[:10] if tags else [],
"categoryId": "22" # People & Blogs
},
"status": {
"privacyStatus": "public",
"selfDeclaredMadeForKids": False
}
}
init_resp = requests.post(
"https://www.googleapis.com/upload/youtube/v3/videos"
"?uploadType=resumable&part=snippet,status",
headers={
"Authorization": f"Bearer {YOUTUBE_API_KEY}",
"Content-Type": "application/json",
"X-Upload-Content-Type": "video/mp4",
},
json=metadata,
timeout=30
)
if init_resp.status_code not in (200, 201):
return {"success": False, "error": f"Init failed: {init_resp.text[:200]}"}
upload_url = init_resp.headers.get("Location", "")
if not upload_url:
return {"success": False, "error": "No upload URL returned"}
# Step 2: Upload video bytes
file_size = os.path.getsize(video_path)
with open(video_path, "rb") as vf:
upload_resp = requests.put(
upload_url,
data=vf,
headers={
"Content-Type": "video/mp4",
"Content-Length": str(file_size)
},
timeout=300
)
if upload_resp.status_code in (200, 201):
vid_id = upload_resp.json().get("id", "")
return {"success": True, "url": f"https://youtube.com/shorts/{vid_id}", "id": vid_id}
else:
return {"success": False, "error": f"Upload failed: {upload_resp.text[:200]}"}
except Exception as e:
return {"success": False, "error": str(e)}
# ════════════════════════════════════════════
# FEATURE: Batch Video Generation
# ════════════════════════════════════════════
def generate_batch_video(topic: str, lang: str, voice: str, aspect: str, idx: int) -> dict:
"""Generate one complete video — used in batch mode."""
result = {"topic": topic, "status": "❌ Failed", "path": "", "metadata": {}}
try:
# Script
script = call_freemodel(topic, lang)
if script.startswith("Error"):
result["status"] = f"❌ Script error"
return result
# Metadata
metadata = call_freemodel_metadata(topic, lang, script)
# TTS
vpath = f"/tmp/batch_voice_{idx}.mp3"
asyncio.run(_tts(script, voice, vpath))
# Nasheed mix
nasheed = download_nasheed(f"/tmp/nasheed_{idx}.mp3")
if nasheed:
vpath = mix_background_nasheed(vpath, nasheed, f"/tmp/batch_mixed_{idx}.mp3")
# Pexels clips
headers = {"Authorization": PEXELS_KEY}
queries = ["islamic architecture golden hour", "desert sunset sand dunes",
"ocean waves peaceful nature", "green forest light rays"]
clip_paths = []
for q in queries:
if len(clip_paths) >= 4:
break
try:
resp = requests.get(
f"https://api.pexels.com/videos/search?query={requests.utils.quote(q)}&per_page=2&orientation=portrait",
headers=headers, timeout=15
)
for v in resp.json().get("videos", []):
for f in v.get("video_files", []):
if f.get("file_type", "").lower() == "video/mp4" and f.get("width", 0) >= 720:
rp = f"/tmp/batch_clip_{idx}_{len(clip_paths)}.mp4"
dl = requests.get(f["link"], stream=True, timeout=60)
with open(rp, "wb") as fh:
for chunk in dl.iter_content(8192):
if chunk: fh.write(chunk)
clip_paths.append(rp)
break
if len(clip_paths) >= 4:
break
except:
pass
if not clip_paths:
result["status"] = "❌ No clips"
return result
# Assemble video
out = f"/tmp/batch_video_{idx}.mp4"
ok = create_video_centered(clip_paths, vpath, out, script, aspect)
if ok:
result["status"] = "✅ Done"
result["path"] = out
result["metadata"] = metadata
result["script"] = script
save_to_history(topic, lang, metadata, out, "")
else:
result["status"] = "❌ Assembly failed"
except Exception as e:
result["status"] = f"❌ {str(e)[:60]}"
return result
# ════════════════════════════════════════════
# FEATURE: Analytics Dashboard
# ════════════════════════════════════════════
def compute_analytics(history: list) -> dict:
if not history:
return {}
total = len(history)
lang_count = {}
topic_words = {}
dates = []
for e in history:
lg = e.get("lang", "Unknown")
lang_count[lg] = lang_count.get(lg, 0) + 1
for w in e.get("topic", "").split():
topic_words[w] = topic_words.get(w, 0) + 1
dates.append(e.get("date", "")[:10])
top_lang = max(lang_count, key=lang_count.get) if lang_count else "-"
top_topics = sorted(topic_words.items(), key=lambda x: x[1], reverse=True)[:5]
unique_days = len(set(dates))
return {
"total": total,
"lang_count": lang_count,
"top_lang": top_lang,
"top_topics": top_topics,
"unique_days": unique_days,
"avg_per_day": round(total / max(unique_days, 1), 1)
}
# ════════════════════════════════════════════
# FEATURE: AI Scene Matching
# ════════════════════════════════════════════
def get_scene_queries(topic: str, lang: str, n: int = 6) -> list:
"""AI picks best Pexels search queries based on script topic."""
prompt = (
f"Given an Islamic video topic: '{topic}', suggest {n} Pexels video search queries "
"that would make beautiful cinematic background footage. "
"Focus on nature, architecture, light, sky, water — NO people, NO text. "
"Output ONLY a JSON array of strings, no explanation."
)
try:
r = requests.post(
f"{FREEMODEL_BASE}/chat/completions",
headers={"Authorization": f"Bearer {FREEMODEL_KEY}", "Content-Type": "application/json"},
json={"model": FREEMODEL_MODEL, "messages": [{"role": "user", "content": prompt}], "max_tokens": 256},
timeout=20
)
raw = r.json()['choices'][0]['message']['content'].strip()
raw = re.sub(r'^```json|^```|```$', '', raw, flags=re.MULTILINE).strip()
queries = json.loads(raw)
return [q for q in queries if isinstance(q, str)][:n]
except Exception as e:
print(f"Scene query error: {e}")
return []
# ════════════════════════════════════════════
# FEATURE: Fade Transitions between clips
# ════════════════════════════════════════════
def apply_fade_to_clip(src: str, dst: str, duration: float, fade_dur: float = 0.5) -> bool:
"""Apply fade-in at start and fade-out at end of clip."""
fade_out_start = max(0, duration - fade_dur)
vf = (
f"fade=t=in:st=0:d={fade_dur},"
f"fade=t=out:st={fade_out_start:.3f}:d={fade_dur}"
)
cmd = [
"ffmpeg", "-y", "-i", src,
"-vf", vf,
"-t", f"{duration:.3f}",
"-c:v", "libx264", "-preset", "ultrafast",
"-an", dst
]
proc = subprocess.run(cmd, capture_output=True, text=True)
return os.path.exists(dst) and os.path.getsize(dst) > 0
# ════════════════════════════════════════════
# FEATURE: Script Editor helper
# ════════════════════════════════════════════
def render_script_editor(script: str) -> str:
"""Show editable script box, return edited version."""
edited = st.text_area(
"✏️ Gyara Script — Edit before generating video",
value=script,
height=200,
key="script_editor_area"
)
return edited.strip()
# TABS
tab1, tab2, tab3 = st.tabs(["🎬 Generate Video", "📦 Batch Mode", "📈 Analytics"])
# SCRIPT FORMULA TEMPLATES
FORMULA_TEMPLATES = {
"📝 Custom (rubuta kanka)": "",
"🏆 Formula #1 — Hook + Hadith + Lesson + CTA": """لا تتجاوز هذا الفيديو...
هذا الحديث غيّر حياتي.
قال النبي ﷺ:
من صلّى عليّ مرة واحدة
صلى الله عليه عشراً
رواه مسلم
كلمة واحدة فقط...
اللهم صلِّ على محمد
تجلب عليك 10 رحمات من الله.
10 رحمات — في ثانية واحدة.
كم مرة قلتها اليوم؟
الله ينتظر...
لسانك لا يتعب.
اكتب صلى الله عليه وسلم في التعليقات.
واشترك — كل يوم حديث جديد.""",
"🥈 Formula #2 — Question + Story + Twist": """هل تعلم ماذا يحدث لك
عند قراءة سورة الإخلاص 3 مرات؟
رجل فقير... لا يملك شيئاً.
لكنه كان يقرأها كل صباح.
فقط 3 مرات.
قال النبي ﷺ:
تعدل ثلث القرآن كاملاً.
يعني — قرأت القرآن كله
في دقيقة واحدة.
من سيقرأها الآن؟
اكتب قرأتها في التعليقات""",
"🥉 Formula #3 — Fear + Hope + Action": """يوم القيامة...
أول ما يُحاسَب عليه العبد — الصلاة.
لكن النبي ﷺ قال:
إن أتمّها وإلا قيل:
انظروا هل له من تطوع
السنن الرواتب — 12 ركعة يومياً.
تبني لك بيتاً في الجنة.
هل تصلي السنن؟
رد بـ نعم أو لا في التعليقات""",
"🌟 Formula #4 — Fact + Dua + Reminder": """99 اسماً لله...
كل اسم يكشف لك باباً من أبواب الجنة.
قال ﷺ:
من أحصاها دخل الجنة
الرحمن... يرحمك.
الرزاق... يرزقك.
الغفار... يغفر لك.
الآن — وفي كل لحظة.
أي اسم يلامس قلبك اليوم؟
اكتبه في التعليقات""",
}
# INPUT PANEL
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">🌙 Zaɓuɓɓuka — Settings</div>', unsafe_allow_html=True)
col1, col2 = st.columns([1, 1])
with col1:
lang = st.selectbox("Harshe / Language", ["Hausa 🇳🇬", "Larabci 🇸🇦", "English 🇺🇸"], key="lang_select")
with col2:
voice_map = {"Hausa 🇳🇬": "ha-NG-AbdullahNeural", "Larabci 🇸🇦": "ar-SA-HamedNeural", "English 🇺🇸": "en-US-AndrewNeural"}
selected_voice = voice_map[lang]
st.text_input("Muryar da za a yi amfani", value=selected_voice, disabled=True)
topic = st.text_input("Maudu'i — Topic", placeholder="e.g. Tuba, Tawakkul, Ƙaunar Allah...", key="topic_input")
# AI Topic Suggestions
col_sug1, col_sug2 = st.columns([3, 1])
with col_sug2:
if st.button("💡 Get Topics", use_container_width=True):
with st.spinner("AI na ba da shawarwari..."):
st.session_state.topic_suggestions = get_topic_suggestions(lang.split()[0])
if st.session_state.topic_suggestions:
st.markdown("<div style='margin-top:8px'>", unsafe_allow_html=True)
st.markdown("<small style='color:#d4af37'>👆 Danna topic ɗaya don zaɓar shi:</small>", unsafe_allow_html=True)
cols = st.columns(2)
for i, sug in enumerate(st.session_state.topic_suggestions):
with cols[i % 2]:
if st.button(f"📌 {sug}", key=f"sug_{i}", use_container_width=True):
st.session_state.selected_topic = sug
st.rerun()
st.markdown("</div>", unsafe_allow_html=True)
if "selected_topic" in st.session_state and st.session_state.selected_topic:
st.info(f"✅ Topic da aka zaɓa: **{st.session_state.selected_topic}**")
st.markdown("<br>", unsafe_allow_html=True)
# Script Formula Templates
selected_formula = st.selectbox(
"📋 Script Formula Template",
options=list(FORMULA_TEMPLATES.keys()),
key="formula_select"
)
formula_text = FORMULA_TEMPLATES[selected_formula]
if formula_text:
st.markdown(f"""
<div style="background:rgba(212,175,55,0.05);border-left:3px solid #d4af37;
border-radius:6px;padding:0.8rem 1rem;margin:0.4rem 0;
color:rgba(212,175,55,0.8);font-size:0.8rem;direction:rtl;text-align:right;
white-space:pre-line;line-height:1.8">{html.escape(formula_text[:300])}...</div>
""", unsafe_allow_html=True)
manual_script = st.text_area(
"✏️ Manual Script — Kwafa Formula ko rubuta naka",
value=formula_text if formula_text else "",
placeholder="Paste your own script here, or select a Formula above.",
height=180,
key="manual_script"
)
# Caption Style selector
caption_style = st.selectbox(
"🎨 Caption Style",
["Netflix (Outline, Bottom)", "TikTok (Box, Center)", "Minimal (Small, Bottom)"],
key="caption_style_select"
)
# Use AI Scene Matching toggle
use_ai_scenes = st.checkbox(
"🌅 AI Scene Matching — AI ya zaɓi background bisa topic",
value=True,
key="use_ai_scenes"
)
# Fade transitions toggle
use_fades = st.checkbox(
"🎬 Fade Transitions tsakanin clips",
value=True,
key="use_fades"
)
video_size = st.selectbox("Video Size / Aspect Ratio", ["Shorts/Reels (9:16)", "Long Video (16:9)"], key="video_size_select")
aspect_map = {"Shorts/Reels (9:16)": "9:16", "Long Video (16:9)": "16:9"}
selected_aspect = aspect_map[video_size]
video_duration = st.slider(
"⏱️ Video Duration (seconds)",
min_value=20, max_value=90, value=35, step=5,
key="video_duration_slider"
)
st.markdown(f"<small style='color:rgba(212,175,55,0.6)'>Script zai kasance kusan {video_duration} seconds</small>", unsafe_allow_html=True)
# Sound Library selector
st.markdown("<br>", unsafe_allow_html=True)
selected_sound_name = st.selectbox(
"🎵 Background Sound — Zaɓi Music",
options=list(SOUND_LIBRARY.keys()),
key="sound_library_select"
)
selected_sound_url = SOUND_LIBRARY[selected_sound_name]
st.markdown('</div>', unsafe_allow_html=True)
# GENERATE BUTTON
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">⚙️ Ƙirƙirar Bidiyo</div>', unsafe_allow_html=True)
if st.button("🚀 GENERATE ISLAMIC VIDEO", use_container_width=True):
# Use selected topic from suggestions if available
final_topic = st.session_state.get("selected_topic", "") or topic.strip()
if not final_topic:
st.warning("⚠️ Rubuta maudu'i da farko ko zaɓi daga suggestions.")
else:
topic = final_topic
st.session_state.selected_topic = "" # reset after use
if final_topic:
if True:
if manual_script and manual_script.strip():
script = manual_script.strip()
else:
with st.spinner("✦ Ana rubuta rubutu..."):
script = call_freemodel(topic, lang.split()[0], duration_sec=video_duration)
st.session_state.script_text = script
st.markdown("**📜 Rubutun da aka ƙirƙira / Used Script:**")
# Force RTL for Arabic scripts
is_arabic = any('؀' <= c <= 'ۿ' for c in script[:50])
dir_attr = 'rtl' if is_arabic else 'ltr'
align_attr = 'right' if is_arabic else 'left'
st.markdown(
f'<div class="script-box" style="direction:{dir_attr};text-align:{align_attr};'
f'font-family:Amiri,Arial,serif;font-size:1.1rem">{html.escape(script)}</div>',
unsafe_allow_html=True
)
with st.spinner("✦ Ana ƙirƙirar metadata..."):
metadata = call_freemodel_metadata(topic, lang.split()[0], script)
st.session_state.metadata = metadata
with st.spinner("✦ Ana ƙirƙirar murya (TTS)..."):
voice_path = "/tmp/tts_audio.mp3"
make_tts(script, selected_voice, voice_path)
# Download selected background sound and mix
if selected_sound_url:
with st.spinner(f"✦ Ana sauke {selected_sound_name}..."):
sound_file = download_sound(selected_sound_url, "/tmp/selected_sound.mp3")
if sound_file:
voice_path = mix_background_nasheed(voice_path, sound_file, "/tmp/mixed_nasheed.mp3")
st.success(f"✅ {selected_sound_name} an haɗa da murya!")
else:
st.warning("⚠️ Ba a sami sound ba — murya kawai.")
st.session_state.nasheed_path = sound_file
else:
st.info("🔇 Murya kawai — babu background music.")
st.session_state.nasheed_path = ""
# ── SCRIPT EDITOR (user can tweak before video generation)
st.markdown('<div class="section-label">✏️ Gyara Script kafin haɗa bidiyo</div>', unsafe_allow_html=True)
script = render_script_editor(script)
st.session_state.script_text = script
# ── VOICE PREVIEW
col_vp1, col_vp2 = st.columns([2, 1])
with col_vp2:
if st.button("🔊 Preview Murya", use_container_width=True, key="preview_voice_btn"):
with st.spinner("Ana ƙirƙirar preview..."):
preview_path = "/tmp/preview_voice.mp3"
make_tts(script[:200], selected_voice, preview_path)
if os.path.exists(preview_path):
with open(preview_path, "rb") as pf:
st.audio(pf.read(), format="audio/mp3")
# ── VIDEO PREVIEW CARD
st.markdown('<div class="section-label">👁️ Preview — Tabbatar kafin haɗa bidiyo</div>', unsafe_allow_html=True)
render_preview_card(script, st.session_state.metadata, selected_aspect)
with st.spinner("✦ Ana neman bidiyon bango masu dacewa..."):
headers = {"Authorization": PEXELS_KEY}
audio_dur_tmp = get_tts_duration(voice_path)
n_clips_needed = max(4, int(audio_dur_tmp / 8))
import hashlib, random
topic_seed = int(hashlib.md5((topic + str(time.time())).encode()).hexdigest()[:4], 16) % 8 + 1
random.seed(topic_seed)
# AI Scene Matching or default list
if use_ai_scenes:
with st.spinner("🌅 AI na zaɓi scenes masu dacewa..."):
ai_queries = get_scene_queries(topic, lang.split()[0], n_clips_needed)
else:
ai_queries = []
fallback_queries = [
"islamic architecture golden hour", "mosque minaret sky",
"desert sunset sand dunes", "ocean waves peaceful nature",
"green forest light rays sunbeam", "mountain landscape sunrise fog",
"night sky stars milky way", "waterfall nature peaceful",
"flowers bloom nature spring", "river stream forest calm",
"aerial city lights night", "clouds sky time lapse",
]
random.shuffle(fallback_queries)
all_queries = ai_queries + fallback_queries
preferred_queries = all_queries[:n_clips_needed]
clip_paths = []
for q in preferred_queries:
if len(clip_paths) >= n_clips_needed:
break
try:
page = random.randint(1, 4)
resp = requests.get(
f"https://api.pexels.com/videos/search?query={requests.utils.quote(q)}&per_page=5&page={page}&orientation=portrait",
headers=headers, timeout=20
)
r = resp.json()
videos = r.get('videos', [])
random.shuffle(videos)
for v in videos:
chosen = None
for f in v.get('video_files', []):
if f.get('file_type', '').lower() == 'video/mp4' and f.get('width', 0) >= 720:
chosen = f
break
if chosen:
raw_path = f"/tmp/r_{len(clip_paths)}.mp4"
dl = requests.get(chosen['link'], stream=True, timeout=60)
with open(raw_path, "wb") as fh:
for chunk in dl.iter_content(chunk_size=8192):
if chunk:
fh.write(chunk)
clip_paths.append(raw_path)
break
except Exception as e:
print(f"Pexels fetch error for query '{q}': {e}")
if not clip_paths:
st.error("❌ Ba a sami bidiyo ba daga Pexels.")
else:
with st.spinner(f"✦ Ana haɗa bidiyo gaba daya..."):
out_path = "final_islamic_video.mp4"
success = create_video_centered(clip_paths, voice_path, out_path, script, selected_aspect, caption_style=caption_style, use_fades=use_fades)
if success:
st.session_state.final_video_path = out_path
st.success("✅ An gama! Bidiyon yana ƙasa.")
# Save to history
save_to_history(
topic, lang.split()[0],
st.session_state.metadata,
out_path,
"/tmp/thumbnail.jpg"
)
st.session_state.video_history = load_history()
# Generate thumbnail
with st.spinner("✦ Ana ƙirƙirar thumbnail..."):
thumb_title = st.session_state.metadata.get("title", topic) if st.session_state.metadata else topic
thumb_ok = generate_thumbnail(thumb_title, selected_aspect, "/tmp/thumbnail.jpg")
if thumb_ok:
st.session_state.thumbnail_path = "/tmp/thumbnail.jpg"
ts = int(time.time())
metadata_filename = f"/tmp/metadata_{ts}.json"
try:
with open(metadata_filename, "w", encoding="utf-8") as mf:
json.dump(st.session_state.metadata, mf, ensure_ascii=False, indent=2)
st.session_state.last_metadata_file = metadata_filename
except Exception as e:
st.session_state.last_metadata_file = ""
md = st.session_state.metadata or {}
uid = str(int(time.time() * 1000))
title_val = html.escape(md.get("title", ""))
desc_val = html.escape(md.get("description", ""))
tags_list = md.get("hashtags", [])
tags_val = " ".join(tags_list) if isinstance(tags_list, list) else str(tags_list)
tags_val = html.escape(tags_val)
meta_html = f"""
<div style="display:flex;flex-direction:column;gap:10px;">
<label style="font-weight:700;color:#d4af37">Suggested Title</label>
<textarea id="title_{uid}" style="width:100%;height:48px;border-radius:8px;padding:8px;">{title_val}</textarea>
<div style="display:flex;gap:8px;margin-top:6px;">
<button onclick="navigator.clipboard.writeText(document.getElementById('title_{uid}').value).then(()=>{{alert('Title copied')}})" style="background:#d4af37;color:#050e18;padding:8px;border-radius:8px;border:none;cursor:pointer">Copy Title</button>
</div>
<label style="font-weight:700;color:#d4af37;margin-top:8px;">Suggested Description</label>
<textarea id="desc_{uid}" style="width:100%;height:160px;border-radius:8px;padding:8px;">{desc_val}</textarea>
<div style="display:flex;gap:8px;margin-top:6px;">
<button onclick="navigator.clipboard.writeText(document.getElementById('desc_{uid}').value).then(()=>{{alert('Description copied')}})" style="background:#d4af37;color:#050e18;padding:8px;border-radius:8px;border:none;cursor:pointer">Copy Description</button>
</div>
<label style="font-weight:700;color:#d4af37;margin-top:8px;">Suggested Hashtags</label>
<textarea id="tags_{uid}" style="width:100%;height:48px;border-radius:8px;padding:8px;">{tags_val}</textarea>
<div style="display:flex;gap:8px;margin-top:6px;">
<button onclick="navigator.clipboard.writeText(document.getElementById('tags_{uid}').value).then(()=>{{alert('Hashtags copied')}})" style="background:#d4af37;color:#050e18;padding:8px;border-radius:8px;border:none;cursor:pointer">Copy Hashtags</button>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button onclick="(function(){{var combined = document.getElementById('title_{uid}').value + '\\n\\n' + document.getElementById('desc_{uid}').value + '\\n\\n' + document.getElementById('tags_{uid}').value; navigator.clipboard.writeText(combined).then(()=>{{alert('All metadata copied (Title + Description + Hashtags)')}})}})()" style="background:#b8942c;color:#050e18;padding:10px;border-radius:10px;border:none;cursor:pointer">Copy All (YouTube Ready)</button>
</div>
</div>
"""
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">📝 Generated Title, Description & Hashtags</div>', unsafe_allow_html=True)
st.markdown(meta_html, unsafe_allow_html=True)
try:
st.download_button(
label="📥 Download metadata JSON",
data=json.dumps(md, ensure_ascii=False, indent=2),
file_name=f"metadata_{ts}.json",
mime="application/json",
use_container_width=True
)
if st.session_state.last_metadata_file:
st.markdown(f"<small style='color:#d4af37'>Server copy: {st.session_state.last_metadata_file}</small>", unsafe_allow_html=True)
except Exception as e:
st.write("Failed to create metadata download:", e)
st.markdown('</div>', unsafe_allow_html=True)
else:
st.error("❌ Kuskure wajen haɗa bidiyo. (Sake gwadawa)")
st.markdown('</div>', unsafe_allow_html=True)
# VIDEO OUTPUT PANEL
if st.session_state.final_video_path and os.path.exists(st.session_state.final_video_path):
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">🎬 Bidiyon da aka Ƙirƙira</div>', unsafe_allow_html=True)
st.video(st.session_state.final_video_path)
st.markdown("<br>", unsafe_allow_html=True)
with open(st.session_state.final_video_path, "rb") as fh:
st.download_button(
label="📥 SAUKE BIDIYO — Download Video",
data=fh,
file_name="Abubakar_Islamic_Short.mp4",
mime="video/mp4",
use_container_width=True
)
st.markdown('</div>', unsafe_allow_html=True)
# THUMBNAIL OUTPUT PANEL
if st.session_state.thumbnail_path and os.path.exists(st.session_state.thumbnail_path):
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">🖼️ YouTube Thumbnail</div>', unsafe_allow_html=True)
st.image(st.session_state.thumbnail_path, use_column_width=True)
st.markdown("<br>", unsafe_allow_html=True)
with open(st.session_state.thumbnail_path, "rb") as fh:
st.download_button(
label="📥 SAUKE THUMBNAIL — Download Thumbnail",
data=fh,
file_name="Abubakar_Islamic_Thumbnail.jpg",
mime="image/jpeg",
use_container_width=True
)
st.markdown('</div>', unsafe_allow_html=True)
# MULTI-PLATFORM EXPORT PANEL
if st.session_state.final_video_path and os.path.exists(st.session_state.final_video_path):
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">📱 Multi-Platform Export</div>', unsafe_allow_html=True)
st.markdown("<small style='color:rgba(212,175,55,0.7)'>Zaɓi platform → app zai canza girman bidiyo kai tsaye</small>", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
for fmt_name, (fw, fh, fsuffix) in EXPORT_FORMATS.items():
col_a, col_b = st.columns([3, 1])
with col_a:
st.markdown(f"<span style='color:#e8dfc0;font-size:0.9rem'>📐 {fmt_name}</span>", unsafe_allow_html=True)
with col_b:
btn_key = f"export_{fsuffix}"
if st.button("Export", key=btn_key, use_container_width=True):
with st.spinner(f"Ana export..."):
ep = export_video_format(st.session_state.final_video_path, fw, fh, fsuffix)
if ep:
st.session_state.export_paths[fsuffix] = ep
# Show download if exported
if fsuffix in st.session_state.export_paths:
ep = st.session_state.export_paths[fsuffix]
if os.path.exists(ep):
with open(ep, "rb") as ef:
st.download_button(
label=f"📥 Download {fsuffix.upper()}",
data=ef,
file_name=f"Islamic_Short_{fsuffix}.mp4",
mime="video/mp4",
key=f"dl_{fsuffix}",
use_container_width=True
)
st.markdown("<hr style='border-color:rgba(212,175,55,0.1)'>", unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
# HISTORY PANEL
if st.session_state.video_history:
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">💾 Tarihin Videos — History</div>', unsafe_allow_html=True)
for entry in st.session_state.video_history[:5]:
col1, col2 = st.columns([4, 1])
with col1:
st.markdown(f"""
<div style="padding:0.6rem 0;border-bottom:1px solid rgba(212,175,55,0.1)">
<div style="color:#d4af37;font-size:0.85rem;font-weight:700">{html.escape(entry.get('title',''))}</div>
<div style="color:rgba(212,175,55,0.5);font-size:0.75rem">{entry.get('date','')} · {entry.get('lang','')} · {entry.get('topic','')}</div>
</div>""", unsafe_allow_html=True)
with col2:
vp = entry.get("video_path", "")
if vp and os.path.exists(vp):
with open(vp, "rb") as hf:
st.download_button(
"📥",
data=hf,
file_name=f"Islamic_{entry['id']}.mp4",
mime="video/mp4",
key=f"hist_{entry['id']}",
use_container_width=True
)
st.markdown('</div>', unsafe_allow_html=True)
# ══════════════════════════════════════════
# YOUTUBE UPLOAD PANEL (inside tab1 video output)
# ══════════════════════════════════════════
if st.session_state.final_video_path and os.path.exists(st.session_state.final_video_path):
if YOUTUBE_API_KEY:
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">📤 Loda YouTube kai tsaye</div>', unsafe_allow_html=True)
md_yt = st.session_state.metadata or {}
yt_title = md_yt.get("title", "Islamic Short")
yt_desc = md_yt.get("description", "")
yt_tags = md_yt.get("hashtags", [])
col_yt1, col_yt2 = st.columns([3, 1])
with col_yt1:
st.markdown(f"<span style='color:#e8dfc0'>📹 {html.escape(yt_title)}</span>", unsafe_allow_html=True)
with col_yt2:
if st.button("🚀 Upload", key="yt_upload", use_container_width=True):
with st.spinner("Ana loda YouTube..."):
yt_result = upload_to_youtube(
st.session_state.final_video_path,
yt_title, yt_desc,
[t.replace("#","") for t in yt_tags]
)
if yt_result["success"]:
st.session_state.yt_status = f"✅ An loda! {yt_result.get('url','')}"
else:
st.session_state.yt_status = f"❌ {yt_result.get('error','')}"
if st.session_state.yt_status:
st.info(st.session_state.yt_status)
st.markdown('</div>', unsafe_allow_html=True)
# ══════════════════════════════════════════
# BATCH MODE TAB
# ══════════════════════════════════════════
with tab2:
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">📦 Batch Mode — Ƙirƙirar Videos Da Yawa</div>', unsafe_allow_html=True)
batch_lang = st.selectbox("Harshe", ["Hausa", "Arabic", "English"], key="batch_lang")
batch_voice_map = {"Hausa": "ha-NG-AbdullahNeural", "Arabic": "ar-SA-HamedNeural", "English": "en-US-AndrewNeural"}
batch_aspect = st.selectbox("Aspect Ratio", ["9:16", "16:9"], key="batch_aspect")
st.markdown("<small style='color:rgba(212,175,55,0.7)'>Rubuta topics ɗaya a kowace layi (max 10)</small>", unsafe_allow_html=True)
batch_topics_raw = st.text_area(
"Topics (ɗaya a kowace layi)",
placeholder="Tuba, Tawakkul, Fadar Annabi, Sabr da lada, Kaunar Allah",
height=180,
key="batch_topics"
)
col_b1, col_b2 = st.columns(2)
with col_b1:
if st.button("💡 Auto-fill Topics (AI)", use_container_width=True, key="batch_autofill"):
with st.spinner("AI na ba da topics..."):
sugs = get_topic_suggestions(batch_lang)
if sugs:
st.session_state["batch_autofilled"] = "\n".join(sugs[:8])
st.rerun()
with col_b2:
run_batch = st.button("🚀 START BATCH", use_container_width=True, key="run_batch")
if "batch_autofilled" in st.session_state:
st.info("Topics sun shiga — kopiya su saka a box sama.")
st.code(st.session_state["batch_autofilled"])
if run_batch:
topics_list = [t.strip() for t in batch_topics_raw.strip().splitlines() if t.strip()][:10]
if not topics_list:
st.warning("⚠️ Rubuta topics da farko!")
else:
st.session_state.batch_results = []
progress = st.progress(0)
status_box = st.empty()
for i, tp in enumerate(topics_list):
status_box.info(f"🎬 Ana ƙirƙirar {i+1}/{len(topics_list)}: **{tp}**")
res = generate_batch_video(tp, batch_lang, batch_voice_map[batch_lang], batch_aspect, i)
st.session_state.batch_results.append(res)
progress.progress((i + 1) / len(topics_list))
status_box.success(f"✅ An gama! Videos {len(topics_list)} sun shirya.")
# Show batch results
if st.session_state.batch_results:
st.markdown('<div class="section-label">📋 Sakamakon Batch</div>', unsafe_allow_html=True)
for res in st.session_state.batch_results:
col_r1, col_r2 = st.columns([4, 1])
with col_r1:
st.markdown(f"""
<div style="padding:0.5rem 0;border-bottom:1px solid rgba(212,175,55,0.1)">
<span style="color:#d4af37">{res['status']}</span>
<span style="color:#e8dfc0;margin-left:8px">{html.escape(res['topic'])}</span>
<div style="color:rgba(212,175,55,0.5);font-size:0.75rem">{html.escape(res.get('metadata',{}).get('title',''))}</div>
</div>""", unsafe_allow_html=True)
with col_r2:
vp = res.get("path", "")
if vp and os.path.exists(vp):
with open(vp, "rb") as bf:
st.download_button(
"📥",
data=bf,
file_name=f"batch_{res['topic'][:20]}.mp4",
mime="video/mp4",
key=f"batch_dl_{res['topic'][:10]}_{id(res)}",
use_container_width=True
)
st.markdown('</div>', unsafe_allow_html=True)
# ══════════════════════════════════════════
# ANALYTICS TAB
# ══════════════════════════════════════════
with tab3:
st.markdown('<div class="panel">', unsafe_allow_html=True)
st.markdown('<div class="section-label">📈 Analytics Dashboard</div>', unsafe_allow_html=True)
history_data = st.session_state.video_history
analytics = compute_analytics(history_data)
if not analytics:
st.markdown("<div style='color:rgba(212,175,55,0.5);text-align:center;padding:2rem'>Babu bayanan tarihi tukuna.<br>Ƙirƙiri videos da yawa sannan analytics zai bayyana.</div>", unsafe_allow_html=True)
else:
# KPI Cards
col_k1, col_k2, col_k3 = st.columns(3)
def kpi_card(label, value, icon):
return f"""<div style="background:rgba(212,175,55,0.08);border:1px solid rgba(212,175,55,0.2);
border-radius:12px;padding:1rem;text-align:center">
<div style="font-size:1.8rem">{icon}</div>
<div style="color:#d4af37;font-size:1.5rem;font-weight:700">{value}</div>
<div style="color:rgba(212,175,55,0.6);font-size:0.75rem;letter-spacing:0.1em">{label}</div>
</div>"""
with col_k1:
st.markdown(kpi_card("Jimillar Videos", analytics["total"], "🎬"), unsafe_allow_html=True)
with col_k2:
st.markdown(kpi_card("Yawan Harshe", analytics["top_lang"], "🌍"), unsafe_allow_html=True)
with col_k3:
st.markdown(kpi_card("Avg / Rana", analytics["avg_per_day"], "📅"), unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
# Language breakdown
st.markdown("<div style='color:#d4af37;font-weight:700;margin-bottom:0.5rem'>🌍 Videos ta Harshe</div>", unsafe_allow_html=True)
for lg, cnt in analytics["lang_count"].items():
pct = int(cnt / analytics["total"] * 100)
st.markdown(f"""
<div style="margin-bottom:0.4rem">
<span style="color:#e8dfc0;font-size:0.85rem">{lg}</span>
<span style="color:#d4af37;float:right">{cnt} ({pct}%)</span>
<div style="background:rgba(212,175,55,0.1);border-radius:4px;height:6px;margin-top:4px">
<div style="background:#d4af37;width:{pct}%;height:6px;border-radius:4px"></div>
</div>
</div>""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
# Top topics
if analytics["top_topics"]:
st.markdown("<div style='color:#d4af37;font-weight:700;margin-bottom:0.5rem'>🔥 Topics da suka fi</div>", unsafe_allow_html=True)
for word, cnt in analytics["top_topics"]:
st.markdown(f"<span style='color:#e8dfc0'>📌 {html.escape(word)}</span> <span style='color:#d4af37'>×{cnt}</span>", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
# Recent history table
st.markdown("<div style='color:#d4af37;font-weight:700;margin-bottom:0.5rem'>🕐 Videos na Ƙarshe</div>", unsafe_allow_html=True)
for e in history_data[:8]:
st.markdown(f"""
<div style="padding:0.4rem 0;border-bottom:1px solid rgba(212,175,55,0.08);
display:flex;justify-content:space-between">
<span style="color:#e8dfc0;font-size:0.85rem">{html.escape(e.get('title','')[:40])}</span>
<span style="color:rgba(212,175,55,0.5);font-size:0.75rem">{e.get('date','')}</span>
</div>""", unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
# FOOTER
st.markdown("""
<div class="footer">
﷽ &nbsp;&nbsp; Abubakar Daily Islamic Shorts &nbsp;&nbsp; ﷽
</div>
""", unsafe_allow_html=True)