feat(phase-4): UI/UX improvements for Podcast Maker Write phase
Frontend Changes: - Add scene numbering badge (1/N) next to scene titles - Add inline status chips (Complete, Audio, Image, Voice, Why Script) - Professional AI-like gradient styling for all chips with shadows - Remove Script Editor header and 'Why This Script Format?' collapsible - Move Voice and Why Script info to per-scene chips - Make scene section mobile-responsive (responsive layout, button sizing) - Rename 'B-Roll Charts' to 'Podcast Charts' with accordion (collapsed by default) - Add sceneIndex prop to SceneEditor for scene numbering - Enhanced accessibility with keyboard navigation and focus states Backend Changes: - Audio handler improvements - B-roll handler enhancements - Script handler updates - B-roll composer and service improvements - Removed temporary broll_temp files Technical: - Full mobile responsiveness for scene cards - Gradient chip styling: vibrant colors with white text and shadows - Non-breaking approval/generation flow preserved - TypeScript compatibility maintained
This commit is contained in:
@@ -51,7 +51,7 @@ def crossfade_concat(scenes: list, fade_dur: float = 0.5):
|
||||
class Insight:
|
||||
key_insight: str
|
||||
supporting_stat: str
|
||||
visual_cue: str # bar_chart_comparison | line_trend | bullet_points | full_avatar
|
||||
visual_cue: str # bar_comparison|bar_horizontal|line_trend|pie|stacked_bar|bullet_points|full_avatar
|
||||
audio_tone: str
|
||||
chart_data: dict = field(default_factory=dict)
|
||||
duration: float = 10.0
|
||||
@@ -173,39 +173,6 @@ def make_horizontal_bar(data: dict, out_path: str, title: str = "",
|
||||
return out_path
|
||||
|
||||
|
||||
def make_line_trend(data: dict, out_path: str, title: str = "",
|
||||
show_area: bool = True, show_markers: bool = True) -> str:
|
||||
"""Render a trend line chart."""
|
||||
x_vals = data.get("x", [])
|
||||
y_vals = data.get("y", [])
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||
ax.set_facecolor("none")
|
||||
|
||||
line_style = data.get("line_style", "-")
|
||||
line_width = data.get("line_width", 2.5)
|
||||
|
||||
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
|
||||
linewidth=line_width, linestyle=line_style,
|
||||
marker="o" if show_markers else None, markersize=7, zorder=3)
|
||||
|
||||
if show_area:
|
||||
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
|
||||
|
||||
|
||||
def make_pie_chart(data: dict, out_path: str, title: str = "",
|
||||
show_labels: bool = True, show_percent: bool = True,
|
||||
donut: bool = False) -> str:
|
||||
@@ -304,17 +271,33 @@ def make_stacked_bar(data: dict, out_path: str, title: str = "",
|
||||
|
||||
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", [])
|
||||
x_labels = data.get("labels", data.get("x", []))
|
||||
y_vals = data.get("values", data.get("y", []))
|
||||
|
||||
if not x_labels or not y_vals:
|
||||
return ""
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||
ax.set_facecolor("none")
|
||||
|
||||
try:
|
||||
x_vals = [float(v) for v in x_labels]
|
||||
except (ValueError, TypeError):
|
||||
x_vals = list(range(len(x_labels)))
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
x_labels_f = [float(v) for v in x_labels]
|
||||
except (ValueError, TypeError):
|
||||
ax.set_xticks(x_vals)
|
||||
ax.set_xticklabels(x_labels, color=CHART_STYLE["text"], fontsize=10)
|
||||
|
||||
if title:
|
||||
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||
fontweight="bold", pad=12)
|
||||
@@ -514,14 +497,31 @@ def dispatch_scene(insight: Insight, assets: SceneAssets,
|
||||
if cue == "full_avatar":
|
||||
return build_full_avatar_scene(assets, insight)
|
||||
|
||||
elif cue in ("bar_chart_comparison", "line_trend"):
|
||||
elif cue in ("bar_comparison", "bar_chart_comparison", "bar_horizontal", "line_trend", "pie", "stacked_bar"):
|
||||
chart_path = "/tmp/chart.png"
|
||||
if cue == "bar_chart_comparison":
|
||||
make_bar_chart(insight.chart_data, chart_path,
|
||||
chart_data = insight.chart_data or {}
|
||||
if cue in ("bar_comparison", "bar_chart_comparison"):
|
||||
# Normalize {labels, values} -> {labels, before, after} for make_bar_chart
|
||||
if not chart_data.get("before") and not chart_data.get("after"):
|
||||
values = chart_data.get("values", [])
|
||||
labels = chart_data.get("labels", [])
|
||||
if values and labels:
|
||||
n = min(len(labels), len(values))
|
||||
chart_data = {**chart_data, "labels": labels[:n], "before": [0] * n, "after": values[:n]}
|
||||
make_bar_chart(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
else:
|
||||
make_line_trend(insight.chart_data, chart_path,
|
||||
elif cue == "bar_horizontal":
|
||||
make_horizontal_bar(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
elif cue == "line_trend":
|
||||
make_line_trend(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
elif cue == "pie":
|
||||
make_pie_chart(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
elif cue == "stacked_bar":
|
||||
make_stacked_bar(chart_data, chart_path,
|
||||
title=insight.key_insight)
|
||||
assets.chart_img = chart_path
|
||||
return build_data_scene(assets, insight)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ class BrollService:
|
||||
self.output_dir = self._get_chart_dir(user_id)
|
||||
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"[BrollService] Initialized with output directory: {self.output_dir}")
|
||||
logger.warning(f"[BrollService] Initialized with output directory: {self.output_dir}")
|
||||
|
||||
def _get_chart_dir(self, user_id: Optional[str] = None) -> Path:
|
||||
"""Get chart directory from podcast constants (workspace-aware)."""
|
||||
@@ -103,9 +103,11 @@ class BrollService:
|
||||
if not before and not after:
|
||||
values = chart_data.get("values", [])
|
||||
if values:
|
||||
# Use original labels, set before to zeros, values go to after
|
||||
before = [0] * len(labels)
|
||||
after = values[:len(labels)]
|
||||
# Normalize to same length, truncating or padding as needed
|
||||
n = min(len(labels), len(values))
|
||||
labels = labels[:n]
|
||||
before = [0] * n
|
||||
after = values[:n]
|
||||
# Create modified data dict with proper format for make_bar_chart
|
||||
chart_data_for_render = {
|
||||
"labels": labels,
|
||||
@@ -123,6 +125,7 @@ class BrollService:
|
||||
logger.warning(f"[BrollService] Data shape mismatch: labels={len(labels)}, before={len(before)}, after={len(after)}")
|
||||
return ""
|
||||
make_bar_chart(chart_data_for_render, out_path, title, subtitle=subtitle)
|
||||
logger.warning(f"[BrollService] bar_comparison rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
elif chart_type == "bar_horizontal":
|
||||
labels = chart_data.get("labels", [])
|
||||
values = chart_data.get("values", [])
|
||||
@@ -130,6 +133,7 @@ class BrollService:
|
||||
logger.warning("[BrollService] Missing required data for bar_horizontal")
|
||||
return ""
|
||||
make_horizontal_bar(chart_data, out_path, title)
|
||||
logger.warning(f"[BrollService] bar_horizontal rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
elif chart_type == "line_trend":
|
||||
labels = chart_data.get("labels", [])
|
||||
values = chart_data.get("values", [])
|
||||
@@ -137,6 +141,7 @@ class BrollService:
|
||||
logger.warning("[BrollService] Missing required data for line_trend")
|
||||
return ""
|
||||
make_line_trend(chart_data, out_path, title)
|
||||
logger.warning(f"[BrollService] line_trend rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
elif chart_type == "pie":
|
||||
labels = chart_data.get("labels", [])
|
||||
values = chart_data.get("values", [])
|
||||
@@ -144,6 +149,7 @@ class BrollService:
|
||||
logger.warning("[BrollService] Missing required data for pie")
|
||||
return ""
|
||||
make_pie_chart(chart_data, out_path, title)
|
||||
logger.warning(f"[BrollService] pie rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
elif chart_type == "stacked_bar":
|
||||
labels = chart_data.get("labels", [])
|
||||
segments = chart_data.get("segments", [])
|
||||
@@ -151,6 +157,7 @@ class BrollService:
|
||||
logger.warning("[BrollService] Missing required data for stacked_bar")
|
||||
return ""
|
||||
make_stacked_bar(chart_data, out_path, title)
|
||||
logger.warning(f"[BrollService] stacked_bar rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
elif chart_type == "bullet" or chart_type == "bullet_points":
|
||||
# Accept both: bullet_points OR labels
|
||||
bullet_points = chart_data.get("bullet_points", [])
|
||||
@@ -163,6 +170,7 @@ class BrollService:
|
||||
bullet_points = labels_fallback
|
||||
if bullet_points:
|
||||
make_bullet_overlay(bullet_points, out_path)
|
||||
logger.warning(f"[BrollService] bullet_points rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||
else:
|
||||
logger.warning("[BrollService] No bullet points provided")
|
||||
return ""
|
||||
@@ -176,7 +184,34 @@ class BrollService:
|
||||
logger.warning(f"[BrollService] Fallback also failed: {fallback_err}")
|
||||
return ""
|
||||
|
||||
logger.info(f"[BrollService] Chart preview generated: {out_path}")
|
||||
logger.warning(f"[BrollService] Chart preview generated: {out_path}, exists={os.path.exists(out_path) if out_path else 'N/A'}")
|
||||
|
||||
# Add source attribution overlay if present
|
||||
source = chart_data.get("source", "").strip()
|
||||
if source and out_path and os.path.exists(out_path):
|
||||
try:
|
||||
from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||
img = PILImage.open(out_path).convert("RGBA")
|
||||
draw = ImageDraw.Draw(img)
|
||||
source_text = f"Source: {source[:80]}"
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
font = ImageFont.truetype("arial.ttf", 11)
|
||||
except (OSError, IOError):
|
||||
font = ImageFont.load_default()
|
||||
text_bbox = draw.textbbox((0, 0), source_text, font=font)
|
||||
text_w = text_bbox[2] - text_bbox[0]
|
||||
text_h = text_bbox[3] - text_bbox[1]
|
||||
x = img.width - text_w - 12
|
||||
y = img.height - text_h - 8
|
||||
draw.rectangle([x - 4, y - 2, x + text_w + 4, y + text_h + 2], fill=(0, 0, 0, 140))
|
||||
draw.text((x, y), source_text, fill=(200, 200, 200, 220), font=font)
|
||||
img.save(out_path)
|
||||
except Exception as src_err:
|
||||
logger.warning(f"[BrollService] Source overlay failed (non-fatal): {src_err}")
|
||||
|
||||
return out_path
|
||||
|
||||
except Exception as e:
|
||||
@@ -189,7 +224,7 @@ class BrollService:
|
||||
key_insight: str,
|
||||
supporting_stat: str,
|
||||
chart_data: Optional[Dict[str, Any]],
|
||||
visual_cue: str, # bar_chart_comparison, bullet_points, full_avatar
|
||||
visual_cue: str, # bar_comparison, bar_horizontal, line_trend, pie, stacked_bar, bullet_points, full_avatar
|
||||
duration: float,
|
||||
background_img_path: str,
|
||||
avatar_video_path: Optional[str] = None,
|
||||
|
||||
Reference in New Issue
Block a user