Fix broll temp asset handling and crossfade precision

This commit is contained in:
ي
2026-04-20 08:37:20 +05:30
parent ba9ddbf368
commit 7e4cc51086
2 changed files with 31 additions and 36 deletions

View File

@@ -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")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -302,28 +304,6 @@ def make_stacked_bar(data: dict, out_path: str, title: str = "",
return out_path return out_path
def make_line_trend(data: dict, out_path: str, title: str = "") -> str:
"""Render a trend line chart. Returns output path."""
x_vals = data.get("x", [])
y_vals = data.get("y", [])
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
ax.set_facecolor("none")
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
linewidth=2.5, marker="o", markersize=7, zorder=3)
ax.fill_between(x_vals, y_vals, alpha=0.12, color=CHART_STYLE["accent"])
ax.spines[:].set_visible(False)
ax.tick_params(colors=CHART_STYLE["text"])
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
if title:
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
fontweight="bold", pad=12)
fig.tight_layout(pad=0.5)
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
plt.close(fig)
return out_path
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Text / Bullet overlay (Pillow → PNG) # Text / Bullet overlay (Pillow → PNG)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -420,7 +400,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
""" """
@@ -444,7 +424,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)
@@ -463,7 +443,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
""" """
@@ -477,7 +457,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)
@@ -507,15 +487,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_chart_comparison", "line_trend"): elif cue in ("bar_chart_comparison", "line_trend"):
chart_path = "/tmp/chart.png" chart_path = str(scene_temp_dir / f"chart_{uuid.uuid4().hex}.png")
if cue == "bar_chart_comparison": if cue == "bar_chart_comparison":
make_bar_chart(insight.chart_data, chart_path, make_bar_chart(insight.chart_data, chart_path,
title=insight.key_insight) title=insight.key_insight)
@@ -523,14 +508,14 @@ def dispatch_scene(insight: Insight, assets: SceneAssets,
make_line_trend(insight.chart_data, chart_path, make_line_trend(insight.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 +556,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 +607,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.")

View File

@@ -17,6 +17,10 @@ from loguru import logger
# Import chart generators directly # Import chart generators directly
from services.podcast.broll_composer import ( from services.podcast.broll_composer import (
Insight,
SceneAssets,
dispatch_scene,
compose_video,
make_bar_chart, make_bar_chart,
make_horizontal_bar, make_horizontal_bar,
make_line_trend, make_line_trend,
@@ -146,9 +150,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)
@@ -250,4 +258,4 @@ def get_broll_service(output_dir: Optional[str] = None) -> BrollService:
global _broll_service_instance global _broll_service_instance
if _broll_service_instance is None: if _broll_service_instance is None:
_broll_service_instance = BrollService(output_dir=output_dir) _broll_service_instance = BrollService(output_dir=output_dir)
return _broll_service_instance return _broll_service_instance