Merge branch 'pr-483'
# Conflicts: # backend/services/podcast/broll_composer.py # backend/services/podcast/broll_service.py
This commit is contained in:
@@ -4,6 +4,8 @@ Layered composition pipeline: Background + Chart + Avatar Circle + Text Overlays
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -40,7 +42,7 @@ def crossfade_concat(scenes: list, fade_dur: float = 0.5):
|
|||||||
if i > 0:
|
if i > 0:
|
||||||
c = c.fx(vfx.CrossFadeIn, fade_dur)
|
c = c.fx(vfx.CrossFadeIn, fade_dur)
|
||||||
faded.append(c)
|
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")
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
return out_path
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Text / Bullet overlay (Pillow → PNG)
|
# 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)
|
# 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
|
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))
|
.fx(vfx.fadeout, 0.4))
|
||||||
layers.append(chart)
|
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)
|
make_insight_card(insight.key_insight, insight.supporting_stat, card_path)
|
||||||
card = (ImageClip(card_path)
|
card = (ImageClip(card_path)
|
||||||
.set_duration(d - 1)
|
.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,
|
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
|
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)
|
bg = bg.fx(vfx.lum_contrast, 0, -50)
|
||||||
layers.append(bg)
|
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)
|
make_bullet_overlay(bullets, bullet_path, width=860)
|
||||||
bullets_clip = (ImageClip(bullet_path)
|
bullets_clip = (ImageClip(bullet_path)
|
||||||
.set_duration(d - 1)
|
.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,
|
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."""
|
"""Dispatch scene based on visual_cue type."""
|
||||||
cue = insight.visual_cue
|
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":
|
if cue == "full_avatar":
|
||||||
return build_full_avatar_scene(assets, insight)
|
return build_full_avatar_scene(assets, insight)
|
||||||
|
|
||||||
elif cue in ("bar_comparison", "bar_chart_comparison", "bar_horizontal", "line_trend", "pie", "stacked_bar"):
|
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 {}
|
chart_data = insight.chart_data or {}
|
||||||
if cue in ("bar_comparison", "bar_chart_comparison"):
|
if cue in ("bar_comparison", "bar_chart_comparison"):
|
||||||
# Normalize {labels, values} -> {labels, before, after} for make_bar_chart
|
# 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,
|
make_stacked_bar(chart_data, chart_path,
|
||||||
title=insight.key_insight)
|
title=insight.key_insight)
|
||||||
assets.chart_img = chart_path
|
assets.chart_img = chart_path
|
||||||
return build_data_scene(assets, insight)
|
return build_data_scene(assets, insight, scene_temp_dir)
|
||||||
|
|
||||||
elif cue == "bullet_points":
|
elif cue == "bullet_points":
|
||||||
lines = bullet_lines or [insight.key_insight, insight.supporting_stat]
|
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:
|
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)
|
data = json.loads(insight_json)
|
||||||
insight = Insight(**{k: data[k] for k in Insight.__dataclass_fields__ if k in data})
|
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)
|
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,
|
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"
|
out = f"/tmp/scene_{insight.visual_cue}.mp4"
|
||||||
compose_video([scene], output_path=out)
|
compose_video([scene], output_path=out)
|
||||||
return out
|
return out
|
||||||
@@ -620,4 +627,4 @@ if __name__ == "__main__":
|
|||||||
})
|
})
|
||||||
print("\nSample Insight JSON:\n", sample_json)
|
print("\nSample Insight JSON:\n", sample_json)
|
||||||
print("\nAll asset generation tests passed.")
|
print("\nAll asset generation tests passed.")
|
||||||
print("To run full video composition, supply real background_img and avatar_video paths.")
|
print("To run full video composition, supply real background_img and avatar_video paths.")
|
||||||
|
|||||||
@@ -139,9 +139,13 @@ class BrollService:
|
|||||||
background_img=background_img_path,
|
background_img=background_img_path,
|
||||||
avatar_video=avatar_video_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
|
# Generate the scene
|
||||||
scene = dispatch_scene(insight, assets)
|
scene = dispatch_scene(insight, assets, temp_dir=scene_temp_dir)
|
||||||
|
|
||||||
# Write video
|
# Write video
|
||||||
compose_video([scene], output_path=out_path)
|
compose_video([scene], output_path=out_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user