rairo commited on
Commit
bfcb421
·
verified ·
1 Parent(s): c4cc4e0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +352 -157
app.py CHANGED
@@ -1,10 +1,16 @@
1
  ##############################################################################
2
- # Sozo Business Studio · 07-Jul-2025 update #
3
- # Fix image-animation issues, clean narrator text, drop visual-style UI #
 
 
 
 
4
  ##############################################################################
 
5
  import os, re, json, hashlib, uuid, base64, io, tempfile, requests, subprocess
6
  from pathlib import Path
7
  from typing import Tuple, Dict, List
 
8
  import streamlit as st
9
  import pandas as pd
10
  import numpy as np
@@ -19,15 +25,17 @@ import cv2
19
 
20
  from langchain_experimental.agents import create_pandas_dataframe_agent
21
  from langchain_google_genai import ChatGoogleGenerativeAI
22
- from google import genai, genai as _g
23
- from google.genai import types # for GenerateContentConfig
24
 
25
- # ─── CONFIG ────────────────────────────────────────────────────────────────
 
 
26
  st.set_page_config(page_title="Sozo Business Studio", layout="wide")
27
  st.title("📊 Sozo Business Studio")
28
  st.caption("AI transforms business data into compelling narratives.")
29
 
30
- FPS, WIDTH, HEIGHT = 24, 1280, 720
31
  MAX_CHARTS, VIDEO_SCENES = 5, 5
32
 
33
  API_KEY = os.getenv("GEMINI_API_KEY")
@@ -35,11 +43,13 @@ if not API_KEY:
35
  st.error("⚠️ GEMINI_API_KEY is not set."); st.stop()
36
  GEM = genai.Client(api_key=API_KEY)
37
 
38
- DG_KEY = os.getenv("DEEPGRAM_API_KEY")
39
  st.session_state.setdefault("bundle", None)
40
  sha1_bytes = lambda b: hashlib.sha1(b).hexdigest()
41
 
42
- # ─── HELPERS ───────────────────────────────────────────────────────────────
 
 
43
  def load_dataframe_safely(buf: bytes, name: str) -> Tuple[pd.DataFrame, str]:
44
  try:
45
  ext = Path(name).suffix.lower()
@@ -49,12 +59,23 @@ def load_dataframe_safely(buf: bytes, name: str) -> Tuple[pd.DataFrame, str]:
49
  if df.empty or len(df.columns) == 0:
50
  raise ValueError("No usable data found")
51
  return df, None
52
- except Exception as e: return None, str(e)
 
 
 
 
 
 
 
 
 
 
53
 
54
  @st.cache_data(show_spinner=False)
55
  def deepgram_tts(txt: str) -> Tuple[bytes, str]:
56
- if not DG_KEY or not txt: return None, None
57
- txt = re.sub(r"[^\w\s.,!?;:-]", "", txt)[:1000]
 
58
  try:
59
  r = requests.post(
60
  "https://api.deepgram.com/v1/speak",
@@ -63,176 +84,350 @@ def deepgram_tts(txt: str) -> Tuple[bytes, str]:
63
  json={"text": txt}, timeout=30)
64
  r.raise_for_status()
65
  return r.content, r.headers.get("Content-Type", "audio/mpeg")
66
- except Exception: return None, None
 
 
67
 
68
- def silence_mp3(dur: float, path: Path):
69
- subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
70
- "-t", f"{dur:.3f}", "-q:a", "9", str(path)],
71
- check=True, capture_output=True)
 
72
 
73
- def audio_len(p: str) -> float:
 
74
  try:
75
- out = subprocess.run(["ffprobe","-v","error","-show_entries","format=duration",
76
- "-of","default=nw=1:nk=1", p],
77
- stdout=subprocess.PIPE,text=True,check=True).stdout.strip()
78
- return float(out)
79
- except Exception: return 5.0
80
-
81
- TAG_RE = re.compile(r'[<[]\s*generate_?chart\s*[:=]?\s*["\']?(?P<d>[^>"\'\]]+?)["\']?\s*[>\]]', re.I)
82
- extract_chart_tags = lambda t: list(dict.fromkeys(m.group("d").strip() for m in TAG_RE.finditer(t or "")))
 
 
 
 
 
 
 
83
  re_scene = re.compile(r"^\s*scene\s*\d+[:.\- ]*", re.I)
84
 
85
- def clean_narr(text: str) -> str:
86
- text = re_scene.sub("", text)
87
- text = TAG_RE.sub("", text)
88
- text = re.sub(r"\s*\([^)]*\)", "", text) # remove parentheticals
89
- text = re.sub(r"\s{2,}", " ", text).strip()
90
- return text
91
 
92
- # ─── PDF helper unchanged – omitted for brevity (keep from previous script) ─
 
 
 
 
 
 
 
 
 
 
93
 
94
- # ─── IMAGE PLACEHOLDER (rarely used now) ───────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def placeholder_img() -> Image.Image:
96
- return Image.new("RGB", (WIDTH, HEIGHT), (230,230,230))
97
-
98
- # ─── CHART ANIMATION (init_func+artists) ───────────────────────────────────
99
- def animate_chart(desc: str, df: pd.DataFrame, dur: float, out: Path) -> str:
100
- ctype,*rest=[s.strip().lower() for s in desc.split("|",1)]; ctype=ctype or"bar"
101
- ttl=rest[0] if rest else desc
102
-
103
- if ctype=="pie":
104
- cat=df.select_dtypes(exclude="number").columns[0]
105
- num=df.select_dtypes(include="number").columns[0]
106
- pdf=df.groupby(cat)[num].sum().sort_values(ascending=False).head(8)
107
- elif ctype in("bar","hist"):
108
- num=df.select_dtypes(include="number").columns[0]
109
- pdf=df[num]
110
- else:
111
- cols=df.select_dtypes(include="number").columns[:2]
112
- pdf=df[list(cols)].sort_index()
113
 
114
- fig,ax=plt.subplots(figsize=(WIDTH/100,HEIGHT/100),dpi=100)
115
- frames=max(10,min(30,int(dur*FPS)))
116
 
117
- if ctype=="pie":
118
- wedges,_=ax.pie(pdf,labels=pdf.index,startangle=90);ax.set_title(ttl)
119
- def init(): [w.set_alpha(0) for w in wedges]; return wedges
120
- def update(i): a=i/frames;[w.set_alpha(a) for w in wedges]; return wedges
121
 
122
- elif ctype=="bar":
123
- bars=ax.bar(pdf.index,np.zeros_like(pdf.values),color="#1f77b4");ax.set_ylim(0,pdf.max()*1.1);ax.set_title(ttl)
124
- def init(): return bars
125
- def update(i): f=i/frames;[b.set_height(h*f) for b,h in zip(bars,pdf.values)]; return bars
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
- elif ctype=="hist":
128
- n,bins,patch=ax.hist(pdf,bins=20,color="#1f77b4",alpha=0);ax.set_title(ttl)
129
- def init(): [p.set_alpha(0) for p in patch]; return patch
130
- def update(i): a=i/frames;[p.set_alpha(a) for p in patch]; return patch
131
 
132
- elif ctype=="scatter":
133
- pts=ax.scatter(pdf.iloc[:,0],pdf.iloc[:,1],s=10,alpha=0);ax.set_title(ttl);ax.grid(alpha=.3)
134
- def init(): pts.set_alpha(0); return [pts]
135
- def update(i): pts.set_alpha(i/frames); return [pts]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  else: # line
138
- line,=ax.plot([],[],lw=2);x=pdf.iloc[:,0] if pdf.shape[1]>1 else np.arange(len(pdf))
139
- y=pdf.iloc[:,1] if pdf.shape[1]>1 else pdf.iloc[:,0]
140
- ax.set_xlim(x.min(),x.max());ax.set_ylim(y.min(),y.max());ax.set_title(ttl);ax.grid(alpha=.3)
141
- def init(): line.set_data([],[]); return [line]
142
- def update(i): k=max(2,int(len(x)*i/frames)); line.set_data(x[:k],y.iloc[:k]); return [line]
143
-
144
- anim=FuncAnimation(fig,update,init_func=init,frames=frames,blit=True,interval=1000/FPS)
145
- anim.save(str(out),writer=FFMpegWriter(fps=FPS,metadata={'artist':'Sozo'}),dpi=144)
146
- plt.close(fig); return str(out)
147
-
148
- def safe_chart(desc,df,dur,out):
149
- try: return animate_chart(desc,df,dur,out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  except Exception:
151
- with plt.ioff(): df.plot(ax=plt.gca()); p=Path(tempfile.gettempdir())/f"{uuid.uuid4()}.png"
152
- plt.savefig(p); plt.close(); img=cv2.resize(cv2.imread(str(p)),(WIDTH,HEIGHT))
153
- blank=placeholder_img(); cv2.imwrite(str(p),cv2.cvtColor(np.array(blank),cv2.COLOR_RGB2BGR))
154
- return animate_image_fade(img,dur,out)
155
-
156
- def animate_image_fade(img_cv2,dur,out,fps=FPS):
157
- frames=max(int(dur*fps),fps); video=cv2.VideoWriter(str(out),cv2.VideoWriter_fourcc(*"mp4v"),fps,(WIDTH,HEIGHT))
158
- blank=np.full_like(img_cv2,255)
159
- for i in range(frames):
160
- a=i/frames; video.write(cv2.addWeighted(blank,1-a,img_cv2,a,0))
161
- video.release(); return str(out)
162
 
163
  def concat_media(paths: List[str], out: Path, kind="video"):
164
- lst=Path(tempfile.gettempdir())/f"{uuid.uuid4()}.txt"
 
 
165
  with lst.open("w") as f:
166
  for p in paths:
167
- if Path(p).exists(): f.write(f"file '{Path(p).resolve()}'\n")
168
- subprocess.run(["ffmpeg","-y","-f","concat","-safe","0","-i",str(lst),
169
- "-c:v" if kind=="video" else "-c:a","copy",str(out)],
170
- check=True,capture_output=True)
 
 
171
  lst.unlink(missing_ok=True)
172
 
173
- # ─── REPORT & VIDEO generators (prompt tweaks) ─────────────────────────────
174
- def story_prompt(ctx_dict):
175
- cols=", ".join(ctx_dict["columns"][:6])
 
 
 
176
  return (
177
- f"Create a script for a short business video with exactly {VIDEO_SCENES} scenes.\n"
178
- "Each scene **must** follow this template:\n"
179
- "• 1–2 sentences of narration (no scene labels, no chart descriptions).\n"
180
- '• Exactly one chart tag such as <generate_chart: "bar | total revenue by month">.\n'
181
- "Valid chart types: bar, pie, line, scatter, hist.\n"
182
- f"Use columns ({cols}) from the dataset; pick sensible aggregations.\n"
183
- "Do **not** mention the tag or chart in the narration.\n"
184
- "Separate scenes with [SCENE_BREAK]."
185
  )
186
 
187
- def build_story(df,ctx):
188
- llm=ChatGoogleGenerativeAI(model="gemini-2.0-flash",google_api_key=API_KEY,temperature=0.2)
189
- ctx_dict={"shape":df.shape,"columns":list(df.columns),"user_ctx":ctx or"General business analysis"}
190
- return llm.invoke(story_prompt(ctx_dict)).content
191
 
192
- # UI ========================================================================
193
- upl=st.file_uploader("Upload CSV or Excel",type=["csv","xlsx","xls"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  if upl:
195
- df,_=load_dataframe_safely(upl.getvalue(),upl.name)
196
- with st.expander("Data preview"): st.dataframe(df.head())
197
-
198
- ctx=st.text_area("Business context or specific instructions (optional)")
199
- if st.button("🚀 Generate video",type="primary",disabled=not upl):
200
- key=sha1_bytes(b"".join([upl.getvalue(),ctx.encode()]))
201
- df,_=load_dataframe_safely(upl.getvalue(),upl.name)
202
-
203
- # 1⎯ Build script --------------------------------------------------------
204
- script=build_story(df,ctx)
205
- scenes=[s.strip() for s in script.split("[SCENE_BREAK]") if s.strip()]
206
- vid_parts,aud_parts,tmp=[],[],[]
207
-
208
- for idx,sc in enumerate(scenes[:VIDEO_SCENES]):
209
- st.progress((idx+1)/VIDEO_SCENES,text=f"Scene {idx+1}/{VIDEO_SCENES}")
210
- descs=extract_chart_tags(sc)
211
- narr = clean_narr(sc)
212
- aud_b, _ = deepgram_tts(narr)
213
- mp3 = Path(tempfile.gettempdir())/f"{uuid.uuid4()}.mp3"
214
- if aud_b: mp3.write_bytes(aud_b); dur=audio_len(str(mp3))
215
- else: dur=5.0; silence_mp3(dur,mp3)
216
- aud_parts.append(str(mp3)); tmp.append(mp3)
217
-
218
- mp4 = Path(tempfile.gettempdir())/f"{uuid.uuid4()}.mp4"
219
- if descs: safe_chart(descs[0],df,dur,mp4)
220
- else: img=cv2.cvtColor(np.array(placeholder_img()),cv2.COLOR_RGB2BGR); animate_image_fade(img,dur,mp4)
221
- vid_parts.append(str(mp4)); tmp.append(mp4)
222
-
223
- silent=Path(tempfile.gettempdir())/f"{uuid.uuid4()}.mp4"
224
- concat_media(vid_parts,silent,"video")
225
- mix=Path(tempfile.gettempdir())/f"{uuid.uuid4()}.mp3"
226
- concat_media(aud_parts,mix,"audio")
227
- final=Path(tempfile.gettempdir())/f"{key}.mp4"
228
- subprocess.run(["ffmpeg","-y","-i",str(silent),"-i",str(mix),"-c:v","copy","-c:a","aac",
229
- "-shortest",str(final)],check=True,capture_output=True)
230
- for p in tmp+[silent,mix]: p.unlink(missing_ok=True)
231
- st.session_state.bundle={"video":str(final),"key":key}; st.rerun()
232
-
233
- # ─── OUTPUT ────────────────────────────────────────────────────────────────
234
- if "bundle" in st.session_state:
235
- v=st.session_state.bundle["video"]
236
- st.video(open(v,"rb").read())
237
- st.download_button("Download video",open(v,"rb"),
238
- f"sozo_{st.session_state.bundle['key'][:8]}.mp4","video/mp4")
 
1
  ##############################################################################
2
+ # Sozo Business Studio · 09-Jul-2025 #
3
+ # Clean narrator text (no scene labels / chart talk) #
4
+ # • Enforce chart-tag-driven visuals (bar, pie, line, scatter, hist) #
5
+ # • Fix image generation (Gemini Flash preview) & placeholder fallback #
6
+ # • Animation starts blank; artists returned for blit=True #
7
+ # • Silent-audio fallback keeps mux lengths equal #
8
  ##############################################################################
9
+
10
  import os, re, json, hashlib, uuid, base64, io, tempfile, requests, subprocess
11
  from pathlib import Path
12
  from typing import Tuple, Dict, List
13
+
14
  import streamlit as st
15
  import pandas as pd
16
  import numpy as np
 
25
 
26
  from langchain_experimental.agents import create_pandas_dataframe_agent
27
  from langchain_google_genai import ChatGoogleGenerativeAI
28
+ from google import genai
29
+ from google.genai import types # GenerateContentConfig for image calls
30
 
31
+ # ────────────────────────────────────────────────────────────────────────────
32
+ # CONFIG
33
+ # ────────────────────────────────────────────────────────────────────────────
34
  st.set_page_config(page_title="Sozo Business Studio", layout="wide")
35
  st.title("📊 Sozo Business Studio")
36
  st.caption("AI transforms business data into compelling narratives.")
37
 
38
+ FPS, WIDTH, HEIGHT = 24, 1280, 720
39
  MAX_CHARTS, VIDEO_SCENES = 5, 5
40
 
41
  API_KEY = os.getenv("GEMINI_API_KEY")
 
43
  st.error("⚠️ GEMINI_API_KEY is not set."); st.stop()
44
  GEM = genai.Client(api_key=API_KEY)
45
 
46
+ DG_KEY = os.getenv("DEEPGRAM_API_KEY") # optional narration
47
  st.session_state.setdefault("bundle", None)
48
  sha1_bytes = lambda b: hashlib.sha1(b).hexdigest()
49
 
50
+ # ────────────────────────────────────────────────────────────────────────────
51
+ # HELPERS
52
+ # ────────────────────────────────────────────────────────────────────────────
53
  def load_dataframe_safely(buf: bytes, name: str) -> Tuple[pd.DataFrame, str]:
54
  try:
55
  ext = Path(name).suffix.lower()
 
59
  if df.empty or len(df.columns) == 0:
60
  raise ValueError("No usable data found")
61
  return df, None
62
+ except Exception as e:
63
+ return None, str(e)
64
+
65
+
66
+ def arrow_df(df: pd.DataFrame) -> pd.DataFrame:
67
+ safe = df.copy()
68
+ for c in safe.columns:
69
+ if safe[c].dtype.name in ("Int64", "Float64", "Boolean"):
70
+ safe[c] = safe[c].astype(safe[c].dtype.name.lower())
71
+ return safe
72
+
73
 
74
  @st.cache_data(show_spinner=False)
75
  def deepgram_tts(txt: str) -> Tuple[bytes, str]:
76
+ if not DG_KEY or not txt:
77
+ return None, None
78
+ txt = re.sub(r"[^\w\s.,!?;:-]", "", txt)[:1000] # Deepgram text hygiene
79
  try:
80
  r = requests.post(
81
  "https://api.deepgram.com/v1/speak",
 
84
  json={"text": txt}, timeout=30)
85
  r.raise_for_status()
86
  return r.content, r.headers.get("Content-Type", "audio/mpeg")
87
+ except Exception:
88
+ return None, None
89
+
90
 
91
+ def generate_silence_mp3(duration: float, out: Path):
92
+ subprocess.run(
93
+ ["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
94
+ "-t", f"{duration:.3f}", "-q:a", "9", str(out)],
95
+ check=True, capture_output=True)
96
 
97
+
98
+ def audio_duration(path: str) -> float:
99
  try:
100
+ res = subprocess.run(
101
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration",
102
+ "-of", "default=nw=1:nk=1", path],
103
+ text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
104
+ return float(res.stdout.strip())
105
+ except Exception:
106
+ return 5.0
107
+
108
+
109
+ TAG_RE = re.compile(
110
+ r'[<[]\s*generate_?chart\s*[:=]?\s*["\']?(?P<d>[^>"\'\]]+?)["\']?\s*[>\]]',
111
+ re.I)
112
+ extract_chart_tags = lambda t: list(dict.fromkeys(m.group("d").strip()
113
+ for m in TAG_RE.finditer(t or "")))
114
+
115
  re_scene = re.compile(r"^\s*scene\s*\d+[:.\- ]*", re.I)
116
 
 
 
 
 
 
 
117
 
118
+ def clean_narration(txt: str) -> str:
119
+ txt = re_scene.sub("", txt)
120
+ txt = TAG_RE.sub("", txt)
121
+ txt = re.sub(r"\s*\([^)]*\)", "", txt) # remove parentheticals
122
+ txt = re.sub(r"\s{2,}", " ", txt).strip()
123
+ return txt
124
+
125
+
126
+ # ─── PDF GENERATION (unchanged logic) ───────────────────────────────────────
127
+ class PDF(FPDF, HTMLMixin):
128
+ pass
129
 
130
+
131
+ def build_pdf(md: str, charts: Dict[str, str]) -> bytes:
132
+ html = MarkdownIt("commonmark", {"breaks": True}).enable("table").render(
133
+ TAG_RE.sub(lambda m: f'<img src="{charts.get(m.group("d").strip(), "")}">', md)
134
+ )
135
+ pdf = PDF()
136
+ pdf.set_auto_page_break(True, margin=15)
137
+ pdf.add_page()
138
+ pdf.set_font("Arial", "B", 18)
139
+ pdf.cell(0, 12, "AI-Generated Business Report", ln=True)
140
+ pdf.ln(3)
141
+ pdf.set_font("Arial", "", 11)
142
+ pdf.write_html(html)
143
+ return bytes(pdf.output(dest="S"))
144
+
145
+
146
+ # ─── IMAGE GENERATION & PLACEHOLDER ────────────────────────────────────────
147
  def placeholder_img() -> Image.Image:
148
+ return Image.new("RGB", (WIDTH, HEIGHT), (230, 230, 230))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
 
 
150
 
151
+ def generate_image_from_prompt(prompt: str) -> Image.Image:
152
+ model_main = "gemini-2.0-flash-exp-image-generation"
153
+ model_fallback = "gemini-2.0-flash-preview-image-generation"
154
+ full_prompt = ("A clean business-presentation illustration: " + prompt)
155
 
156
+ def fetch(model_name):
157
+ res = GEM.models.generate_content(
158
+ model=model_name,
159
+ contents=full_prompt,
160
+ config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
161
+ )
162
+ for part in res.candidates[0].content.parts:
163
+ if getattr(part, "inline_data", None):
164
+ return Image.open(io.BytesIO(part.inline_data.data)).convert("RGB")
165
+ return None
166
+
167
+ try:
168
+ img = fetch(model_main) or fetch(model_fallback)
169
+ return img if img else placeholder_img()
170
+ except Exception:
171
+ return placeholder_img()
172
 
 
 
 
 
173
 
174
+ # ─── ANIMATION HELPERS ─────────────────────────────────────────────────────
175
+ def animate_image_fade(img_cv2: np.ndarray, dur: float, out: Path, fps: int = FPS) -> str:
176
+ frames = max(int(dur * fps), fps)
177
+ vid = cv2.VideoWriter(str(out), cv2.VideoWriter_fourcc(*"mp4v"), fps, (WIDTH, HEIGHT))
178
+ blank = np.full_like(img_cv2, 255)
179
+ for i in range(frames):
180
+ a = i / frames
181
+ vid.write(cv2.addWeighted(blank, 1 - a, img_cv2, a, 0))
182
+ vid.release()
183
+ return str(out)
184
+
185
+
186
+ def animate_chart(desc: str, df: pd.DataFrame, dur: float, out: Path, fps: int = FPS) -> str:
187
+ ctype, *rest = [s.strip().lower() for s in desc.split("|", 1)]
188
+ ctype = ctype or "bar"
189
+ title = rest[0] if rest else desc
190
+
191
+ # aggregate or prepare data
192
+ if ctype == "pie":
193
+ cat = df.select_dtypes(exclude="number").columns[0]
194
+ num = df.select_dtypes(include="number").columns[0]
195
+ pdf = df.groupby(cat)[num].sum().sort_values(ascending=False).head(8)
196
+ elif ctype in ("bar", "hist"):
197
+ num = df.select_dtypes(include="number").columns[0]
198
+ pdf = df[num]
199
+ else: # line/scatter
200
+ cols = df.select_dtypes(include="number").columns[:2]
201
+ pdf = df[list(cols)].sort_index()
202
+
203
+ fig, ax = plt.subplots(figsize=(WIDTH / 100, HEIGHT / 100), dpi=100)
204
+ frames = max(10, min(30, int(dur * fps)))
205
+
206
+ if ctype == "pie":
207
+ wedges, _ = ax.pie(pdf, labels=pdf.index, startangle=90)
208
+ ax.set_title(title)
209
+
210
+ def init():
211
+ for w in wedges: w.set_alpha(0)
212
+ return wedges
213
+
214
+ def update(i):
215
+ a = i / frames
216
+ for w in wedges: w.set_alpha(a)
217
+ return wedges
218
+
219
+ elif ctype == "bar":
220
+ bars = ax.bar(pdf.index, np.zeros_like(pdf.values), color="#1f77b4")
221
+ ax.set_ylim(0, pdf.max() * 1.1)
222
+ ax.set_title(title)
223
+
224
+ def init():
225
+ return bars
226
+
227
+ def update(i):
228
+ f = i / frames
229
+ for b, h in zip(bars, pdf.values):
230
+ b.set_height(h * f)
231
+ return bars
232
+
233
+ elif ctype == "hist":
234
+ _, _, patches = ax.hist(pdf, bins=20, color="#1f77b4", alpha=0)
235
+ ax.set_title(title)
236
+
237
+ def init():
238
+ for p in patches: p.set_alpha(0)
239
+ return patches
240
+
241
+ def update(i):
242
+ a = i / frames
243
+ for p in patches: p.set_alpha(a)
244
+ return patches
245
+
246
+ elif ctype == "scatter":
247
+ pts = ax.scatter(pdf.iloc[:, 0], pdf.iloc[:, 1], s=10, alpha=0)
248
+ ax.set_title(title)
249
+ ax.grid(alpha=0.3)
250
+
251
+ def init():
252
+ pts.set_alpha(0)
253
+ return [pts]
254
+
255
+ def update(i):
256
+ pts.set_alpha(i / frames)
257
+ return [pts]
258
 
259
  else: # line
260
+ line, = ax.plot([], [], lw=2)
261
+ x_full = pdf.iloc[:, 0] if pdf.shape[1] > 1 else np.arange(len(pdf))
262
+ y_full = pdf.iloc[:, 1] if pdf.shape[1] > 1 else pdf.iloc[:, 0]
263
+ ax.set_xlim(x_full.min(), x_full.max())
264
+ ax.set_ylim(y_full.min(), y_full.max())
265
+ ax.set_title(title)
266
+ ax.grid(alpha=0.3)
267
+
268
+ def init():
269
+ line.set_data([], [])
270
+ return [line]
271
+
272
+ def update(i):
273
+ k = max(2, int(len(x_full) * i / frames))
274
+ line.set_data(x_full[:k], y_full.iloc[:k])
275
+ return [line]
276
+
277
+ anim = FuncAnimation(
278
+ fig, update, init_func=init, frames=frames,
279
+ blit=True, interval=1000 / fps)
280
+ anim.save(str(out), writer=FFMpegWriter(fps=fps, metadata={'artist': 'Sozo'}), dpi=144)
281
+ plt.close(fig)
282
+ return str(out)
283
+
284
+
285
+ def safe_chart(desc, df, dur, out):
286
+ try:
287
+ return animate_chart(desc, df, dur, out)
288
  except Exception:
289
+ with plt.ioff():
290
+ df.plot(ax=plt.gca())
291
+ tmp_png = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.png"
292
+ plt.savefig(tmp_png, bbox_inches="tight")
293
+ plt.close()
294
+ img = cv2.resize(cv2.imread(str(tmp_png)), (WIDTH, HEIGHT))
295
+ return animate_image_fade(img, dur, out)
296
+
 
 
 
297
 
298
  def concat_media(paths: List[str], out: Path, kind="video"):
299
+ if not paths:
300
+ return
301
+ lst = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.txt"
302
  with lst.open("w") as f:
303
  for p in paths:
304
+ if Path(p).exists():
305
+ f.write(f"file '{Path(p).resolve()}'\n")
306
+ subprocess.run(
307
+ ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(lst),
308
+ "-c:v" if kind == "video" else "-c:a", "copy", str(out)],
309
+ check=True, capture_output=True)
310
  lst.unlink(missing_ok=True)
311
 
312
+
313
+ # ────────────────────────────────────────────────────────────────────────────
314
+ # PROMPT HELPERS
315
+ # ────────────────────────────────────────────────────────────────────────────
316
+ def build_story_prompt(ctx_dict):
317
+ cols = ", ".join(ctx_dict["columns"][:6])
318
  return (
319
+ f"Create a script for a short business video with exactly {VIDEO_SCENES} scenes.\n"
320
+ "Each scene must include:\n"
321
+ "• 1–2 sentences of narration (no scene labels, no chart descriptions).\n"
322
+ '• Exactly one chart tag, e.g. <generate_chart: "bar | total revenue by month">.\n'
323
+ "Valid chart types: bar, pie, line, scatter, hist.\n"
324
+ f"Use the dataset columns ({cols}) with sensible aggregations.\n"
325
+ "Separate scenes with [SCENE_BREAK]."
 
326
  )
327
 
 
 
 
 
328
 
329
+ # ────────────────────────────────────────────────────────────────────────────
330
+ # VIDEO GENERATION
331
+ # ──────────────────────────────��─────────────────────────────────────────────
332
+ def generate_video(buf: bytes, name: str, ctx: str, key: str):
333
+ try:
334
+ subprocess.run(["ffmpeg", "-version"], check=True, capture_output=True)
335
+ except Exception:
336
+ st.error("🔴 FFmpeg not available — cannot render video."); return None
337
+
338
+ df, err = load_dataframe_safely(buf, name)
339
+ if err:
340
+ st.error(err); return None
341
+
342
+ llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash",
343
+ google_api_key=API_KEY, temperature=0.2)
344
+
345
+ ctx_dict = {
346
+ "shape": df.shape,
347
+ "columns": list(df.columns),
348
+ "user_ctx": ctx or "General business analysis",
349
+ }
350
+ script = llm.invoke(build_story_prompt(ctx_dict)).content
351
+ scenes = [s.strip() for s in script.split("[SCENE_BREAK]") if s.strip()]
352
+
353
+ video_parts, audio_parts, temps = [], [], []
354
+ for idx, sc in enumerate(scenes[:VIDEO_SCENES]):
355
+ st.progress((idx + 1) / VIDEO_SCENES,
356
+ text=f"Rendering Scene {idx + 1}/{VIDEO_SCENES}")
357
+
358
+ descs = extract_chart_tags(sc)
359
+ narrative = clean_narration(sc)
360
+
361
+ # ----- audio ---------------------------------------------------------
362
+ audio_bytes, _ = deepgram_tts(narrative)
363
+ mp3_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp3"
364
+ if audio_bytes:
365
+ mp3_path.write_bytes(audio_bytes)
366
+ dur = audio_duration(str(mp3_path))
367
+ else:
368
+ dur = 5.0
369
+ generate_silence_mp3(dur, mp3_path)
370
+ audio_parts.append(str(mp3_path)); temps.append(mp3_path)
371
+
372
+ # ----- visual --------------------------------------------------------
373
+ mp4_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp4"
374
+ if descs:
375
+ safe_chart(descs[0], df, dur, mp4_path)
376
+ else:
377
+ img = generate_image_from_prompt(narrative)
378
+ img_cv = cv2.cvtColor(np.array(img.resize((WIDTH, HEIGHT))), cv2.COLOR_RGB2BGR)
379
+ animate_image_fade(img_cv, dur, mp4_path)
380
+ video_parts.append(str(mp4_path)); temps.append(mp4_path)
381
+
382
+ # ----- concatenate -------------------------------------------------------
383
+ silent_vid = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp4"
384
+ concat_media(video_parts, silent_vid, "video")
385
+ audio_mix = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp3"
386
+ concat_media(audio_parts, audio_mix, "audio")
387
+
388
+ final_vid = Path(tempfile.gettempdir()) / f"{key}.mp4"
389
+ subprocess.run(
390
+ ["ffmpeg", "-y", "-i", str(silent_vid), "-i", str(audio_mix),
391
+ "-c:v", "copy", "-c:a", "aac", "-shortest", str(final_vid)],
392
+ check=True, capture_output=True)
393
+
394
+ for p in temps + [silent_vid, audio_mix]:
395
+ p.unlink(missing_ok=True)
396
+
397
+ return str(final_vid)
398
+
399
+
400
+ # ────────────────────────────────────────────────────────────────────────────
401
+ # UI
402
+ # ────────────────────────────────────────────────────────────────────────────
403
+ upl = st.file_uploader("Upload CSV or Excel", type=["csv", "xlsx", "xls"])
404
  if upl:
405
+ df_preview, _ = load_dataframe_safely(upl.getvalue(), upl.name)
406
+ with st.expander("📊 Data Preview"):
407
+ st.dataframe(arrow_df(df_preview.head()))
408
+
409
+ ctx = st.text_area("Business context or specific instructions (optional)")
410
+
411
+ if st.button("🚀 Generate Video", type="primary", disabled=not upl):
412
+ key = sha1_bytes(b"".join([upl.getvalue(), ctx.encode()]))
413
+ st.session_state.bundle = None
414
+ with st.spinner("Generating…"):
415
+ path = generate_video(upl.getvalue(), upl.name, ctx, key)
416
+ if path:
417
+ st.session_state.bundle = {"video_path": path, "key": key}
418
+ st.rerun()
419
+
420
+ # ────────────────────────────────────────────────────────────────────────────
421
+ # OUTPUT
422
+ # ────────────────────────────────────────────────────────────────────────────
423
+ if bundle := st.session_state.get("bundle"):
424
+ vp = bundle["video_path"]
425
+ if Path(vp).exists():
426
+ with open(vp, "rb") as f:
427
+ st.video(f.read())
428
+ with open(vp, "rb") as f:
429
+ st.download_button("Download Video", f,
430
+ f"sozo_narrative_{bundle['key'][:8]}.mp4",
431
+ "video/mp4")
432
+ else:
433
+ st.error("Video file missing – generation failed.")