streamlit / app.py
09van's picture
Update app.py
e2b6d23 verified
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","<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")
# --- 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}")