| 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 |
|
|
| |
| 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) |
|
|
| |
| 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.") |
| |
| |
| match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL) |
| if match: |
| json_str = match.group(1) |
| else: |
| |
| 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","<up to 7 topical>"], |
| "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") |
|
|
| |
| 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.") |
|
|
| |
| try: |
| |
| api_key = st.secrets["GEMINI_API_KEY"] |
| except (KeyError, FileNotFoundError): |
| |
| 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() |
|
|
| |
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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) |
| except Exception as e: |
| st.error(f"An unexpected error occurred: {e}") |