File size: 9,783 Bytes
e2b6d23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c43864f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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}")