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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +160 -526
app.py CHANGED
@@ -1,22 +1,10 @@
1
  ##############################################################################
2
- # Sozo Business Studio · AI transforms business data into compelling stories #
3
- # (video branch-with-animation PDF branch untouched) #
4
  ##############################################################################
5
- # DROP-IN REPLACEMENT 07-Jul-2025
6
- #
7
- # ▸ Fixes
8
- # 1. Correct Gemini image-generation call (fallback placeholder kept)
9
- # 2. Narration text now strips scene labels & chart tags
10
- # 3. Animation initialises from blank frame and returns artists for blit
11
- # 4. Robust graceful-failure path keeps video & audio lengths aligned
12
- #
13
- # ────────────────────────────────────────────────────────────────────────────
14
-
15
- import os, re, json, hashlib, uuid, base64, io, tempfile, wave, requests, subprocess
16
  from pathlib import Path
17
  from typing import Tuple, Dict, List
18
-
19
- # ─── Third-party ────────────────────────────────────────────────────────────
20
  import streamlit as st
21
  import pandas as pd
22
  import numpy as np
@@ -29,576 +17,222 @@ from markdown_it import MarkdownIt
29
  from PIL import Image
30
  import cv2
31
 
32
- try:
33
- # optional helper for bar-race
34
- import bar_chart_race as bcr
35
- HAS_BCR = True
36
- except ImportError:
37
- HAS_BCR = False
38
-
39
  from langchain_experimental.agents import create_pandas_dataframe_agent
40
  from langchain_google_genai import ChatGoogleGenerativeAI
41
- from google import genai
42
- from google.genai import types # needed only for image generation call
43
 
44
- # ────────────────────────────────────────────────────────────────────────────
45
- # CONFIG & CONSTANTS
46
- # ────────────────────────────────────────────────────────────────────────────
47
  st.set_page_config(page_title="Sozo Business Studio", layout="wide")
48
  st.title("📊 Sozo Business Studio")
49
  st.caption("AI transforms business data into compelling narratives.")
50
 
51
- FPS, WIDTH, HEIGHT = 24, 1280, 720 # video parameters
52
  MAX_CHARTS, VIDEO_SCENES = 5, 5
53
 
54
  API_KEY = os.getenv("GEMINI_API_KEY")
55
  if not API_KEY:
56
  st.error("⚠️ GEMINI_API_KEY is not set."); st.stop()
 
57
 
58
- GEM = genai.Client(api_key=API_KEY) # keep original client usage
59
-
60
- DG_KEY = os.getenv("DEEPGRAM_API_KEY") # optional (narration)
61
-
62
  st.session_state.setdefault("bundle", None)
63
-
64
  sha1_bytes = lambda b: hashlib.sha1(b).hexdigest()
65
 
66
- # ────────────────────────────────────────────────────────────────────────────
67
- # BASIC HELPERS
68
- # ────────────────────────────────────────────────────────────────────────────
69
  def load_dataframe_safely(buf: bytes, name: str) -> Tuple[pd.DataFrame, str]:
70
- """Attempt CSV/Excel load - return (df, err) tuple."""
71
  try:
72
  ext = Path(name).suffix.lower()
73
- df = pd.read_excel(io.BytesIO(buf)) if ext in (".xlsx", ".xls") else pd.read_csv(io.BytesIO(buf))
74
  df.columns = df.columns.astype(str).str.strip()
75
  df = df.dropna(how="all")
76
  if df.empty or len(df.columns) == 0:
77
  raise ValueError("No usable data found")
78
  return df, None
79
- except Exception as e:
80
- return None, str(e)
81
-
82
-
83
- def arrow_df(df: pd.DataFrame) -> pd.DataFrame:
84
- """Return a Streamlit-friendly df with nullable dtypes for Arrow."""
85
- safe = df.copy()
86
- for c in safe.columns:
87
- if safe[c].dtype.name in ("Int64", "Float64", "Boolean"):
88
- safe[c] = safe[c].astype(safe[c].dtype.name.lower())
89
- return safe
90
-
91
 
92
  @st.cache_data(show_spinner=False)
93
- def deepgram_tts(text: str) -> Tuple[bytes, str]:
94
- """Call Deepgram TTS, return (audio_bytes, mime) or (None, None)."""
95
- if not DG_KEY or not text:
96
- return None, None
97
- text = re.sub(r"[^\w\s.,!?;:-]", "", text)[:1000] # Deepgram max tokens
98
  try:
99
  r = requests.post(
100
  "https://api.deepgram.com/v1/speak",
101
  params={"model": "aura-asteria-en"},
102
  headers={"Authorization": f"Token {DG_KEY}", "Content-Type": "application/json"},
103
- json={"text": text},
104
- timeout=30,
105
- )
106
  r.raise_for_status()
107
  return r.content, r.headers.get("Content-Type", "audio/mpeg")
108
- except Exception:
109
- return None, None
110
-
111
-
112
- def generate_silence(duration: float, out_path: Path) -> None:
113
- """Generate a silent MP3 of exact duration using ffmpeg."""
114
- subprocess.run(
115
- ["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
116
- "-t", f"{duration:.3f}", "-q:a", "9", str(out_path)],
117
- check=True, capture_output=True
118
- )
119
 
 
 
 
 
120
 
121
- def get_audio_duration(mp3_path: str) -> float:
122
- """Return duration seconds via ffprobe; fallback 5.0."""
123
  try:
124
- out = subprocess.run(
125
- ["ffprobe", "-v", "error", "-show_entries", "format=duration",
126
- "-of", "default=noprint_wrappers=1:nokey=1", mp3_path],
127
- text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
128
- ).stdout.strip()
129
  return float(out)
130
- except Exception:
131
- return 5.0
132
-
133
 
134
- TAG_RE = re.compile(r'[<[]\s*generate_?chart\s*[:=]?\s*["\']?(?P<d>[^>"\'\]]+?)["\']?\s*[>\]]', re.I)
135
  extract_chart_tags = lambda t: list(dict.fromkeys(m.group("d").strip() for m in TAG_RE.finditer(t or "")))
136
-
137
- def repl_tags(txt: str, mp: Dict[str, str], fn):
138
- """Replace chart tags using map + fn()."""
139
- return TAG_RE.sub(lambda m: fn(mp[m.group("d").strip()]) if m.group("d").strip() in mp else m.group(0), txt)
140
-
141
-
142
- # ────────────────────────────────────────────────────────────────────────────
143
- # PDF GENERATION (UNCHANGED)
144
- # ────────────────────────────────────────────────────────────────────────────
145
- class PDF(FPDF, HTMLMixin):
146
- pass
147
-
148
- def build_pdf(md: str, charts: Dict[str, str]) -> bytes:
149
- html = MarkdownIt("commonmark", {"breaks": True}).enable("table").render(
150
- repl_tags(md.replace("•", "*"), charts, lambda p: f'<img src="{p}">')
151
- )
152
- pdf = PDF(); pdf.set_auto_page_break(True, margin=15)
153
- pdf.add_page(); pdf.set_font("Arial", "B", 18)
154
- pdf.cell(0, 12, "AI-Generated Business Report", ln=True); pdf.ln(3)
155
- pdf.set_font("Arial", "", 11); pdf.write_html(html)
156
- return bytes(pdf.output(dest="S"))
157
-
158
-
159
- # ────────────────────────────────────────────────────────────────────────────
160
- # IMAGE GENERATION
161
- # ────────────────────────────────────────────────────────────────────────────
162
- def generate_image_from_prompt(prompt: str, style: str) -> Image.Image:
163
- """
164
- Use Gemini native image generation; fallback to placeholder.
165
- Keeps default model name but gracefully tries preview model if needed.
166
- """
167
- model_name = "gemini-2.0-flash-exp-image-generation" # ✳ keep original
168
- full_prompt = (
169
- "A professional, clean, illustrative image for a business presentation: "
170
- f"{prompt}, in the style of {style}."
171
- )
172
-
173
- def _decode(parts):
174
- for part in parts:
175
- if getattr(part, "inline_data", None) is not None:
176
- return Image.open(io.BytesIO(part.inline_data.data)).convert("RGB")
177
- return None
178
-
179
- try:
180
- response = GEM.models.generate_content(
181
- model=model_name,
182
- contents=full_prompt,
183
- config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
184
- )
185
- img = _decode(response.candidates[0].content.parts)
186
- if img:
187
- return img
188
- except Exception as e:
189
- # try preview SKU once
190
- try:
191
- response = GEM.models.generate_content(
192
- model="gemini-2.0-flash-preview-image-generation",
193
- contents=full_prompt,
194
- config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
195
- )
196
- img = _decode(response.candidates[0].content.parts)
197
- if img:
198
- return img
199
- except Exception:
200
- st.warning(f"Illustrative image generation failed: {e}. Using placeholder.")
201
-
202
- return Image.new("RGB", (WIDTH, HEIGHT), color=(230, 230, 230))
203
-
204
-
205
- # ────────────────────────────────────────────────────────────────────────────
206
- # NARRATION CLEAN-UP
207
- # ────────────────────────────────────────────────────────────────────────────
208
  re_scene = re.compile(r"^\s*scene\s*\d+[:.\- ]*", re.I)
209
 
210
- def clean_narration(text: str) -> str:
211
- """Strip scene labels, chart tags, and excess whitespace."""
212
  text = re_scene.sub("", text)
213
  text = TAG_RE.sub("", text)
 
214
  text = re.sub(r"\s{2,}", " ", text).strip()
215
  return text
216
 
 
217
 
218
- # ────────────────────────────────────────────────────────────────────────────
219
- # GENERIC ANIMATION HELPERS (VIDEO PATH ONLY)
220
- # ────────────────────────────────────────────────────────────────────────────
221
- def animate_image_fade(img_cv2: np.ndarray, duration: float, out_path: Path, fps: int = FPS) -> str:
222
- frames = max(int(duration * fps), fps) # at least 1 s
223
- video = cv2.VideoWriter(str(out_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (WIDTH, HEIGHT))
224
- blank = np.full_like(img_cv2, 255)
225
- for i in range(frames):
226
- alpha = i / frames
227
- frame = cv2.addWeighted(blank, 1 - alpha, img_cv2, alpha, 0)
228
- video.write(frame)
229
- video.release()
230
- return str(out_path)
231
-
232
-
233
- def animate_chart(desc: str, df: pd.DataFrame, duration: float, out_path: Path, fps: int = FPS) -> str:
234
- """
235
- Build static figure then animate reveal (≤30 frames). Returns MP4 path.
236
- Guaranteed to succeed; will raise to caller if fatal.
237
- """
238
- chart_type, *rest = [s.strip().lower() for s in desc.split("|", 1)]
239
- chart_type = chart_type or "line"
240
- title = rest[0] if rest else desc
241
-
242
- # === Prepare aggregated data ============================================
243
- if chart_type == "pie":
244
- cat = df.select_dtypes(exclude="number").columns[0]
245
- num = df.select_dtypes(include="number").columns[0]
246
- plot_df = df.groupby(cat)[num].sum().sort_values(ascending=False).head(8)
247
- elif chart_type in ("bar", "hist"):
248
- num = df.select_dtypes(include="number").columns[0]
249
- plot_df = df[num]
250
- else: # line / scatter
251
- nums = df.select_dtypes(include="number").columns[:2]
252
- plot_df = df[list(nums)].sort_index()
253
-
254
- # === Build figure =======================================================
255
- fig, ax = plt.subplots(figsize=(WIDTH / 100, HEIGHT / 100), dpi=100)
256
- frames = max(10, min(30, int(duration * fps)))
257
- artists = []
258
-
259
- if chart_type == "pie":
260
- wedges, _ = ax.pie(plot_df, labels=plot_df.index, startangle=90)
261
- ax.set_title(title)
262
-
263
- def init():
264
- for w in wedges:
265
- w.set_alpha(0)
266
- return wedges
267
-
268
- def update(i):
269
- alpha = i / frames
270
- for w in wedges:
271
- w.set_alpha(alpha)
272
- return wedges
273
-
274
- elif chart_type == "bar":
275
- bars = ax.bar(plot_df.index, np.zeros_like(plot_df.values), color="#1f77b4")
276
- ax.set_ylim(0, plot_df.max() * 1.1); ax.set_title(title)
277
-
278
- def init():
279
- return bars
280
-
281
- def update(i):
282
- frac = i / frames
283
- for b, h in zip(bars, plot_df.values):
284
- b.set_height(h * frac)
285
- return bars
286
-
287
- elif chart_type == "hist":
288
- n, bins, patches = ax.hist(plot_df, bins=20, color="#1f77b4", alpha=0)
289
- ax.set_title(title)
290
-
291
- def init():
292
- for p in patches: p.set_alpha(0)
293
- return patches
294
-
295
- def update(i):
296
- alpha = i / frames
297
- for p in patches: p.set_alpha(alpha)
298
- return patches
299
-
300
- elif chart_type == "scatter":
301
- pts = ax.scatter(plot_df.iloc[:, 0], plot_df.iloc[:, 1], s=10, alpha=0)
302
- ax.set_title(title); ax.grid(alpha=0.3)
303
-
304
- def init():
305
- pts.set_alpha(0); return [pts]
306
-
307
- def update(i):
308
- pts.set_alpha(i / frames)
309
- return [pts]
310
 
311
- else: # line
312
- line, = ax.plot([], [], lw=2)
313
- x_full = plot_df.iloc[:, 0] if chart_type == "line" and plot_df.shape[1] > 1 else np.arange(len(plot_df))
314
- y_full = plot_df.iloc[:, 1] if plot_df.shape[1] > 1 else plot_df.iloc[:, 0]
315
- ax.set_xlim(x_full.min(), x_full.max()); ax.set_ylim(y_full.min(), y_full.max())
316
- ax.set_title(title); ax.grid(alpha=0.3)
317
-
318
- def init():
319
- line.set_data([], [])
320
- return [line]
321
-
322
- def update(i):
323
- k = max(2, int(len(x_full) * i / frames))
324
- line.set_data(x_full[:k], y_full.iloc[:k])
325
- return [line]
326
-
327
- anim = FuncAnimation(
328
- fig, update, frames=frames, init_func=init,
329
- blit=True, interval=1000 / fps
330
- )
331
- anim.save(str(out_path), writer=FFMpegWriter(fps=fps, metadata={'artist': 'Sozo'}), dpi=144)
332
- plt.close(fig)
333
- return str(out_path)
334
 
 
 
 
 
 
 
 
 
 
 
335
 
336
- def safe_animate_chart(desc: str, df: pd.DataFrame, duration: float, out_path: Path, fps: int = FPS) -> str:
337
- """Wrapper that falls back to static-fade if chart animation fails."""
338
- try:
339
- return animate_chart(desc, df, duration, out_path, fps)
340
- except Exception:
341
- with plt.ioff():
342
- df.plot(ax=plt.gca())
343
- tmp_png = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.png"
344
- plt.savefig(tmp_png, bbox_inches="tight"); plt.close()
345
- img = cv2.resize(cv2.imread(str(tmp_png)), (WIDTH, HEIGHT))
346
- return animate_image_fade(img, duration, out_path, fps)
347
-
348
-
349
- def concat_media(inputs: List[str], output: Path, kind: str = "video") -> None:
350
- """FFmpeg safe concat for audio or video."""
351
- if not inputs:
352
- return
353
- lst = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.txt"
354
- with lst.open("w") as f:
355
- for p in inputs:
356
- if Path(p).exists():
357
- f.write(f"file '{Path(p).resolve()}'\n")
358
- subprocess.run(
359
- ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(lst),
360
- "-c:v" if kind == "video" else "-c:a", "copy", str(output)],
361
- check=True, capture_output=True
362
- )
363
- lst.unlink(missing_ok=True)
364
-
365
-
366
- # ────────────────────────────────────────────────────────────────────────────
367
- # REPORT GENERATION (unchanged model names)
368
- # ────────────────────────────────────────────────────────────────────────────
369
- def generate_report_assets(key, buf, name, ctx):
370
- df, err = load_dataframe_safely(buf, name)
371
- if err:
372
- st.error(err); return None
373
-
374
- llm = ChatGoogleGenerativeAI(
375
- model="gemini-2.0-flash", google_api_key=API_KEY, temperature=0.1
376
- )
377
 
378
- ctx_dict = {
379
- "shape": df.shape,
380
- "columns": list(df.columns),
381
- "user_ctx": ctx or "General business analysis",
382
- }
383
-
384
- report_prompt = (
385
- "You are a senior business analyst. Write an executive-level Markdown report "
386
- "with insights & recommendations.\n"
387
- 'When you need a visual, insert a tag like <generate_chart: "pie | sales by region"> '
388
- "(chart_type first, then a description). "
389
- "Valid chart_type values: line, bar, scatter, pie, hist.\n"
390
- f"Data Context: {json.dumps(ctx_dict, indent=2)}"
391
- )
392
 
393
- md = llm.invoke(report_prompt).content
394
-
395
- # ---------------------------------------------------------------- charts
396
- chart_descs = extract_chart_tags(md)[:MAX_CHARTS]
397
- charts: Dict[str, str] = {}
398
-
399
- if chart_descs:
400
- agent = create_pandas_dataframe_agent(
401
- llm=llm, df=df, verbose=False, allow_dangerous_code=True
402
- )
403
- for d in chart_descs:
404
- with st.spinner(f"Generating chart: {d}"):
405
- with plt.ioff():
406
- try:
407
- agent.run(f"Create a {d} with Matplotlib and save.")
408
- fig = plt.gcf()
409
- if fig.axes:
410
- p = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.png"
411
- fig.savefig(p, dpi=300, bbox_inches="tight", facecolor="white")
412
- charts[d] = str(p)
413
- plt.close("all")
414
- except Exception:
415
- plt.close("all")
416
-
417
- preview = repl_tags(
418
- md, charts,
419
- lambda p: f'<img src="data:image/png;base64,{base64.b64encode(Path(p).read_bytes()).decode()}">'
420
- )
421
- pdf = build_pdf(md, charts)
422
 
423
- return {
424
- "type": "report",
425
- "preview": preview,
426
- "pdf": pdf,
427
- "report_md": md,
428
- "key": key,
429
- }
430
 
 
 
 
 
431
 
432
- # ────────────────────────────────────────────────────────────────────────────
433
- # VIDEO GENERATION (animated charts)
434
- # ────────────────────────────────────────────────────────────────────────────
435
- def generate_video_assets(key, buf, name, ctx, style, animate_charts: bool = True):
436
- try:
437
- subprocess.run(["ffmpeg", "-version"], check=True, capture_output=True)
 
 
 
 
 
 
 
438
  except Exception:
439
- st.error("🔴 FFmpeg not available — cannot render video.")
440
- return None
441
-
442
- df, err = load_dataframe_safely(buf, name)
443
- if err:
444
- st.error(err); return None
445
-
446
- llm = ChatGoogleGenerativeAI(
447
- model="gemini-2.0-flash", google_api_key=API_KEY, temperature=0.2
448
- )
 
449
 
450
- ctx_dict = {
451
- "shape": df.shape,
452
- "columns": list(df.columns),
453
- "user_ctx": ctx or "General business analysis",
454
- }
455
-
456
- story_prompt = (
457
- f"Create a script for a short business video with exactly {VIDEO_SCENES} scenes.\n"
458
- "For each scene:\n"
459
- "1. Provide 1–2 sentences of narration.\n"
460
- '2. If a visual is helpful, add <generate_chart: "bar | monthly revenue"> (chart_type first).\n'
461
- "3. Separate scenes with [SCENE_BREAK].\n"
462
- f"Data Context: {json.dumps(ctx_dict, indent=2)}"
463
- )
464
 
465
- script = llm.invoke(story_prompt).content
466
- scenes = [s.strip() for s in script.split("[SCENE_BREAK]") if s.strip()]
467
- video_parts: List[str] = []
468
- audio_parts: List[str] = []
469
- temps: List[Path] = []
470
-
471
- for idx, scene in enumerate(scenes[:VIDEO_SCENES]):
472
- st.progress((idx + 1) / VIDEO_SCENES, text=f"Processing Scene {idx+1}/{VIDEO_SCENES}…")
473
-
474
- chart_tags = extract_chart_tags(scene)
475
- narrative = clean_narration(repl_tags(scene, {}, lambda _: "")).strip()
476
-
477
- # ─────────────── audio ────────────────────────────
478
- audio_bytes, _ = deepgram_tts(narrative)
479
- audio_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp3"
480
-
481
- if audio_bytes:
482
- audio_path.write_bytes(audio_bytes)
483
- duration = get_audio_duration(str(audio_path))
484
- else:
485
- duration = 5.0
486
- generate_silence(duration, audio_path)
487
-
488
- audio_parts.append(str(audio_path)); temps.append(audio_path)
489
-
490
- # ─────────────── visual ───────────────────────────
491
- clip_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp4"
492
- if chart_tags and animate_charts:
493
- safe_animate_chart(chart_tags[0], df, duration, clip_path, FPS)
494
- else:
495
- img = generate_image_from_prompt(narrative, style)
496
- png_tmp = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.png"
497
- img.save(png_tmp); temps.append(png_tmp)
498
- animate_image_fade(
499
- cv2.cvtColor(np.array(img.resize((WIDTH, HEIGHT))), cv2.COLOR_RGB2BGR),
500
- duration, clip_path, FPS
501
- )
502
- video_parts.append(str(clip_path)); temps.append(clip_path)
503
-
504
- # ───────── concatenate ───────────────────────────────
505
- silent_vid = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp4"
506
- concat_media(video_parts, silent_vid, "video")
507
- audio_mix = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp3"
508
- concat_media(audio_parts, audio_mix, "audio")
509
-
510
- final_vid = Path(tempfile.gettempdir()) / f"{key}.mp4"
511
- subprocess.run(
512
- ["ffmpeg", "-y", "-i", str(silent_vid), "-i", str(audio_mix),
513
- "-c:v", "copy", "-c:a", "aac", "-shortest", str(final_vid)],
514
- check=True, capture_output=True,
515
  )
516
 
517
- # cleanup tmp
518
- for p in temps:
519
- p.unlink(missing_ok=True)
520
- silent_vid.unlink(missing_ok=True); audio_mix.unlink(missing_ok=True)
521
-
522
- return {"type": "video", "video_path": str(final_vid), "key": key}
523
-
524
-
525
- # ────────────────────────────────────────────────────────────────────────────
526
- # UI
527
- # ────────────────────────────────────────────────────────────────────────────
528
- mode = st.radio("Select Output Format:", ["Report (PDF)", "Video Narrative"], horizontal=True)
529
- video_style, animate_charts_flag = "professional illustration", True
530
-
531
- if mode == "Video Narrative":
532
- with st.sidebar:
533
- st.subheader("🎬 Video Options")
534
- video_style = st.selectbox(
535
- "Visual Style",
536
- ["professional illustration", "minimalist infographic", "photorealistic",
537
- "cinematic", "data visualization aesthetic"]
538
- )
539
- animate_charts_flag = st.toggle("Animate Charts", value=True)
540
- st.caption("Disable to use static slides with a simple fade-in.")
541
-
542
- upl = st.file_uploader("Upload CSV or Excel", type=["csv", "xlsx", "xls"])
543
 
 
 
544
  if upl:
545
- df_sample, _ = load_dataframe_safely(upl.getvalue(), upl.name)
546
- with st.expander("📊 Data Preview"):
547
- st.dataframe(arrow_df(df_sample.head()))
548
-
549
- ctx = st.text_area("Business context or specific instructions (optional)")
550
-
551
- if st.button("🚀 Generate", type="primary"):
552
- if not upl:
553
- st.warning("Please upload a file first."); st.stop()
554
-
555
- bkey = sha1_bytes(b"".join([
556
- upl.getvalue(), mode.encode(), ctx.encode(),
557
- video_style.encode(), str(animate_charts_flag).encode()
558
- ]))
559
-
560
- if mode == "Report (PDF)":
561
- with st.spinner("Generating report…"):
562
- st.session_state.bundle = generate_report_assets(bkey, upl.getvalue(), upl.name, ctx)
563
- else:
564
- st.session_state.bundle = generate_video_assets(
565
- bkey, upl.getvalue(), upl.name, ctx, video_style, animate_charts_flag
566
- )
567
- st.rerun()
568
-
569
- # ────────────────────────────────────────────────────────────────────────────
570
- # OUTPUT
571
- # ────────────────────────────────────────────────────────────────────────────
572
- if st.session_state.get("bundle"):
573
- bundle = st.session_state.bundle
574
-
575
- if bundle.get("type") == "report":
576
- st.subheader("📄 Generated Report")
577
- with st.expander("View Report", expanded=True):
578
- st.markdown(bundle["preview"], unsafe_allow_html=True)
579
-
580
- c1, c2 = st.columns(2)
581
- with c1:
582
- st.download_button(
583
- "Download PDF", bundle["pdf"], "business_report.pdf",
584
- "application/pdf", use_container_width=True
585
- )
586
- with c2:
587
- if DG_KEY and st.button("🔊 Narrate Summary", use_container_width=True):
588
- txt = re.sub(r"<[^>]+>", "", bundle["report_md"])
589
- audio, mime = deepgram_tts(txt)
590
- st.audio(audio, format=mime) if audio else st.error("Narration failed.")
591
-
592
- elif bundle.get("type") == "video":
593
- st.subheader("🎬 Generated Video Narrative")
594
- vp = bundle["video_path"]
595
- if Path(vp).exists():
596
- with open(vp, "rb") as f:
597
- st.video(f.read())
598
- with open(vp, "rb") as f:
599
- st.download_button(
600
- "Download Video", f,
601
- f"sozo_narrative_{bundle['key'][:8]}.mp4", "video/mp4"
602
- )
603
- else:
604
- st.error("Video file missing – generation failed.")
 
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
 
17
  from PIL import Image
18
  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")
34
  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()
46
+ df = (pd.read_excel if ext in (".xlsx", ".xls") else pd.read_csv)(io.BytesIO(buf))
47
  df.columns = df.columns.astype(str).str.strip()
48
  df = df.dropna(how="all")
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",
61
  params={"model": "aura-asteria-en"},
62
  headers={"Authorization": f"Token {DG_KEY}", "Content-Type": "application/json"},
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")