|
|
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}") |