Mrsadeeqnextgenhub's picture
Rename app.py to app.py4
08a8126 verified
Raw
History Blame Contribute Delete
67.1 kB
import streamlit as st
import requests
import edge_tts
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
# ════════════════════════════════════════════
# FEATURE 3: Real Nasheed from archive.org
# ════════════════════════════════════════════
NASHEED_URLS = [
"https://archive.org/download/quran-recitation-mishary/001.mp3",
"https://archive.org/download/Mishari_Rashid_Al-Afasy_Quran/001.mp3",
"https://download.quranicaudio.com/quran/mishaari_raashid_al_3afaasee/001.mp3",
"https://archive.org/download/SurahAl-Fatihah/Al-Fatihah.mp3",
]
def download_nasheed(out_path: str = "/tmp/nasheed.mp3") -> str:
"""Try to download a real nasheed; fallback to sine tone."""
if os.path.exists(out_path) and os.path.getsize(out_path) > 50000:
return out_path # already cached
for url in NASHEED_URLS:
try:
r = requests.get(url, timeout=20, stream=True)
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.getsize(out_path) > 50000:
return out_path
except Exception as e:
print(f"Nasheed download failed ({url}): {e}")
return "" # will fallback to sine
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 ""
async def tts(text, voice, path):
clean = re.sub(r'[#*()<>]', '', text)
# Slower rate + slightly lower pitch = more imam-like delivery
comm = edge_tts.Communicate(clean, voice, rate="-10%", pitch="-5Hz")
await comm.save(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):
"""
Professional caption style:
- Bottom third positioning (like YouTube/Netflix)
- Large bold white text with strong black outline (no semi-transparent box)
- 4-5 words per line max
- Two lines at a time
- Smooth word-timed display
"""
if aspect == "9:16":
fontsize = 62
y_pos = int(th * 0.72) # 72% from top = lower third for portrait
chunk_size = 4
else:
fontsize = 52
y_pos = int(th * 0.78) # lower third for landscape
chunk_size = 5
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})"
# LINE 1 — outline via borderw (professional look, no box)
df1 = (
f"drawtext=text='{line1}':"
f"fontcolor=white:fontsize={fontsize}:font=Arial:"
f"borderw=4:bordercolor=black@0.95:"
f"x=(w-text_w)/2:y={y_pos - fontsize - 10}:"
f"enable='{enable}'"
)
drawtext_filters.append(df1)
# LINE 2 (if exists)
if line2:
df2 = (
f"drawtext=text='{line2}':"
f"fontcolor=white:fontsize={fontsize}:font=Arial:"
f"borderw=4:bordercolor=black@0.95:"
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) -> 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 ok:
processed.append(dst)
else:
# fallback: simple scale+crop without zoom
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)
if os.path.exists(fb):
processed.append(fb)
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 professional captions
clean_script = re.sub(r'[^\w\s]', '', script)
words = clean_script.split()
caption_filters = build_professional_captions(words, audio_dur, tw, th, aspect)
# ── 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):
filter_complex = (
f"[2:v]scale=110:110[logo];"
f"[0:v][logo]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)
}
# TABS
tab1, tab2, tab3 = st.tabs(["🎬 Generate Video", "📦 Batch Mode", "📈 Analytics"])
with tab1:
# 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)
manual_script = st.text_area(
"Manual Script (paste here to bypass AI generation)",
value="",
placeholder="Paste your own spoken script here. If filled, AI generation will be skipped.",
height=150,
key="manual_script"
)
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)
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:**")
st.markdown(f'<div class="script-box">{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"
asyncio.run(_tts(script, selected_voice, voice_path))
# Download real nasheed and mix
with st.spinner("✦ Ana nemo Nasheed..."):
nasheed_file = download_nasheed("/tmp/nasheed.mp3")
if nasheed_file:
voice_path = mix_background_nasheed(voice_path, nasheed_file, "/tmp/mixed_nasheed.mp3")
else:
voice_path = mix_background_music(voice_path, "/tmp/mixed_audio.mp3")
st.session_state.nasheed_path = nasheed_file
# ── 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}
# Dynamic Islamic cinematic queries based on audio duration
audio_dur_tmp = get_tts_duration(voice_path)
n_clips_needed = max(4, int(audio_dur_tmp / 8))
preferred_queries = [
"islamic architecture golden hour",
"desert sunset sand dunes",
"ocean waves peaceful nature",
"green forest light rays",
"mountain landscape sunrise",
"night sky stars peaceful",
][:n_clips_needed]
clip_paths = []
for q in preferred_queries:
if len(clip_paths) >= n_clips_needed:
break
try:
resp = requests.get(
f"https://api.pexels.com/videos/search?query={requests.utils.quote(q)}&per_page=3&orientation=portrait",
headers=headers, timeout=20
)
r = resp.json()
for v in r.get('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)
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)