rairo commited on
Commit
f7a13b6
Β·
verified Β·
1 Parent(s): 1196ed8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +299 -114
app.py CHANGED
@@ -99,11 +99,23 @@ 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
 
108
  # ─────────────────────────────────────────────────────────────────────────────
109
  # PDF GENERATION (UNCHANGED)
@@ -123,13 +135,20 @@ def build_pdf(md, charts):
123
  # GENERIC ANIMATION HELPERS (VIDEO PATH ONLY)
124
  # ─────────────────────────────────────────────────────────────────────────────
125
  def animate_image_fade(img_cv2: np.ndarray, duration: float, out_path: Path, fps: int = FPS) -> str:
 
126
  frames = max(int(duration * fps), fps) # at least 1 second
127
- video = cv2.VideoWriter(str(out_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (WIDTH, HEIGHT))
128
- blank = np.full_like(img_cv2, 255)
 
 
 
 
 
129
  for i in range(frames):
130
- alpha = i / frames
131
  frame = cv2.addWeighted(blank, 1 - alpha, img_cv2, alpha, 0)
132
  video.write(frame)
 
133
  video.release()
134
  return str(out_path)
135
 
@@ -138,113 +157,241 @@ def animate_chart(desc: str, df: pd.DataFrame, duration: float,
138
  """
139
  desc -> 'chart_type | explanation'
140
  Valid chart_type: line, bar, scatter, pie, hist
141
- Builds an accurate static figure, then animates a quick reveal (≀30 frames).
142
  """
143
  try:
144
  chart_type, *rest = [s.strip().lower() for s in desc.split("|", 1)]
145
  chart_type = chart_type or "line"
146
- title = rest[0] if rest else desc
147
 
148
  # === Prepare aggregated data =================================================
149
  if chart_type == "pie":
150
- cat = df.select_dtypes(exclude="number").columns[0]
151
- num = df.select_dtypes(include="number").columns[0]
 
 
 
 
152
  plot_df = df.groupby(cat)[num].sum().sort_values(ascending=False).head(8)
153
  elif chart_type in ("bar", "hist"):
154
- num = df.select_dtypes(include="number").columns[0]
155
- plot_df = df[num]
 
 
 
 
 
 
 
 
 
 
 
156
  else: # line / scatter
157
- nums = df.select_dtypes(include="number").columns[:2]
158
- plot_df = df[list(nums)].sort_index()
 
 
159
 
160
- # === Build figure ============================================================
161
  fig, ax = plt.subplots(figsize=(WIDTH / 100, HEIGHT / 100), dpi=100)
162
- frames = max(10, min(30, int(duration * fps)))
 
 
 
163
 
164
  if chart_type == "pie":
165
- wedges, _ = ax.pie(plot_df, labels=plot_df.index, startangle=90)
166
- ax.set_title(title)
167
- for w in wedges: w.set_alpha(0) # start invisible
168
- def update(i):
169
- alpha = i / frames
170
- for w in wedges: w.set_alpha(alpha)
 
 
 
 
171
 
172
  elif chart_type == "bar":
173
- bars = ax.bar(plot_df.index, np.zeros_like(plot_df.values), color="#1f77b4")
174
- ax.set_ylim(0, plot_df.max()*1.1); ax.set_title(title)
175
- def update(i):
176
- frac = i / frames
177
- for b, h in zip(bars, plot_df.values):
178
- b.set_height(h * frac)
 
 
 
 
 
 
 
 
 
179
 
180
  elif chart_type == "hist":
181
- n, bins, patches = ax.hist(plot_df, bins=20, color="#1f77b4", alpha=0)
182
- ax.set_title(title)
183
- def update(i):
184
- alpha = i / frames
185
- for p in patches: p.set_alpha(alpha)
 
 
 
 
186
 
187
  elif chart_type == "scatter":
188
- pts = ax.scatter(plot_df.iloc[:,0], plot_df.iloc[:,1], s=10, alpha=0)
189
- ax.set_title(title); ax.grid(alpha=0.3)
190
- def update(i): pts.set_alpha(i / frames)
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  else: # line
193
- line, = ax.plot([], [], lw=2)
194
- x_full = plot_df.iloc[:,0] if chart_type == "line" and plot_df.shape[1] > 1 else np.arange(len(plot_df))
195
- y_full = plot_df.iloc[:,1] if plot_df.shape[1] > 1 else plot_df.iloc[:,0]
196
- ax.set_xlim(x_full.min(), x_full.max()); ax.set_ylim(y_full.min(), y_full.max())
197
- ax.set_title(title); ax.grid(alpha=0.3)
198
- def update(i):
199
- k = max(2, int(len(x_full) * i / frames))
200
- line.set_data(x_full[:k], y_full.iloc[:k])
201
- return line,
202
-
203
- anim = FuncAnimation(fig, update, frames=frames, blit=False)
204
- anim.save(str(out_path),
205
- writer=FFMpegWriter(fps=fps, metadata={'artist':'Sozo'}), dpi=144)
 
 
 
 
 
 
 
 
 
 
206
  plt.close(fig)
207
  return str(out_path)
208
 
209
  except Exception as e:
210
- # graceful fallback: fade-in static auto-plot
211
- with plt.ioff():
212
- df.plot(ax=plt.gca())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  tmp_png = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.png"
214
- plt.savefig(tmp_png, bbox_inches="tight"); plt.close()
215
- img = cv2.resize(cv2.imread(str(tmp_png)), (WIDTH, HEIGHT))
216
- return animate_image_fade(img, duration, out_path, fps)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
  def concat_media(inputs, output, kind="video"):
 
 
 
 
219
  lst = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.txt"
220
- with lst.open("w") as f:
221
- for p in inputs:
222
- f.write(f"file '{Path(p).resolve()}'\n")
223
- subprocess.run(
224
- ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(lst),
225
- "-c:v" if kind == "video" else "-c:a", "copy", str(output)],
226
- check=True, capture_output=True
227
- )
228
- lst.unlink(missing_ok=True)
 
 
 
 
 
 
 
 
229
 
230
  # ─────────────────────────────────────────────────────────────────────────────
231
- # IMAGE GENERATION (keeps original Client.generate_content call)
232
  # ─────────────────────────────────────────────────────────────────────────────
233
  def generate_image_from_prompt(prompt, style):
 
234
  try:
 
 
 
 
 
235
  full_prompt = (f"A professional, clean, illustrative image for a business presentation: "
236
- f"{prompt}, in the style of {style}.")
 
237
  response = GEM.generate_content(
238
- contents=full_prompt,
239
- model="gemini-2.0-flash-exp-image-generation",
240
- generation_config={"response_mime_type": "image/png"},
 
 
 
241
  )
242
- img_bytes = response.parts[0].blob.data
243
- return Image.open(io.BytesIO(img_bytes)).convert("RGB")
 
 
 
 
 
 
 
 
 
 
 
244
  except Exception as e:
245
- st.warning(f"Illustrative image generation failed: {e}. Using placeholder.")
246
- return Image.new("RGB", (WIDTH, HEIGHT), color=(230, 230, 230))
247
 
 
 
 
 
 
248
 
249
  # ─────────────────────────────────────────────────────────────────────────────
250
  # REPORT GENERATION (unchanged models – prompt now local)
@@ -314,9 +461,8 @@ def generate_report_assets(key, buf, name, ctx):
314
  "key": key,
315
  }
316
 
317
-
318
  # ─────────────────────────────────────────────────────────────────────────────
319
- # VIDEO GENERATION (animated charts – prompt now local)
320
  # ─────────────────────────────────────────────────────────────────────────────
321
  def generate_video_assets(key, buf, name, ctx, style, animate_charts=True):
322
  try:
@@ -343,10 +489,11 @@ def generate_video_assets(key, buf, name, ctx, style, animate_charts=True):
343
  story_prompt = (
344
  f"Create a script for a short business video with exactly {VIDEO_SCENES} scenes.\n"
345
  "For each scene:\n"
346
- "1. Provide 1–2 sentences of narration.\n"
347
  '2. If a visual is helpful, add <generate_chart: "bar | monthly revenue"> '
348
  "(chart_type first).\n"
349
  "3. Separate scenes with [SCENE_BREAK].\n"
 
350
  f"Data Context: {json.dumps(ctx_dict, indent=2)}"
351
  )
352
 
@@ -354,71 +501,109 @@ def generate_video_assets(key, buf, name, ctx, style, animate_charts=True):
354
  scenes = [s.strip() for s in script.split("[SCENE_BREAK]") if s.strip()]
355
 
356
  video_parts, audio_parts, temps = [], [], []
 
357
 
358
  for idx, scene in enumerate(scenes[:VIDEO_SCENES]):
359
  st.progress((idx + 1) / VIDEO_SCENES, text=f"Processing Scene {idx+1}/{VIDEO_SCENES}…")
360
 
361
  chart_tags = extract_chart_tags(scene)
362
- narrative = repl_tags(scene, {}, lambda _: "").strip()
 
 
 
 
363
 
364
  # ---------------- audio -------------------------------------------
365
  audio_bytes, _ = deepgram_tts(narrative)
366
  audio_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp3"
 
367
  if audio_bytes:
368
  audio_path.write_bytes(audio_bytes)
369
- duration = get_audio_duration(str(audio_path)) if audio_bytes else 5.0
 
 
 
 
 
 
 
 
 
 
 
370
  audio_parts.append(str(audio_path))
371
  temps.append(audio_path)
 
372
 
373
  # ---------------- visual ------------------------------------------
374
- if chart_tags and animate_charts:
375
- clip_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp4"
376
- animate_chart(chart_tags[0], df, duration, clip_path, FPS)
 
 
 
 
 
 
 
 
 
377
  video_parts.append(str(clip_path))
378
  temps.append(clip_path)
379
- else:
380
- img = generate_image_from_prompt(narrative, style)
381
- png_tmp = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.png"
382
- img.save(png_tmp)
383
- temps.append(png_tmp)
384
- clip_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp4"
385
- animate_image_fade(
386
- cv2.cvtColor(np.array(img.resize((WIDTH, HEIGHT))), cv2.COLOR_RGB2BGR),
387
- duration,
388
- clip_path,
389
- FPS,
390
- )
391
  video_parts.append(str(clip_path))
392
  temps.append(clip_path)
393
 
394
  # -------------- concatenate ----------------------------------------------
395
- silent_vid = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp4"
396
- concat_media(video_parts, silent_vid, "video")
397
- audio_mix = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}.mp3"
398
- concat_media(audio_parts, audio_mix, "audio")
399
-
400
- final_vid = Path(tempfile.gettempdir()) / f"{key}.mp4"
401
- subprocess.run(
402
- [
403
- "ffmpeg",
404
- "-y",
405
- "-i",
406
- str(silent_vid),
407
- "-i",
408
- str(audio_mix),
409
- "-c:v",
410
- "copy",
411
- "-c:a",
412
- "aac",
 
 
413
  "-shortest",
 
 
414
  str(final_vid),
415
- ],
416
- check=True,
417
- capture_output=True,
418
- )
 
 
 
419
 
420
- return {"type": "video", "video_path": str(final_vid), "key": key}
 
 
 
 
 
 
 
421
 
 
422
  # ─────────────────────────────────────────────────────────────────────────────
423
  # UI
424
  # ─────────────────────────────────────────────────────────────────────────────
 
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)
 
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
 
 
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)
 
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:
 
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
 
 
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
  # ─────────────────────────────────────────────────────────────────────────────