| import streamlit as st |
| import requests |
| |
| 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() |
|
|
| |
| 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_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: |
| |
| 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 = "" |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
|
|
| 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": []} |
|
|
| |
| |
| |
| 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 [] |
|
|
| |
| |
| |
| 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 |
|
|
| |
| img = Image.new("RGB", (w, h), (5, 14, 24)) |
| draw = ImageDraw.Draw(img) |
|
|
| |
| for y in range(h // 3): |
| alpha = int(80 * (1 - y / (h / 3))) |
| draw.line([(0, y), (w, y)], fill=(212, 175, 55, alpha)) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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)) |
|
|
| |
| draw.line([(w // 4, border + 80), (3 * w // 4, border + 80)], fill=(212, 175, 55), width=2) |
|
|
| |
| 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 |
| |
| draw.text((x + 3, y + 3), line, font=font_title, fill=(0, 0, 0)) |
| |
| draw.text((x, y), line, font=font_title, fill=(212, 175, 55)) |
|
|
| |
| 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 = { |
| "🎵 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: |
| |
| presets = { |
| |
| "__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" |
| ), |
| |
| "__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" |
| ), |
| |
| "__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" |
| ), |
| |
| "__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" |
| ), |
| |
| "__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" |
| ), |
| |
| "__174_DRONE__": ( |
| 174, |
| "volume=0.09," |
| "aecho=0.7:0.7:100:0.5," |
| "lowpass=f=400," |
| "bass=g=6" |
| ), |
| |
| "__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 "" |
| |
| if url.startswith("__") and url.endswith("__"): |
| return generate_islamic_tone(url, out_path) |
| |
| 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}") |
| |
| 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): |
| |
| 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 |
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| 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] |
| 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}") |
|
|
| |
| |
| |
| 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() |
|
|
| |
| if "ar-SA" in voice or "ar-" in voice: |
| lang_code = "ar" |
| elif "ha-NG" in voice or "ha-" in voice: |
| lang_code = "en" |
| else: |
| lang_code = "en" |
|
|
| |
| 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}") |
|
|
| |
| 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}") |
|
|
| |
| 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" |
| |
| 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""" |
| |
| text = re.sub(r"[':=\\,\[\]@{}()]", " ", text) |
| |
| 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: |
| 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) |
| |
| sw = int(tw * 1.3) |
| sh = int(th * 1.3) |
| cx = sw // 2 |
| cy = sh // 2 |
|
|
| if zoom_dir == "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: |
| |
| 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): |
| |
| 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) |
| |
| dur_per_clip = audio_dur / n_clips |
|
|
| |
| zoom_dirs = ["in", "out", "in", "out"] |
| 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: |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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})'" |
| ) |
|
|
| |
| all_text_filters = caption_filters + [watermark_draw] |
| combined_text = ",".join(all_text_filters) |
|
|
| |
| if os.path.exists(LOGO_FILE): |
| |
| filter_complex = ( |
| f"[2:v]scale=110:110[logo_sq];" |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| |
| 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"} |
|
|
| |
| metadata = { |
| "snippet": { |
| "title": title[:100], |
| "description": description, |
| "tags": tags[:10] if tags else [], |
| "categoryId": "22" |
| }, |
| "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"} |
|
|
| |
| 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)} |
|
|
| |
| |
| |
| 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 = call_freemodel(topic, lang) |
| if script.startswith("Error"): |
| result["status"] = f"❌ Script error" |
| return result |
|
|
| |
| metadata = call_freemodel_metadata(topic, lang, script) |
|
|
| |
| vpath = f"/tmp/batch_voice_{idx}.mp3" |
| asyncio.run(_tts(script, voice, vpath)) |
|
|
| |
| nasheed = download_nasheed(f"/tmp/nasheed_{idx}.mp3") |
| if nasheed: |
| vpath = mix_background_nasheed(vpath, nasheed, f"/tmp/batch_mixed_{idx}.mp3") |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| |
| 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) |
| } |
|
|
| |
| |
| |
| 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 [] |
|
|
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| 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() |
|
|
| |
| tab1, tab2, tab3 = st.tabs(["🎬 Generate Video", "📦 Batch Mode", "📈 Analytics"]) |
|
|
| |
| 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 اسماً لله... |
| كل اسم يكشف لك باباً من أبواب الجنة. |
| |
| قال ﷺ: |
| من أحصاها دخل الجنة |
| |
| الرحمن... يرحمك. |
| الرزاق... يرزقك. |
| الغفار... يغفر لك. |
| الآن — وفي كل لحظة. |
| |
| أي اسم يلامس قلبك اليوم؟ |
| اكتبه في التعليقات""", |
| } |
|
|
| |
| 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") |
|
|
| |
| 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) |
|
|
| |
| 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 = st.selectbox( |
| "🎨 Caption Style", |
| ["Netflix (Outline, Bottom)", "TikTok (Box, Center)", "Minimal (Small, Bottom)"], |
| key="caption_style_select" |
| ) |
|
|
| |
| use_ai_scenes = st.checkbox( |
| "🌅 AI Scene Matching — AI ya zaɓi background bisa topic", |
| value=True, |
| key="use_ai_scenes" |
| ) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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): |
| |
| 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 = "" |
| 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:**") |
| |
| 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) |
|
|
| |
| 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 = "" |
|
|
| |
| 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 |
|
|
| |
| 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") |
|
|
| |
| 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) |
|
|
| |
| 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( |
| topic, lang.split()[0], |
| st.session_state.metadata, |
| out_path, |
| "/tmp/thumbnail.jpg" |
| ) |
| st.session_state.video_history = load_history() |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| 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.") |
|
|
| |
| 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) |
|
|
| |
| |
| |
| 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: |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| st.markdown(""" |
| <div class="footer"> |
| ﷽ Abubakar Daily Islamic Shorts ﷽ |
| </div> |
| """, unsafe_allow_html=True) |
|
|