diff --git a/backend/services/podcast/broll_composer.py b/backend/services/podcast/broll_composer.py index 7f10e2dd..988b2251 100644 --- a/backend/services/podcast/broll_composer.py +++ b/backend/services/podcast/broll_composer.py @@ -4,6 +4,8 @@ Layered composition pipeline: Background + Chart + Avatar Circle + Text Overlays """ import json +import tempfile +import uuid import numpy as np from pathlib import Path from dataclasses import dataclass, field @@ -40,7 +42,7 @@ def crossfade_concat(scenes: list, fade_dur: float = 0.5): if i > 0: c = c.fx(vfx.CrossFadeIn, fade_dur) faded.append(c) - return concatenate_videoclips(faded, padding=-int(fade_dur), method="compose") + return concatenate_videoclips(faded, padding=-fade_dur, method="compose") # --------------------------------------------------------------------------- @@ -305,8 +307,6 @@ def make_line_trend(data: dict, out_path: str, title: str = "") -> str: fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight") plt.close(fig) return out_path - - # --------------------------------------------------------------------------- # Text / Bullet overlay (Pillow → PNG) # --------------------------------------------------------------------------- @@ -403,7 +403,7 @@ def ken_burns(clip: ImageClip, zoom_ratio: float = 0.08) -> ImageClip: # Scene builders (one per visual_cue type) # --------------------------------------------------------------------------- -def build_data_scene(assets: SceneAssets, insight: Insight) -> CompositeVideoClip: +def build_data_scene(assets: SceneAssets, insight: Insight, temp_dir: Path) -> CompositeVideoClip: """ Layout: Background (Ken Burns) + Chart (fade-in) + Avatar circle (corner) + Insight card """ @@ -427,7 +427,7 @@ def build_data_scene(assets: SceneAssets, insight: Insight) -> CompositeVideoCli .fx(vfx.fadeout, 0.4)) layers.append(chart) - card_path = "/tmp/insight_card.png" + card_path = str(temp_dir / f"insight_card_{uuid.uuid4().hex}.png") make_insight_card(insight.key_insight, insight.supporting_stat, card_path) card = (ImageClip(card_path) .set_duration(d - 1) @@ -446,7 +446,7 @@ def build_data_scene(assets: SceneAssets, insight: Insight) -> CompositeVideoCli def build_bullet_scene(assets: SceneAssets, insight: Insight, - bullets: list[str]) -> CompositeVideoClip: + bullets: list[str], temp_dir: Path) -> CompositeVideoClip: """ Layout: AI image (Ken Burns) + Bullet overlay + Avatar circle """ @@ -460,7 +460,7 @@ def build_bullet_scene(assets: SceneAssets, insight: Insight, bg = bg.fx(vfx.lum_contrast, 0, -50) layers.append(bg) - bullet_path = "/tmp/bullets.png" + bullet_path = str(temp_dir / f"bullets_{uuid.uuid4().hex}.png") make_bullet_overlay(bullets, bullet_path, width=860) bullets_clip = (ImageClip(bullet_path) .set_duration(d - 1) @@ -490,15 +490,20 @@ def build_full_avatar_scene(assets: SceneAssets, insight: Insight) -> VideoFileC # --------------------------------------------------------------------------- def dispatch_scene(insight: Insight, assets: SceneAssets, - bullet_lines: Optional[list[str]] = None): + bullet_lines: Optional[list[str]] = None, + temp_dir: Optional[str | Path] = None): """Dispatch scene based on visual_cue type.""" cue = insight.visual_cue + scene_temp_dir = Path(temp_dir) if temp_dir else Path( + tempfile.mkdtemp(prefix=f"broll_{cue}_") + ) + scene_temp_dir.mkdir(parents=True, exist_ok=True) if cue == "full_avatar": return build_full_avatar_scene(assets, insight) elif cue in ("bar_comparison", "bar_chart_comparison", "bar_horizontal", "line_trend", "pie", "stacked_bar"): - chart_path = "/tmp/chart.png" + chart_path = str(scene_temp_dir / f"chart_{uuid.uuid4().hex}.png") chart_data = insight.chart_data or {} if cue in ("bar_comparison", "bar_chart_comparison"): # Normalize {labels, values} -> {labels, before, after} for make_bar_chart @@ -523,14 +528,14 @@ def dispatch_scene(insight: Insight, assets: SceneAssets, make_stacked_bar(chart_data, chart_path, title=insight.key_insight) assets.chart_img = chart_path - return build_data_scene(assets, insight) + return build_data_scene(assets, insight, scene_temp_dir) elif cue == "bullet_points": lines = bullet_lines or [insight.key_insight, insight.supporting_stat] - return build_bullet_scene(assets, insight, lines) + return build_bullet_scene(assets, insight, lines, scene_temp_dir) else: - return build_data_scene(assets, insight) + return build_data_scene(assets, insight, scene_temp_dir) # --------------------------------------------------------------------------- @@ -571,8 +576,10 @@ def pipeline_from_json(insight_json: str, data = json.loads(insight_json) insight = Insight(**{k: data[k] for k in Insight.__dataclass_fields__ if k in data}) assets = SceneAssets(background_img=background_img, avatar_video=avatar_video) + scene_temp_dir = Path(tempfile.mkdtemp(prefix=f"scene_{insight.visual_cue}_")) scene = dispatch_scene(insight, assets, - bullet_lines=data.get("bullet_lines")) + bullet_lines=data.get("bullet_lines"), + temp_dir=scene_temp_dir) out = f"/tmp/scene_{insight.visual_cue}.mp4" compose_video([scene], output_path=out) return out @@ -620,4 +627,4 @@ if __name__ == "__main__": }) print("\nSample Insight JSON:\n", sample_json) print("\nAll asset generation tests passed.") - print("To run full video composition, supply real background_img and avatar_video paths.") \ No newline at end of file + print("To run full video composition, supply real background_img and avatar_video paths.") diff --git a/backend/services/podcast/broll_service.py b/backend/services/podcast/broll_service.py index 3d19ca8f..744329ae 100644 --- a/backend/services/podcast/broll_service.py +++ b/backend/services/podcast/broll_service.py @@ -139,9 +139,13 @@ class BrollService: background_img=background_img_path, avatar_video=avatar_video_path, ) + scene_temp_dir = self.get_output_path( + f"scene_assets_{scene_id_safe}_{uuid.uuid4().hex[:8]}" + ) + scene_temp_dir.mkdir(parents=True, exist_ok=True) # Generate the scene - scene = dispatch_scene(insight, assets) + scene = dispatch_scene(insight, assets, temp_dir=scene_temp_dir) # Write video compose_video([scene], output_path=out_path)