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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +349 -423
app.py CHANGED
@@ -1,11 +1,22 @@
1
- ###############################################################################
2
- # Sozo Business Studio · AI transforms business data into compelling narratives
3
- # (video branch now supports animated charts – PDF branch untouched)
4
- ###############################################################################
 
 
 
 
 
 
 
 
 
 
5
  import os, re, json, hashlib, uuid, base64, io, tempfile, wave, requests, subprocess
6
  from pathlib import Path
 
7
 
8
- # ─── Third-party ──────────────────────────────────────────────────────────────
9
  import streamlit as st
10
  import pandas as pd
11
  import numpy as np
@@ -16,9 +27,10 @@ from matplotlib.animation import FuncAnimation, FFMpegWriter
16
  from fpdf import FPDF, HTMLMixin
17
  from markdown_it import MarkdownIt
18
  from PIL import Image
19
- import cv2 # video processing
20
 
21
- try: # optional helper for bar-race
 
22
  import bar_chart_race as bcr
23
  HAS_BCR = True
24
  except ImportError:
@@ -26,35 +38,39 @@ except ImportError:
26
 
27
  from langchain_experimental.agents import create_pandas_dataframe_agent
28
  from langchain_google_genai import ChatGoogleGenerativeAI
29
- from google import genai # ← original import path
 
30
 
31
- # ─────────────────────────────────────────────────────────────────────────────
32
  # CONFIG & CONSTANTS
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 # video parameters
39
  MAX_CHARTS, VIDEO_SCENES = 5, 5
40
 
41
  API_KEY = os.getenv("GEMINI_API_KEY")
42
  if not API_KEY:
43
  st.error("⚠️ GEMINI_API_KEY is not set."); st.stop()
44
- GEM = genai.Client(api_key=API_KEY) # ← still using Client pattern
45
 
46
- DG_KEY = os.getenv("DEEPGRAM_API_KEY") # optional (narration)
 
 
47
 
48
  st.session_state.setdefault("bundle", None)
 
49
  sha1_bytes = lambda b: hashlib.sha1(b).hexdigest()
50
 
51
- # ─────────────────────────────────────────────────────────────────────────────
52
  # BASIC HELPERS
53
- # ───────────────────────────────────────────────────────��─────────────────────
54
- def load_dataframe_safely(buf: bytes, name: str):
 
55
  try:
56
  ext = Path(name).suffix.lower()
57
- df = pd.read_excel(io.BytesIO(buf)) if ext in (".xlsx", ".xls") else pd.read_csv(io.BytesIO(buf))
58
  df.columns = df.columns.astype(str).str.strip()
59
  df = df.dropna(how="all")
60
  if df.empty or len(df.columns) == 0:
@@ -63,18 +79,22 @@ def load_dataframe_safely(buf: bytes, name: str):
63
  except Exception as e:
64
  return None, str(e)
65
 
66
- def arrow_df(df: 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
  @st.cache_data(show_spinner=False)
74
- def deepgram_tts(text: str):
 
75
  if not DG_KEY or not text:
76
  return None, None
77
- text = re.sub(r"[^\w\s.,!?;:-]", "", text)[:1000]
78
  try:
79
  r = requests.post(
80
  "https://api.deepgram.com/v1/speak",
@@ -88,7 +108,18 @@ def deepgram_tts(text: str):
88
  except Exception:
89
  return None, None
90
 
 
 
 
 
 
 
 
 
 
 
91
  def get_audio_duration(mp3_path: str) -> float:
 
92
  try:
93
  out = subprocess.run(
94
  ["ffprobe", "-v", "error", "-show_entries", "format=duration",
@@ -99,29 +130,22 @@ def get_audio_duration(mp3_path: str) -> float:
99
  except Exception:
100
  return 5.0
101
 
102
- TAG_RE = re.compile(r'[<\[]\s*generate_?chart\s*[:=]?\s*["\']?(?P<d>[^>\]"\']+?)["\']?\s*[>\]]', re.I)
 
103
  extract_chart_tags = lambda t: list(dict.fromkeys(m.group("d").strip() for m in TAG_RE.finditer(t or "")))
104
- def repl_tags(txt: str, mp: dict, fn): # fn replaces tag text
 
 
105
  return TAG_RE.sub(lambda m: fn(mp[m.group("d").strip()]) if m.group("d").strip() in mp else m.group(0), txt)
106
 
107
- def clean_narrator_text(text: str) -> str:
108
- """Clean text for narrator by removing scene numbers and chart descriptions."""
109
- # Remove scene numbers (e.g., "Scene 1:", "1.", etc.)
110
- text = re.sub(r'(?i)(?:^|\n)\s*(?:scene\s*\d+[:.]?\s*|^\d+\.?\s*)', '', text)
111
- # Remove chart generation tags completely
112
- text = TAG_RE.sub('', text)
113
- # Remove common chart descriptions and references
114
- text = re.sub(r'(?i)(?:the\s+)?chart\s+(?:shows|displays|illustrates|demonstrates)[^.]*\.?', '', text)
115
- text = re.sub(r'(?i)(?:as\s+)?(?:shown|displayed|illustrated|demonstrated)\s+(?:in\s+)?(?:the\s+)?(?:chart|graph|figure)[^.]*\.?', '', text)
116
- # Clean up extra whitespace
117
- text = re.sub(r'\s+', ' ', text).strip()
118
- return text
119
 
120
- # ─────────────────────────────────────────────────────────────────────────────
121
  # PDF GENERATION (UNCHANGED)
122
- # ─────────────────────────────────────────────────────────────────────────────
123
- class PDF(FPDF, HTMLMixin): pass
124
- def build_pdf(md, charts):
 
 
125
  html = MarkdownIt("commonmark", {"breaks": True}).enable("table").render(
126
  repl_tags(md.replace("•", "*"), charts, lambda p: f'<img src="{p}">')
127
  )
@@ -131,284 +155,228 @@ def build_pdf(md, charts):
131
  pdf.set_font("Arial", "", 11); pdf.write_html(html)
132
  return bytes(pdf.output(dest="S"))
133
 
134
- # ─────────────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  # GENERIC ANIMATION HELPERS (VIDEO PATH ONLY)
136
- # ─────────────────────────────────────────────────────────────────────────────
137
  def animate_image_fade(img_cv2: np.ndarray, duration: float, out_path: Path, fps: int = FPS) -> str:
138
- """Animate image with fade-in effect."""
139
- frames = max(int(duration * fps), fps) # at least 1 second
140
- fourcc = cv2.VideoWriter_fourcc(*"mp4v")
141
- video = cv2.VideoWriter(str(out_path), fourcc, fps, (WIDTH, HEIGHT))
142
-
143
- if video is None or not video.isOpened():
144
- raise RuntimeError(f"Failed to create video writer for {out_path}")
145
-
146
- blank = np.full_like(img_cv2, 255)
147
  for i in range(frames):
148
- alpha = i / (frames - 1) if frames > 1 else 1.0
149
  frame = cv2.addWeighted(blank, 1 - alpha, img_cv2, alpha, 0)
150
  video.write(frame)
151
-
152
  video.release()
153
  return str(out_path)
154
 
155
- def animate_chart(desc: str, df: pd.DataFrame, duration: float,
156
- out_path: Path, fps: int = FPS) -> str:
157
  """
158
- desc -> 'chart_type | explanation'
159
- Valid chart_type: line, bar, scatter, pie, hist
160
- Builds an accurate static figure, then animates a smooth reveal.
161
  """
162
- try:
163
- chart_type, *rest = [s.strip().lower() for s in desc.split("|", 1)]
164
- chart_type = chart_type or "line"
165
- title = rest[0] if rest else desc
166
-
167
- # === Prepare aggregated data =================================================
168
- if chart_type == "pie":
169
- cat_cols = df.select_dtypes(exclude="number").columns
170
- num_cols = df.select_dtypes(include="number").columns
171
- if len(cat_cols) == 0 or len(num_cols) == 0:
172
- raise ValueError("Need categorical and numeric columns for pie chart")
173
- cat = cat_cols[0]
174
- num = num_cols[0]
175
- plot_df = df.groupby(cat)[num].sum().sort_values(ascending=False).head(8)
176
- elif chart_type in ("bar", "hist"):
177
- num_cols = df.select_dtypes(include="number").columns
178
- if len(num_cols) == 0:
179
- raise ValueError("Need numeric columns for bar/hist chart")
180
- num = num_cols[0]
181
- if chart_type == "bar":
182
- # For bar chart, group by index or first categorical column
183
- cat_cols = df.select_dtypes(exclude="number").columns
184
- if len(cat_cols) > 0:
185
- plot_df = df.groupby(cat_cols[0])[num].sum().sort_values(ascending=False).head(15)
186
- else:
187
- plot_df = df[num].head(15)
188
- else: # hist
189
- plot_df = df[num].dropna()
190
- else: # line / scatter
191
- nums = df.select_dtypes(include="number").columns
192
- if len(nums) == 0:
193
- raise ValueError("Need numeric columns for line/scatter chart")
194
- plot_df = df[list(nums[:2])].dropna()
195
-
196
- # === Build animated figure ===================================================
197
- fig, ax = plt.subplots(figsize=(WIDTH / 100, HEIGHT / 100), dpi=100)
198
- fig.patch.set_facecolor('white')
199
- ax.set_facecolor('white')
200
-
201
- frames = max(24, min(60, int(duration * fps))) # 1-2.5 seconds of animation
202
-
203
- if chart_type == "pie":
204
- # Start with all wedges at 0 size, grow to full size
205
- def update(frame):
206
- ax.clear()
207
- ax.set_facecolor('white')
208
- progress = frame / (frames - 1) if frames > 1 else 1.0
209
- sizes = plot_df.values * progress
210
- if progress > 0:
211
- wedges, texts = ax.pie(sizes, labels=plot_df.index, startangle=90, autopct='%1.1f%%')
212
- ax.set_title(title, fontsize=16, pad=20)
213
- return ax.patches
214
-
215
- elif chart_type == "bar":
216
- # Bars grow from 0 to full height
217
- final_heights = plot_df.values if hasattr(plot_df, 'values') else plot_df
218
- x_pos = np.arange(len(final_heights))
219
- def update(frame):
220
- ax.clear()
221
- ax.set_facecolor('white')
222
- progress = frame / (frames - 1) if frames > 1 else 1.0
223
- heights = final_heights * progress
224
- bars = ax.bar(x_pos, heights, color="#1f77b4", alpha=0.8)
225
- ax.set_ylim(0, max(final_heights) * 1.1)
226
- ax.set_title(title, fontsize=16, pad=20)
227
- ax.set_xticks(x_pos)
228
- ax.set_xticklabels(plot_df.index if hasattr(plot_df, 'index') else range(len(final_heights)), rotation=45)
229
- ax.grid(True, alpha=0.3)
230
- return bars
231
-
232
- elif chart_type == "hist":
233
- # Histogram bars fade in
234
- def update(frame):
235
- ax.clear()
236
- ax.set_facecolor('white')
237
- progress = frame / (frames - 1) if frames > 1 else 1.0
238
- n, bins, patches = ax.hist(plot_df, bins=min(20, len(plot_df)//5), color="#1f77b4", alpha=progress*0.8)
239
- ax.set_title(title, fontsize=16, pad=20)
240
- ax.grid(True, alpha=0.3)
241
- return patches
242
-
243
- elif chart_type == "scatter":
244
- # Points appear progressively
245
- x_data = plot_df.iloc[:, 0]
246
- y_data = plot_df.iloc[:, 1] if plot_df.shape[1] > 1 else plot_df.iloc[:, 0]
247
- def update(frame):
248
- ax.clear()
249
- ax.set_facecolor('white')
250
- progress = frame / (frames - 1) if frames > 1 else 1.0
251
- n_points = max(1, int(len(x_data) * progress))
252
- ax.scatter(x_data[:n_points], y_data[:n_points], s=50, alpha=0.7, color="#1f77b4")
253
- ax.set_xlim(x_data.min() * 0.9, x_data.max() * 1.1)
254
- ax.set_ylim(y_data.min() * 0.9, y_data.max() * 1.1)
255
- ax.set_title(title, fontsize=16, pad=20)
256
- ax.grid(True, alpha=0.3)
257
- return ax.collections
258
-
259
- else: # line
260
- # Line draws progressively
261
- x_data = plot_df.iloc[:, 0] if plot_df.shape[1] > 1 else np.arange(len(plot_df))
262
- y_data = plot_df.iloc[:, 1] if plot_df.shape[1] > 1 else plot_df.iloc[:, 0]
263
- def update(frame):
264
- ax.clear()
265
- ax.set_facecolor('white')
266
- progress = frame / (frames - 1) if frames > 1 else 1.0
267
- n_points = max(2, int(len(x_data) * progress))
268
- ax.plot(x_data[:n_points], y_data[:n_points], lw=3, color="#1f77b4", marker='o', markersize=4)
269
- ax.set_xlim(x_data.min(), x_data.max())
270
- ax.set_ylim(y_data.min() * 0.9, y_data.max() * 1.1)
271
- ax.set_title(title, fontsize=16, pad=20)
272
- ax.grid(True, alpha=0.3)
273
- return ax.lines
274
-
275
- # Create animation
276
- plt.tight_layout()
277
- anim = FuncAnimation(fig, update, frames=frames, interval=1000/fps, blit=False, repeat=False)
278
-
279
- # Save with proper writer
280
- writer = FFMpegWriter(fps=fps, metadata={'artist': 'Sozo'}, bitrate=1800)
281
- anim.save(str(out_path), writer=writer, dpi=100)
282
-
283
- plt.close(fig)
284
- return str(out_path)
285
 
286
- except Exception as e:
287
- # Graceful fallback: create a static chart and animate it with fade-in
288
- try:
289
- plt.figure(figsize=(WIDTH / 100, HEIGHT / 100), dpi=100)
290
- plt.style.use('default')
291
-
292
- # Create a simple plot based on data
293
- if not df.empty:
294
- numeric_cols = df.select_dtypes(include='number').columns
295
- if len(numeric_cols) > 0:
296
- if len(numeric_cols) == 1:
297
- df[numeric_cols[0]].plot(kind='line', title=title or "Data Overview")
298
- else:
299
- df[numeric_cols[:2]].plot(kind='line', title=title or "Data Overview")
300
- else:
301
- # If no numeric columns, show value counts of first column
302
- df.iloc[:, 0].value_counts().head(10).plot(kind='bar', title=title or "Data Overview")
303
-
304
- plt.tight_layout()
305
  tmp_png = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.png"
306
- plt.savefig(tmp_png, bbox_inches="tight", facecolor='white', dpi=100)
307
- plt.close()
308
-
309
- # Convert to video with fade-in
310
- img = cv2.imread(str(tmp_png))
311
- if img is None:
312
- raise RuntimeError("Failed to load generated chart image")
313
- img = cv2.resize(img, (WIDTH, HEIGHT))
314
- result = animate_image_fade(img, duration, out_path, fps)
315
- tmp_png.unlink(missing_ok=True)
316
- return result
317
-
318
- except Exception as fallback_error:
319
- # Ultimate fallback: create placeholder
320
- plt.close('all')
321
- placeholder_img = np.full((HEIGHT, WIDTH, 3), 200, dtype=np.uint8)
322
- # Add text to placeholder
323
- cv2.putText(placeholder_img, "Chart Placeholder", (WIDTH//2-100, HEIGHT//2),
324
- cv2.FONT_HERSHEY_SIMPLEX, 1, (100, 100, 100), 2)
325
- return animate_image_fade(placeholder_img, duration, out_path, fps)
326
-
327
- def concat_media(inputs, output, kind="video"):
328
- """Concatenate video or audio files."""
329
  if not inputs:
330
- raise ValueError("No input files provided")
331
-
332
  lst = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.txt"
333
- try:
334
- with lst.open("w") as f:
335
- for p in inputs:
336
- if not Path(p).exists():
337
- raise FileNotFoundError(f"Input file not found: {p}")
338
  f.write(f"file '{Path(p).resolve()}'\n")
339
-
340
- cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(lst)]
341
- if kind == "video":
342
- cmd.extend(["-c:v", "libx264", "-c:a", "aac"])
343
- else:
344
- cmd.extend(["-c:a", "copy"])
345
- cmd.append(str(output))
346
-
347
- subprocess.run(cmd, check=True, capture_output=True)
348
- finally:
349
- lst.unlink(missing_ok=True)
350
-
351
- # ─────────────────────────────────────────────────────────────────────────────
352
- # IMAGE GENERATION (FIXED - using correct API pattern)
353
- # ─────────────────────────────────────────────────────────────────────────────
354
- def generate_image_from_prompt(prompt, style):
355
- """Generate image using Gemini API with proper error handling."""
356
- try:
357
- # Clean prompt text
358
- clean_prompt = clean_narrator_text(prompt)
359
- if not clean_prompt:
360
- clean_prompt = "Professional business presentation slide"
361
-
362
- full_prompt = (f"A professional, clean, illustrative image for a business presentation: "
363
- f"{clean_prompt}, in the style of {style}. High quality, clear, business appropriate.")
364
-
365
- response = GEM.generate_content(
366
- contents=[full_prompt],
367
- model="gemini-2.0-flash-exp",
368
- generation_config={
369
- "response_mime_type": "image/png",
370
- "max_output_tokens": 4096,
371
- },
372
- )
373
-
374
- if response and response.candidates and len(response.candidates) > 0:
375
- candidate = response.candidates[0]
376
- if candidate.content and candidate.content.parts:
377
- for part in candidate.content.parts:
378
- if hasattr(part, 'blob') and part.blob:
379
- img_bytes = part.blob.data
380
- return Image.open(io.BytesIO(img_bytes)).convert("RGB")
381
-
382
- # If we get here, the API call succeeded but didn't return image data
383
- st.warning("Image generation succeeded but no image data returned. Using placeholder.")
384
- return create_placeholder_image()
385
-
386
- except Exception as e:
387
- st.warning(f"Image generation failed: {str(e)[:100]}... Using placeholder.")
388
- return create_placeholder_image()
389
-
390
- def create_placeholder_image():
391
- """Create a professional-looking placeholder image."""
392
- img = Image.new("RGB", (WIDTH, HEIGHT), color=(240, 240, 245))
393
- # This would require PIL's ImageDraw, but keeping it simple
394
- return img
395
-
396
- # ─────────────────────────────────────────────────────────────────────────────
397
- # REPORT GENERATION (unchanged models – prompt now local)
398
- # ─────────────────────────────────────────────────────────────────────────────
399
  def generate_report_assets(key, buf, name, ctx):
400
  df, err = load_dataframe_safely(buf, name)
401
  if err:
402
- st.error(err)
403
- return None
404
 
405
  llm = ChatGoogleGenerativeAI(
406
  model="gemini-2.0-flash", google_api_key=API_KEY, temperature=0.1
407
  )
408
 
409
- # build context dict **after** df exists
410
  ctx_dict = {
411
- "shape": df.shape,
412
  "columns": list(df.columns),
413
  "user_ctx": ctx or "General business analysis",
414
  }
@@ -424,9 +392,10 @@ def generate_report_assets(key, buf, name, ctx):
424
 
425
  md = llm.invoke(report_prompt).content
426
 
427
- # ------------------------------------------------------------------ charts
428
  chart_descs = extract_chart_tags(md)[:MAX_CHARTS]
429
- charts = {}
 
430
  if chart_descs:
431
  agent = create_pandas_dataframe_agent(
432
  llm=llm, df=df, verbose=False, allow_dangerous_code=True
@@ -446,10 +415,8 @@ def generate_report_assets(key, buf, name, ctx):
446
  plt.close("all")
447
 
448
  preview = repl_tags(
449
- md,
450
- charts,
451
- lambda p: f'<img src="data:image/png;base64,'
452
- f'{base64.b64encode(Path(p).read_bytes()).decode()}">'
453
  )
454
  pdf = build_pdf(md, charts)
455
 
@@ -461,10 +428,11 @@ def generate_report_assets(key, buf, name, ctx):
461
  "key": key,
462
  }
463
 
464
- # ─────────────────────────────────────────────────────────────────────────────
465
- # VIDEO GENERATION (animated charts – improved error handling)
466
- # ─────────────────────────────────────────────────────────────────────────────
467
- def generate_video_assets(key, buf, name, ctx, style, animate_charts=True):
 
468
  try:
469
  subprocess.run(["ffmpeg", "-version"], check=True, capture_output=True)
470
  except Exception:
@@ -473,15 +441,14 @@ def generate_video_assets(key, buf, name, ctx, style, animate_charts=True):
473
 
474
  df, err = load_dataframe_safely(buf, name)
475
  if err:
476
- st.error(err)
477
- return None
478
 
479
  llm = ChatGoogleGenerativeAI(
480
  model="gemini-2.0-flash", google_api_key=API_KEY, temperature=0.2
481
  )
482
 
483
  ctx_dict = {
484
- "shape": df.shape,
485
  "columns": list(df.columns),
486
  "user_ctx": ctx or "General business analysis",
487
  }
@@ -489,139 +456,91 @@ def generate_video_assets(key, buf, name, ctx, style, animate_charts=True):
489
  story_prompt = (
490
  f"Create a script for a short business video with exactly {VIDEO_SCENES} scenes.\n"
491
  "For each scene:\n"
492
- "1. Provide 1–2 sentences of narration that flows naturally when spoken.\n"
493
- '2. If a visual is helpful, add <generate_chart: "bar | monthly revenue"> '
494
- "(chart_type first).\n"
495
  "3. Separate scenes with [SCENE_BREAK].\n"
496
- "Focus on clear, concise narration suitable for voice synthesis.\n"
497
  f"Data Context: {json.dumps(ctx_dict, indent=2)}"
498
  )
499
 
500
- script = llm.invoke(story_prompt).content
501
- scenes = [s.strip() for s in script.split("[SCENE_BREAK]") if s.strip()]
502
-
503
- video_parts, audio_parts, temps = [], [], []
504
- total_duration = 0
505
 
506
  for idx, scene in enumerate(scenes[:VIDEO_SCENES]):
507
  st.progress((idx + 1) / VIDEO_SCENES, text=f"Processing Scene {idx+1}/{VIDEO_SCENES}…")
508
 
509
  chart_tags = extract_chart_tags(scene)
510
- # Clean narrative text for TTS
511
- narrative = clean_narrator_text(scene)
512
-
513
- if not narrative:
514
- narrative = f"Scene {idx + 1} presents key business insights from our data analysis."
515
 
516
- # ---------------- audio -------------------------------------------
517
  audio_bytes, _ = deepgram_tts(narrative)
518
- audio_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp3"
519
-
520
  if audio_bytes:
521
  audio_path.write_bytes(audio_bytes)
522
  duration = get_audio_duration(str(audio_path))
523
  else:
524
- # Create silent audio as fallback
525
  duration = 5.0
526
- # Create a short silent audio file
527
- silent_audio = np.zeros(int(duration * 22050), dtype=np.int16)
528
- with wave.open(str(audio_path), 'w') as wav_file:
529
- wav_file.setnchannels(1)
530
- wav_file.setsampwidth(2)
531
- wav_file.setframerate(22050)
532
- wav_file.writeframes(silent_audio.tobytes())
533
-
534
- audio_parts.append(str(audio_path))
535
- temps.append(audio_path)
536
- total_duration += duration
537
-
538
- # ---------------- visual ------------------------------------------
539
  clip_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp4"
540
-
541
- try:
542
- if chart_tags and animate_charts:
543
- animate_chart(chart_tags[0], df, duration, clip_path, FPS)
544
- else:
545
- # Generate image and animate it
546
- img = generate_image_from_prompt(narrative, style)
547
- img_resized = img.resize((WIDTH, HEIGHT))
548
- img_cv2 = cv2.cvtColor(np.array(img_resized), cv2.COLOR_RGB2BGR)
549
- animate_image_fade(img_cv2, duration, clip_path, FPS)
550
-
551
- video_parts.append(str(clip_path))
552
- temps.append(clip_path)
553
-
554
- except Exception as e:
555
- st.warning(f"Scene {idx+1} visual generation failed: {str(e)[:50]}... Using placeholder.")
556
- # Create placeholder video
557
- placeholder_img = np.full((HEIGHT, WIDTH, 3), 200, dtype=np.uint8)
558
- cv2.putText(placeholder_img, f"Scene {idx+1}", (WIDTH//2-50, HEIGHT//2),
559
- cv2.FONT_HERSHEY_SIMPLEX, 1, (100, 100, 100), 2)
560
- animate_image_fade(placeholder_img, duration, clip_path, FPS)
561
- video_parts.append(str(clip_path))
562
- temps.append(clip_path)
563
-
564
- # -------------- concatenate ----------------------------------------------
565
- try:
566
- if not video_parts or not audio_parts:
567
- st.error("No video or audio parts generated.")
568
- return None
569
-
570
- silent_vid = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp4"
571
- concat_media(video_parts, silent_vid, "video")
572
-
573
- audio_mix = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp3"
574
- concat_media(audio_parts, audio_mix, "audio")
575
-
576
- final_vid = Path(tempfile.gettempdir()) / f"{key}.mp4"
577
-
578
- # Combine video and audio
579
- subprocess.run([
580
- "ffmpeg", "-y",
581
- "-i", str(silent_vid),
582
- "-i", str(audio_mix),
583
- "-c:v", "libx264",
584
- "-c:a", "aac",
585
- "-shortest",
586
- "-b:v", "1000k",
587
- "-b:a", "128k",
588
- str(final_vid),
589
- ], check=True, capture_output=True)
590
-
591
- # Clean up temp files
592
- for temp_file in temps:
593
- Path(temp_file).unlink(missing_ok=True)
594
- silent_vid.unlink(missing_ok=True)
595
- audio_mix.unlink(missing_ok=True)
596
-
597
- return {"type": "video", "video_path": str(final_vid), "key": key}
598
-
599
- except Exception as e:
600
- st.error(f"Video generation failed during final assembly: {str(e)}")
601
- # Still try to return something if possible
602
- if video_parts:
603
- return {"type": "video", "video_path": str(video_parts[0]), "key": key}
604
- return None
605
 
606
- # ─────────────────────────────────────────────────────────────────────────────
607
- # ─────────────────────────────────────────────────────────────────────────────
608
  # UI
609
- # ─────────────────────────────────────────────────────────────────────────────
610
  mode = st.radio("Select Output Format:", ["Report (PDF)", "Video Narrative"], horizontal=True)
611
-
612
  video_style, animate_charts_flag = "professional illustration", True
 
613
  if mode == "Video Narrative":
614
  with st.sidebar:
615
  st.subheader("🎬 Video Options")
616
  video_style = st.selectbox(
617
  "Visual Style",
618
- ["professional illustration", "minimalist infographic",
619
- "photorealistic", "cinematic", "data visualization aesthetic"]
620
  )
621
  animate_charts_flag = st.toggle("Animate Charts", value=True)
622
  st.caption("Disable to use static slides with a simple fade-in.")
623
 
624
  upl = st.file_uploader("Upload CSV or Excel", type=["csv", "xlsx", "xls"])
 
625
  if upl:
626
  df_sample, _ = load_dataframe_safely(upl.getvalue(), upl.name)
627
  with st.expander("📊 Data Preview"):
@@ -632,39 +551,44 @@ ctx = st.text_area("Business context or specific instructions (optional)")
632
  if st.button("🚀 Generate", type="primary"):
633
  if not upl:
634
  st.warning("Please upload a file first."); st.stop()
 
635
  bkey = sha1_bytes(b"".join([
636
  upl.getvalue(), mode.encode(), ctx.encode(),
637
  video_style.encode(), str(animate_charts_flag).encode()
638
  ]))
 
639
  if mode == "Report (PDF)":
640
  with st.spinner("Generating report…"):
641
  st.session_state.bundle = generate_report_assets(bkey, upl.getvalue(), upl.name, ctx)
642
  else:
643
  st.session_state.bundle = generate_video_assets(
644
- bkey, upl.getvalue(), upl.name, ctx,
645
- video_style, animate_charts_flag
646
  )
647
  st.rerun()
648
 
649
- # ─────────────────────────────────────────────────────────────────────────────
650
  # OUTPUT
651
- # ─────────────────────────────────────────────────────────────────────────────
652
  if st.session_state.get("bundle"):
653
  bundle = st.session_state.bundle
 
654
  if bundle.get("type") == "report":
655
  st.subheader("📄 Generated Report")
656
  with st.expander("View Report", expanded=True):
657
  st.markdown(bundle["preview"], unsafe_allow_html=True)
658
- c1, c2 = st.columns(2)
659
- with c1:
660
- st.download_button("Download PDF", bundle["pdf"],
661
- "business_report.pdf", "application/pdf",
662
- use_container_width=True)
663
- with c2:
664
- if DG_KEY and st.button("🔊 Narrate Summary", use_container_width=True):
665
- txt = re.sub(r"<[^>]+>", "", bundle["report_md"])
666
- audio, mime = deepgram_tts(txt)
667
- st.audio(audio, format=mime) if audio else st.error("Narration failed.")
 
 
 
668
  elif bundle.get("type") == "video":
669
  st.subheader("🎬 Generated Video Narrative")
670
  vp = bundle["video_path"]
@@ -672,7 +596,9 @@ if st.session_state.get("bundle"):
672
  with open(vp, "rb") as f:
673
  st.video(f.read())
674
  with open(vp, "rb") as f:
675
- st.download_button("Download Video", f,
676
- f"sozo_narrative_{bundle['key'][:8]}.mp4", "video/mp4")
 
 
677
  else:
678
  st.error("Video file missing – generation failed.")
 
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
 
27
  from fpdf import FPDF, HTMLMixin
28
  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:
 
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:
 
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",
 
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",
 
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
  )
 
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
  }
 
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
 
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
 
 
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:
 
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
  }
 
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"):
 
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"]
 
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.")