import os import re import json import io import datetime as dt import pandas as pd import streamlit as st from dotenv import load_dotenv import google.generativeai as genai # --- Setup --- load_dotenv() DEFAULT_MODEL = "gemini-1.5-flash" def configure_gemini(api_key: str): """Initializes the Gemini client with the provided API key.""" genai.configure(api_key=api_key) # --- Utilities --- def extract_json(text: str) -> dict: """ Pulls a JSON object from a string, even if it's wrapped in markdown code fences. Returns a Python dictionary or raises an error if parsing fails. """ if not text: raise ValueError("Received an empty response from the model.") # Look for JSON within ```json ... ``` markdown block match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL) if match: json_str = match.group(1) else: # Fallback: find the first '{' and last '}' start = text.find('{') end = text.rfind('}') if start == -1 or end == -1: raise json.JSONDecodeError("No JSON object found in the response text.", text, 0) json_str = text[start:end+1] return json.loads(json_str) def seconds_to_ts(s: int) -> str: """Converts an integer of seconds to a MM:SS timestamp string.""" m, sec = divmod(int(s), 60) return f"{m:02d}:{sec:02d}" def make_prompt(topic: str, idea_count: int, total_seconds: int, scene_count: int) -> str: """Creates the detailed, structured prompt for the generative model.""" return f""" You are a YouTube Shorts producer for "Contentmaniacs" (nature, cosmos, paradoxes, AI). Goal: Create viral, factual, poetic-science Shorts with clear visuals. Generate EXACTLY {idea_count} ideas for topic: "{topic}". Return ONLY a single, valid JSON object (no markdown). The root object must contain one key, "ideas", which is a list of idea objects. The schema for each idea object in the list is: {{ "title": "string (<= 60 chars, no quotes)", "keywords": ["kw1","kw2","kw3"], "description": "1–2 lines, SEO-rich, natural language", "hashtags": ["Shorts","YouTubeShorts","Contentmaniacs",""], "thumbnail_prompt": "clear 9:16 visual brief (no text)", "video_plan": {{ "duration_seconds": {total_seconds}, "scenes_count": {scene_count}, "scenes": [ {{ "scene_no": 1, "start_sec": 0, "end_sec": 0, "voiceover": "1–2 punchy lines, simple language", "on_screen_text": "few words, optional, no hashtags", "visual_direction": "what to show (subject, motion, environment, mood, lighting)", "shot_type": "macro | wide | medium | timelapse | drone | slow-mo | infographic", "prompt": "text-to-video/image prompt for Canva/Runway (no text overlay)", "broll_ideas": ["alt idea 1","alt idea 2"], "sfx_music": "sound design notes (subtle, cinematic, ambient, etc.)" }} ] }}, "full_transcript": "Combine all voiceover lines into a clean 45–60s transcript." }} RULES: - Factual, inspiring, no clickbait lies. - Keep each scene's voiceover short (<= 18 words). - Distribute time evenly across scenes so end_sec of last scene == duration_seconds. - Output MUST be a single, valid JSON object only. """ def idea_json_to_overview_rows(topic: str, idea: dict) -> dict: """Creates a dictionary for the overview DataFrame from a single idea JSON.""" return { "Topic": topic, "Title": (idea.get("title") or "").strip(), "Keywords": ", ".join(idea.get("keywords") or []), "Description": (idea.get("description") or "").strip(), "Hashtags": " ".join(("#" + h.lstrip("#")) for h in (idea.get("hashtags") or [])), "ThumbnailPrompt": (idea.get("thumbnail_prompt") or "").strip(), "DurationSec": idea.get("video_plan", {}).get("duration_seconds", "") } def idea_json_to_scenes_df(topic: str, idea: dict) -> pd.DataFrame: """Creates a DataFrame for the scene-by-scene shot list.""" scenes = idea.get("video_plan", {}).get("scenes", []) or [] rows = [] for sc in scenes: rows.append({ "Topic": topic, "Title": idea.get("title", ""), "SceneNo": sc.get("scene_no", ""), "Start": seconds_to_ts(sc.get("start_sec", 0)), "End": seconds_to_ts(sc.get("end_sec", 0)), "Voiceover": (sc.get("voiceover") or "").strip(), "OnScreenText": (sc.get("on_screen_text") or "").strip(), "VisualDirection": (sc.get("visual_direction") or "").strip(), "ShotType": (sc.get("shot_type") or "").strip(), "Prompt": (sc.get("prompt") or "").strip(), "BrollIdeas": ", ".join(sc.get("broll_ideas") or []), "SFX_Music": (sc.get("sfx_music") or "").strip() }) return pd.DataFrame(rows) def df_to_csv_bytes(df: pd.DataFrame) -> bytes: """Converts a DataFrame to UTF-8 encoded CSV bytes for downloading.""" return df.to_csv(index=False).encode("utf-8") def transcript_bytes(title: str, transcript: str) -> bytes: """Creates bytes for a simple text file containing the title and transcript.""" content = f"TITLE\n{title}\n\nFULL TRANSCRIPT\n{transcript}\n" return content.encode("utf-8") # --- Streamlit UI --- st.set_page_config(page_title="Contentmaniacs Producer", page_icon="🎬", layout="wide") st.title("🎬 Contentmaniacs — Shorts Producer") st.caption("Generate ideas → transcript → scene prompts with one click.") # Load Gemini API key from Hugging Face Secrets or local .env file try: # This is for deployed apps on Hugging Face api_key = st.secrets["GEMINI_API_KEY"] except (KeyError, FileNotFoundError): # This is for local development api_key = os.getenv("GEMINI_API_KEY", "") if not api_key: st.error("⚠️ Gemini API key is missing! Please set it in your .env file locally, or in the Hugging Face Space secrets.") st.stop() # Inputs c1, c2, c3, c4 = st.columns([2, 1, 1, 1]) with c1: topic = st.text_input("Topic", placeholder="Cosmic paradoxes, Deep ocean mysteries, AI vs Humans…") with c2: idea_count = st.number_input("Ideas", min_value=1, max_value=5, value=1, step=1) with c3: total_seconds = st.number_input("Video length (sec)", min_value=30, max_value=90, value=60, step=5) with c4: scene_count = st.number_input("Scenes", min_value=3, max_value=10, value=6, step=1) model_name = st.selectbox("Model", [DEFAULT_MODEL, "gemini-1.5-pro"], index=0) go = st.button("✨ Generate") if go: if not api_key: st.error("Please paste your Gemini API key.") st.stop() if not topic.strip(): st.error("Please enter a topic.") st.stop() try: configure_gemini(api_key) model = genai.GenerativeModel(model_name) prompt = make_prompt(topic.strip(), int(idea_count), int(total_seconds), int(scene_count)) with st.spinner("Producing ideas, transcript and scenes…"): resp = model.generate_content(prompt) data = extract_json(resp.text) ideas = data.get("ideas", []) if not ideas: st.warning("No ideas returned. Try again with a simpler topic or check the model's response format.") st.stop() # Display each idea in its own tab tab_names = [f"Idea {i+1}" for i in range(len(ideas))] tabs = st.tabs(tab_names) ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S") for i, (tab, idea) in enumerate(zip(tabs, ideas), start=1): with tab: overview_row = idea_json_to_overview_rows(topic.strip(), idea) scenes_df = idea_json_to_scenes_df(topic.strip(), idea) transcript = idea.get("full_transcript", "").strip() title = overview_row["Title"] st.subheader("Overview") st.dataframe(pd.DataFrame([overview_row]), use_container_width=True) st.subheader("Scenes / Shot List") st.dataframe(scenes_df, use_container_width=True) # Download buttons colA, colB, colC = st.columns(3) file_prefix = f"idea{i}_{ts}" with colA: st.download_button( "⬇️ Download Scenes CSV", data=df_to_csv_bytes(scenes_df), file_name=f"scenes_{file_prefix}.csv", mime="text/csv" ) with colB: st.download_button( "⬇️ Download Transcript TXT", data=transcript_bytes(title, transcript), file_name=f"transcript_{file_prefix}.txt", mime="text/plain" ) with colC: st.download_button( "⬇️ Download Overview CSV", data=df_to_csv_bytes(pd.DataFrame([overview_row])), file_name=f"overview_{file_prefix}.csv", mime="text/csv" ) with st.expander("👀 Quick copy: Transcript"): st.code(transcript or "No transcript returned.", language="markdown") with st.expander("🎯 Thumbnail Prompt"): st.markdown(overview_row["ThumbnailPrompt"] or "_No prompt returned._") except json.JSONDecodeError: st.error("The model response wasn’t valid JSON. Click 'Generate' again or try a simpler topic.") st.code(resp.text) # Show the faulty text except Exception as e: st.error(f"An unexpected error occurred: {e}")