streamlit / app.py
09van's picture
Update app.py
e2b6d23 verified
Raw
History Blame Contribute Delete
9.78 kB
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}")