Podcast Maker: Fix progress modals, research JSON, header stepper, voice/podcastMode chips

This commit is contained in:
ajaysi
2026-04-19 13:16:59 +05:30
parent ff61708e29
commit e704aa7d87
61 changed files with 7965 additions and 368 deletions

View File

@@ -0,0 +1,666 @@
# Programmatic B-Roll Composer
A layered video composition pipeline that assembles AI-generated images, programmatic data charts, Pillow text overlays, and circular-masked avatar videos into a single output MP4. Driven by structured JSON from an LLM, exposed via a FastAPI server.
---
## Table of Contents
1. [Architecture overview](#1-architecture-overview)
2. [File structure](#2-file-structure)
3. [Installation](#3-installation)
4. [Core concepts](#4-core-concepts)
- 4.1 [The Insight dataclass](#41-the-insight-dataclass)
- 4.2 [The SceneAssets dataclass](#42-the-sceneassets-dataclass)
- 4.3 [The layer stack](#43-the-layer-stack)
- 4.4 [The JSON bridge](#44-the-json-bridge)
5. [Asset generators](#5-asset-generators)
- 5.1 [Bar chart — make_bar_chart](#51-bar-chart--make_bar_chart)
- 5.2 [Line trend — make_line_trend](#52-line-trend--make_line_trend)
- 5.3 [Bullet overlay — make_bullet_overlay](#53-bullet-overlay--make_bullet_overlay)
- 5.4 [Insight card — make_insight_card](#54-insight-card--make_insight_card)
6. [Video effects](#6-video-effects)
- 6.1 [Circular avatar mask — apply_circle_mask](#61-circular-avatar-mask--apply_circle_mask)
- 6.2 [Ken Burns zoom — ken_burns](#62-ken-burns-zoom--ken_burns)
7. [Scene builders](#7-scene-builders)
- 7.1 [Data scene — build_data_scene](#71-data-scene--build_data_scene)
- 7.2 [Bullet scene — build_bullet_scene](#72-bullet-scene--build_bullet_scene)
- 7.3 [Full avatar scene — build_full_avatar_scene](#73-full-avatar-scene--build_full_avatar_scene)
8. [Scene dispatcher — dispatch_scene](#8-scene-dispatcher--dispatch_scene)
9. [Crossfade transitions](#9-crossfade-transitions)
- 9.1 [How crossfade_concat works](#91-how-crossfade_concat-works)
- 9.2 [The set_duration gotcha](#92-the-set_duration-gotcha)
10. [Master compositor — compose_video](#10-master-compositor--compose_video)
11. [FastAPI server](#11-fastapi-server)
- 11.1 [Request models](#111-request-models)
- 11.2 [Job lifecycle](#112-job-lifecycle)
- 11.3 [API endpoints](#113-api-endpoints)
12. [Running the project](#12-running-the-project)
- 12.1 [Smoke test (no media files needed)](#121-smoke-test-no-media-files-needed)
- 12.2 [Full video composition](#122-full-video-composition)
- 12.3 [API server](#123-api-server)
13. [Calling the API](#13-calling-the-api)
14. [Production notes](#14-production-notes)
15. [Extending the pipeline](#15-extending-the-pipeline)
---
## 1. Architecture overview
The pipeline follows a **Layered Composition** model. Rather than generating video in one pass, it assembles independent visual layers — each produced by the cheapest appropriate tool — into a single timeline using MoviePy as the compositor.
```
LLM JSON output
dispatch_scene() ← routes visual_cue → builder function
├─ build_data_scene()
│ ├─ ImageClip (background) ← AI-generated image
│ ├─ ImageClip (chart PNG) ← Matplotlib, transparent bg
│ ├─ ImageClip (insight card) ← Pillow RGBA
│ └─ VideoFileClip (avatar) ← circular numpy mask
├─ build_bullet_scene()
│ ├─ ImageClip (background)
│ ├─ ImageClip (bullet overlay) ← Pillow RGBA
│ └─ VideoFileClip (avatar)
└─ build_full_avatar_scene()
└─ VideoFileClip (full-screen)
crossfade_concat() ← dissolve between scenes
write_videofile() ← H.264 MP4 via ffmpeg
```
The key design decision: charts and text are **never** rendered by a generative model. Matplotlib produces pixel-perfect data graphics from real numbers; Pillow renders crisp, deterministic text. Only the background and the talking-head avatar come from AI generation, minimising both cost and hallucination risk.
---
## 2. File structure
```
.
├── broll_composer.py # Core library — all composition logic
├── api_server.py # FastAPI wrapper — HTTP interface to the pipeline
└── requirements.txt # Python dependencies
```
`broll_composer.py` has no FastAPI dependency and can be imported and called directly from scripts, notebooks, or other web frameworks.
---
## 3. Installation
```bash
# System dependency — must be on PATH
apt-get install ffmpeg
# Python packages
pip install -r requirements.txt
```
**requirements.txt**
```
moviepy==1.0.3
Pillow>=10.0
matplotlib>=3.8
numpy>=1.26
fastapi>=0.111
uvicorn[standard]>=0.29
python-multipart>=0.0.9
```
MoviePy 1.0.3 is pinned because 2.x introduced breaking API changes to `CompositeVideoClip` and the effects interface. The rest can float within the specified lower bounds.
---
## 4. Core concepts
### 4.1 The Insight dataclass
Every scene is driven by a single `Insight` object. This is the contract between the LLM and the composition pipeline:
```python
@dataclass
class Insight:
key_insight: str # Headline text rendered on the insight card
supporting_stat: str # Sub-headline rendered below the headline
visual_cue: str # Selects which scene builder to use (see §8)
audio_tone: str # Passed through for downstream TTS / audio selection
chart_data: dict # Data payload consumed by chart generators (see §5)
duration: float # Scene length in seconds, default 10.0
```
The `audio_tone` field is not used by the video pipeline itself — it is metadata for whatever system generates or selects the voiceover audio track for the scene.
### 4.2 The SceneAssets dataclass
`SceneAssets` carries file paths to the media assets for a given scene:
```python
@dataclass
class SceneAssets:
background_img: str # Required — path to JPEG or PNG background
chart_img: Optional[str] # Populated by dispatch_scene after chart generation
avatar_video: Optional[str] # Optional — path to MP4 avatar clip
bullet_img: Optional[str] # Reserved for pre-rendered bullet overlays
```
`chart_img` starts as `None` and is written to by `dispatch_scene` after it generates the Matplotlib PNG, so the scene builders receive a fully-populated `SceneAssets` by the time they run.
### 4.3 The layer stack
Every scene is a `CompositeVideoClip` — a MoviePy object that renders multiple clips on a shared canvas by alpha-compositing them bottom-to-top. The layer order is consistent across all scene types:
| Z-order | Layer | Source | Notes |
|---------|-------|--------|-------|
| 0 (bottom) | Background | AI image + Ken Burns | Darkened to make overlays legible |
| 1 | Chart or bullet overlay | Matplotlib or Pillow PNG | Transparent background; fades in |
| 2 | Insight card | Pillow RGBA | Positioned at y=820 (near bottom) |
| 3 (top) | Avatar circle | MP4 + numpy mask | Bottom-right corner |
### 4.4 The JSON bridge
The LLM is prompted to return a structured JSON object — not prose — so the pipeline can consume it without parsing ambiguity:
```json
{
"key_insight": "AI tools reduced content cycles by 40%",
"supporting_stat": "HubSpot 2026 report — 12% lift in CTR",
"visual_cue": "bar_chart_comparison",
"audio_tone": "authoritative_and_surprising",
"duration": 10.0,
"chart_data": {
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
"before": [30, 22, 18, 60],
"after": [72, 34, 41, 38]
}
}
```
`pipeline_from_json()` is the single-call entry point that accepts this JSON string, constructs the dataclasses, runs `dispatch_scene`, and writes the output MP4.
---
## 5. Asset generators
These functions produce static image files (PNG with alpha transparency) that are loaded as `ImageClip` objects in the scene builders. They are completely independent of MoviePy and can be called and previewed without assembling any video.
### 5.1 Bar chart — `make_bar_chart`
```python
make_bar_chart(data: dict, out_path: str, title: str = "") -> str
```
Produces a side-by-side "before vs after" bar chart using Matplotlib. The critical detail is the renderer configuration and save parameters:
```python
matplotlib.use("Agg") # Non-interactive backend — no display required
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
ax.set_facecolor("none") # Transparent axes background
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
```
Setting both `facecolor="none"` on the figure and `transparent=True` on `savefig` is necessary because they control different things: the figure background and the PNG alpha channel respectively. Without both, a white box appears behind the chart when it is composited over the video background.
**Expected `data` shape:**
```python
{
"labels": ["Category A", "Category B"], # X-axis labels
"before": [30, 22], # Bar heights (left bars)
"after": [72, 34] # Bar heights (right bars)
}
```
### 5.2 Line trend — `make_line_trend`
```python
make_line_trend(data: dict, out_path: str, title: str = "") -> str
```
Produces a time-series line chart with a translucent fill under the curve (`alpha=0.12`). Suited for growth trends, adoption curves, and any metric tracked over sequential time periods.
**Expected `data` shape:**
```python
{
"x": [2021, 2022, 2023, 2024, 2025], # X-axis values (numeric or strings)
"y": [10, 18, 30, 45, 72] # Y-axis values
}
```
### 5.3 Bullet overlay — `make_bullet_overlay`
```python
make_bullet_overlay(lines: list[str], out_path: str,
width: int = 900, font_size: int = 32) -> str
```
Renders a list of bullet-point strings onto a semi-transparent dark rounded rectangle using Pillow. The image height is computed dynamically from the number of lines:
```python
img_h = padding * 2 + len(lines) * line_h + 12
```
The fill colour `(10, 10, 10, 185)` gives roughly 73% opacity — dark enough for text legibility over any background, light enough that the background remains visible. The bullet character (`•`) is prepended in Python rather than in the font, so no special Unicode font support is required.
Font loading tries the DejaVu Sans Bold path common on Debian/Ubuntu systems, falling back to Pillow's built-in bitmap font if the TTF is absent.
### 5.4 Insight card — `make_insight_card`
```python
make_insight_card(insight: str, stat: str, out_path: str,
width: int = 960, height: int = 200) -> str
```
Renders a two-line card: a large bold headline (`font_size=34`) and a smaller supporting stat line (`font_size=20`). A solid red rectangle (`#E63946`) is drawn as a left-edge accent bar — a visual device borrowed from print editorial design that gives the card a distinct identity when overlaid on varied backgrounds.
The card uses `fill=(10, 10, 10, 200)` — approximately 78% opacity — slightly more opaque than the bullet overlay because the headline text is denser.
---
## 6. Video effects
### 6.1 Circular avatar mask — `apply_circle_mask`
```python
apply_circle_mask(clip: VideoFileClip, diameter: int) -> VideoFileClip
```
Takes an MP4 avatar clip and returns it with a circular alpha mask applied, so only the circle region is visible when the clip is composited over other layers.
The mask is built using NumPy's `ogrid`, which creates coordinate arrays without materialising a full mesh:
```python
Y, X = np.ogrid[:h, :w]
cx, cy = w / 2, h / 2
mask_arr = ((X - cx)**2 + (Y - cy)**2 <= (min(w, h) / 2)**2).astype(float)
```
This produces a 2D float array (values 0.0 or 1.0) where all pixels within the inscribed circle are 1 (opaque) and all pixels outside are 0 (transparent). MoviePy requires mask arrays in this float format — it does not accept uint8 or boolean arrays directly.
The mask array is wrapped in an `ImageClip` with `ismask=True` and the duration is set to match the source clip before calling `clip.set_mask()`.
**Why not use imagemagick or a pre-made circular PNG?** The numpy approach has no subprocess dependency, works for any input resolution, and the mask is computed once and reused for every frame without disk I/O.
### 6.2 Ken Burns zoom — `ken_burns`
```python
ken_burns(clip: ImageClip, zoom_ratio: float = 0.08) -> ImageClip
```
Applies a slow continuous zoom-in to a static image clip, creating the illusion of camera movement. This prevents the background from looking visually "dead" during the scene.
The implementation uses `clip.fl()`, MoviePy's frame-level transform function, which receives both `get_frame` (a callable that returns the frame array at time `t`) and the current time `t`:
```python
def zoom_frame(get_frame, t):
frame = get_frame(t)
frac = 1 + zoom_ratio * (t / clip.duration) # grows from 1.0 to 1+zoom_ratio
h, w = frame.shape[:2]
new_h, new_w = int(h / frac), int(w / frac) # shrink crop window
y1 = (h - new_h) // 2 # center the crop
x1 = (w - new_w) // 2
cropped = frame[y1:y1 + new_h, x1:x1 + new_w]
return np.array(Image.fromarray(cropped).resize((w, h), Image.LANCZOS))
```
At `t=0`, `frac=1.0` so the crop is the full frame. At `t=duration`, `frac=1+zoom_ratio` so the crop is slightly smaller, and upscaling it back to full resolution creates the zoom effect. `zoom_ratio=0.08` means an 8% zoom over the full duration — perceptible but not distracting.
`apply_to=["mask"]` passes the same transform to the mask channel if one is present, keeping the mask geometrically in sync with the image.
---
## 7. Scene builders
Scene builders assemble the layers for a given `visual_cue` type into a `CompositeVideoClip`. Each builder follows the same pattern: build layers bottom-to-top, append to a list, return `CompositeVideoClip(layers, size=bg.size).set_duration(d)`.
The explicit `.set_duration(d)` on the return value is mandatory — see [§9.2](#92-the-set_duration-gotcha) for why.
### 7.1 Data scene — `build_data_scene`
Used for `visual_cue` values `bar_chart_comparison` and `line_trend`. The most information-dense layout:
- **Background**: full-canvas `ImageClip`, Ken Burns zoom at 8%, brightness reduced by 40 units via `vfx.lum_contrast(0, -40)`.
- **Chart**: resized to 700px wide, centred horizontally, positioned 180px from the top. Fades in over 0.6s starting at `t=0.5` and fades out over 0.4s at the end.
- **Insight card**: centred horizontally at y=820 (approximately the lower fifth of a 1080p frame). Fades in over 0.5s.
- **Avatar**: circular-masked at 240px diameter, positioned 40px from the bottom-right corner (`bg.w - 280, bg.h - 280`).
### 7.2 Bullet scene — `build_bullet_scene`
Used for `visual_cue` value `bullet_points`. A simpler layout suited to lists of supporting facts:
- **Background**: Ken Burns at 5% zoom (slower than the data scene — more contemplative pacing), brightness reduced by 50 units.
- **Bullet overlay**: rendered by `make_bullet_overlay`, centred both horizontally and vertically, fades in over 0.7s.
- **Avatar**: circular-masked at 200px diameter (slightly smaller than in the data scene), positioned 40px from the bottom-right corner.
If `bullet_lines` is not provided by the caller, the builder falls back to using `insight.key_insight` and `insight.supporting_stat` as two bullet points.
### 7.3 Full avatar scene — `build_full_avatar_scene`
Used for `visual_cue` value `full_avatar`. The "Hook" scene — designed to open a piece with a direct-to-camera delivery that grabs attention before the data arrives. No overlays; the avatar fills the entire frame:
```python
avatar = VideoFileClip(assets.avatar_video).subclip(0, d)
return avatar.resize(height=1080).set_duration(d)
```
This is the only scene type that does not use a `CompositeVideoClip` — it returns a `VideoFileClip` directly. The explicit `.set_duration(d)` is still applied (see §9.2).
---
## 8. Scene dispatcher — `dispatch_scene`
```python
dispatch_scene(insight: Insight, assets: SceneAssets,
bullet_lines: Optional[list[str]] = None) -> CompositeVideoClip
```
The dispatcher is the JSON bridge's execution layer. It reads `insight.visual_cue` and routes to the correct builder, generating any intermediate assets (charts) along the way:
```
visual_cue value Action
─────────────────────────────────────────────────────
"full_avatar" → build_full_avatar_scene()
"bar_chart_comparison" → make_bar_chart() → build_data_scene()
"line_trend" → make_line_trend() → build_data_scene()
"bullet_points" → build_bullet_scene()
<anything else> → build_data_scene() with no chart (fallback)
```
Chart PNGs are written to `/tmp/chart.png`. This is intentionally a fixed path — each call overwrites the previous chart, which is fine because `dispatch_scene` is called sequentially per scene. If scenes are ever parallelised, use a `job_id`-prefixed temp path instead.
---
## 9. Crossfade transitions
### 9.1 How `crossfade_concat` works
```python
def crossfade_concat(scenes: list, fade_dur: float = 0.5) -> CompositeVideoClip:
faded = []
for i, clip in enumerate(scenes):
c = clip
if i > 0:
c = c.fx(vfx.crossfadein, fade_dur)
faded.append(c)
return concatenate_videoclips(faded, padding=-fade_dur, method="compose")
```
`vfx.crossfadein` makes a clip's opacity ramp from 0 to 1 over `fade_dur` seconds from its start point. This handles the incoming side of the dissolve.
`padding=-fade_dur` is the critical parameter. By default, `concatenate_videoclips` places each clip immediately after the previous one ends. A negative padding shifts each clip left by `fade_dur` seconds, so it starts while the previous clip is still playing. The overlap window is exactly `fade_dur` seconds, which matches the duration of the `crossfadein` effect — this is what produces a dissolve rather than a hard cut or a gap.
`method="compose"` tells MoviePy to use `CompositeVideoClip` internally for the overlapping portions rather than trying to blend frames at the pixel level, which is how the alpha ramp from `crossfadein` is correctly respected.
The default `fade_dur` of `0.5s` is appropriate for fast-paced content. Increase to `0.81.0s` for a more cinematic feel. The total output duration is `sum(scene.duration for scene in scenes) - (len(scenes) - 1) * fade_dur`.
### 9.2 The `set_duration` gotcha
`CompositeVideoClip` infers its total duration by scanning the durations of all constituent clips. When sub-clips have `set_start` offsets — such as the chart clip which starts at `t=0.5` and has a duration of `d - 1.5`, and the insight card which starts at `t=0.5` with a duration of `d - 1.0` — MoviePy computes the composite's duration as `max(clip.start + clip.duration for clip in layers)`.
In most cases this yields a value slightly larger than `d` due to floating-point arithmetic on the offset calculations, or occasionally slightly smaller if a sub-clip ends fractionally before the background. Either error causes `crossfade_concat`'s `padding=-fade_dur` overlap to be miscalculated, typically producing a black flash frame at each scene boundary.
The fix is to explicitly call `.set_duration(d)` on every scene builder's return value, overriding the inferred value with the authoritative duration from the `Insight`:
```python
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
```
This must be applied to all three builders, including `build_full_avatar_scene`, because a `resize()` call on a `VideoFileClip` creates a new clip object whose duration is re-derived from the source — it does not inherit the `subclip(0, d)` duration reliably on all platforms.
---
## 10. Master compositor — `compose_video`
```python
def compose_video(scenes: list, output_path: str = "output.mp4",
fps: int = 24, fade_dur: float = 0.5) -> str
```
The final assembly step. Calls `crossfade_concat` to produce the dissolved timeline, then writes to an H.264 MP4 via MoviePy's `write_videofile`:
```python
final.write_videofile(
output_path,
fps=fps,
codec="libx264",
audio_codec="aac",
threads=4,
preset="fast",
logger=None,
)
```
`preset="fast"` is a reasonable default for a production pipeline — it is significantly faster than `slow` or `medium` with only a marginal quality difference at typical web streaming bitrates. Change to `slow` for archive-quality output. `logger=None` suppresses the verbose ffmpeg progress output; remove it during debugging.
`threads=4` maps to ffmpeg's `-threads` flag. Increase if the host has more cores available. This affects the encoding step only — MoviePy's frame rendering is single-threaded.
---
## 11. FastAPI server
`api_server.py` wraps the composition pipeline behind an HTTP API, enabling it to be called from any frontend, automation script, or orchestration system.
### 11.1 Request models
**`InsightPayload`** — mirrors the `Insight` dataclass with Pydantic validation:
| Field | Type | Constraints | Description |
|-------|------|-------------|-------------|
| `key_insight` | str | required | Headline text |
| `supporting_stat` | str | required | Sub-headline text |
| `visual_cue` | str | required | Scene template selector |
| `audio_tone` | str | required | Downstream audio metadata |
| `duration` | float | 3.060.0 | Scene length in seconds |
| `chart_data` | dict | optional | Data payload for chart generators |
| `bullet_lines` | list[str] | optional | Explicit bullet text (overrides defaults) |
**`ComposeRequest`** — the top-level request body:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `insights` | list[InsightPayload] | required | Ordered list of scenes |
| `fps` | int | 24 | Output frame rate (1260) |
| `fade_dur` | float | 0.5 | Crossfade duration in seconds (0.02.0) |
**`JobStatus`** — the response model for job tracking:
| Field | Values | Description |
|-------|--------|-------------|
| `job_id` | UUID hex string | Unique identifier for polling |
| `status` | `queued`, `processing`, `done`, `error` | Current state |
| `output_url` | `/download/{job_id}` or null | Available when `status == "done"` |
| `error` | string or null | Error message when `status == "error"` |
### 11.2 Job lifecycle
Video composition is CPU-intensive and typically takes 30120 seconds for a multi-scene piece. The API uses FastAPI's `BackgroundTasks` to run composition asynchronously so the HTTP response is immediate:
```
POST /compose
├─ Validates payload, saves uploaded files to /tmp/broll_jobs/{job_id}/
├─ Creates JobStatus(status="queued")
├─ Registers BackgroundTask → _compose_worker()
└─ Returns 202 Accepted with job_id
_compose_worker() (background)
├─ Sets status = "processing"
├─ Runs _sync_compose() in a thread pool (loop.run_in_executor)
│ └─ Iterates insights → dispatch_scene() → compose_video()
├─ On success: status = "done", output_url = "/download/{job_id}"
└─ On error: status = "error", error = str(exc)
GET /status/{job_id} ← poll until status == "done" or "error"
GET /download/{job_id} ← returns MP4 file
```
`loop.run_in_executor(None, _sync_compose)` is important: MoviePy's frame rendering and ffmpeg's encoding are blocking operations. Running them directly in an `async` function would block the entire event loop. `run_in_executor` offloads the work to a thread pool, keeping the server responsive to other requests during composition.
The job store is currently a plain Python dict (`_jobs`). This is appropriate for a single-worker development server. Replace with Redis (using `aioredis` or `redis-py`) for multi-worker or multi-instance deployments.
### 11.3 API endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/compose` | Start a composition job (multipart form) |
| `GET` | `/status/{job_id}` | Poll job status |
| `GET` | `/download/{job_id}` | Download finished MP4 |
| `POST` | `/preview/chart` | Generate and return a chart PNG (no video) |
| `GET` | `/health` | Liveness check |
Interactive documentation is available at `http://localhost:8000/docs` once the server is running (FastAPI's built-in Swagger UI).
---
## 12. Running the project
### 12.1 Smoke test (no media files needed)
The smoke test validates all asset generators — chart PNGs, bullet overlays, and insight cards — without requiring any background images or avatar videos:
```bash
python broll_composer.py
```
Expected output:
```
Chart saved → /tmp/demo_chart.png
Bullets saved → /tmp/demo_bullets.png
Insight card saved → /tmp/demo_card.png
Sample Insight JSON: { ... }
All asset generation tests passed.
To run full video composition, supply real background_img and avatar_video paths.
```
Inspect the PNG files in `/tmp/` to visually verify chart rendering before running the full pipeline.
### 12.2 Full video composition
```python
from broll_composer import pipeline_from_json
insight_json = """{
"key_insight": "AI reduced production time by 40%",
"supporting_stat": "HubSpot 2026: 12% CTR lift",
"visual_cue": "bar_chart_comparison",
"audio_tone": "authoritative_and_surprising",
"duration": 10.0,
"chart_data": {
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
"before": [30, 22, 18, 60],
"after": [72, 34, 41, 38]
}
}"""
output_path = pipeline_from_json(
insight_json,
background_img="path/to/background.jpg",
avatar_video="path/to/avatar.mp4", # optional
)
print(f"Video written to {output_path}")
```
### 12.3 API server
```bash
uvicorn api_server:app --host 0.0.0.0 --port 8000
```
For development with auto-reload:
```bash
uvicorn api_server:app --reload
```
---
## 13. Calling the API
The `/compose` endpoint accepts `multipart/form-data` with three parts: `payload` (JSON string), `background` (image file), and optionally `avatar` (video file).
```bash
curl -X POST http://localhost:8000/compose \
-F 'payload={
"insights": [{
"key_insight": "AI reduced production time by 40%",
"supporting_stat": "HubSpot 2026: 12% CTR lift",
"visual_cue": "bar_chart_comparison",
"audio_tone": "authoritative_and_surprising",
"duration": 10.0,
"chart_data": {
"labels": ["Velocity","CTR","Engagement","Cost/Lead"],
"before": [30, 22, 18, 60],
"after": [72, 34, 41, 38]
}
}],
"fps": 24,
"fade_dur": 0.5
}' \
-F 'background=@./bg.jpg' \
-F 'avatar=@./avatar.mp4'
```
This returns a `JobStatus` with a `job_id`. Poll for completion:
```bash
curl http://localhost:8000/status/{job_id}
# → {"job_id": "...", "status": "done", "output_url": "/download/..."}
```
Download the finished video:
```bash
curl -O http://localhost:8000/download/{job_id}
```
Preview a chart without video assembly:
```bash
curl -X POST "http://localhost:8000/preview/chart?title=My+Chart&chart_type=bar_chart_comparison" \
-H "Content-Type: application/json" \
-d '{"labels":["A","B"],"before":[30,22],"after":[72,34]}' \
--output preview.png
```
---
## 14. Production notes
**Concurrency**: FastAPI's `BackgroundTasks` runs in the same process as the web server. Under concurrent load, multiple composition jobs will share the same thread pool, which can cause memory pressure (each MoviePy frame rendering buffers several seconds of uncompressed video). For production, move composition to a dedicated worker queue (Celery + Redis, or ARQ) and have the API server dispatch jobs to it rather than running them in-process.
**Temp file isolation**: Chart PNGs and insight card PNGs are written to fixed paths under `/tmp/`. This is safe for sequential processing but will cause race conditions if jobs are parallelised. Prefix all temp file paths with the `job_id` to isolate them:
```python
chart_path = f"/tmp/{job_id}_chart.png"
```
**Memory**: MoviePy loads entire video clips into memory for compositing. For scenes longer than ~30 seconds with a high-resolution avatar, memory use can reach several GB. If this is a concern, render scenes individually and use ffmpeg's `concat` demuxer to join them in a second pass rather than compositing them all in Python.
**ffmpeg version**: MoviePy 1.0.3 delegates encoding to ffmpeg. Versions prior to 4.x may not support all `preset` values or the `aac` codec without additional flags. The pipeline is tested against ffmpeg 5.x and 6.x.
**File cleanup**: Completed job files accumulate in `/tmp/broll_jobs/`. Add a cleanup background task or cron job that deletes job directories older than a configurable TTL (e.g. 1 hour).
---
## 15. Extending the pipeline
**Adding a new scene template**: add a builder function following the `build_*_scene` naming convention, then add a `visual_cue` string → function mapping in `dispatch_scene`. No other changes are needed.
**Adding a new chart type**: add a `make_*` function that writes a transparent PNG, then handle the new `visual_cue` in `dispatch_scene` by calling it before passing `assets` to a builder.
**Supporting multiple backgrounds per script**: `SceneAssets` currently takes a single `background_img`. To vary the background per scene, add a `background_img` field to `InsightPayload` in the API model and pass it through to `SceneAssets` in the compose worker.
**Audio**: the pipeline produces silent video. Attach a voiceover by loading it as a MoviePy `AudioFileClip`, setting its start time to align with each scene, and passing the composite audio to `final.set_audio()` before calling `write_videofile`.

View File

@@ -0,0 +1,229 @@
"""
FastAPI wrapper for the B-Roll Composer pipeline.
POST /compose → triggers scene assembly, returns video download URL.
"""
from __future__ import annotations
import os
import uuid
import json
import asyncio
from pathlib import Path
from typing import Optional, List
from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from broll_composer import (
Insight, SceneAssets, dispatch_scene, compose_video,
make_bar_chart, make_line_trend, make_bullet_overlay,
)
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
app = FastAPI(
title="B-Roll Composer API",
description="Programmatic video composition: Background + Chart + Avatar Circle",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
WORK_DIR = Path("/tmp/broll_jobs")
WORK_DIR.mkdir(exist_ok=True)
# ---------------------------------------------------------------------------
# Request / Response models
# ---------------------------------------------------------------------------
class InsightPayload(BaseModel):
key_insight: str = Field(..., example="AI tools reduced content cycles by 40% in 2025.")
supporting_stat: str = Field(..., example="HubSpot 2026 report cites a 12% lift in CTR.")
visual_cue: str = Field(
...,
example="bar_chart_comparison",
description="bar_chart_comparison | line_trend | bullet_points | full_avatar",
)
audio_tone: str = Field(..., example="authoritative_and_surprising")
duration: float = Field(default=10.0, ge=3.0, le=60.0)
chart_data: dict = Field(default_factory=dict)
bullet_lines: Optional[List[str]] = None
class ComposeRequest(BaseModel):
insights: List[InsightPayload]
fps: int = Field(default=24, ge=12, le=60)
fade_dur: float = Field(default=0.5, ge=0.0, le=2.0,
description="Crossfade duration in seconds between scenes")
class JobStatus(BaseModel):
job_id: str
status: str # queued | processing | done | error
output_url: Optional[str] = None
error: Optional[str] = None
# ---------------------------------------------------------------------------
# In-memory job store (replace with Redis in production)
# ---------------------------------------------------------------------------
_jobs: dict[str, JobStatus] = {}
# ---------------------------------------------------------------------------
# Background task: composition worker
# ---------------------------------------------------------------------------
async def _compose_worker(
job_id: str,
request: ComposeRequest,
bg_path: str,
avatar_path: Optional[str],
):
job = _jobs[job_id]
job.status = "processing"
try:
loop = asyncio.get_running_loop()
out_path = str(WORK_DIR / f"{job_id}.mp4")
def _sync_compose():
scenes = []
for i, payload in enumerate(request.insights):
insight = Insight(
key_insight=payload.key_insight,
supporting_stat=payload.supporting_stat,
visual_cue=payload.visual_cue,
audio_tone=payload.audio_tone,
chart_data=payload.chart_data,
duration=payload.duration,
)
assets = SceneAssets(
background_img=bg_path,
avatar_video=avatar_path,
)
scene = dispatch_scene(insight, assets, payload.bullet_lines)
scenes.append(scene)
compose_video(scenes, output_path=out_path, fps=request.fps,
fade_dur=request.fade_dur)
return out_path
await loop.run_in_executor(None, _sync_compose)
job.status = "done"
job.output_url = f"/download/{job_id}"
except Exception as exc:
job.status = "error"
job.error = str(exc)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@app.post("/compose", response_model=JobStatus, status_code=202)
async def start_compose(
background_tasks: BackgroundTasks,
payload: str = Form(..., description="JSON string matching ComposeRequest schema"),
background: UploadFile = File(..., description="Background image (JPEG/PNG)"),
avatar: Optional[UploadFile] = File(None, description="Avatar video (MP4) — optional"),
):
"""
Kick off a video composition job.
- **payload**: JSON body (ComposeRequest)
- **background**: background image file
- **avatar**: optional avatar video file
Returns a job_id — poll GET /status/{job_id} for progress.
"""
try:
request = ComposeRequest(**json.loads(payload))
except Exception as e:
raise HTTPException(status_code=422, detail=f"Invalid payload: {e}")
job_id = uuid.uuid4().hex
# Save uploads
job_dir = WORK_DIR / job_id
job_dir.mkdir(exist_ok=True)
bg_path = str(job_dir / background.filename)
with open(bg_path, "wb") as f:
f.write(await background.read())
avatar_path = None
if avatar:
avatar_path = str(job_dir / avatar.filename)
with open(avatar_path, "wb") as f:
f.write(await avatar.read())
# Register job
job = JobStatus(job_id=job_id, status="queued")
_jobs[job_id] = job
# Launch background worker
background_tasks.add_task(
_compose_worker, job_id, request, bg_path, avatar_path
)
return job
@app.get("/status/{job_id}", response_model=JobStatus)
async def get_status(job_id: str):
"""Poll composition job status."""
job = _jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.get("/download/{job_id}")
async def download_video(job_id: str):
"""Download the finished video."""
job = _jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job.status != "done":
raise HTTPException(status_code=409, detail=f"Job status: {job.status}")
out_path = WORK_DIR / f"{job_id}.mp4"
if not out_path.exists():
raise HTTPException(status_code=404, detail="Output file missing")
return FileResponse(
path=str(out_path),
media_type="video/mp4",
filename=f"broll_{job_id}.mp4",
)
@app.post("/preview/chart")
async def preview_chart(
chart_data: dict,
title: str = "",
chart_type: str = "bar_chart_comparison",
):
"""Generate and return a chart PNG for preview (no video assembly)."""
out = str(WORK_DIR / f"preview_{uuid.uuid4().hex}.png")
if chart_type == "bar_chart_comparison":
make_bar_chart(chart_data, out, title)
else:
make_line_trend(chart_data, out, title)
return FileResponse(path=out, media_type="image/png")
@app.get("/health")
async def health():
return {"status": "ok"}

View File

@@ -0,0 +1,456 @@
"""
Programmatic B-Roll Composer
Layered composition pipeline: Background + Chart + Avatar Circle + Text Overlays
"""
import json
import numpy as np
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from PIL import Image, ImageDraw, ImageFont
from moviepy.editor import (
VideoFileClip, ImageClip, CompositeVideoClip,
TextClip, ColorClip, concatenate_videoclips,
)
import moviepy.video.fx.all as vfx
# ---------------------------------------------------------------------------
# Crossfade concat (Option 1: crossfadein + negative padding)
# ---------------------------------------------------------------------------
def crossfade_concat(scenes: list, fade_dur: float = 0.5) -> CompositeVideoClip:
"""
Concatenate scenes with a dissolve transition between each pair.
Each clip (except the first) gets a crossfadein effect.
padding=-fade_dur overlaps consecutive clips so the fade actually fires
instead of creating a black gap. set_duration on every scene is
mandatory — CompositeVideoClip.duration can be ambiguous without it,
which makes the overlap math wrong.
"""
faded = []
for i, clip in enumerate(scenes):
c = clip
if i > 0:
c = c.fx(vfx.crossfadein, fade_dur)
faded.append(c)
return concatenate_videoclips(faded, padding=-fade_dur, method="compose")
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class Insight:
key_insight: str
supporting_stat: str
visual_cue: str # bar_chart_comparison | line_trend | bullet_points | full_avatar
audio_tone: str
chart_data: dict = field(default_factory=dict)
duration: float = 10.0
@dataclass
class SceneAssets:
background_img: str
chart_img: Optional[str] = None
avatar_video: Optional[str] = None
bullet_img: Optional[str] = None
# ---------------------------------------------------------------------------
# Chart generator (Matplotlib → PNG with transparency)
# ---------------------------------------------------------------------------
CHART_STYLE = {
"bg": "#0D0D0D",
"bar_before": "#2E4057",
"bar_after": "#E63946",
"text": "#F1F1EF",
"grid": "#2A2A2A",
"accent": "#E63946",
}
def make_bar_chart(data: dict, out_path: str, title: str = "") -> str:
"""Render a side-by-side comparison bar chart. Returns output path."""
labels = data.get("labels", [])
before = data.get("before", [])
after = data.get("after", [])
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
ax.set_facecolor("none")
x = np.arange(len(labels))
w = 0.35
bars_b = ax.bar(x - w / 2, before, w, color=CHART_STYLE["bar_before"],
label="Before", zorder=3, edgecolor="none")
bars_a = ax.bar(x + w / 2, after, w, color=CHART_STYLE["bar_after"],
label="After", zorder=3, edgecolor="none")
ax.set_xticks(x)
ax.set_xticklabels(labels, color=CHART_STYLE["text"], fontsize=11)
ax.tick_params(axis="y", colors=CHART_STYLE["text"])
ax.spines[:].set_visible(False)
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
ax.set_axisbelow(True)
# Value labels on bars
for bar in [*bars_b, *bars_a]:
h = bar.get_height()
ax.text(bar.get_x() + bar.get_width() / 2, h + 0.5, f"{h:.0f}%",
ha="center", va="bottom", color=CHART_STYLE["text"], fontsize=9,
fontweight="bold")
legend = ax.legend(frameon=False, labelcolor=CHART_STYLE["text"],
fontsize=10, loc="upper left")
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_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)
# ---------------------------------------------------------------------------
def make_bullet_overlay(lines: list[str], out_path: str,
width: int = 900, font_size: int = 32) -> str:
"""Render bullet points on a semi-transparent dark pill. Returns path."""
padding = 32
line_h = font_size + 16
img_h = padding * 2 + len(lines) * line_h + 12
img = Image.new("RGBA", (width, img_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Semi-transparent background pill
draw.rounded_rectangle([0, 0, width - 1, img_h - 1],
radius=18, fill=(10, 10, 10, 185))
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
font_size)
except OSError:
font = ImageFont.load_default()
y = padding
for line in lines:
draw.text((padding + 18, y), f"{line}", font=font, fill=(241, 241, 239, 255))
y += line_h
img.save(out_path, format="PNG")
return out_path
def make_insight_card(insight: str, stat: str, out_path: str,
width: int = 960, height: int = 200) -> str:
"""Render a bold insight card (headline + supporting stat). Returns path."""
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.rounded_rectangle([0, 0, width - 1, height - 1],
radius=14, fill=(10, 10, 10, 200))
# Red accent bar
draw.rectangle([28, 24, 36, height - 24], fill=(230, 57, 70, 255))
try:
font_lg = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
font_sm = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
except OSError:
font_lg = font_sm = ImageFont.load_default()
draw.text((58, 36), insight, font=font_lg, fill=(241, 241, 239, 255))
draw.text((58, 90), stat, font=font_sm, fill=(180, 180, 178, 230))
img.save(out_path, format="PNG")
return out_path
# ---------------------------------------------------------------------------
# Circular avatar mask
# ---------------------------------------------------------------------------
def apply_circle_mask(clip: VideoFileClip, diameter: int) -> VideoFileClip:
"""Resize clip and apply a circular alpha mask."""
clip = clip.resize(height=diameter)
w, h = clip.size
# Build a circular mask array (1 = opaque, 0 = transparent)
Y, X = np.ogrid[:h, :w]
cx, cy = w / 2, h / 2
mask_arr = ((X - cx) ** 2 + (Y - cy) ** 2 <= (min(w, h) / 2) ** 2).astype(float)
mask_clip = ImageClip(mask_arr, ismask=True).set_duration(clip.duration)
return clip.set_mask(mask_clip)
# ---------------------------------------------------------------------------
# Ken Burns zoom effect
# ---------------------------------------------------------------------------
def ken_burns(clip: ImageClip, zoom_ratio: float = 0.08) -> ImageClip:
"""Apply a slow zoom-in over the clip duration."""
def zoom_frame(get_frame, t):
frame = get_frame(t)
frac = 1 + zoom_ratio * (t / clip.duration)
h, w = frame.shape[:2]
new_h, new_w = int(h / frac), int(w / frac)
y1 = (h - new_h) // 2
x1 = (w - new_w) // 2
cropped = frame[y1:y1 + new_h, x1:x1 + new_w]
return np.array(Image.fromarray(cropped).resize((w, h), Image.LANCZOS))
return clip.fl(zoom_frame, apply_to=["mask"])
# ---------------------------------------------------------------------------
# Scene builders (one per visual_cue type)
# ---------------------------------------------------------------------------
def build_data_scene(assets: SceneAssets, insight: Insight) -> CompositeVideoClip:
"""
Layout: Background (Ken Burns) + Chart (fade-in) + Avatar circle (corner) + Insight card
"""
d = insight.duration
layers = []
# 1. Background
bg = (ImageClip(assets.background_img)
.set_duration(d)
.resize(height=1080))
bg = ken_burns(bg)
bg = bg.fx(vfx.lum_contrast, 0, -40) # darken 40 units
layers.append(bg)
# 2. Programmatic chart
if assets.chart_img:
chart = (ImageClip(assets.chart_img)
.set_duration(d - 1.5)
.set_start(0.5)
.resize(width=700)
.set_position(("center", 180))
.fx(vfx.fadein, 0.6)
.fx(vfx.fadeout, 0.4))
layers.append(chart)
# 3. Insight card at bottom
card_path = "/tmp/insight_card.png"
make_insight_card(insight.key_insight, insight.supporting_stat, card_path)
card = (ImageClip(card_path)
.set_duration(d - 1)
.set_start(0.5)
.set_position(("center", 820))
.fx(vfx.fadein, 0.5))
layers.append(card)
# 4. Avatar circle (bottom-right corner)
if assets.avatar_video:
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
avatar = apply_circle_mask(avatar_raw, diameter=240)
avatar = avatar.set_position((bg.w - 280, bg.h - 280))
layers.append(avatar)
# set_duration is required: CompositeVideoClip infers duration from its
# constituent clips, which can be ambiguous when sub-clips have set_start
# offsets. Without this, crossfade_concat's overlap math goes wrong.
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
def build_bullet_scene(assets: SceneAssets, insight: Insight,
bullets: list[str]) -> CompositeVideoClip:
"""
Layout: AI image (Ken Burns) + Bullet overlay + Avatar circle
"""
d = insight.duration
layers = []
bg = (ImageClip(assets.background_img)
.set_duration(d)
.resize(height=1080))
bg = ken_burns(bg, zoom_ratio=0.05)
bg = bg.fx(vfx.lum_contrast, 0, -50)
layers.append(bg)
bullet_path = "/tmp/bullets.png"
make_bullet_overlay(bullets, bullet_path, width=860)
bullets_clip = (ImageClip(bullet_path)
.set_duration(d - 1)
.set_start(0.5)
.set_position(("center", "center"))
.fx(vfx.fadein, 0.7))
layers.append(bullets_clip)
if assets.avatar_video:
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
avatar = apply_circle_mask(avatar_raw, diameter=200)
avatar = avatar.set_position((bg.w - 240, bg.h - 240))
layers.append(avatar)
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
def build_full_avatar_scene(assets: SceneAssets, insight: Insight) -> VideoFileClip:
"""Full-screen avatar — the expensive 'Hook' scene. No overlay."""
d = insight.duration
avatar = VideoFileClip(assets.avatar_video).subclip(0, d)
return avatar.resize(height=1080).set_duration(d)
# ---------------------------------------------------------------------------
# Scene dispatcher — maps visual_cue → builder
# ---------------------------------------------------------------------------
def dispatch_scene(insight: Insight, assets: SceneAssets,
bullet_lines: Optional[list[str]] = None) -> CompositeVideoClip:
cue = insight.visual_cue
if cue == "full_avatar":
return build_full_avatar_scene(assets, insight)
elif cue in ("bar_chart_comparison", "line_trend"):
chart_path = "/tmp/chart.png"
if cue == "bar_chart_comparison":
make_bar_chart(insight.chart_data, chart_path,
title=insight.key_insight)
else:
make_line_trend(insight.chart_data, chart_path,
title=insight.key_insight)
assets.chart_img = chart_path
return build_data_scene(assets, insight)
elif cue == "bullet_points":
lines = bullet_lines or [insight.key_insight, insight.supporting_stat]
return build_bullet_scene(assets, insight, lines)
else:
# Fallback: data scene without chart
return build_data_scene(assets, insight)
# ---------------------------------------------------------------------------
# Master compositor — assembles all scenes into one video
# ---------------------------------------------------------------------------
def compose_video(scenes: list, output_path: str = "output.mp4",
fps: int = 24, fade_dur: float = 0.5) -> str:
"""Concatenate scenes with crossfade transitions and write final video file."""
final = crossfade_concat(scenes, fade_dur=fade_dur)
final.write_videofile(
output_path,
fps=fps,
codec="libx264",
audio_codec="aac",
threads=4,
preset="fast",
logger=None,
)
return output_path
# ---------------------------------------------------------------------------
# JSON bridge — LLM insight → assets + scene
# ---------------------------------------------------------------------------
def pipeline_from_json(insight_json: str,
background_img: str,
avatar_video: Optional[str] = None) -> str:
"""
Full pipeline:
1. Parse LLM insight JSON
2. Generate chart / overlay assets
3. Build scene
4. Write video
Returns path to output video.
"""
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 = dispatch_scene(insight, assets,
bullet_lines=data.get("bullet_lines"))
out = f"/tmp/scene_{insight.visual_cue}.mp4"
compose_video([scene], output_path=out)
return out
# ---------------------------------------------------------------------------
# Demo / smoke-test (no real media files needed for chart generation)
# ---------------------------------------------------------------------------
if __name__ == "__main__":
# --- Test 1: Chart PNG generation only ---
sample_bar_data = {
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
"before": [30, 22, 18, 60],
"after": [72, 34, 41, 38],
}
chart_out = make_bar_chart(
sample_bar_data,
"/tmp/demo_chart.png",
title="AI Tools Impact: Before vs After (2025)",
)
print(f"Chart saved → {chart_out}")
# --- Test 2: Bullet overlay PNG ---
bullets = [
"AI reduced content cycles by 40% in 2025",
"HubSpot: 12% lift in CTR with AI-assisted copy",
"Video production cost down 3x with hybrid pipeline",
]
bullet_out = make_bullet_overlay(bullets, "/tmp/demo_bullets.png")
print(f"Bullets saved → {bullet_out}")
# --- Test 3: Insight card PNG ---
card_out = make_insight_card(
"AI tools reduced content cycles by 40%",
"HubSpot 2026 report — 12% lift in CTR",
"/tmp/demo_card.png",
)
print(f"Insight card saved → {card_out}")
# --- Test 4: JSON bridge (chart only, no video files required) ---
sample_json = json.dumps({
"key_insight": "AI reduced production time by 40%",
"supporting_stat": "HubSpot 2026: 12% CTR lift",
"visual_cue": "bar_chart_comparison",
"audio_tone": "authoritative_and_surprising",
"duration": 8.0,
"chart_data": sample_bar_data,
})
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.")

View File

@@ -51,6 +51,7 @@ async def enhance_podcast_idea(
# In podcast-only mode, skip bible generation since onboarding is disabled
bible_context = ""
if not _is_podcast_only_mode():
logger.warning(f"[Podcast Enhance] Podcast mode=full — attempting Bible generation for user {user_id}")
try:
bible_service = PodcastBibleService()
if request.bible:
@@ -65,6 +66,7 @@ async def enhance_podcast_idea(
logger.warning(f"[Podcast Enhance] Failed to parse or generate bible context: {exc}")
else:
# In podcast mode, use the provided bible directly if available
logger.warning(f"[Podcast Enhance] Podcast mode=podcast_only — skipping Bible generation for user {user_id}")
if request.bible:
try:
from models.podcast_bible_models import PodcastBible
@@ -209,7 +211,11 @@ async def analyze_podcast_idea(
final_avatar_url = request.avatar_url
final_avatar_prompt = None
if not final_avatar_url:
# Skip avatar generation for audio_only mode
podcast_mode = getattr(request, 'podcast_mode', None) or 'video_only'
should_generate_avatar = not final_avatar_url and podcast_mode != 'audio_only'
if should_generate_avatar:
logger.info(f"[Podcast Analyze] No avatar_url provided, generating one for user {user_id}")
try:
# 1. PRE-FLIGHT VALIDATION: Check subscription limits for image generation
@@ -240,8 +246,9 @@ async def analyze_podcast_idea(
if image_result and image_result.image_bytes:
img_id = str(uuid.uuid4())[:8]
filename = f"presenter_podcast_{user_id}_{img_id}.png"
output_path = PODCAST_IMAGES_DIR / filename
PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
avatars_dir = PODCAST_IMAGES_DIR / "avatars"
avatars_dir.mkdir(parents=True, exist_ok=True)
output_path = avatars_dir / filename
with open(output_path, "wb") as f:
f.write(image_result.image_bytes)
@@ -253,13 +260,14 @@ async def analyze_podcast_idea(
db=db,
user_id=user_id,
asset_type="image",
file_url=final_avatar_url,
source_module="podcast_analysis",
filename=filename,
file_url=final_avatar_url,
title=f"Presenter Avatar - {request.idea[:40]}",
description=f"AI-generated podcast presenter for: {request.idea}",
provider=image_result.provider,
model=image_result.model,
cost=image_result.cost
cost=0.0 # Cost tracked in generate_image
)
logger.info(f"[Podcast Analyze] ✅ Generated and saved avatar to {final_avatar_url}")
except Exception as e:

View File

@@ -0,0 +1,241 @@
"""
B-Roll Handlers
API endpoints for B-roll chart preview and video generation.
"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from typing import Dict, Any, Optional, List
from pydantic import BaseModel, Field
import uuid
from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user
from services.podcast.broll_service import get_broll_service
from loguru import logger
router = APIRouter()
class ChartPreviewRequest(BaseModel):
"""Request model for chart preview generation."""
chart_data: Dict[str, Any] = Field(..., description="Chart data (labels, before/after, etc.)")
chart_type: str = Field(
default="bar_comparison",
description="bar_comparison | bar_horizontal | line_trend | pie | stacked_bar | bullet"
)
title: str = Field(default="", description="Chart title")
subtitle: Optional[str] = Field(default="", description="Optional subtitle at bottom")
class ChartPreviewResponse(BaseModel):
"""Response for chart preview."""
preview_url: str
chart_id: str
class BrollSceneRequest(BaseModel):
"""Request for generating B-roll video for a scene."""
scene_id: str
key_insight: str
supporting_stat: str
chart_data: Optional[Dict[str, Any]] = None
visual_cue: str = Field(default="bar_chart_comparison", description="bar_chart_comparison | bullet_points")
duration: float = Field(default=10.0, ge=3.0, le=60.0)
background_image_url: str
avatar_video_url: Optional[str] = None
class BrollSceneResponse(BaseModel):
"""Response for B-roll scene generation."""
scene_id: str
broll_video_url: str
broll_video_path: str
class BrollComposeRequest(BaseModel):
"""Request for composing multiple B-roll videos."""
scene_video_paths: List[str]
output_filename: str = "final_broll.mp4"
fade_dur: float = Field(default=0.5, ge=0.0, le=2.0)
fps: int = Field(default=24, ge=12, le=60)
class BrollComposeResponse(BaseModel):
"""Response for B-roll composition."""
final_video_url: str
final_video_path: str
@router.post("/preview/chart", response_model=ChartPreviewResponse)
async def generate_chart_preview(
request: ChartPreviewRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Generate a chart PNG preview (static image for Write phase).
This endpoint is called from the Write phase to show users chart previews
before they commit to B-roll video generation.
"""
user_id = require_authenticated_user(current_user)
try:
broll_service = get_broll_service()
preview_path = broll_service.generate_chart_preview(
chart_data=request.chart_data,
chart_type=request.chart_type,
title=request.title,
subtitle=request.subtitle or "",
)
if not preview_path:
raise HTTPException(status_code=500, detail="Failed to generate chart preview")
chart_id = uuid.uuid4().hex[:8]
preview_url = f"/api/podcast/broll/preview/{chart_id}/{preview_path.split('/')[-1]}"
return ChartPreviewResponse(
preview_url=preview_url,
chart_id=chart_id,
)
except Exception as e:
logger.error(f"[Broll] Chart preview generation failed: {e}")
raise HTTPException(status_code=500, detail=f"Chart preview failed: {str(e)}")
@router.post("/render/broll-scene", response_model=BrollSceneResponse)
async def generate_broll_scene(
request: BrollSceneRequest,
background_tasks: BackgroundTasks,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Generate a B-roll video for a single scene.
This creates a programmatic video with:
- Background image with Ken Burns effect
- Chart overlay (if chart_data provided)
- Avatar circle in corner (if avatar_video_url provided)
- Insight card at bottom
Returns a task_id for polling since video generation can take time.
"""
user_id = require_authenticated_user(current_user)
try:
# Validate visual_cue
valid_cues = ["bar_chart_comparison", "bullet_points", "full_avatar"]
if request.visual_cue not in valid_cues:
raise HTTPException(
status_code=400,
detail=f"Invalid visual_cue. Must be one of: {valid_cues}"
)
# For now, return a placeholder - full video generation requires
# resolving image/video URLs to actual file paths
# In V2, this will integrate with the actual video generation
logger.info(f"[Broll] B-roll scene request for scene: {request.scene_id}")
return BrollSceneResponse(
scene_id=request.scene_id,
broll_video_url="",
broll_video_path="",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Broll] B-roll scene generation failed: {e}")
raise HTTPException(status_code=500, detail=f"B-roll generation failed: {str(e)}")
@router.post("/render/broll-compose", response_model=BrollComposeResponse)
async def compose_broll_videos(
request: BrollComposeRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Compose multiple B-roll scene videos into a final video.
Applies crossfade transitions between scenes.
"""
user_id = require_authenticated_user(current_user)
try:
broll_service = get_broll_service()
final_path = broll_service.compose_final_video(
video_paths=request.scene_video_paths,
output_filename=request.output_filename,
fade_dur=request.fade_dur,
fps=request.fps,
)
final_filename = final_path.split('/')[-1]
final_url = f"/api/podcast/broll/final/{final_filename}"
return BrollComposeResponse(
final_video_url=final_url,
final_video_path=final_path,
)
except Exception as e:
logger.error(f"[Broll] Video composition failed: {e}")
raise HTTPException(status_code=500, detail=f"Video composition failed: {str(e)}")
@router.get("/preview/{chart_id}/{filename}")
async def serve_chart_preview(
chart_id: str,
filename: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Serve chart preview PNG files."""
from pathlib import Path
user_id = require_authenticated_user(current_user)
broll_service = get_broll_service()
file_path = broll_service.output_dir / f"chart_preview_{chart_id}.png"
if not file_path.exists():
raise HTTPException(status_code=404, detail="Chart preview not found")
return FileResponse(
path=str(file_path),
media_type="image/png",
filename=filename,
)
@router.get("/final/{filename}")
async def serve_final_broll(
filename: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Serve final composed B-roll video files."""
user_id = require_authenticated_user(current_user)
broll_service = get_broll_service()
file_path = broll_service.output_dir / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="Video not found")
return FileResponse(
path=str(file_path),
media_type="video/mp4",
filename=filename,
)
@router.get("/health")
async def broll_health():
"""Health check for B-roll service."""
return {"status": "ok", "service": "broll"}

View File

@@ -119,7 +119,7 @@ async def update_project(
project = service.update_project(user_id, project_id, **updates)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
return PodcastProjectResponse.model_validate(project)
except HTTPException:

View File

@@ -22,6 +22,7 @@ from ..models import (
PodcastExaSource,
PodcastExaConfig,
PodcastResearchInsight,
PodcastResearchOutput,
)
router = APIRouter()
@@ -159,43 +160,50 @@ As a podcast research expert, analyze this data and create content that will:
4. Include a compelling call-to-action for listeners
REQUIRED OUTPUT (JSON):
=======================
======================
{{
"summary": "2-3 paragraph comprehensive summary in Markdown. Start with a hook that matches the episode intro. Include specific data points, expert quotes, and trends.",
"summary": "2-3 paragraph comprehensive summary in Markdown. Start with a hook that matches the episode intro.",
"key_insights": [
{{
"title": "Catchy, engaging title for this insight",
"content": "3-4 sentences with specific facts, quotes, or data. Write in a conversational tone suitable for a podcast host to discuss.",
"source_indices": [1, 2, 3],
"podcast_talking_points": ["Point 1 host can expand on", "Counter-point or follow-up", "Question to ask guest"]
"title": "Insight title",
"content": "3-4 sentences with specific facts, quotes, or data for podcast host.",
"source_indices": [1, 2],
"podcast_talking_points": ["Point host can expand on", "Counter-point"]
}}
],
"expert_quotes": [
{{
"quote": "Direct quote from source",
"quote": "Direct quote from source text",
"source_index": 1,
"context": "Why this quote matters for the podcast"
}}
],
"listener_cta_suggestions": ["Specific action listener can take", "Resource to share", "Next episode preview"]
"listener_cta_suggestions": ["Action listener can take", "Resource to share", "Next episode preview"],
"mapped_angles": [
{{
"title": "Content angle title",
"why": "Why compelling for audience",
"mapped_fact_ids": [1, 2]
}}
]
}}
IMPORTANT: You must include ALL fields above with valid data. expert_quotes, listener_cta_suggestions, and mapped_angles must have content - do NOT leave them empty!
QUALITY STANDARDS:
==================
- INSIGHTS MUST BE DEEP, not superficial - avoid generic statements
- Include SPECIFIC DATA POINTS, percentages, statistics when available
- Extract EXPERT QUOTES that hosts can reference
- Identify GAPS in the research where more depth is needed
- Make content naturally flow into the planned episode hook and CTA
- Write in a CONVERSATIONAL tone - how a host would actually speak
- Flag any CONTROVERSIAL or debatable claims for host to address
=================
- Include at least 2 expert_quotes with source_index
- Include at least 2 listener_cta_suggestions
- Include at least 2 mapped_angles
- Include specific data points, percentages, statistics
- Write in conversational tone
"""
try:
logger.warning(f"[Podcast Research] Calling LLM for insight extraction...")
logger.warning(f"[Podcast Research] Calling LLM with json_struct...")
llm_response = llm_text_gen(
prompt=prompt,
user_id=user_id,
json_struct=None,
json_struct=PodcastResearchOutput.model_json_schema(),
preferred_provider=None,
flow_type="premium_tool",
)

View File

@@ -54,6 +54,7 @@ class PodcastAnalyzeRequest(BaseModel):
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
avatar_url: Optional[str] = Field(None, description="Current avatar URL if selected")
feedback: Optional[str] = Field(None, description="User feedback for regeneration")
podcast_mode: Optional[str] = Field(None, description="Podcast mode: audio_only, video_only, or audio_video")
class PodcastAnalyzeResponse(BaseModel):
@@ -171,6 +172,15 @@ class PodcastResearchInsight(BaseModel):
listener_cta_suggestions: Optional[List[str]] = [] # CTA suggestions
class PodcastResearchOutput(BaseModel):
"""Structured JSON output for LLM research extraction using json_struct."""
summary: str = ""
key_insights: List[PodcastResearchInsight] = []
expert_quotes: List[Dict[str, Any]] = [] # [{"quote": str, "source_index": int, "context": str}]
listener_cta_suggestions: List[str] = [] # List of CTA suggestions
mapped_angles: List[Dict[str, Any]] = [] # [{"title": str, "why": str, "mapped_fact_ids": []}]
class PodcastExaResearchResponse(BaseModel):
sources: List[PodcastExaSource]
search_queries: List[str] = []
@@ -180,6 +190,9 @@ class PodcastExaResearchResponse(BaseModel):
search_type: Optional[str] = None
provider: str = "exa"
content: Optional[str] = None # Raw aggregated content (deprecated)
mapped_angles: List[Dict[str, Any]] = [] # Content angles for the episode
expert_quotes: List[Dict[str, Any]] = [] # Expert quotes from research
listener_cta_suggestions: List[str] = [] # CTA suggestions
class PodcastScriptResponse(BaseModel):

View File

@@ -2,6 +2,7 @@
Pre-flight check endpoints for operation validation and cost estimation.
"""
import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Dict, Any
@@ -34,6 +35,7 @@ async def preflight_check(
Uses caching to minimize DB load (< 100ms with cache hit).
"""
start_time = time.time()
try:
user_id = get_user_id_from_token(current_user)
@@ -229,13 +231,19 @@ async def preflight_check(
'remaining': max(0, video_limit - video_current) if video_limit > 0 else float('inf')
}
elapsed_ms = (time.time() - start_time) * 1000
logger.warning(f"[PreflightCheck] Completed in {elapsed_ms:.0f}ms for user {user_id}")
return {
"success": True,
"data": response_data
}
except HTTPException:
elapsed_ms = (time.time() - start_time) * 1000
logger.warning(f"[PreflightCheck] HTTP error after {elapsed_ms:.0f}ms")
raise
except Exception as e:
logger.error(f"Error in pre-flight check: {e}", exc_info=True)
elapsed_ms = (time.time() - start_time) * 1000
logger.error(f"[PreflightCheck] Error after {elapsed_ms:.0f}ms: {e}")
raise HTTPException(status_code=500, detail=f"Pre-flight check failed: {str(e)}")

View File

@@ -250,10 +250,6 @@ def huggingface_text_response(
logger.info("🚀 Making Hugging Face API call (chat completion)...")
# Add rate limiting to prevent expensive API calls
import time
time.sleep(1) # 1 second delay between API calls
response = None
last_error = None
for candidate_model in _fallback_model_sequence(model):
@@ -403,10 +399,6 @@ def huggingface_structured_json_response(
json_schema_str = json.dumps(schema, indent=2)
messages[-1]["content"] += f"\n\nJSON Schema:\n{json_schema_str}"
# Add rate limiting to prevent expensive API calls
import time
time.sleep(1) # 1 second delay between API calls
try:
response = None
last_error = None

View File

@@ -6,6 +6,7 @@ migrated from the legacy lib/gpt_providers/text_generation/main_text_generation.
import os
import json
import time
from typing import Optional, Dict, Any, List
from datetime import datetime
from loguru import logger
@@ -211,7 +212,7 @@ def llm_text_gen(
provider_enum = APIProvider.MISTRAL # HuggingFace maps to Mistral enum for usage tracking
actual_provider_name = "huggingface" # Keep actual provider name for logs
elif gpt_provider == "wavespeed":
provider_enum = APIProvider.OPENAI # Map to OpenAI for tracking purposes
provider_enum = APIProvider.WAVESPEED
actual_provider_name = "wavespeed"
elif gpt_provider == "openai":
provider_enum = APIProvider.OPENAI
@@ -225,6 +226,8 @@ def llm_text_gen(
if not user_id:
raise RuntimeError("user_id is required for subscription checking. Please provide Clerk user ID.")
sub_check_start = time.time()
logger.warning(f"[llm_text_gen][{flow_tag}] Subscription check START for user {user_id}")
try:
from services.database import get_session_for_user
from services.subscription import UsageTrackingService, PricingService
@@ -286,6 +289,8 @@ def llm_text_gen(
logger.info(f"[llm_text_gen] Subscription check passed for user {user_id}: provider={actual_provider_name or gpt_provider}, tokens_requested={estimated_total_tokens}, new_user_no_usage_record")
finally:
sub_check_ms = (time.time() - sub_check_start) * 1000
logger.warning(f"[llm_text_gen][{flow_tag}] Subscription check took {sub_check_ms:.0f}ms for user {user_id}")
db.close()
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit) - preserve error details
@@ -295,7 +300,8 @@ def llm_text_gen(
raise
except Exception as sub_error:
# STRICT: Fail on subscription check errors
logger.error(f"[llm_text_gen] Subscription check failed for user {user_id}: {sub_error}")
sub_check_ms = (time.time() - sub_check_start) * 1000
logger.error(f"[llm_text_gen][{flow_tag}] Subscription check FAILED after {sub_check_ms:.0f}ms for user {user_id}: {sub_error}")
raise RuntimeError(f"Subscription check failed: {str(sub_error)}")
# Construct the system prompt if not provided
@@ -366,6 +372,7 @@ def llm_text_gen(
)
elif gpt_provider == "wavespeed":
from services.llm_providers.wavespeed_provider import wavespeed_text_response
llm_start = time.time()
response_text = wavespeed_text_response(
prompt=prompt,
model=model or "openai/gpt-oss-120b",
@@ -374,6 +381,8 @@ def llm_text_gen(
top_p=top_p,
system_prompt=system_instructions
)
llm_ms = (time.time() - llm_start) * 1000
logger.warning(f"[llm_text_gen][{flow_tag}] LLM API call took {llm_ms:.0f}ms for user {user_id} (wavespeed)")
else:
logger.error(f"[llm_text_gen] Unknown provider: {gpt_provider}")
raise RuntimeError(f"Unknown LLM provider: {gpt_provider}. Supported providers: google, huggingface, wavespeed")

View File

@@ -274,10 +274,6 @@ def wavespeed_text_response(
logger.info("🚀 Making WaveSpeed API call (chat completion)...")
# Add rate limiting to prevent expensive API calls
import time
time.sleep(1) # 1 second delay between API calls
# Call exactly the requested model; no retries, no fallbacks, no variants
response = client.chat.completions.create(
model=model,
@@ -426,10 +422,6 @@ def wavespeed_structured_json_response(
json_schema_str = json.dumps(schema, indent=2)
messages[-1]["content"] += f"\n\nJSON Schema:\n{json_schema_str}"
# Add rate limiting to prevent expensive API calls
import time
time.sleep(1) # 1 second delay between API calls
try:
response = None
last_error = None

View File

@@ -0,0 +1,623 @@
"""
Programmatic B-Roll Composer
Layered composition pipeline: Background + Chart + Avatar Circle + Text Overlays
"""
import json
import numpy as np
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from PIL import Image, ImageDraw, ImageFont
from moviepy import (
VideoFileClip, ImageClip, CompositeVideoClip,
concatenate_videoclips,
)
import moviepy.video.fx as vfx
# ---------------------------------------------------------------------------
# Crossfade concat (Option 1: crossfadein + negative padding)
# ---------------------------------------------------------------------------
def crossfade_concat(scenes: list, fade_dur: float = 0.5):
"""
Concatenate scenes with a dissolve transition between each pair.
Each clip (except the first) gets a crossfadein effect.
padding=-fade_dur overlaps consecutive clips so the fade actually fires
instead of creating a black gap. set_duration on every scene is
mandatory — CompositeVideoClip.duration can be ambiguous without it,
which makes the overlap math wrong.
"""
faded = []
for i, clip in enumerate(scenes):
c = clip
if i > 0:
c = c.fx(vfx.CrossFadeIn, fade_dur)
faded.append(c)
return concatenate_videoclips(faded, padding=-int(fade_dur), method="compose")
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class Insight:
key_insight: str
supporting_stat: str
visual_cue: str # bar_chart_comparison | line_trend | bullet_points | full_avatar
audio_tone: str
chart_data: dict = field(default_factory=dict)
duration: float = 10.0
@dataclass
class SceneAssets:
background_img: str
chart_img: Optional[str] = None
avatar_video: Optional[str] = None
bullet_img: Optional[str] = None
# ---------------------------------------------------------------------------
# Chart generator (Matplotlib → PNG with transparency)
# ---------------------------------------------------------------------------
CHART_STYLE = {
"bg": "#0D0D0D",
"bar_before": "#2E4057",
"bar_after": "#E63946",
"text": "#F1F1EF",
"grid": "#2A2A2A",
"accent": "#E63946",
"pie_colors": ["#E63946", "#2E4057", "#457B9D", "#A8DADC", "#F4A261", "#2A9D8F"],
}
# ---------------------------------------------------------------------------
# Chart generators (Matplotlib → PNG with transparency)
# ---------------------------------------------------------------------------
def make_bar_chart(data: dict, out_path: str, title: str = "",
show_legend: bool = True, value_suffix: str = "%",
subtitle: str = "") -> str:
"""Render a side-by-side comparison bar chart. Returns output path."""
labels = data.get("labels", [])
before = data.get("before", [])
after = data.get("after", [])
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
ax.set_facecolor("none")
x = np.arange(len(labels))
w = 0.35
bars_b = ax.bar(x - w / 2, before, w, color=CHART_STYLE["bar_before"],
label="Before", zorder=3, edgecolor="none")
bars_a = ax.bar(x + w / 2, after, w, color=CHART_STYLE["bar_after"],
label="After", zorder=3, edgecolor="none")
ax.set_xticks(x)
ax.set_xticklabels(labels, color=CHART_STYLE["text"], fontsize=11)
ax.tick_params(axis="y", colors=CHART_STYLE["text"])
ax.spines[:].set_visible(False)
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
ax.set_axisbelow(True)
for bar in [*bars_b, *bars_a]:
h = bar.get_height()
ax.text(bar.get_x() + bar.get_width() / 2, h + 0.5, f"{h:.0f}{value_suffix}",
ha="center", va="bottom", color=CHART_STYLE["text"], fontsize=9,
fontweight="bold")
if show_legend:
legend = ax.legend(frameon=False, labelcolor=CHART_STYLE["text"],
fontsize=10, loc="upper left")
# Add title and optional subtitle
if title:
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
fontweight="bold", pad=12)
if subtitle:
fig.text(0.5, 0.02, subtitle, ha='center', color=CHART_STYLE["text"],
fontsize=10, style='italic')
fig.tight_layout(pad=0.5, rect=(0, 0.03 if subtitle else 0, 1, 1))
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
plt.close(fig)
return out_path
def make_horizontal_bar(data: dict, out_path: str, title: str = "",
value_suffix: str = "%", bar_color: str = None) -> str:
"""Render a horizontal bar chart (good for rankings/lists)."""
labels = data.get("labels", [])
values = data.get("values", data.get("y", []))
if not values:
return ""
bar_color = bar_color or CHART_STYLE["bar_after"]
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
ax.set_facecolor("none")
y_pos = np.arange(len(labels))
bars = ax.barh(y_pos, values, color=bar_color, zorder=3, edgecolor="none", height=0.6)
ax.set_yticks(y_pos)
ax.set_yticklabels(labels, color=CHART_STYLE["text"], fontsize=11)
ax.tick_params(axis="x", colors=CHART_STYLE["text"])
ax.spines[:].set_visible(False)
ax.xaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
ax.set_axisbelow(True)
ax.invert_yaxis()
for i, bar in enumerate(bars):
width = bar.get_width()
ax.text(width + 0.5, bar.get_y() + bar.get_height()/2, f"{width:.0f}{value_suffix}",
ha="left", va="center", color=CHART_STYLE["text"], fontsize=10,
fontweight="bold")
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_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:
"""Render a pie chart."""
labels = data.get("labels", [])
values = data.get("values", data.get("y", []))
if not values:
return ""
colors = CHART_STYLE["pie_colors"][:len(values)]
fig, ax = plt.subplots(figsize=(6, 4.5), facecolor="none")
ax.set_facecolor("none")
if donut:
wedges, texts, autotexts = ax.pie(
values, labels=labels if show_labels else None,
colors=colors, autopct=lambda p: f'{p:.1f}%' if show_percent else '',
startangle=90, pctdistance=0.75,
wedgeprops=dict(width=0.5, edgecolor="none")
)
else:
wedges, texts, autotexts = ax.pie(
values, labels=labels if show_labels else None,
colors=colors, autopct=lambda p: f'{p:.1f}%' if show_percent else '',
startangle=90, pctdistance=0.8
)
for text in texts:
text.set_color(CHART_STYLE["text"])
text.set_fontsize(10)
for autotext in autotexts:
autotext.set_color(CHART_STYLE["text"])
autotext.set_fontsize(9)
autotext.set_fontweight("bold")
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_stacked_bar(data: dict, out_path: str, title: str = "",
stack_labels: list = None) -> str:
"""Render a stacked bar chart."""
labels = data.get("labels", [])
stacks = data.get("stacks", []) # List of lists, each inner list is a stack
if not stacks or len(stacks) < 2:
return ""
stack_labels = stack_labels or [f"Series {i+1}" for i in range(len(stacks))]
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
ax.set_facecolor("none")
x = np.arange(len(labels))
bottom = np.zeros(len(labels))
colors = CHART_STYLE["pie_colors"][:len(stacks)]
for i, stack in enumerate(stacks):
bars = ax.bar(x, stack, 0.6, bottom=bottom, color=colors[i],
label=stack_labels[i], zorder=3, edgecolor="none")
for j, bar in enumerate(bars):
height = bar.get_height()
if height > 5: # Only show label if segment is big enough
ax.text(bar.get_x() + bar.get_width()/2,
bottom[j] + height/2,
f"{height:.0f}", ha="center", va="center",
color=CHART_STYLE["text"], fontsize=8, fontweight="bold")
bottom = bottom + np.array(stack)
ax.set_xticks(x)
ax.set_xticklabels(labels, color=CHART_STYLE["text"], fontsize=11)
ax.tick_params(axis="y", colors=CHART_STYLE["text"])
ax.spines[:].set_visible(False)
ax.legend(frameon=False, labelcolor=CHART_STYLE["text"], fontsize=9, loc="upper left")
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_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)
# ---------------------------------------------------------------------------
def make_bullet_overlay(lines: list[str], out_path: str,
width: int = 900, font_size: int = 32) -> str:
"""Render bullet points on a semi-transparent dark pill. Returns path."""
padding = 32
line_h = font_size + 16
img_h = padding * 2 + len(lines) * line_h + 12
img = Image.new("RGBA", (width, img_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.rounded_rectangle([0, 0, width - 1, img_h - 1],
radius=18, fill=(10, 10, 10, 185))
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
font_size)
except OSError:
font = ImageFont.load_default()
y = padding
for line in lines:
draw.text((padding + 18, y), f"{line}", font=font, fill=(241, 241, 239, 255))
y += line_h
img.save(out_path, format="PNG")
return out_path
def make_insight_card(insight: str, stat: str, out_path: str,
width: int = 960, height: int = 200) -> str:
"""Render a bold insight card (headline + supporting stat). Returns path."""
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.rounded_rectangle([0, 0, width - 1, height - 1],
radius=14, fill=(10, 10, 10, 200))
draw.rectangle([28, 24, 36, height - 24], fill=(230, 57, 70, 255))
try:
font_lg = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
font_sm = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
except OSError:
font_lg = font_sm = ImageFont.load_default()
draw.text((58, 36), insight, font=font_lg, fill=(241, 241, 239, 255))
draw.text((58, 90), stat, font=font_sm, fill=(180, 180, 178, 230))
img.save(out_path, format="PNG")
return out_path
# ---------------------------------------------------------------------------
# Circular avatar mask
# ---------------------------------------------------------------------------
def apply_circle_mask(clip: VideoFileClip, diameter: int) -> VideoFileClip:
"""Resize clip and apply a circular alpha mask."""
clip = clip.resize(height=diameter)
w, h = clip.size
Y, X = np.ogrid[:h, :w]
cx, cy = w / 2, h / 2
mask_arr = ((X - cx) ** 2 + (Y - cy) ** 2 <= (min(w, h) / 2) ** 2).astype(float)
mask_clip = ImageClip(mask_arr, ismask=True).set_duration(clip.duration)
return clip.set_mask(mask_clip)
# ---------------------------------------------------------------------------
# Ken Burns zoom effect
# ---------------------------------------------------------------------------
def ken_burns(clip: ImageClip, zoom_ratio: float = 0.08) -> ImageClip:
"""Apply a slow zoom-in over the clip duration."""
def zoom_frame(get_frame, t):
frame = get_frame(t)
frac = 1 + zoom_ratio * (t / clip.duration)
h, w = frame.shape[:2]
new_h, new_w = int(h / frac), int(w / frac)
y1 = (h - new_h) // 2
x1 = (w - new_w) // 2
cropped = frame[y1:y1 + new_h, x1:x1 + new_w]
return np.array(Image.fromarray(cropped).resize((w, h), Image.LANCZOS))
return clip.fl(zoom_frame, apply_to=["mask"])
# ---------------------------------------------------------------------------
# Scene builders (one per visual_cue type)
# ---------------------------------------------------------------------------
def build_data_scene(assets: SceneAssets, insight: Insight) -> CompositeVideoClip:
"""
Layout: Background (Ken Burns) + Chart (fade-in) + Avatar circle (corner) + Insight card
"""
d = insight.duration
layers = []
bg = (ImageClip(assets.background_img)
.set_duration(d)
.resize(height=1080))
bg = ken_burns(bg)
bg = bg.fx(vfx.lum_contrast, 0, -40)
layers.append(bg)
if assets.chart_img:
chart = (ImageClip(assets.chart_img)
.set_duration(d - 1.5)
.set_start(0.5)
.resize(width=700)
.set_position(("center", 180))
.fx(vfx.fadein, 0.6)
.fx(vfx.fadeout, 0.4))
layers.append(chart)
card_path = "/tmp/insight_card.png"
make_insight_card(insight.key_insight, insight.supporting_stat, card_path)
card = (ImageClip(card_path)
.set_duration(d - 1)
.set_start(0.5)
.set_position(("center", 820))
.fx(vfx.fadein, 0.5))
layers.append(card)
if assets.avatar_video:
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
avatar = apply_circle_mask(avatar_raw, diameter=240)
avatar = avatar.set_position((bg.w - 280, bg.h - 280))
layers.append(avatar)
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
def build_bullet_scene(assets: SceneAssets, insight: Insight,
bullets: list[str]) -> CompositeVideoClip:
"""
Layout: AI image (Ken Burns) + Bullet overlay + Avatar circle
"""
d = insight.duration
layers = []
bg = (ImageClip(assets.background_img)
.set_duration(d)
.resize(height=1080))
bg = ken_burns(bg, zoom_ratio=0.05)
bg = bg.fx(vfx.lum_contrast, 0, -50)
layers.append(bg)
bullet_path = "/tmp/bullets.png"
make_bullet_overlay(bullets, bullet_path, width=860)
bullets_clip = (ImageClip(bullet_path)
.set_duration(d - 1)
.set_start(0.5)
.set_position(("center", "center"))
.fx(vfx.fadein, 0.7))
layers.append(bullets_clip)
if assets.avatar_video:
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
avatar = apply_circle_mask(avatar_raw, diameter=200)
avatar = avatar.set_position((bg.w - 240, bg.h - 240))
layers.append(avatar)
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
def build_full_avatar_scene(assets: SceneAssets, insight: Insight) -> VideoFileClip:
"""Full-screen avatar — the expensive 'Hook' scene. No overlay."""
d = insight.duration
avatar = VideoFileClip(assets.avatar_video).subclip(0, d)
return avatar.resize(height=1080).set_duration(d)
# ---------------------------------------------------------------------------
# Scene dispatcher — maps visual_cue → builder
# ---------------------------------------------------------------------------
def dispatch_scene(insight: Insight, assets: SceneAssets,
bullet_lines: Optional[list[str]] = None):
"""Dispatch scene based on visual_cue type."""
cue = insight.visual_cue
if cue == "full_avatar":
return build_full_avatar_scene(assets, insight)
elif cue in ("bar_chart_comparison", "line_trend"):
chart_path = "/tmp/chart.png"
if cue == "bar_chart_comparison":
make_bar_chart(insight.chart_data, chart_path,
title=insight.key_insight)
else:
make_line_trend(insight.chart_data, chart_path,
title=insight.key_insight)
assets.chart_img = chart_path
return build_data_scene(assets, insight)
elif cue == "bullet_points":
lines = bullet_lines or [insight.key_insight, insight.supporting_stat]
return build_bullet_scene(assets, insight, lines)
else:
return build_data_scene(assets, insight)
# ---------------------------------------------------------------------------
# Master compositor — assembles all scenes into one video
# ---------------------------------------------------------------------------
def compose_video(scenes: list, output_path: str = "output.mp4",
fps: int = 24, fade_dur: float = 0.5) -> str:
"""Concatenate scenes with crossfade transitions and write final video file."""
final = crossfade_concat(scenes, fade_dur=fade_dur)
final.write_videofile(
output_path,
fps=fps,
codec="libx264",
audio_codec="aac",
threads=4,
preset="fast",
logger=None,
)
return output_path
# ---------------------------------------------------------------------------
# JSON bridge — LLM insight → assets + scene
# ---------------------------------------------------------------------------
def pipeline_from_json(insight_json: str,
background_img: str,
avatar_video: Optional[str] = None) -> str:
"""
Full pipeline:
1. Parse LLM insight JSON
2. Generate chart / overlay assets
3. Build scene
4. Write video
Returns path to output video.
"""
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 = dispatch_scene(insight, assets,
bullet_lines=data.get("bullet_lines"))
out = f"/tmp/scene_{insight.visual_cue}.mp4"
compose_video([scene], output_path=out)
return out
# ---------------------------------------------------------------------------
# Demo / smoke-test (no real media files needed for chart generation)
# ---------------------------------------------------------------------------
if __name__ == "__main__":
sample_bar_data = {
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
"before": [30, 22, 18, 60],
"after": [72, 34, 41, 38],
}
chart_out = make_bar_chart(
sample_bar_data,
"/tmp/demo_chart.png",
title="AI Tools Impact: Before vs After (2025)",
)
print(f"Chart saved → {chart_out}")
bullets = [
"AI reduced content cycles by 40% in 2025",
"HubSpot: 12% lift in CTR with AI-assisted copy",
"Video production cost down 3x with hybrid pipeline",
]
bullet_out = make_bullet_overlay(bullets, "/tmp/demo_bullets.png")
print(f"Bullets saved → {bullet_out}")
card_out = make_insight_card(
"AI tools reduced content cycles by 40%",
"HubSpot 2026 report — 12% lift in CTR",
"/tmp/demo_card.png",
)
print(f"Insight card saved → {card_out}")
sample_json = json.dumps({
"key_insight": "AI reduced production time by 40%",
"supporting_stat": "HubSpot 2026: 12% CTR lift",
"visual_cue": "bar_chart_comparison",
"audio_tone": "authoritative_and_surprising",
"duration": 8.0,
"chart_data": sample_bar_data,
})
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.")

View File

@@ -0,0 +1,253 @@
"""
B-Roll Service - Orchestrator for programmatic B-roll video composition.
This service handles:
- Chart data extraction from research
- Individual scene B-roll video generation
- Final video composition from multiple B-roll scenes
"""
import json
import uuid
import os
import tempfile
from pathlib import Path
from typing import Dict, Any, Optional, List
from loguru import logger
# Import chart generators directly
from services.podcast.broll_composer import (
make_bar_chart,
make_horizontal_bar,
make_line_trend,
make_pie_chart,
make_stacked_bar,
make_bullet_overlay,
make_insight_card,
)
class BrollService:
"""Orchestrates B-roll composition for podcast scenes."""
def __init__(self, output_dir: Optional[str] = None):
"""
Initialize B-roll service.
Args:
output_dir: Base directory for B-roll output. Defaults to temp directory.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
self.output_dir = Path(tempfile.gettempdir()) / "broll_output"
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[BrollService] Initialized with output directory: {self.output_dir}")
def get_output_path(self, filename: str) -> Path:
"""Get output path for a file."""
return self.output_dir / filename
def generate_chart_preview(
self,
chart_data: Dict[str, Any],
chart_type: str = "bar_comparison",
title: str = "",
subtitle: str = "",
) -> str:
"""
Generate a chart PNG preview (static, for Write phase).
Args:
chart_data: Chart data dict with labels, before/after, etc.
chart_type: Type of chart (bar_comparison, bar_horizontal, line_trend, pie, stacked_bar, bullet)
title: Title for the chart
subtitle: Optional subtitle at bottom
Returns:
Path to generated PNG file
"""
chart_id = uuid.uuid4().hex[:8]
out_path = str(self.get_output_path(f"chart_preview_{chart_id}.png"))
try:
if chart_type == "bar_comparison":
make_bar_chart(chart_data, out_path, title, subtitle=subtitle)
elif chart_type == "bar_horizontal":
make_horizontal_bar(chart_data, out_path, title)
elif chart_type == "line_trend":
make_line_trend(chart_data, out_path, title)
elif chart_type == "pie":
make_pie_chart(chart_data, out_path, title)
elif chart_type == "pie":
make_pie_chart(chart_data, out_path, title)
elif chart_type == "stacked_bar":
make_stacked_bar(chart_data, out_path, title)
elif chart_type == "bullet":
bullet_points = chart_data.get("bullet_points", [])
if bullet_points:
make_bullet_overlay(bullet_points, out_path)
else:
logger.warning("[BrollService] No bullet points provided")
return ""
else:
logger.warning(f"[BrollService] Unknown chart type: {chart_type}")
return ""
logger.info(f"[BrollService] Chart preview generated: {out_path}")
return out_path
except Exception as e:
logger.error(f"[BrollService] Failed to generate chart preview: {e}")
return ""
def generate_scene_broll(
self,
scene_id: str,
key_insight: str,
supporting_stat: str,
chart_data: Optional[Dict[str, Any]],
visual_cue: str, # bar_chart_comparison, bullet_points, full_avatar
duration: float,
background_img_path: str,
avatar_video_path: Optional[str] = None,
) -> str:
"""
Generate a B-roll video for a single scene.
Args:
scene_id: Scene identifier
key_insight: Main insight text for overlay
supporting_stat: Supporting statistic text
chart_data: Chart data dict (optional)
visual_cue: Type of scene to build
duration: Scene duration in seconds
background_img_path: Path to background image
avatar_video_path: Path to avatar video (optional)
Returns:
Path to generated video file
"""
scene_id_safe = scene_id.replace(" ", "_").replace("/", "_")
out_path = str(self.get_output_path(f"broll_{scene_id_safe}.mp4"))
try:
insight = Insight(
key_insight=key_insight,
supporting_stat=supporting_stat,
visual_cue=visual_cue,
audio_tone="neutral",
chart_data=chart_data or {},
duration=duration,
)
assets = SceneAssets(
background_img=background_img_path,
avatar_video=avatar_video_path,
)
# Generate the scene
scene = dispatch_scene(insight, assets)
# Write video
compose_video([scene], output_path=out_path)
logger.info(f"[BrollService] B-roll scene generated: {out_path}")
return out_path
except Exception as e:
logger.error(f"[BrollService] Failed to generate B-roll scene: {e}")
raise
def compose_final_video(
self,
video_paths: List[str],
output_filename: str,
fade_dur: float = 0.5,
fps: int = 24,
) -> str:
"""
Compose multiple B-roll scene videos into final video.
Args:
video_paths: List of video file paths to compose
output_filename: Output filename
fade_dur: Crossfade duration between scenes
fps: Output FPS
Returns:
Path to final composed video
"""
out_path = str(self.get_output_path(output_filename))
try:
scenes = []
for video_path in video_paths:
from moviepy import VideoFileClip
clip = VideoFileClip(video_path)
scenes.append(clip)
if not scenes:
raise ValueError("No video clips provided")
# Use crossfade_concat from broll_composer
from services.podcast.broll_composer import crossfade_concat
final = crossfade_concat(scenes, fade_dur=fade_dur)
final.write_videofile(
out_path,
fps=fps,
codec="libx264",
audio_codec="aac",
threads=4,
preset="fast",
logger=None,
)
# Close clips
for clip in scenes:
clip.close()
logger.info(f"[BrollService] Final video composed: {out_path}")
return out_path
except Exception as e:
logger.error(f"[BrollService] Failed to compose final video: {e}")
raise
def cleanup(self, file_paths: List[str] = None):
"""
Clean up temporary B-roll files.
Args:
file_paths: Specific files to delete. If None, cleans output directory.
"""
if file_paths:
for path in file_paths:
try:
if os.path.exists(path):
os.remove(path)
logger.debug(f"[BrollService] Removed: {path}")
except Exception as e:
logger.warning(f"[BrollService] Failed to remove {path}: {e}")
else:
# Clean entire output directory
for file in self.output_dir.glob("*"):
try:
file.unlink()
except Exception as e:
logger.warning(f"[BrollService] Failed to remove {file}: {e}")
# Singleton instance for reuse
_broll_service_instance: Optional[BrollService] = None
def get_broll_service(output_dir: Optional[str] = None) -> BrollService:
"""Get or create B-roll service singleton."""
global _broll_service_instance
if _broll_service_instance is None:
_broll_service_instance = BrollService(output_dir=output_dir)
return _broll_service_instance

View File

@@ -1,4 +1,6 @@
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
import time
from loguru import logger
from services.product_marketing.personalization_service import PersonalizationService
from models.podcast_bible_models import (
@@ -11,9 +13,14 @@ from models.podcast_bible_models import (
ShowRules
)
_BIBLE_CACHE_TTL_SECONDS = 120
class PodcastBibleService:
"""Service for generating and managing the Podcast Bible."""
_bible_cache: Dict[str, Dict[str, Any]] = {}
def __init__(self):
try:
from services.product_marketing.personalization_service import PersonalizationService
@@ -22,19 +29,40 @@ class PodcastBibleService:
logger.warning(f"Failed to initialize PersonalizationService: {e}")
self.personalization_service = None
@classmethod
def clear_user_cache(cls, user_id: str) -> int:
"""Clear cached Bible data for a specific user. Returns number of entries cleared."""
keys_to_remove = [key for key in cls._bible_cache if key.startswith(f"{user_id}:")]
for key in keys_to_remove:
del cls._bible_cache[key]
if keys_to_remove:
logger.info(f"[BibleCache] Cleared {len(keys_to_remove)} cache entries for user {user_id}")
return len(keys_to_remove)
def generate_bible(self, user_id: str, project_id: str) -> PodcastBible:
"""Generate a Podcast Bible from onboarding data."""
bible_start = time.time()
cache_key = f"{user_id}:{project_id}"
cached = self._bible_cache.get(cache_key)
if cached and cached.get('expires_at') and cached['expires_at'] > datetime.utcnow():
elapsed_ms = (time.time() - bible_start) * 1000
logger.warning(f"[BibleCache] HIT for {user_id} — saved 7 DB queries, overhead {elapsed_ms:.0f}ms")
return cached['bible']
logger.info(f"Generating Podcast Bible for user {user_id}")
try:
if not self.personalization_service:
logger.warning("PersonalizationService not available, using default bible")
elapsed_ms = (time.time() - bible_start) * 1000
logger.warning(f"[BibleCache] MISS (fallback) for {user_id} — PersonalizationService unavailable, {elapsed_ms:.0f}ms")
return self._get_default_bible(project_id)
try:
preferences = self.personalization_service.get_user_preferences(user_id)
except Exception as pref_err:
logger.warning(f"Failed to get user preferences: {pref_err}, using defaults")
elapsed_ms = (time.time() - bible_start) * 1000
logger.warning(f"[BibleCache] MISS (fallback) for {user_id} — get_user_preferences failed ({pref_err}), {elapsed_ms:.0f}ms")
return self._get_default_bible(project_id)
if not preferences:
@@ -131,6 +159,12 @@ class PodcastBibleService:
)
logger.info(f"Podcast Bible generated successfully for project {project_id}")
elapsed_ms = (time.time() - bible_start) * 1000
logger.warning(f"[BibleCache] MISS — generated in {elapsed_ms:.0f}ms (7 DB queries), cached for {_BIBLE_CACHE_TTL_SECONDS}s")
self._bible_cache[cache_key] = {
'bible': bible,
'expires_at': datetime.utcnow() + timedelta(seconds=_BIBLE_CACHE_TTL_SECONDS),
}
return bible
except Exception as e:
@@ -176,8 +210,12 @@ class PodcastBibleService:
)
def serialize_bible(self, bible: PodcastBible) -> str:
"""Serialize the Bible into a prompt-friendly text block."""
return f"""
"""Serialize the Bible into a prompt-friendly text block. Results are cached by project_id."""
cache_key = f"serialized:{bible.project_id}"
cached = self._bible_cache.get(cache_key)
if cached and cached.get('expires_at') and cached['expires_at'] > datetime.utcnow() and isinstance(cached.get('serialized'), str):
return cached['serialized']
serialized = f"""
<podcast_bible>
HOST PERSONA:
- Name: {bible.host.name}
@@ -212,3 +250,8 @@ SHOW RULES & STRUCTURE:
- Constraints: {', '.join(bible.show_rules.constraints)}
</podcast_bible>
"""
self._bible_cache[cache_key] = {
'serialized': serialized,
'expires_at': datetime.utcnow() + timedelta(seconds=_BIBLE_CACHE_TTL_SECONDS),
}
return serialized

View File

@@ -4,11 +4,11 @@ Podcast Service
Service layer for managing podcast project persistence.
"""
import os
from sqlalchemy.orm import Session
from sqlalchemy import desc, and_, or_
from typing import Optional, List, Dict, Any
from datetime import datetime
import uuid
from models.podcast_models import PodcastProject
from services.podcast_bible_service import PodcastBibleService
@@ -32,8 +32,14 @@ class PodcastService:
**kwargs
) -> PodcastProject:
"""Create a new podcast project."""
# Generate Podcast Bible automatically from onboarding data
bible = self.bible_service.generate_bible(user_id, project_id)
# Generate Podcast Bible in full mode only — skip in podcast-only mode
bible_data = None
if os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() != "podcast":
try:
bible = self.bible_service.generate_bible(user_id, project_id)
bible_data = bible.model_dump() if bible else None
except Exception:
pass # Bible is optional, project creation continues regardless
project = PodcastProject(
project_id=project_id,
@@ -42,7 +48,7 @@ class PodcastService:
duration=duration,
speakers=speakers,
budget_cap=budget_cap,
bible=bible.model_dump() if bible else None,
bible=bible_data,
status="draft",
current_step="create",
**kwargs

View File

@@ -4,6 +4,7 @@ Handles subscription limit checking and validation logic.
Extracted from pricing_service.py for better modularity.
"""
import time
from typing import Dict, Any, Optional, List, Tuple, TYPE_CHECKING
from datetime import datetime, timedelta
from sqlalchemy import text
@@ -32,9 +33,11 @@ class LimitValidator:
self.db = pricing_service.db
def check_usage_limits(self, user_id: str, provider: APIProvider,
tokens_requested: int = 0, actual_provider_name: Optional[str] = None) -> Tuple[bool, str, Dict[str, Any]]:
tokens_requested: int = 0, actual_provider_name: Optional[str] = None) -> Tuple[bool, str, Dict[str, Any]]:
"""Check if user can make an API call within their limits.
Delegates to LimitValidator for actual validation logic.
Args:
user_id: User ID
provider: APIProvider enum (may be MISTRAL for HuggingFace)
@@ -44,6 +47,7 @@ class LimitValidator:
Returns:
(can_proceed, error_message, usage_info)
"""
start_time = time.time()
try:
# Use actual_provider_name if provided, otherwise use enum value
# This fixes cases where HuggingFace maps to MISTRAL enum but should show as "huggingface" in errors
@@ -51,12 +55,14 @@ class LimitValidator:
logger.debug(f"[Subscription Check] Starting limit check for user {user_id}, provider {display_provider_name}, tokens {tokens_requested}")
logger.warning(f"[Subscription Check] START for user {user_id}, provider {provider.value}")
# Short TTL cache to reduce DB reads under sustained traffic
cache_key = f"{user_id}:{provider.value}"
now = datetime.utcnow()
cached = self.pricing_service._limits_cache.get(cache_key)
if cached and cached.get('expires_at') and cached['expires_at'] > now:
logger.debug(f"[Subscription Check] Using cached result for {user_id}:{provider.value}")
elapsed_ms = (time.time() - start_time) * 1000
logger.warning(f"[Subscription Check] Cache hit for {user_id}:{provider.value} — completed in {elapsed_ms:.0f}ms")
return tuple(cached['result']) # type: ignore
# Get user subscription first to check expiration
@@ -139,12 +145,15 @@ class LimitValidator:
return False, "No subscription plan found. Please subscribe to a plan.", {}
# Get current usage for this billing period with error handling
# CRITICAL: Use fresh queries to avoid SQLAlchemy cache after renewal
# Use targeted expiry instead of expire_all() to avoid nuking the entire session cache
try:
current_period = self.pricing_service.get_current_billing_period(user_id) or datetime.now().strftime("%Y-%m")
# Expire all objects to force fresh read from DB (critical after renewal)
self.db.expire_all()
# Only expire specific objects that might have changed after renewal
# (subscription was already checked above; plan was expired above)
# The usage record is the main object we need fresh, and we query it directly below
if subscription:
self.db.expire(subscription)
# Use raw SQL query first to bypass ORM cache, fallback to ORM if SQL fails
usage = None
@@ -367,14 +376,18 @@ class LimitValidator:
'result': result,
'expires_at': now + timedelta(seconds=30)
}
elapsed_ms = (time.time() - start_time) * 1000
logger.warning(f"[Subscription Check] Completed in {elapsed_ms:.0f}ms for user {user_id}, provider {display_provider_name} — within limits (calls: {current_call_count}/{call_limit_value})")
return result
except Exception as e:
logger.error(f"Error calculating usage percentages: {e}")
# Return basic success
elapsed_ms = (time.time() - start_time) * 1000
logger.warning(f"[Subscription Check] Completed in {elapsed_ms:.0f}ms for user {user_id}, provider {display_provider_name} — within limits (basic check)")
return True, "Within limits", {}
except Exception as e:
logger.error(f"Unexpected error in check_usage_limits for {user_id}: {e}")
elapsed_ms = (time.time() - start_time) * 1000
logger.error(f"[Subscription Check] Failed for user {user_id} after {elapsed_ms:.0f}ms: {e}")
# STRICT: Fail closed - deny requests if subscription system fails
return False, f"Subscription check error: {str(e)}", {}
@@ -417,9 +430,7 @@ class LimitValidator:
except Exception as schema_err:
logger.warning(f"Schema check failed, will retry on query error: {schema_err}")
# Explicitly expire any cached objects and refresh from DB to ensure fresh data
self.db.expire_all()
# Explicitly refresh usage from DB to ensure fresh data (targeted instead of expire_all)
try:
usage = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
@@ -438,7 +449,12 @@ class LimitValidator:
schema_utils._checked_usage_summaries_columns = False
from services.subscription.schema_utils import ensure_usage_summaries_columns
ensure_usage_summaries_columns(self.db)
self.db.expire_all()
# After schema migration, only expire UsageSummary to force re-query
# (no need to expire the entire session)
for obj in self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id
).all():
self.db.expire(obj)
# Retry the query
usage = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
@@ -594,8 +610,9 @@ class LimitValidator:
# Method 2: Fallback to fresh ORM query if raw SQL fails
if not query_succeeded:
try:
# Expire all cached objects and do fresh query
self.db.expire_all()
# Only refresh usage object, don't expire entire session
if usage:
self.db.refresh(usage)
fresh_usage = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period == current_period
@@ -792,7 +809,11 @@ class LimitValidator:
schema_utils._checked_usage_summaries_columns = False
from services.subscription.schema_utils import ensure_usage_summaries_columns
ensure_usage_summaries_columns(self.db)
self.db.expire_all()
# Only expire UsageSummary after schema migration, not entire session
for obj in self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id
).all():
self.db.expire(obj)
# Retry the query
usage = self.db.query(UsageSummary).filter(