| 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" |
| ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "").strip() |
| PEXELS_KEY = os.getenv("PEXELS_API_KEY", "").strip() |
|
|
| 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 = "" |
|
|
| # 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_claude(topic, lang): |
| prompt = ( |
| f"Write a 35-second spiritual Islamic script in {lang} about '{topic}'. " |
| "Output ONLY the spoken text. No titles, no hashtags, no stage directions. " |
| "Make sure the script ends with a short, thought-provoking question to encourage comments." |
| ) |
| try: |
| r = requests.post( |
| "https://api.anthropic.com/v1/messages", |
| headers={ |
| "x-api-key": ANTHROPIC_KEY, |
| "anthropic-version": "2023-06-01", |
| "content-type": "application/json" |
| }, |
| json={ |
| "model": "claude-sonnet-4-5-20251001", |
| "max_tokens": 1024, |
| "messages": [{"role": "user", "content": prompt}] |
| }, |
| timeout=30 |
| ) |
| return r.json()['content'][0]['text'].strip() |
| except Exception as e: |
| return f"Error: {e}" |
|
|
| def call_claude_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( |
| "https://api.anthropic.com/v1/messages", |
| headers={ |
| "x-api-key": ANTHROPIC_KEY, |
| "anthropic-version": "2023-06-01", |
| "content-type": "application/json" |
| }, |
| json={ |
| "model": "claude-sonnet-4-5-20251001", |
| "max_tokens": 1024, |
| "messages": [{"role": "user", "content": prompt}] |
| }, |
| timeout=30 |
| ) |
| raw = r.json()['content'][0]['text'].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": []} |
|
|
| async def tts(text, voice, path): |
| clean = re.sub(r'[#*()<>]', '', text) |
| comm = edge_tts.Communicate(clean, voice) |
| await comm.save(path) |
|
|
| _tts = tts |
|
|
| 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 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 |
|
|
| # ── Step 1: Scale & Crop clips |
| scaled = [] |
| for i, src in enumerate(clip_paths): |
| dst = f"/tmp/scaled_{i}.mp4" |
| scale_cmd = [ |
| "ffmpeg", "-y", |
| "-i", src, |
| "-vf", f"scale={tw}:{th}:force_original_aspect_ratio=increase,crop={tw}:{th}", |
| "-preset", "ultrafast", |
| "-an", dst |
| ] |
| subprocess.run(scale_cmd, capture_output=True, text=True) |
| if os.path.exists(dst): |
| scaled.append(dst) |
|
|
| if not scaled: |
| return False |
|
|
| # Concatenate |
| list_file = "/tmp/concat_list.txt" |
| with open(list_file, "w") as lf: |
| for p in scaled: |
| 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) |
|
|
| # ── Step 2: Loop/trim to audio duration |
| looped = "/tmp/looped_bg.mp4" |
| epsilon = 0.05 |
| subprocess.run([ |
| "ffmpeg", "-y", "-stream_loop", "-1", "-i", joined, |
| "-t", f"{audio_dur + epsilon:.3f}", "-c", "copy", looped |
| ], capture_output=True, text=True) |
| if not os.path.exists(looped): |
| looped = joined |
|
|
| # ── Step 3: 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 4: 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 5: Build ffmpeg command |
| 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", looped, |
| "-i", voice_path, |
| "-i", LOGO_FILE, |
| "-filter_complex", filter_complex, |
| "-map", "[vout]", |
| "-map", "1:a", |
| "-c:v", "libx264", "-preset", "ultrafast", |
| "-shortest", "-t", f"{audio_dur:.3f}", |
| out_path |
| ] |
| else: |
| filter_complex = f"[0:v]{combined_text}[vout]" |
| ff_cmd = [ |
| "ffmpeg", "-y", |
| "-i", looped, |
| "-i", voice_path, |
| "-filter_complex", filter_complex, |
| "-map", "[vout]", |
| "-map", "1:a", |
| "-c:v", "libx264", "-preset", "ultrafast", |
| "-shortest", "-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 |
|
|
| # 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") |
|
|
| 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] |
|
|
| 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): |
| if not topic.strip(): |
| st.warning("⚠️ Rubuta maudu'i da farko.") |
| else: |
| if manual_script and manual_script.strip(): |
| script = manual_script.strip() |
| else: |
| with st.spinner("✦ Ana rubuta rubutu..."): |
| script = call_claude(topic, lang.split()[0]) |
| 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_claude_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)) |
|
|
| with st.spinner("✦ Ana neman bidiyon bango masu dacewa..."): |
| headers = {"Authorization": PEXELS_KEY} |
| preferred_queries = ["beautiful mosques", "flowing river water", "deep green forest", "majestic wildlife"] |
| clip_paths = [] |
| for q in preferred_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=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.") |
|
|
| 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) |
|
|
| # FOOTER |
| st.markdown(""" |
| <div class="footer"> |
| ﷽ Abubakar Daily Islamic Shorts ﷽ |
| </div> |
| """, unsafe_allow_html=True) |
|
|