feat(phase-4): UI/UX improvements for Podcast Maker Write phase
Frontend Changes: - Add scene numbering badge (1/N) next to scene titles - Add inline status chips (Complete, Audio, Image, Voice, Why Script) - Professional AI-like gradient styling for all chips with shadows - Remove Script Editor header and 'Why This Script Format?' collapsible - Move Voice and Why Script info to per-scene chips - Make scene section mobile-responsive (responsive layout, button sizing) - Rename 'B-Roll Charts' to 'Podcast Charts' with accordion (collapsed by default) - Add sceneIndex prop to SceneEditor for scene numbering - Enhanced accessibility with keyboard navigation and focus states Backend Changes: - Audio handler improvements - B-roll handler enhancements - Script handler updates - B-roll composer and service improvements - Removed temporary broll_temp files Technical: - Full mobile responsiveness for scene cards - Gradient chip styling: vibrant colors with white text and shadows - Non-breaking approval/generation flow preserved - TypeScript compatibility maintained
This commit is contained in:
@@ -1,666 +0,0 @@
|
|||||||
# 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.8–1.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.0–60.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 (12–60) |
|
|
||||||
| `fade_dur` | float | 0.5 | Crossfade duration in seconds (0.0–2.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 30–120 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`.
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
"""
|
|
||||||
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"}
|
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
"""
|
|
||||||
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.")
|
|
||||||
@@ -280,10 +280,11 @@ async def generate_podcast_audio(
|
|||||||
|
|
||||||
if voice_sample_url:
|
if voice_sample_url:
|
||||||
from services.llm_providers.main_audio_generation import qwen3_voice_clone, cosyvoice_voice_clone
|
from services.llm_providers.main_audio_generation import qwen3_voice_clone, cosyvoice_voice_clone
|
||||||
|
from utils.media_utils import detect_audio_format
|
||||||
|
|
||||||
engine = (request.voice_clone_engine or "qwen3").lower()
|
engine = (request.voice_clone_engine or "qwen3").lower()
|
||||||
logger.warning(f"[Podcast] 🔊 Voice clone path: engine={engine}, scene='{request.scene_title}', voice_sample_url={voice_sample_url[:80]}...")
|
logger.warning(f"[Podcast] 🔊 Voice clone path: engine={engine}, scene='{request.scene_title}', voice_sample_url={voice_sample_url[:80]}...")
|
||||||
|
|
||||||
# Download voice sample from URL (with caching)
|
# Download voice sample from URL (with caching)
|
||||||
logger.warning(f"[Podcast] Fetching voice sample from: {voice_sample_url}")
|
logger.warning(f"[Podcast] Fetching voice sample from: {voice_sample_url}")
|
||||||
try:
|
try:
|
||||||
@@ -294,6 +295,11 @@ async def generate_podcast_audio(
|
|||||||
logger.warning(f"[Podcast] Voice sample fetch result: {len(voice_sample_bytes) if voice_sample_bytes else 0} bytes")
|
logger.warning(f"[Podcast] Voice sample fetch result: {len(voice_sample_bytes) if voice_sample_bytes else 0} bytes")
|
||||||
if not voice_sample_bytes:
|
if not voice_sample_bytes:
|
||||||
raise HTTPException(status_code=400, detail=f"Could not fetch voice sample from {voice_sample_url}")
|
raise HTTPException(status_code=400, detail=f"Could not fetch voice sample from {voice_sample_url}")
|
||||||
|
|
||||||
|
# Detect actual audio format from bytes (may differ from file extension)
|
||||||
|
detected_fmt, detected_mime = detect_audio_format(voice_sample_bytes)
|
||||||
|
logger.warning(f"[Podcast] 🔊 Detected voice sample format: {detected_fmt} ({detected_mime}), {len(voice_sample_bytes)} bytes")
|
||||||
|
voice_mime_type = detected_mime or "audio/wav"
|
||||||
|
|
||||||
scene_text = request.text.strip()
|
scene_text = request.text.strip()
|
||||||
if len(scene_text) > 4000:
|
if len(scene_text) > 4000:
|
||||||
@@ -329,6 +335,7 @@ async def generate_podcast_audio(
|
|||||||
audio_bytes=voice_sample_bytes,
|
audio_bytes=voice_sample_bytes,
|
||||||
text=scene_text,
|
text=scene_text,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
audio_mime_type=voice_mime_type,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
audio_bytes = result_obj.preview_audio_bytes
|
audio_bytes = result_obj.preview_audio_bytes
|
||||||
@@ -341,6 +348,7 @@ async def generate_podcast_audio(
|
|||||||
audio_bytes=voice_sample_bytes,
|
audio_bytes=voice_sample_bytes,
|
||||||
text=scene_text,
|
text=scene_text,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
audio_mime_type=voice_mime_type,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
audio_bytes = result_obj.preview_audio_bytes
|
audio_bytes = result_obj.preview_audio_bytes
|
||||||
@@ -419,9 +427,14 @@ async def generate_podcast_audio(
|
|||||||
result["audio_url"] = f"/api/podcast/audio/{audio_filename}"
|
result["audio_url"] = f"/api/podcast/audio/{audio_filename}"
|
||||||
|
|
||||||
logger.warning(f"[Podcast] Audio generated - path: {result.get('audio_path')}, url: {result.get('audio_url')}")
|
logger.warning(f"[Podcast] Audio generated - path: {result.get('audio_path')}, url: {result.get('audio_url')}")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"[Podcast] ❌ Audio generation failed: {exc}", exc_info=True)
|
exc_type = type(exc).__name__
|
||||||
raise HTTPException(status_code=500, detail=f"Audio generation failed: {exc}")
|
exc_msg = str(exc)[:500]
|
||||||
|
logger.error(f"[Podcast] Audio generation failed ({exc_type}): {exc_msg}")
|
||||||
|
logger.error(f"[Podcast] Audio generation traceback:", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Audio generation failed ({exc_type}): {exc_msg}")
|
||||||
|
|
||||||
# Save to asset library (podcast module)
|
# Save to asset library (podcast module)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from pydantic import BaseModel, Field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from middleware.auth_middleware import get_current_user
|
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
|
||||||
from api.story_writer.utils.auth import require_authenticated_user
|
from api.story_writer.utils.auth import require_authenticated_user
|
||||||
from api.story_writer.task_manager import task_manager
|
from api.story_writer.task_manager import task_manager
|
||||||
from api.podcast.utils import _resolve_podcast_media_file
|
from api.podcast.utils import _resolve_podcast_media_file
|
||||||
@@ -23,7 +23,7 @@ from utils.media_utils import resolve_media_path
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(prefix="/broll", tags=["B-Roll"])
|
||||||
|
|
||||||
|
|
||||||
def _resolve_broll_background_image_path(background_image_url: str) -> str:
|
def _resolve_broll_background_image_path(background_image_url: str) -> str:
|
||||||
@@ -148,7 +148,7 @@ class BrollSceneRequest(BaseModel):
|
|||||||
key_insight: str
|
key_insight: str
|
||||||
supporting_stat: str
|
supporting_stat: str
|
||||||
chart_data: Optional[Dict[str, Any]] = None
|
chart_data: Optional[Dict[str, Any]] = None
|
||||||
visual_cue: str = Field(default="bar_chart_comparison", description="bar_chart_comparison | bullet_points")
|
visual_cue: str = Field(default="bar_comparison", description="bar_comparison | bar_horizontal | line_trend | pie | stacked_bar | bullet_points | full_avatar")
|
||||||
duration: float = Field(default=10.0, ge=3.0, le=60.0)
|
duration: float = Field(default=10.0, ge=3.0, le=60.0)
|
||||||
background_image_url: str
|
background_image_url: str
|
||||||
avatar_video_url: Optional[str] = None
|
avatar_video_url: Optional[str] = None
|
||||||
@@ -216,7 +216,9 @@ async def generate_chart_preview(
|
|||||||
)
|
)
|
||||||
|
|
||||||
preview_filename = Path(preview_path).name
|
preview_filename = Path(preview_path).name
|
||||||
preview_url = f"/api/podcast/preview/{chart_id}/{preview_filename}"
|
preview_url = f"/api/podcast/broll/preview/{chart_id}/{preview_filename}"
|
||||||
|
|
||||||
|
logger.warning(f"[Broll] Chart preview generated: chart_id={chart_id}, path={preview_path}, url={preview_url}")
|
||||||
|
|
||||||
return ChartPreviewResponse(
|
return ChartPreviewResponse(
|
||||||
preview_url=preview_url,
|
preview_url=preview_url,
|
||||||
@@ -249,7 +251,7 @@ async def generate_broll_scene(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Validate visual_cue
|
# Validate visual_cue
|
||||||
valid_cues = ["bar_chart_comparison", "bullet_points", "full_avatar"]
|
valid_cues = ["bar_comparison", "bar_chart_comparison", "bar_horizontal", "line_trend", "pie", "stacked_bar", "bullet_points", "full_avatar"]
|
||||||
if request.visual_cue not in valid_cues:
|
if request.visual_cue not in valid_cues:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -333,37 +335,39 @@ async def compose_broll_videos(
|
|||||||
async def serve_chart_preview(
|
async def serve_chart_preview(
|
||||||
chart_id: str,
|
chart_id: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
user_id: Optional[str] = None,
|
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Serve chart preview PNG files.
|
Serve chart preview PNG files.
|
||||||
|
|
||||||
- user_id passed as query param for multi-tenant workspace resolution
|
Uses authentication via Authorization header or token query parameter,
|
||||||
- endpoint is public (no auth) to allow direct image loading in browser
|
matching the pattern used by /api/podcast/images/ for browser <img> tags.
|
||||||
"""
|
"""
|
||||||
|
from api.podcast.constants import get_podcast_media_dir
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
# Validate filename to prevent directory traversal
|
# Validate filename to prevent directory traversal
|
||||||
if ".." in filename or "/" in filename or "\\" in filename:
|
if ".." in filename or "/" in filename or "\\" in filename:
|
||||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
logger.warning(f"[Broll] serve_chart_preview: chart_id={chart_id}, filename={filename}, user_id={user_id}")
|
logger.warning(f"[Broll] serve_chart_preview: chart_id={chart_id}, filename={filename}, user_id={user_id}")
|
||||||
|
|
||||||
broll_service = get_broll_service(user_id=user_id)
|
charts_dir = get_podcast_media_dir("chart", user_id)
|
||||||
expected_filename = broll_service.get_chart_preview_filename(chart_id)
|
file_path = charts_dir / filename
|
||||||
if filename != expected_filename:
|
|
||||||
raise HTTPException(status_code=404, detail="Chart preview not found")
|
|
||||||
|
|
||||||
# Use expected_filename to get the correct path
|
|
||||||
file_path = broll_service.get_output_path(expected_filename)
|
|
||||||
|
|
||||||
logger.warning(f"[Broll] serve_chart_preview: resolved path={file_path}, exists={file_path.exists()}")
|
logger.warning(f"[Broll] serve_chart_preview: resolved path={file_path}, exists={file_path.exists()}")
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="Chart preview not found")
|
raise HTTPException(status_code=404, detail="Chart preview not found")
|
||||||
|
|
||||||
|
# Security: ensure resolved path is within charts_dir
|
||||||
|
if not str(file_path.resolve()).startswith(str(charts_dir.resolve())):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=str(file_path),
|
path=str(file_path),
|
||||||
media_type="image/png",
|
media_type="image/png",
|
||||||
filename=expected_filename,
|
filename=filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -141,16 +141,18 @@ async def generate_podcast_script(
|
|||||||
if numeric_pairs:
|
if numeric_pairs:
|
||||||
labels = [p[0] for p in numeric_pairs]
|
labels = [p[0] for p in numeric_pairs]
|
||||||
values = [p[1] for p in numeric_pairs]
|
values = [p[1] for p in numeric_pairs]
|
||||||
|
sources = [f.get("url", f.get("source", "")) for f in research_fact_cards[:12] if f.get("url") or f.get("source")]
|
||||||
return {
|
return {
|
||||||
"type": "bar_comparison",
|
"type": "bar_comparison",
|
||||||
"title": scene_title,
|
"title": scene_title,
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
"values": values,
|
"values": values,
|
||||||
"takeaway": "Data points sourced from research facts used in this scene.",
|
"takeaway": "Data points sourced from research facts used in this scene.",
|
||||||
|
"source": sources[0] if sources else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "bullet",
|
"type": "bullet_points",
|
||||||
"title": scene_title,
|
"title": scene_title,
|
||||||
"bullet_points": ["Key point 1", "Key point 2", "Key point 3"],
|
"bullet_points": ["Key point 1", "Key point 2", "Key point 3"],
|
||||||
"takeaway": "Narration summary for this scene.",
|
"takeaway": "Narration summary for this scene.",
|
||||||
@@ -233,11 +235,15 @@ Return JSON with scenes array. Each scene:
|
|||||||
- ttsHints: optional list from [pause_300ms, pause_700ms, smile, serious_tone, emphasize_data]
|
- ttsHints: optional list from [pause_300ms, pause_700ms, smile, serious_tone, emphasize_data]
|
||||||
- Plain text only, no markdown
|
- Plain text only, no markdown
|
||||||
- chart_data: object for B-roll mapping (required in audio_only)
|
- chart_data: object for B-roll mapping (required in audio_only)
|
||||||
- type: bar_comparison|line_trend|bullet_points
|
- type: bar_comparison|bar_horizontal|line_trend|pie|stacked_bar|bullet_points
|
||||||
- title: short chart title
|
- title: short chart title
|
||||||
- labels: list
|
- labels: list
|
||||||
- values: list (same length as labels)
|
- values: list (same length as labels, required for bar/line/pie)
|
||||||
|
- before/after: parallel lists of numbers (for bar_comparison only)
|
||||||
|
- segments: list of {{name, values}} (for stacked_bar only)
|
||||||
|
- bullet_points: list of strings (for bullet_points only)
|
||||||
- takeaway: one sentence tying chart to narration
|
- takeaway: one sentence tying chart to narration
|
||||||
|
- source: URL or citation for the data (e.g. "Research fact #3" or a URL from the research context)
|
||||||
|
|
||||||
COST OPTIMIZATION:
|
COST OPTIMIZATION:
|
||||||
- 5-6 scenes max for {request.duration_minutes} min episode
|
- 5-6 scenes max for {request.duration_minutes} min episode
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ def crossfade_concat(scenes: list, fade_dur: float = 0.5):
|
|||||||
class Insight:
|
class Insight:
|
||||||
key_insight: str
|
key_insight: str
|
||||||
supporting_stat: str
|
supporting_stat: str
|
||||||
visual_cue: str # bar_chart_comparison | line_trend | bullet_points | full_avatar
|
visual_cue: str # bar_comparison|bar_horizontal|line_trend|pie|stacked_bar|bullet_points|full_avatar
|
||||||
audio_tone: str
|
audio_tone: str
|
||||||
chart_data: dict = field(default_factory=dict)
|
chart_data: dict = field(default_factory=dict)
|
||||||
duration: float = 10.0
|
duration: float = 10.0
|
||||||
@@ -173,39 +173,6 @@ def make_horizontal_bar(data: dict, out_path: str, title: str = "",
|
|||||||
return out_path
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
def make_line_trend(data: dict, out_path: str, title: str = "",
|
|
||||||
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 = "",
|
def make_pie_chart(data: dict, out_path: str, title: str = "",
|
||||||
show_labels: bool = True, show_percent: bool = True,
|
show_labels: bool = True, show_percent: bool = True,
|
||||||
donut: bool = False) -> str:
|
donut: bool = False) -> str:
|
||||||
@@ -304,17 +271,33 @@ def make_stacked_bar(data: dict, out_path: str, title: str = "",
|
|||||||
|
|
||||||
def make_line_trend(data: dict, out_path: str, title: str = "") -> str:
|
def make_line_trend(data: dict, out_path: str, title: str = "") -> str:
|
||||||
"""Render a trend line chart. Returns output path."""
|
"""Render a trend line chart. Returns output path."""
|
||||||
x_vals = data.get("x", [])
|
x_labels = data.get("labels", data.get("x", []))
|
||||||
y_vals = data.get("y", [])
|
y_vals = data.get("values", data.get("y", []))
|
||||||
|
|
||||||
|
if not x_labels or not y_vals:
|
||||||
|
return ""
|
||||||
|
|
||||||
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||||
ax.set_facecolor("none")
|
ax.set_facecolor("none")
|
||||||
|
|
||||||
|
try:
|
||||||
|
x_vals = [float(v) for v in x_labels]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
x_vals = list(range(len(x_labels)))
|
||||||
|
|
||||||
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
|
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
|
||||||
linewidth=2.5, marker="o", markersize=7, zorder=3)
|
linewidth=2.5, marker="o", markersize=7, zorder=3)
|
||||||
ax.fill_between(x_vals, y_vals, alpha=0.12, color=CHART_STYLE["accent"])
|
ax.fill_between(x_vals, y_vals, alpha=0.12, color=CHART_STYLE["accent"])
|
||||||
ax.spines[:].set_visible(False)
|
ax.spines[:].set_visible(False)
|
||||||
ax.tick_params(colors=CHART_STYLE["text"])
|
ax.tick_params(colors=CHART_STYLE["text"])
|
||||||
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
x_labels_f = [float(v) for v in x_labels]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
ax.set_xticks(x_vals)
|
||||||
|
ax.set_xticklabels(x_labels, color=CHART_STYLE["text"], fontsize=10)
|
||||||
|
|
||||||
if title:
|
if title:
|
||||||
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||||
fontweight="bold", pad=12)
|
fontweight="bold", pad=12)
|
||||||
@@ -514,14 +497,31 @@ def dispatch_scene(insight: Insight, assets: SceneAssets,
|
|||||||
if cue == "full_avatar":
|
if cue == "full_avatar":
|
||||||
return build_full_avatar_scene(assets, insight)
|
return build_full_avatar_scene(assets, insight)
|
||||||
|
|
||||||
elif cue in ("bar_chart_comparison", "line_trend"):
|
elif cue in ("bar_comparison", "bar_chart_comparison", "bar_horizontal", "line_trend", "pie", "stacked_bar"):
|
||||||
chart_path = "/tmp/chart.png"
|
chart_path = "/tmp/chart.png"
|
||||||
if cue == "bar_chart_comparison":
|
chart_data = insight.chart_data or {}
|
||||||
make_bar_chart(insight.chart_data, chart_path,
|
if cue in ("bar_comparison", "bar_chart_comparison"):
|
||||||
|
# Normalize {labels, values} -> {labels, before, after} for make_bar_chart
|
||||||
|
if not chart_data.get("before") and not chart_data.get("after"):
|
||||||
|
values = chart_data.get("values", [])
|
||||||
|
labels = chart_data.get("labels", [])
|
||||||
|
if values and labels:
|
||||||
|
n = min(len(labels), len(values))
|
||||||
|
chart_data = {**chart_data, "labels": labels[:n], "before": [0] * n, "after": values[:n]}
|
||||||
|
make_bar_chart(chart_data, chart_path,
|
||||||
title=insight.key_insight)
|
title=insight.key_insight)
|
||||||
else:
|
elif cue == "bar_horizontal":
|
||||||
make_line_trend(insight.chart_data, chart_path,
|
make_horizontal_bar(chart_data, chart_path,
|
||||||
|
title=insight.key_insight)
|
||||||
|
elif cue == "line_trend":
|
||||||
|
make_line_trend(chart_data, chart_path,
|
||||||
|
title=insight.key_insight)
|
||||||
|
elif cue == "pie":
|
||||||
|
make_pie_chart(chart_data, chart_path,
|
||||||
title=insight.key_insight)
|
title=insight.key_insight)
|
||||||
|
elif cue == "stacked_bar":
|
||||||
|
make_stacked_bar(chart_data, chart_path,
|
||||||
|
title=insight.key_insight)
|
||||||
assets.chart_img = chart_path
|
assets.chart_img = chart_path
|
||||||
return build_data_scene(assets, insight)
|
return build_data_scene(assets, insight)
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class BrollService:
|
|||||||
self.output_dir = self._get_chart_dir(user_id)
|
self.output_dir = self._get_chart_dir(user_id)
|
||||||
|
|
||||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
logger.info(f"[BrollService] Initialized with output directory: {self.output_dir}")
|
logger.warning(f"[BrollService] Initialized with output directory: {self.output_dir}")
|
||||||
|
|
||||||
def _get_chart_dir(self, user_id: Optional[str] = None) -> Path:
|
def _get_chart_dir(self, user_id: Optional[str] = None) -> Path:
|
||||||
"""Get chart directory from podcast constants (workspace-aware)."""
|
"""Get chart directory from podcast constants (workspace-aware)."""
|
||||||
@@ -103,9 +103,11 @@ class BrollService:
|
|||||||
if not before and not after:
|
if not before and not after:
|
||||||
values = chart_data.get("values", [])
|
values = chart_data.get("values", [])
|
||||||
if values:
|
if values:
|
||||||
# Use original labels, set before to zeros, values go to after
|
# Normalize to same length, truncating or padding as needed
|
||||||
before = [0] * len(labels)
|
n = min(len(labels), len(values))
|
||||||
after = values[:len(labels)]
|
labels = labels[:n]
|
||||||
|
before = [0] * n
|
||||||
|
after = values[:n]
|
||||||
# Create modified data dict with proper format for make_bar_chart
|
# Create modified data dict with proper format for make_bar_chart
|
||||||
chart_data_for_render = {
|
chart_data_for_render = {
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
@@ -123,6 +125,7 @@ class BrollService:
|
|||||||
logger.warning(f"[BrollService] Data shape mismatch: labels={len(labels)}, before={len(before)}, after={len(after)}")
|
logger.warning(f"[BrollService] Data shape mismatch: labels={len(labels)}, before={len(before)}, after={len(after)}")
|
||||||
return ""
|
return ""
|
||||||
make_bar_chart(chart_data_for_render, out_path, title, subtitle=subtitle)
|
make_bar_chart(chart_data_for_render, out_path, title, subtitle=subtitle)
|
||||||
|
logger.warning(f"[BrollService] bar_comparison rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||||
elif chart_type == "bar_horizontal":
|
elif chart_type == "bar_horizontal":
|
||||||
labels = chart_data.get("labels", [])
|
labels = chart_data.get("labels", [])
|
||||||
values = chart_data.get("values", [])
|
values = chart_data.get("values", [])
|
||||||
@@ -130,6 +133,7 @@ class BrollService:
|
|||||||
logger.warning("[BrollService] Missing required data for bar_horizontal")
|
logger.warning("[BrollService] Missing required data for bar_horizontal")
|
||||||
return ""
|
return ""
|
||||||
make_horizontal_bar(chart_data, out_path, title)
|
make_horizontal_bar(chart_data, out_path, title)
|
||||||
|
logger.warning(f"[BrollService] bar_horizontal rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||||
elif chart_type == "line_trend":
|
elif chart_type == "line_trend":
|
||||||
labels = chart_data.get("labels", [])
|
labels = chart_data.get("labels", [])
|
||||||
values = chart_data.get("values", [])
|
values = chart_data.get("values", [])
|
||||||
@@ -137,6 +141,7 @@ class BrollService:
|
|||||||
logger.warning("[BrollService] Missing required data for line_trend")
|
logger.warning("[BrollService] Missing required data for line_trend")
|
||||||
return ""
|
return ""
|
||||||
make_line_trend(chart_data, out_path, title)
|
make_line_trend(chart_data, out_path, title)
|
||||||
|
logger.warning(f"[BrollService] line_trend rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||||
elif chart_type == "pie":
|
elif chart_type == "pie":
|
||||||
labels = chart_data.get("labels", [])
|
labels = chart_data.get("labels", [])
|
||||||
values = chart_data.get("values", [])
|
values = chart_data.get("values", [])
|
||||||
@@ -144,6 +149,7 @@ class BrollService:
|
|||||||
logger.warning("[BrollService] Missing required data for pie")
|
logger.warning("[BrollService] Missing required data for pie")
|
||||||
return ""
|
return ""
|
||||||
make_pie_chart(chart_data, out_path, title)
|
make_pie_chart(chart_data, out_path, title)
|
||||||
|
logger.warning(f"[BrollService] pie rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||||
elif chart_type == "stacked_bar":
|
elif chart_type == "stacked_bar":
|
||||||
labels = chart_data.get("labels", [])
|
labels = chart_data.get("labels", [])
|
||||||
segments = chart_data.get("segments", [])
|
segments = chart_data.get("segments", [])
|
||||||
@@ -151,6 +157,7 @@ class BrollService:
|
|||||||
logger.warning("[BrollService] Missing required data for stacked_bar")
|
logger.warning("[BrollService] Missing required data for stacked_bar")
|
||||||
return ""
|
return ""
|
||||||
make_stacked_bar(chart_data, out_path, title)
|
make_stacked_bar(chart_data, out_path, title)
|
||||||
|
logger.warning(f"[BrollService] stacked_bar rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||||
elif chart_type == "bullet" or chart_type == "bullet_points":
|
elif chart_type == "bullet" or chart_type == "bullet_points":
|
||||||
# Accept both: bullet_points OR labels
|
# Accept both: bullet_points OR labels
|
||||||
bullet_points = chart_data.get("bullet_points", [])
|
bullet_points = chart_data.get("bullet_points", [])
|
||||||
@@ -163,6 +170,7 @@ class BrollService:
|
|||||||
bullet_points = labels_fallback
|
bullet_points = labels_fallback
|
||||||
if bullet_points:
|
if bullet_points:
|
||||||
make_bullet_overlay(bullet_points, out_path)
|
make_bullet_overlay(bullet_points, out_path)
|
||||||
|
logger.warning(f"[BrollService] bullet_points rendered: {out_path}, exists={os.path.exists(out_path)}")
|
||||||
else:
|
else:
|
||||||
logger.warning("[BrollService] No bullet points provided")
|
logger.warning("[BrollService] No bullet points provided")
|
||||||
return ""
|
return ""
|
||||||
@@ -176,7 +184,34 @@ class BrollService:
|
|||||||
logger.warning(f"[BrollService] Fallback also failed: {fallback_err}")
|
logger.warning(f"[BrollService] Fallback also failed: {fallback_err}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
logger.info(f"[BrollService] Chart preview generated: {out_path}")
|
logger.warning(f"[BrollService] Chart preview generated: {out_path}, exists={os.path.exists(out_path) if out_path else 'N/A'}")
|
||||||
|
|
||||||
|
# Add source attribution overlay if present
|
||||||
|
source = chart_data.get("source", "").strip()
|
||||||
|
if source and out_path and os.path.exists(out_path):
|
||||||
|
try:
|
||||||
|
from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||||
|
img = PILImage.open(out_path).convert("RGBA")
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
source_text = f"Source: {source[:80]}"
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
|
||||||
|
except (OSError, IOError):
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("arial.ttf", 11)
|
||||||
|
except (OSError, IOError):
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
text_bbox = draw.textbbox((0, 0), source_text, font=font)
|
||||||
|
text_w = text_bbox[2] - text_bbox[0]
|
||||||
|
text_h = text_bbox[3] - text_bbox[1]
|
||||||
|
x = img.width - text_w - 12
|
||||||
|
y = img.height - text_h - 8
|
||||||
|
draw.rectangle([x - 4, y - 2, x + text_w + 4, y + text_h + 2], fill=(0, 0, 0, 140))
|
||||||
|
draw.text((x, y), source_text, fill=(200, 200, 200, 220), font=font)
|
||||||
|
img.save(out_path)
|
||||||
|
except Exception as src_err:
|
||||||
|
logger.warning(f"[BrollService] Source overlay failed (non-fatal): {src_err}")
|
||||||
|
|
||||||
return out_path
|
return out_path
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -189,7 +224,7 @@ class BrollService:
|
|||||||
key_insight: str,
|
key_insight: str,
|
||||||
supporting_stat: str,
|
supporting_stat: str,
|
||||||
chart_data: Optional[Dict[str, Any]],
|
chart_data: Optional[Dict[str, Any]],
|
||||||
visual_cue: str, # bar_chart_comparison, bullet_points, full_avatar
|
visual_cue: str, # bar_comparison, bar_horizontal, line_trend, pie, stacked_bar, bullet_points, full_avatar
|
||||||
duration: float,
|
duration: float,
|
||||||
background_img_path: str,
|
background_img_path: str,
|
||||||
avatar_video_path: Optional[str] = None,
|
avatar_video_path: Optional[str] = None,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export const DEFAULT_KNOBS: Knobs = {
|
|||||||
voice_speed: 1,
|
voice_speed: 1,
|
||||||
voice_id: "Wise_Woman",
|
voice_id: "Wise_Woman",
|
||||||
custom_voice_id: undefined,
|
custom_voice_id: undefined,
|
||||||
|
is_voice_clone: undefined,
|
||||||
|
voice_sample_url: undefined,
|
||||||
|
voice_clone_engine: undefined,
|
||||||
resolution: "720p",
|
resolution: "720p",
|
||||||
scene_length_target: 45,
|
scene_length_target: 45,
|
||||||
sample_rate: 24000,
|
sample_rate: 24000,
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ import {
|
|||||||
Delete as DeleteIcon,
|
Delete as DeleteIcon,
|
||||||
Fullscreen as FullscreenIcon,
|
Fullscreen as FullscreenIcon,
|
||||||
Close as CloseIcon,
|
Close as CloseIcon,
|
||||||
|
Refresh as RefreshIcon,
|
||||||
|
Info as InfoIcon,
|
||||||
|
Mic as MicIcon,
|
||||||
|
HelpOutline as HelpOutlineIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { Scene, Line, Knobs } from "../types";
|
import { Scene, Line, Knobs } from "../types";
|
||||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||||
|
import { OperationButton } from "../../shared/OperationButton";
|
||||||
import { LineEditor } from "./LineEditor";
|
import { LineEditor } from "./LineEditor";
|
||||||
import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal";
|
import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal";
|
||||||
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
|
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
|
||||||
@@ -33,6 +38,7 @@ interface SceneEditorProps {
|
|||||||
idea?: string; // Podcast idea for image generation context
|
idea?: string; // Podcast idea for image generation context
|
||||||
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
|
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
|
||||||
totalScenes?: number; // Total number of scenes in the script
|
totalScenes?: number; // Total number of scenes in the script
|
||||||
|
sceneIndex?: number; // Current scene index (0-based) for 1/N numbering
|
||||||
analysis?: {
|
analysis?: {
|
||||||
audience?: string;
|
audience?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
@@ -53,6 +59,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
|||||||
idea,
|
idea,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
totalScenes,
|
totalScenes,
|
||||||
|
sceneIndex,
|
||||||
analysis,
|
analysis,
|
||||||
}) => {
|
}) => {
|
||||||
const [localGenerating, setLocalGenerating] = useState(false);
|
const [localGenerating, setLocalGenerating] = useState(false);
|
||||||
@@ -65,6 +72,8 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
|||||||
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
|
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
|
||||||
const [showAudioModal, setShowAudioModal] = useState(false);
|
const [showAudioModal, setShowAudioModal] = useState(false);
|
||||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||||
|
const [showApprovalInfo, setShowApprovalInfo] = useState(false);
|
||||||
|
const [showWhyScript, setShowWhyScript] = useState(false);
|
||||||
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
|
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
|
||||||
voiceId: knobs.voice_id || "Wise_Woman",
|
voiceId: knobs.voice_id || "Wise_Woman",
|
||||||
customVoiceId: knobs.custom_voice_id || undefined,
|
customVoiceId: knobs.custom_voice_id || undefined,
|
||||||
@@ -283,6 +292,15 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
|||||||
const generating = generatingAudioId === scene.id || localGenerating;
|
const generating = generatingAudioId === scene.id || localGenerating;
|
||||||
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
|
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
|
||||||
const hasImage = Boolean(scene.imageUrl);
|
const hasImage = Boolean(scene.imageUrl);
|
||||||
|
|
||||||
|
// Completion status for visual feedback
|
||||||
|
const isComplete = hasAudio && hasImage;
|
||||||
|
const completionPercent = (hasAudio ? 50 : 0) + (hasImage ? 50 : 0);
|
||||||
|
|
||||||
|
// Scene order for 1/N badge display
|
||||||
|
const sceneOrder = sceneIndex != null ? sceneIndex + 1 : null;
|
||||||
|
const totalScenesInline = totalScenes ?? null;
|
||||||
|
const showOrderBadge = sceneOrder != null && totalScenesInline != null;
|
||||||
|
|
||||||
const handleApproveAndGenerate = async (settings?: AudioGenerationSettings) => {
|
const handleApproveAndGenerate = async (settings?: AudioGenerationSettings) => {
|
||||||
const wasAlreadyApproved = scene.approved;
|
const wasAlreadyApproved = scene.approved;
|
||||||
@@ -507,124 +525,402 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassyCard sx={glassyCardSx}>
|
<GlassyCard
|
||||||
<Stack spacing={2.5}>
|
sx={{
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
...glassyCardSx,
|
||||||
<Box sx={{ flex: 1 }}>
|
border: isComplete
|
||||||
|
? "2px solid #10b981"
|
||||||
|
: completionPercent > 0
|
||||||
|
? "2px solid #f59e0b"
|
||||||
|
: "1px solid rgba(15, 23, 42, 0.08)",
|
||||||
|
background: isComplete
|
||||||
|
? "linear-gradient(135deg, rgba(16, 185, 129, 0.03) 0%, rgba(255, 255, 255, 0.9) 100%)"
|
||||||
|
: completionPercent > 0
|
||||||
|
? "linear-gradient(135deg, rgba(245, 158, 11, 0.03) 0%, rgba(255, 255, 255, 0.9) 100%)"
|
||||||
|
: glassyCardSx.background,
|
||||||
|
boxShadow: isComplete
|
||||||
|
? "0 4px 20px rgba(16, 185, 129, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)"
|
||||||
|
: completionPercent > 0
|
||||||
|
? "0 4px 20px rgba(245, 158, 11, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)"
|
||||||
|
: glassyCardSx.boxShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Completion Progress Bar */}
|
||||||
|
<Box sx={{ position: "relative", height: 4, mb: 2, borderRadius: 2, overflow: "hidden", backgroundColor: "rgba(0,0,0,0.04)" }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: `${completionPercent}%`,
|
||||||
|
background: isComplete
|
||||||
|
? "linear-gradient(90deg, #10b981 0%, #059669 100%)"
|
||||||
|
: "linear-gradient(90deg, #f59e0b 0%, #d97706 100%)",
|
||||||
|
transition: "width 0.5s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack spacing={{ xs: 2, sm: 2.5 }}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: 'stretch', sm: 'flex-start' }}
|
||||||
|
spacing={{ xs: 2, sm: 0 }}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 1.5,
|
gap: { xs: 1, sm: 1.5 },
|
||||||
mb: 1,
|
mb: { xs: 0.75, sm: 1 },
|
||||||
color: "#0f172a",
|
color: "#0f172a",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "1.25rem",
|
fontSize: { xs: "1.1rem", sm: "1.25rem" },
|
||||||
letterSpacing: "-0.01em",
|
letterSpacing: "-0.01em",
|
||||||
|
flexWrap: 'wrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
|
<EditNoteIcon fontSize="small" sx={{ color: isComplete ? "#059669" : completionPercent > 0 ? "#d97706" : "#667eea", fontSize: { xs: "1.25rem", sm: "1.5rem" } }} />
|
||||||
{scene.title}
|
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: { xs: 'normal', sm: 'nowrap' } }}>
|
||||||
|
{scene.title}
|
||||||
|
</Box>
|
||||||
|
{showOrderBadge && (
|
||||||
|
<Chip
|
||||||
|
label={`${sceneOrder}/${totalScenesInline}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
ml: { xs: 0, sm: 0.5 },
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
border: 'none',
|
||||||
|
height: { xs: 20, sm: 24 },
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.7rem' },
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#ffffff',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
px: { xs: 0.75, sm: 1 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
<Stack direction="row" spacing={{ xs: 1, sm: 1.5 }} alignItems="center" flexWrap="wrap">
|
||||||
|
{/* Completion Status Chip */}
|
||||||
<Chip
|
<Chip
|
||||||
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
|
icon={isComplete ? <CheckCircleIcon /> : completionPercent > 0 ? <RefreshIcon /> : <RadioButtonUncheckedIcon />}
|
||||||
label={scene.approved ? "Approved" : "Pending Approval"}
|
label={isComplete ? "Complete" : completionPercent > 0 ? `In Progress ${completionPercent}%` : "Pending"}
|
||||||
size="small"
|
size="small"
|
||||||
color={scene.approved ? "success" : "warning"}
|
|
||||||
sx={{
|
sx={{
|
||||||
background: scene.approved
|
background: isComplete
|
||||||
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
|
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||||
: "linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.12) 100%)",
|
: completionPercent > 0
|
||||||
color: scene.approved ? "#059669" : "#d97706",
|
? "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)"
|
||||||
border: scene.approved
|
: "linear-gradient(135deg, #64748b 0%, #475569 100%)",
|
||||||
? "1px solid rgba(16, 185, 129, 0.25)"
|
color: "#ffffff",
|
||||||
: "1px solid rgba(245, 158, 11, 0.25)",
|
border: "none",
|
||||||
fontWeight: 600,
|
fontWeight: 700,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
height: 26,
|
height: 26,
|
||||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
borderRadius: "12px",
|
||||||
|
px: 1,
|
||||||
|
boxShadow: isComplete
|
||||||
|
? "0 3px 12px rgba(16, 185, 129, 0.4)"
|
||||||
|
: completionPercent > 0
|
||||||
|
? "0 3px 12px rgba(245, 158, 11, 0.4)"
|
||||||
|
: "0 2px 6px rgba(100, 116, 139, 0.3)",
|
||||||
|
'& .MuiChip-icon': {
|
||||||
|
fontSize: '1rem',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
pl: 0.5,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
|
|
||||||
|
{/* Audio Status */}
|
||||||
|
<Chip
|
||||||
|
icon={hasAudio ? <CheckCircleIcon sx={{ fontSize: '0.875rem !important' }} /> : <VolumeUpIcon sx={{ fontSize: '0.875rem !important' }} />}
|
||||||
|
label={hasAudio ? "Audio Ready" : "No Audio"}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
background: hasAudio
|
||||||
|
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||||
|
: "linear-gradient(135deg, #94a3b8 0%, #64748b 100%)",
|
||||||
|
color: "#ffffff",
|
||||||
|
border: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "10px",
|
||||||
|
px: 0.75,
|
||||||
|
boxShadow: hasAudio ? "0 2px 8px rgba(16, 185, 129, 0.35)" : "0 1px 4px rgba(100, 116, 139, 0.25)",
|
||||||
|
'& .MuiChip-icon': {
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
pl: 0.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Image Status */}
|
||||||
|
<Chip
|
||||||
|
icon={hasImage ? <CheckCircleIcon sx={{ fontSize: '0.875rem !important' }} /> : <ImageIcon sx={{ fontSize: '0.875rem !important' }} />}
|
||||||
|
label={hasImage ? "Image Ready" : "No Image"}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
background: hasImage
|
||||||
|
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||||
|
: "linear-gradient(135deg, #94a3b8 0%, #64748b 100%)",
|
||||||
|
color: "#ffffff",
|
||||||
|
border: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "10px",
|
||||||
|
px: 0.75,
|
||||||
|
boxShadow: hasImage ? "0 2px 8px rgba(16, 185, 129, 0.35)" : "0 1px 4px rgba(100, 116, 139, 0.25)",
|
||||||
|
'& .MuiChip-icon': {
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
pl: 0.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem", ml: 1 }}>
|
||||||
Duration: {scene.duration}s
|
Duration: {scene.duration}s
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{/* Approval Info Panel - Inline chips for guidance */}
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" sx={{ mt: 1 }}>
|
||||||
|
{/* Active Voice indicator */}
|
||||||
|
<Chip
|
||||||
|
icon={<MicIcon sx={{ fontSize: '0.875rem !important' }} />}
|
||||||
|
label={`Voice: ${knobs.voice_id === "Wise_Woman" ? "Wise Woman" : knobs.voice_id?.replace(/_/g, " ") || "Default"}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
color: "#ffffff",
|
||||||
|
border: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "10px",
|
||||||
|
px: 0.75,
|
||||||
|
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
||||||
|
'& .MuiChip-icon': { color: '#ffffff' },
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
pl: 0.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Why Script chip - opens modal with guidance */}
|
||||||
|
<Chip
|
||||||
|
icon={<HelpOutlineIcon sx={{ fontSize: '0.875rem !important' }} />}
|
||||||
|
label="Why Script?"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowWhyScript(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowWhyScript(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label="Learn why scene approval is required"
|
||||||
|
sx={{
|
||||||
|
background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)",
|
||||||
|
color: "#ffffff",
|
||||||
|
border: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "10px",
|
||||||
|
px: 0.75,
|
||||||
|
cursor: "pointer",
|
||||||
|
boxShadow: "0 2px 8px rgba(245, 158, 11, 0.35)",
|
||||||
|
'& .MuiChip-icon': { color: '#ffffff' },
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
pl: 0.5,
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
background: "linear-gradient(135deg, #d97706 0%, #b45309 100%)",
|
||||||
|
boxShadow: "0 3px 10px rgba(245, 158, 11, 0.45)",
|
||||||
|
},
|
||||||
|
'&:focus': {
|
||||||
|
outline: '2px solid rgba(245, 158, 11, 0.6)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
|
<Stack
|
||||||
<PrimaryButton
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
onClick={handleAudioRegenerateClick}
|
spacing={{ xs: 1, sm: 1.5 }}
|
||||||
disabled={approving || generating}
|
flexWrap="wrap"
|
||||||
loading={approving || generating}
|
useFlexGap
|
||||||
startIcon={
|
sx={{
|
||||||
hasAudio && !generating ? (
|
width: { xs: '100%', sm: 'auto' },
|
||||||
<VolumeUpIcon />
|
'& > *': { width: { xs: '100%', sm: 'auto' } }
|
||||||
) : generating ? (
|
}}
|
||||||
<CircularProgress size={16} sx={{ color: "white" }} />
|
>
|
||||||
) : (
|
<Tooltip
|
||||||
<PlayArrowIcon />
|
title={
|
||||||
)
|
|
||||||
}
|
|
||||||
tooltip={
|
|
||||||
hasAudio && !generating
|
hasAudio && !generating
|
||||||
? "Regenerate audio for this scene with custom settings"
|
? "✓ Audio generated! Click to regenerate with different settings"
|
||||||
: generating
|
: generating
|
||||||
? "Generating audio..."
|
? "Generating audio... please wait"
|
||||||
: scene.approved
|
: scene.approved
|
||||||
? "Generate audio for this scene"
|
? "Generate audio for this scene"
|
||||||
: "Approve scene and generate audio"
|
: "Approve scene and generate audio"
|
||||||
}
|
}
|
||||||
sx={{
|
|
||||||
minWidth: 200,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{hasAudio && !generating
|
<Box>
|
||||||
? "Regenerate Audio"
|
<OperationButton
|
||||||
: generating
|
operation={{
|
||||||
? "Generating Audio..."
|
provider: "audio",
|
||||||
: scene.approved
|
model: "minimax/speech-02-hd",
|
||||||
? "Generate Audio"
|
tokens_requested: scene.lines.reduce((sum, l) => sum + l.text.length, 0),
|
||||||
: "Approve & Generate Audio"}
|
operation_type: "tts_full_render",
|
||||||
</PrimaryButton>
|
actual_provider_name: "wavespeed",
|
||||||
<PrimaryButton
|
}}
|
||||||
onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()}
|
label={
|
||||||
disabled={generatingImage}
|
hasAudio && !generating
|
||||||
loading={generatingImage}
|
? "✓ Regenerate Audio"
|
||||||
startIcon={
|
: generating
|
||||||
hasImage && !generatingImage ? (
|
? "Generating Audio..."
|
||||||
<ImageIcon />
|
: scene.approved
|
||||||
) : generatingImage ? (
|
? "Generate Audio"
|
||||||
<CircularProgress size={16} sx={{ color: "white" }} />
|
: "Approve & Generate Audio"
|
||||||
) : (
|
}
|
||||||
<ImageIcon />
|
variant="contained"
|
||||||
)
|
size="medium"
|
||||||
}
|
startIcon={
|
||||||
tooltip={
|
hasAudio && !generating ? (
|
||||||
hasImage
|
<RefreshIcon />
|
||||||
? "Regenerate image for this scene"
|
) : generating ? (
|
||||||
|
<CircularProgress size={16} sx={{ color: "white" }} />
|
||||||
|
) : (
|
||||||
|
<PlayArrowIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showCost={true}
|
||||||
|
checkOnHover={true}
|
||||||
|
checkOnMount={false}
|
||||||
|
onClick={handleAudioRegenerateClick}
|
||||||
|
disabled={approving || generating}
|
||||||
|
loading={approving || generating}
|
||||||
|
sx={{
|
||||||
|
minWidth: { xs: '100%', sm: 200 },
|
||||||
|
background: hasAudio
|
||||||
|
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||||
|
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
color: "white",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "none",
|
||||||
|
fontSize: { xs: '0.8rem', sm: '0.875rem' },
|
||||||
|
py: { xs: 0.75, sm: 1 },
|
||||||
|
boxShadow: hasAudio
|
||||||
|
? "0 4px 14px rgba(16, 185, 129, 0.35)"
|
||||||
|
: "0 4px 14px rgba(102, 126, 234, 0.35)",
|
||||||
|
"&:hover": {
|
||||||
|
background: hasAudio
|
||||||
|
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
|
||||||
|
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||||
|
boxShadow: hasAudio
|
||||||
|
? "0 6px 20px rgba(16, 185, 129, 0.45)"
|
||||||
|
: "0 6px 20px rgba(102, 126, 234, 0.45)",
|
||||||
|
},
|
||||||
|
"&:disabled": {
|
||||||
|
background: alpha("#9ca3af", 0.3),
|
||||||
|
color: alpha("#fff", 0.5),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
hasImage && !generatingImage
|
||||||
|
? "✓ Image generated! Click to regenerate with different settings"
|
||||||
: generatingImage
|
: generatingImage
|
||||||
? "Generating image..."
|
? "Generating image... please wait"
|
||||||
: "Generate image for video (optional)"
|
: "Generate image for video (optional but recommended)"
|
||||||
}
|
}
|
||||||
sx={{
|
|
||||||
minWidth: 180,
|
|
||||||
background: hasImage
|
|
||||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
|
||||||
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
|
||||||
"&:hover": {
|
|
||||||
background: hasImage
|
|
||||||
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
|
|
||||||
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{hasImage && !generatingImage
|
<Box>
|
||||||
? "Regenerate Image"
|
<OperationButton
|
||||||
: generatingImage
|
operation={{
|
||||||
? "Generating Image..."
|
provider: "stability",
|
||||||
: "Generate Image"}
|
operation_type: "image_generation",
|
||||||
</PrimaryButton>
|
actual_provider_name: "wavespeed",
|
||||||
|
}}
|
||||||
|
label={
|
||||||
|
hasImage && !generatingImage
|
||||||
|
? "✓ Regenerate Image"
|
||||||
|
: generatingImage
|
||||||
|
? "Generating Image..."
|
||||||
|
: "Generate Image"
|
||||||
|
}
|
||||||
|
variant="contained"
|
||||||
|
size="medium"
|
||||||
|
startIcon={
|
||||||
|
hasImage && !generatingImage ? (
|
||||||
|
<RefreshIcon />
|
||||||
|
) : generatingImage ? (
|
||||||
|
<CircularProgress size={16} sx={{ color: "white" }} />
|
||||||
|
) : (
|
||||||
|
<ImageIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showCost={true}
|
||||||
|
checkOnHover={true}
|
||||||
|
checkOnMount={false}
|
||||||
|
onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()}
|
||||||
|
disabled={generatingImage}
|
||||||
|
loading={generatingImage}
|
||||||
|
sx={{
|
||||||
|
minWidth: { xs: '100%', sm: 180 },
|
||||||
|
background: hasImage
|
||||||
|
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||||
|
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
color: "white",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "none",
|
||||||
|
fontSize: { xs: '0.8rem', sm: '0.875rem' },
|
||||||
|
py: { xs: 0.75, sm: 1 },
|
||||||
|
boxShadow: hasImage
|
||||||
|
? "0 4px 14px rgba(16, 185, 129, 0.35)"
|
||||||
|
: "0 4px 14px rgba(102, 126, 234, 0.35)",
|
||||||
|
"&:hover": {
|
||||||
|
background: hasImage
|
||||||
|
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
|
||||||
|
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||||
|
boxShadow: hasImage
|
||||||
|
? "0 6px 20px rgba(16, 185, 129, 0.45)"
|
||||||
|
: "0 6px 20px rgba(102, 126, 234, 0.45)",
|
||||||
|
},
|
||||||
|
"&:disabled": {
|
||||||
|
background: alpha("#9ca3af", 0.3),
|
||||||
|
color: alpha("#fff", 0.5),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
|
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -635,7 +931,8 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
|||||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||||
border: "1px solid rgba(239, 68, 68, 0.2)",
|
border: "1px solid rgba(239, 68, 68, 0.2)",
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
padding: 1.5,
|
padding: { xs: 1, sm: 1.5 },
|
||||||
|
alignSelf: { xs: 'center', sm: 'auto' },
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: "rgba(239, 68, 68, 0.15)",
|
backgroundColor: "rgba(239, 68, 68, 0.15)",
|
||||||
borderColor: "rgba(239, 68, 68, 0.3)",
|
borderColor: "rgba(239, 68, 68, 0.3)",
|
||||||
@@ -647,7 +944,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
|
<DeleteIcon sx={{ fontSize: { xs: "1.1rem", sm: "1.25rem" } }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -903,6 +1200,70 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Why Script Modal - Guidance for scene approval */}
|
||||||
|
<Dialog
|
||||||
|
open={showWhyScript}
|
||||||
|
onClose={() => setShowWhyScript(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
p: 2,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<HelpOutlineIcon sx={{ color: '#d97706', fontSize: '1.5rem' }} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||||
|
Why Approve This Scene?
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.7 }}>
|
||||||
|
Each scene requires <strong>approval</strong> before audio can be generated. Here's why the approval process matters:
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{
|
||||||
|
p: 2,
|
||||||
|
background: 'rgba(245, 158, 11, 0.08)',
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||||
|
}}>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||||
|
<CheckCircleIcon sx={{ color: '#10b981', fontSize: '1.25rem' }} />
|
||||||
|
<Typography variant="body2" sx={{ color: '#059669', flex: 1 }}>
|
||||||
|
<strong>Script Accuracy:</strong> The AI generates audio based on the script text. Once approved, the text is locked to ensure consistency.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||||
|
<CheckCircleIcon sx={{ color: '#10b981', fontSize: '1.25rem' }} />
|
||||||
|
<Typography variant="body2" sx={{ color: '#059669', flex: 1 }}>
|
||||||
|
<strong>Cost Control:</strong> Audio generation uses your subscription credits. Approving ensures you only pay for scenes you intend to render.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||||
|
<CheckCircleIcon sx={{ color: '#10b981', fontSize: '1.25rem' }} />
|
||||||
|
<Typography variant="body2" sx={{ color: '#059669', flex: 1 }}>
|
||||||
|
<strong>Quality Check:</strong> Review your script for tone, pacing, and accuracy before spending credits on audio generation.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||||
|
Pro tip: You can always regenerate audio later with different voice settings after approval.
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<PrimaryButton onClick={() => setShowWhyScript(false)}>
|
||||||
|
Got it
|
||||||
|
</PrimaryButton>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</GlassyCard>
|
</GlassyCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider, Chip, Tooltip } from "@mui/material";
|
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, IconButton, Divider, Chip, Tooltip } from "@mui/material";
|
||||||
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon, Mic as MicIcon } from "@mui/icons-material";
|
import { CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
|
||||||
import { Script, Knobs, Scene } from "../types";
|
import { Script, Knobs, Scene } from "../types";
|
||||||
import { BlogResearchResponse } from "../../../services/blogWriterApi";
|
import { BlogResearchResponse } from "../../../services/blogWriterApi";
|
||||||
import { podcastApi } from "../../../services/podcastApi";
|
import { podcastApi } from "../../../services/podcastApi";
|
||||||
|
import { aiApiClient } from "../../../api/client";
|
||||||
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
|
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
|
||||||
import { SceneEditor } from "./SceneEditor";
|
import { SceneEditor } from "./SceneEditor";
|
||||||
import { InlineAudioPlayer } from "../InlineAudioPlayer";
|
import { InlineAudioPlayer } from "../InlineAudioPlayer";
|
||||||
import { aiApiClient, getApiUrl } from "../../../api/client";
|
|
||||||
import { BrollInfoPanel } from "./parts/BrollInfoPanel";
|
import { BrollInfoPanel } from "./parts/BrollInfoPanel";
|
||||||
import { ScriptEditorProvider } from "./ScriptEditorContext";
|
import { ScriptEditorProvider } from "./ScriptEditorContext";
|
||||||
|
|
||||||
@@ -53,8 +53,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||||
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
||||||
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false);
|
|
||||||
const [generatingChartId, setGeneratingChartId] = useState<string | null>(null);
|
|
||||||
const [combiningAudio, setCombiningAudio] = useState(false);
|
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||||
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||||
url: string;
|
url: string;
|
||||||
@@ -242,108 +240,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
|||||||
}
|
}
|
||||||
}, [script, projectId, onError]);
|
}, [script, projectId, onError]);
|
||||||
|
|
||||||
const generateChartPreviews = useCallback(async () => {
|
|
||||||
if (!script) return;
|
|
||||||
|
|
||||||
const scenesWithData = script.scenes.filter(
|
|
||||||
(scene) => scene.chart_data && Object.keys(scene.chart_data).length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (scenesWithData.length === 0) {
|
|
||||||
onError("No scenes have chart data to generate previews.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setGeneratingChartId("all");
|
|
||||||
|
|
||||||
const updatedScenes = await Promise.all(
|
|
||||||
script.scenes.map(async (scene) => {
|
|
||||||
if (!scene.chart_data || Object.keys(scene.chart_data).length === 0) {
|
|
||||||
return scene;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await podcastApi.generateChartPreview({
|
|
||||||
chart_data: scene.chart_data,
|
|
||||||
chart_type: scene.chart_data.type || "bar_comparison",
|
|
||||||
title: scene.title,
|
|
||||||
});
|
|
||||||
console.log(`[ChartPreview] Scene ${scene.id}: type=${scene.chart_data.type || 'bar_comparison'}, data=`, scene.chart_data);
|
|
||||||
|
|
||||||
const toFullUrl = (url: string) => {
|
|
||||||
if (/^https?:\/\//i.test(url)) return url;
|
|
||||||
return `${getApiUrl()}${url.startsWith("/") ? url : `/${url}`}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...scene,
|
|
||||||
broll_preview_url: toFullUrl(result.preview_url),
|
|
||||||
chart_id: result.chart_id,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[ChartPreview] Failed for scene ${scene.id}:`, error);
|
|
||||||
return scene;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedScript = { ...script, scenes: updatedScenes };
|
|
||||||
setScript(updatedScript);
|
|
||||||
emitScriptChange(updatedScript);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Chart preview generation failed:", error);
|
|
||||||
onError(`Failed to generate chart previews: ${error.message || error}`);
|
|
||||||
} finally {
|
|
||||||
setGeneratingChartId(null);
|
|
||||||
}
|
|
||||||
}, [script, emitScriptChange, onError]);
|
|
||||||
|
|
||||||
const regenerateChart = useCallback(async (sceneId: string) => {
|
|
||||||
if (!script) return;
|
|
||||||
const scene = script.scenes.find((s) => s.id === sceneId);
|
|
||||||
if (!scene?.chart_data) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setGeneratingChartId(sceneId);
|
|
||||||
const result = await podcastApi.generateChartPreview({
|
|
||||||
chart_data: scene.chart_data,
|
|
||||||
chart_type: scene.chart_data.type || "bar_comparison",
|
|
||||||
title: scene.title,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedScript = {
|
|
||||||
...script,
|
|
||||||
scenes: script.scenes.map((s) =>
|
|
||||||
s.id === sceneId
|
|
||||||
? { ...s, broll_preview_url: result.preview_url, chart_id: result.chart_id }
|
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
setScript(updatedScript);
|
|
||||||
emitScriptChange(updatedScript);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Chart regeneration failed:", error);
|
|
||||||
onError(`Failed to regenerate chart: ${error.message || error}`);
|
|
||||||
} finally {
|
|
||||||
setGeneratingChartId(null);
|
|
||||||
}
|
|
||||||
}, [script, emitScriptChange, onError]);
|
|
||||||
|
|
||||||
const removeChart = useCallback((sceneId: string) => {
|
|
||||||
if (!script) return;
|
|
||||||
const updatedScript = {
|
|
||||||
...script,
|
|
||||||
scenes: script.scenes.map((scene) =>
|
|
||||||
scene.id === sceneId
|
|
||||||
? { ...scene, chart_data: undefined, broll_preview_url: undefined, broll_video_url: undefined }
|
|
||||||
: scene
|
|
||||||
),
|
|
||||||
};
|
|
||||||
setScript(updatedScript);
|
|
||||||
emitScriptChange(updatedScript);
|
|
||||||
}, [script, emitScriptChange]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScriptEditorProvider
|
<ScriptEditorProvider
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@@ -367,50 +263,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
|||||||
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
|
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
|
||||||
Back to Research
|
Back to Research
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<Box sx={{ flex: 1 }}>
|
</Stack>
|
||||||
<Typography
|
|
||||||
variant="h4"
|
|
||||||
sx={{
|
|
||||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
|
||||||
WebkitBackgroundClip: "text",
|
|
||||||
WebkitTextFillColor: "transparent",
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: "-0.02em",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 1.5,
|
|
||||||
fontSize: { xs: "1.75rem", md: "2rem" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditNoteIcon sx={{ fontSize: "2rem" }} />
|
|
||||||
Script Editor
|
|
||||||
{knobs.voice_id && (() => {
|
|
||||||
const vid = knobs.voice_id;
|
|
||||||
const isCustom = Boolean(vid && !vid.startsWith("builtin:") && !["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"].includes(vid));
|
|
||||||
const vName = isCustom ? "My Voice Clone" : (vid === "Wise_Woman" ? "Wise Woman" : vid === "Friendly_Person" ? "Friendly Person" : vid === "Deep_Voice_Man" ? "Deep Voice Man" : vid?.replace(/_/g, " ") || "Default");
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
|
|
||||||
label={`Active Voice: ${vName}`}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
ml: 2,
|
|
||||||
background: isCustom ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
|
|
||||||
color: isCustom ? "#10b981" : "#6366f1",
|
|
||||||
border: `1px solid ${isCustom ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
|
|
||||||
'& .MuiChip-icon': { color: isCustom ? "#10b981" : "#6366f1" },
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
|
|
||||||
Review and refine your podcast script before rendering
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -455,225 +308,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
|||||||
|
|
||||||
{script && (
|
{script && (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
{/* Script Format Explanation Panel */}
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 3,
|
|
||||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)",
|
|
||||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
|
||||||
borderRadius: 2,
|
|
||||||
boxShadow: "0 2px 8px rgba(99, 102, 241, 0.08)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: showScriptFormatInfo ? 2 : 0 }}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InfoIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
|
||||||
Why This Script Format?
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
|
|
||||||
Understanding how your script creates natural, human-like audio
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
|
|
||||||
sx={{
|
|
||||||
color: "#6366f1",
|
|
||||||
"&:hover": {
|
|
||||||
background: "rgba(99, 102, 241, 0.1)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Collapse in={showScriptFormatInfo}>
|
|
||||||
<Stack spacing={2.5}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8, mb: 2 }}>
|
|
||||||
Our AI script generator creates scripts specifically optimized for <strong style={{ fontWeight: 600 }}>high-quality text-to-speech</strong>.
|
|
||||||
The format you see here is designed to produce audio that sounds natural and human-like, not robotic.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
minWidth: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: "8px",
|
|
||||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
|
||||||
1
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Natural Pauses & Rhythm
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
|
||||||
The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns
|
|
||||||
and conversation flow, just like real human speech. Without these pauses, the audio would sound rushed and robotic.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
minWidth: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: "8px",
|
|
||||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
|
||||||
2
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Emphasis Markers
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
|
||||||
Lines marked with emphasis help highlight important points, statistics, or key insights. The AI voice will naturally
|
|
||||||
stress these parts, making your podcast more engaging and easier to follow—just like a real host would emphasize important information.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
minWidth: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: "8px",
|
|
||||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
|
||||||
3
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Short, Conversational Sentences
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
|
||||||
The script uses shorter sentences (15-20 words) written in a conversational style. This matches how people actually
|
|
||||||
speak, making the audio sound more natural. Long, complex sentences would sound awkward when spoken aloud.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
minWidth: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: "8px",
|
|
||||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
|
||||||
4
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Scene-Specific Emotions
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
|
||||||
Each scene has an emotional tone (excited, serious, curious, etc.) that guides the AI voice's delivery. This creates
|
|
||||||
variety and keeps listeners engaged, just like a real podcast host would vary their tone based on the topic.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
minWidth: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: "8px",
|
|
||||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
|
||||||
5
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
|
||||||
Optimized for Podcast Narration
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
|
||||||
The script is optimized with slightly slower pacing and natural pronunciation settings specifically for podcast narration.
|
|
||||||
This ensures clarity and makes the content easy to understand, even when listeners are multitasking.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Alert
|
|
||||||
severity="info"
|
|
||||||
sx={{
|
|
||||||
mt: 1,
|
|
||||||
background: "rgba(99, 102, 241, 0.06)",
|
|
||||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
|
||||||
"& .MuiAlert-icon": {
|
|
||||||
color: "#6366f1",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
|
|
||||||
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences.
|
|
||||||
The format will be preserved when rendering, ensuring your audio still sounds natural and professional.
|
|
||||||
</Typography>
|
|
||||||
</Alert>
|
|
||||||
</Stack>
|
|
||||||
</Collapse>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
severity="info"
|
severity="info"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -693,10 +327,10 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
|||||||
|
|
||||||
<BrollInfoPanel
|
<BrollInfoPanel
|
||||||
activeScript={script}
|
activeScript={script}
|
||||||
generatingChartId={generatingChartId}
|
generatingChartId={undefined}
|
||||||
generateChartPreviews={generateChartPreviews}
|
generateChartPreviews={undefined}
|
||||||
regenerateChart={regenerateChart}
|
regenerateChart={undefined}
|
||||||
removeChart={removeChart}
|
removeChart={undefined}
|
||||||
scenesWithCharts={script.scenes.filter((s) => s.chart_data && Object.keys(s.chart_data).length > 0).length}
|
scenesWithCharts={script.scenes.filter((s) => s.chart_data && Object.keys(s.chart_data).length > 0).length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -717,6 +351,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
|||||||
approvingSceneId={approvingSceneId}
|
approvingSceneId={approvingSceneId}
|
||||||
generatingAudioId={generatingAudioId}
|
generatingAudioId={generatingAudioId}
|
||||||
totalScenes={script.scenes.length}
|
totalScenes={script.scenes.length}
|
||||||
|
sceneIndex={idx}
|
||||||
onAudioGenerationStart={(sceneId) => {
|
onAudioGenerationStart={(sceneId) => {
|
||||||
setGeneratingAudioId(sceneId);
|
setGeneratingAudioId(sceneId);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||||
import { Script, Knobs, Scene, PodcastMode } from "../types";
|
import { Script, Knobs, Scene, PodcastMode } from "../types";
|
||||||
import { podcastApi } from "../../../services/podcastApi";
|
import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi";
|
||||||
import { getApiUrl } from "../../../api/client";
|
import { getApiUrl, getAuthTokenGetter } from "../../../api/client";
|
||||||
|
|
||||||
interface ScriptEditorContextType {
|
interface ScriptEditorContextType {
|
||||||
// State
|
// State
|
||||||
@@ -63,9 +63,23 @@ const toUsablePreviewUrl = (previewUrl?: string): string | undefined => {
|
|||||||
if (!previewUrl) return undefined;
|
if (!previewUrl) return undefined;
|
||||||
if (/^https?:\/\//i.test(previewUrl)) return previewUrl;
|
if (/^https?:\/\//i.test(previewUrl)) return previewUrl;
|
||||||
const cleanPath = previewUrl.startsWith("/") ? previewUrl : `/${previewUrl}`;
|
const cleanPath = previewUrl.startsWith("/") ? previewUrl : `/${previewUrl}`;
|
||||||
|
// Build base URL — auth token will be appended lazily when the URL is used
|
||||||
return `${getApiUrl()}${cleanPath}`;
|
return `${getApiUrl()}${cleanPath}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const appendAuthToken = async (url: string): Promise<string> => {
|
||||||
|
const tokenGetter = getAuthTokenGetter();
|
||||||
|
if (!tokenGetter) return url;
|
||||||
|
try {
|
||||||
|
const token = await tokenGetter();
|
||||||
|
if (token) {
|
||||||
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
|
return `${url}${separator}token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
interface ScriptEditorProviderProps {
|
interface ScriptEditorProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -316,13 +330,14 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
|
|||||||
lines: scene.lines.map((line) => ({ text: line.text })),
|
lines: scene.lines.map((line) => ({ text: line.text })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const cachedClone = getCachedVoiceCloneInfo();
|
||||||
const result = await podcastApi.generateBatchAudio({
|
const result = await podcastApi.generateBatchAudio({
|
||||||
scenes: sceneData,
|
scenes: sceneData,
|
||||||
voiceId: knobs.voice_id,
|
voiceId: knobs.voice_id,
|
||||||
customVoiceId: knobs.custom_voice_id,
|
customVoiceId: knobs.custom_voice_id || cachedClone?.customVoiceId,
|
||||||
useVoiceClone: knobs.is_voice_clone,
|
useVoiceClone: knobs.is_voice_clone || cachedClone?.isVoiceClone || false,
|
||||||
voiceSampleUrl: knobs.voice_sample_url,
|
voiceSampleUrl: knobs.voice_sample_url || cachedClone?.voiceSampleUrl || undefined,
|
||||||
voiceCloneEngine: knobs.voice_clone_engine,
|
voiceCloneEngine: knobs.voice_clone_engine || cachedClone?.engine || undefined,
|
||||||
speed: knobs.voice_speed,
|
speed: knobs.voice_speed,
|
||||||
emotion: knobs.voice_emotion,
|
emotion: knobs.voice_emotion,
|
||||||
englishNormalization: true,
|
englishNormalization: true,
|
||||||
@@ -423,9 +438,12 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
|
|||||||
title: scene.title,
|
title: scene.title,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const baseUrl = toUsablePreviewUrl(result.preview_url);
|
||||||
|
const authUrl = baseUrl ? await appendAuthToken(baseUrl) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...scene,
|
...scene,
|
||||||
broll_preview_url: toUsablePreviewUrl(result.preview_url),
|
broll_preview_url: authUrl,
|
||||||
chart_id: result.chart_id,
|
chart_id: result.chart_id,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -461,9 +479,12 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
|
|||||||
title: scene.title,
|
title: scene.title,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const baseUrl = toUsablePreviewUrl(result.preview_url);
|
||||||
|
const authUrl = baseUrl ? await appendAuthToken(baseUrl) : undefined;
|
||||||
|
|
||||||
const updatedScenes = activeScript.scenes.map((s) =>
|
const updatedScenes = activeScript.scenes.map((s) =>
|
||||||
s.id === sceneId
|
s.id === sceneId
|
||||||
? { ...s, broll_preview_url: toUsablePreviewUrl(result.preview_url), chart_id: result.chart_id }
|
? { ...s, broll_preview_url: authUrl, chart_id: result.chart_id }
|
||||||
: s
|
: s
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Stack, Box, Typography, Paper, Button, CircularProgress, Chip, IconButton, Tooltip } from "@mui/material";
|
import { Stack, Box, Typography, Paper, Button, CircularProgress, Chip, IconButton, Tooltip, Accordion, AccordionSummary, AccordionDetails, Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon, Fullscreen as FullscreenIcon, Visibility as VisibilityIcon } from "@mui/icons-material";
|
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon, Fullscreen as FullscreenIcon, ExpandMore as ExpandMoreIcon, Close as CloseIcon, ZoomOutMap as ZoomOutMapIcon } from "@mui/icons-material";
|
||||||
import { useScriptEditor } from "../ScriptEditorContext";
|
import { useScriptEditor } from "../ScriptEditorContext";
|
||||||
import { Script } from "../../types";
|
import { Script } from "../../types";
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ interface BrollInfoPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const BrollInfoPanel: React.FC<BrollInfoPanelProps> = (props) => {
|
export const BrollInfoPanel: React.FC<BrollInfoPanelProps> = (props) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [previewModal, setPreviewModal] = useState<{ url: string; title: string } | null>(null);
|
||||||
const ctx = useScriptEditor();
|
const ctx = useScriptEditor();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -39,249 +41,363 @@ export const BrollInfoPanel: React.FC<BrollInfoPanelProps> = (props) => {
|
|||||||
const hasChartData = scenesWithData.length > 0;
|
const hasChartData = scenesWithData.length > 0;
|
||||||
const resolvedScenesWithCharts = props.scenesWithCharts ?? ctxScenesWithCharts ?? scenesWithData.length;
|
const resolvedScenesWithCharts = props.scenesWithCharts ?? ctxScenesWithCharts ?? scenesWithData.length;
|
||||||
|
|
||||||
return (
|
const openPreview = (url: string, title: string) => {
|
||||||
<Paper
|
setPreviewModal({ url, title });
|
||||||
sx={{
|
};
|
||||||
p: 2.5,
|
|
||||||
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.03) 0%, rgba(16, 185, 129, 0.03) 100%)",
|
|
||||||
border: "1px solid rgba(34, 197, 94, 0.15)",
|
|
||||||
borderRadius: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
|
||||||
<Box sx={{
|
|
||||||
p: 0.75,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center"
|
|
||||||
}}>
|
|
||||||
<BarChartIcon sx={{ fontSize: 18, color: "#fff" }} />
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: "#0f172a", lineHeight: 1.2 }}>
|
|
||||||
B-Roll Charts
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
|
|
||||||
{resolvedScenesWithCharts} chart{resolvedScenesWithCharts !== 1 ? 's' : ''} for visual storytelling
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
{hasChartData && (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
startIcon={resolvedGeneratingChartId ? <CircularProgress size={14} color="inherit" /> : <AutoAwesomeIcon sx={{ fontSize: 16 }} />}
|
|
||||||
onClick={resolvedGenerateChartPreviews}
|
|
||||||
disabled={!!resolvedGeneratingChartId}
|
|
||||||
sx={{
|
|
||||||
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
py: 0.5,
|
|
||||||
px: 1.5,
|
|
||||||
textTransform: "none",
|
|
||||||
fontWeight: 600,
|
|
||||||
boxShadow: "0 2px 8px rgba(34, 197, 94, 0.3)",
|
|
||||||
"&:hover": {
|
|
||||||
background: "linear-gradient(135deg, #16a34a 0%, #15803d 100%)",
|
|
||||||
},
|
|
||||||
"&:disabled": {
|
|
||||||
background: "rgba(34, 197, 94, 0.5)",
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{resolvedGeneratingChartId ? "Generating..." : "Generate Charts"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{hasChartData ? (
|
const closePreview = () => {
|
||||||
<Stack spacing={1.5}>
|
setPreviewModal(null);
|
||||||
{scenesWithData.map((scene) => {
|
};
|
||||||
const chartData = scene.chart_data;
|
|
||||||
const hasPreview = !!scene.broll_preview_url;
|
return (
|
||||||
|
<>
|
||||||
return (
|
<Accordion
|
||||||
<Box
|
expanded={expanded}
|
||||||
key={scene.id}
|
onChange={(_, isExpanded) => setExpanded(isExpanded)}
|
||||||
|
sx={{
|
||||||
|
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.03) 0%, rgba(16, 185, 129, 0.03) 100%)",
|
||||||
|
border: "1px solid rgba(34, 197, 94, 0.15)",
|
||||||
|
borderRadius: 2,
|
||||||
|
'&:before': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
'&.MuiAccordion-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
'& .MuiAccordionSummary-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMoreIcon sx={{ color: '#22c55e' }} />}
|
||||||
|
sx={{
|
||||||
|
'& .MuiAccordionSummary-content': {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||||
|
<Box sx={{
|
||||||
|
p: 0.75,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center"
|
||||||
|
}}>
|
||||||
|
<BarChartIcon sx={{ fontSize: 18, color: "#fff" }} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: "#0f172a", lineHeight: 1.2 }}>
|
||||||
|
Podcast Charts
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
|
||||||
|
{resolvedScenesWithCharts} chart{resolvedScenesWithCharts !== 1 ? 's' : ''} for visual storytelling
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</AccordionSummary>
|
||||||
|
|
||||||
|
<AccordionDetails sx={{ pt: 0 }}>
|
||||||
|
{hasChartData && (
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={resolvedGeneratingChartId ? <CircularProgress size={14} color="inherit" /> : <AutoAwesomeIcon sx={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
resolvedGenerateChartPreviews?.();
|
||||||
|
}}
|
||||||
|
disabled={!!resolvedGeneratingChartId}
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
|
||||||
background: "#fff",
|
fontSize: "0.75rem",
|
||||||
borderRadius: 1.5,
|
py: 0.5,
|
||||||
border: "1px solid rgba(0,0,0,0.06)",
|
px: 1.5,
|
||||||
display: "flex",
|
textTransform: "none",
|
||||||
alignItems: "center",
|
fontWeight: 600,
|
||||||
gap: 2,
|
boxShadow: "0 2px 8px rgba(34, 197, 94, 0.3)",
|
||||||
transition: "all 0.2s ease",
|
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
borderColor: "rgba(34, 197, 94, 0.3)",
|
background: "linear-gradient(135deg, #16a34a 0%, #15803d 100%)",
|
||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
|
},
|
||||||
|
"&:disabled": {
|
||||||
|
background: "rgba(34, 197, 94, 0.5)",
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{resolvedGeneratingChartId ? "Generating..." : "Generate Charts"}
|
||||||
<Box
|
</Button>
|
||||||
sx={{
|
</Box>
|
||||||
width: 72,
|
)}
|
||||||
height: 48,
|
|
||||||
flexShrink: 0,
|
{hasChartData ? (
|
||||||
borderRadius: 1,
|
<Stack spacing={1.5}>
|
||||||
overflow: "hidden",
|
{scenesWithData.map((scene) => {
|
||||||
background: hasPreview ? "rgba(0,0,0,0.04)" : "linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%)",
|
const chartData = scene.chart_data;
|
||||||
display: "flex",
|
const hasPreview = !!scene.broll_preview_url;
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
return (
|
||||||
cursor: hasPreview ? "pointer" : "default",
|
<Box
|
||||||
transition: "all 0.2s ease",
|
key={scene.id}
|
||||||
"&:hover": hasPreview ? {
|
sx={{
|
||||||
transform: "scale(1.05)",
|
p: 1.5,
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
background: "#fff",
|
||||||
} : {}
|
borderRadius: 1.5,
|
||||||
}}
|
border: "1px solid rgba(0,0,0,0.06)",
|
||||||
>
|
display: "flex",
|
||||||
{resolvedGeneratingChartId === scene.id ? (
|
alignItems: "center",
|
||||||
<CircularProgress size={24} sx={{ color: "#22c55e" }} />
|
gap: 2,
|
||||||
) : hasPreview && scene.broll_preview_url ? (
|
transition: "all 0.2s ease",
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: "rgba(34, 197, 94, 0.3)",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
onClick={() => hasPreview && scene.broll_preview_url && openPreview(scene.broll_preview_url, scene.title)}
|
||||||
src={scene.broll_preview_url}
|
|
||||||
alt={`Chart for ${scene.title}`}
|
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: 72,
|
||||||
height: "100%",
|
height: 48,
|
||||||
objectFit: "cover",
|
flexShrink: 0,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
background: hasPreview ? "rgba(0,0,0,0.04)" : "linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
cursor: hasPreview ? "pointer" : "default",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
position: "relative",
|
||||||
|
"&:hover": hasPreview ? {
|
||||||
|
transform: "scale(1.05)",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||||
|
"& .zoom-overlay": {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
} : {}
|
||||||
}}
|
}}
|
||||||
onClick={() => window.open(scene.broll_preview_url, '_blank')}
|
>
|
||||||
/>
|
{resolvedGeneratingChartId === scene.id ? (
|
||||||
) : (
|
<CircularProgress size={24} sx={{ color: "#22c55e" }} />
|
||||||
<BarChartIcon sx={{ fontSize: 20, color: "#94a3b8" }} />
|
) : hasPreview && scene.broll_preview_url ? (
|
||||||
)}
|
<>
|
||||||
</Box>
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={scene.broll_preview_url}
|
||||||
|
alt={`Chart for ${scene.title}`}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
className="zoom-overlay"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.4)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: 0,
|
||||||
|
transition: "opacity 0.2s ease",
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ZoomOutMapIcon sx={{ color: "#fff", fontSize: 18 }} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<BarChartIcon sx={{ fontSize: 20, color: "#94a3b8" }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Chart Info */}
|
{/* Chart Info */}
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography variant="subtitle2" sx={{
|
<Typography variant="subtitle2" sx={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "#1e293b",
|
color: "#1e293b",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.8rem",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}>
|
}}>
|
||||||
{scene.title}
|
{scene.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.25 }}>
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.25 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={chartData?.type || "chart"}
|
label={chartData?.type || "chart"}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
height: 18,
|
height: 18,
|
||||||
fontSize: "0.65rem",
|
fontSize: "0.65rem",
|
||||||
background: "rgba(34, 197, 94, 0.1)",
|
background: "rgba(34, 197, 94, 0.1)",
|
||||||
color: "#16a34a",
|
color: "#16a34a",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
|
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
|
||||||
{chartData?.labels?.length || 0} labels
|
{chartData?.labels?.length || 0} labels
|
||||||
</Typography>
|
</Typography>
|
||||||
{hasPreview && (
|
{hasPreview && (
|
||||||
<Chip
|
<Chip
|
||||||
label="Ready"
|
label="Ready"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
height: 18,
|
height: 18,
|
||||||
fontSize: "0.65rem",
|
fontSize: "0.65rem",
|
||||||
background: "rgba(34, 197, 94, 0.15)",
|
background: "rgba(34, 197, 94, 0.15)",
|
||||||
color: "#16a34a",
|
color: "#16a34a",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Takeaway */}
|
||||||
|
{chartData?.takeaway && (
|
||||||
|
<Box sx={{
|
||||||
|
flex: 1.5,
|
||||||
|
display: { xs: "none", md: "block" },
|
||||||
|
px: 1,
|
||||||
|
py: 0.5,
|
||||||
|
background: "rgba(34, 197, 94, 0.04)",
|
||||||
|
borderRadius: 1,
|
||||||
|
}}>
|
||||||
|
<Typography variant="caption" sx={{
|
||||||
|
color: "#475569",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontStyle: "italic",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
"{chartData.takeaway}"
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Takeaway */}
|
{/* Actions */}
|
||||||
{chartData?.takeaway && (
|
<Stack direction="row" spacing={0.5}>
|
||||||
<Box sx={{
|
{hasPreview && (
|
||||||
flex: 1.5,
|
<Tooltip title="View fullsize">
|
||||||
display: { xs: "none", md: "block" },
|
<IconButton
|
||||||
px: 1,
|
size="small"
|
||||||
py: 0.5,
|
onClick={() => scene.broll_preview_url && openPreview(scene.broll_preview_url, scene.title)}
|
||||||
background: "rgba(34, 197, 94, 0.04)",
|
sx={{
|
||||||
borderRadius: 1,
|
color: "#64748b",
|
||||||
}}>
|
"&:hover": { color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }
|
||||||
<Typography variant="caption" sx={{
|
}}
|
||||||
color: "#475569",
|
>
|
||||||
fontSize: "0.7rem",
|
<FullscreenIcon sx={{ fontSize: 18 }} />
|
||||||
fontStyle: "italic",
|
</IconButton>
|
||||||
display: "-webkit-box",
|
</Tooltip>
|
||||||
WebkitLineClamp: 2,
|
)}
|
||||||
WebkitBoxOrient: "vertical",
|
<Tooltip title="Regenerate">
|
||||||
overflow: "hidden",
|
<IconButton
|
||||||
}}>
|
size="small"
|
||||||
"{chartData.takeaway}"
|
onClick={() => resolvedRegenerateChart?.(scene.id)}
|
||||||
</Typography>
|
disabled={!resolvedRegenerateChart || !!resolvedGeneratingChartId}
|
||||||
|
sx={{
|
||||||
|
color: "#64748b",
|
||||||
|
"&:hover": { color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)" }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Remove chart">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => resolvedRemoveChart?.(scene.id)}
|
||||||
|
disabled={!resolvedRemoveChart}
|
||||||
|
sx={{
|
||||||
|
color: "#64748b",
|
||||||
|
"&:hover": { color: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ py: 3, textAlign: "center" }}>
|
||||||
|
<BarChartIcon sx={{ fontSize: 36, color: "#cbd5e1", mb: 1 }} />
|
||||||
|
<Typography variant="body2" sx={{ color: "#64748b", fontSize: "0.8rem" }}>
|
||||||
|
No chart data yet. Add chart data to scenes to generate B-roll visuals.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Full-size chart preview modal */}
|
||||||
<Stack direction="row" spacing={0.5}>
|
<Dialog
|
||||||
{hasPreview && (
|
open={!!previewModal}
|
||||||
<Tooltip title="View fullsize">
|
onClose={closePreview}
|
||||||
<IconButton
|
maxWidth="md"
|
||||||
size="small"
|
fullWidth
|
||||||
onClick={() => scene.broll_preview_url && window.open(scene.broll_preview_url, '_blank')}
|
PaperProps={{
|
||||||
sx={{
|
sx: {
|
||||||
color: "#64748b",
|
borderRadius: 3,
|
||||||
"&:hover": { color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }
|
background: "#0f172a",
|
||||||
}}
|
overflow: "hidden",
|
||||||
>
|
}
|
||||||
<FullscreenIcon sx={{ fontSize: 18 }} />
|
}}
|
||||||
</IconButton>
|
>
|
||||||
</Tooltip>
|
{previewModal && (
|
||||||
)}
|
<>
|
||||||
<Tooltip title="Regenerate">
|
<DialogTitle sx={{
|
||||||
<IconButton
|
display: "flex",
|
||||||
size="small"
|
alignItems: "center",
|
||||||
onClick={() => resolvedRegenerateChart?.(scene.id)}
|
justifyContent: "space-between",
|
||||||
disabled={!resolvedRegenerateChart || !!resolvedGeneratingChartId}
|
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||||
sx={{
|
color: "#f1f5f9",
|
||||||
color: "#64748b",
|
py: 1.5,
|
||||||
"&:hover": { color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)" }
|
px: 2,
|
||||||
}}
|
}}>
|
||||||
>
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
<RefreshIcon sx={{ fontSize: 18 }} />
|
<BarChartIcon sx={{ fontSize: 20, color: "#22c55e" }} />
|
||||||
</IconButton>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: "#f1f5f9" }}>
|
||||||
</Tooltip>
|
{previewModal.title}
|
||||||
<Tooltip title="Remove chart">
|
</Typography>
|
||||||
<IconButton
|
</Stack>
|
||||||
size="small"
|
<IconButton
|
||||||
onClick={() => resolvedRemoveChart?.(scene.id)}
|
onClick={closePreview}
|
||||||
disabled={!resolvedRemoveChart}
|
size="small"
|
||||||
sx={{
|
sx={{ color: "#94a3b8", "&:hover": { color: "#f1f5f9" } }}
|
||||||
color: "#64748b",
|
>
|
||||||
"&:hover": { color: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }
|
<CloseIcon fontSize="small" />
|
||||||
}}
|
</IconButton>
|
||||||
>
|
</DialogTitle>
|
||||||
<DeleteIcon sx={{ fontSize: 18 }} />
|
<DialogContent sx={{ p: 0, display: "flex", justifyContent: "center", alignItems: "center", minHeight: 300, background: "#0f172a" }}>
|
||||||
</IconButton>
|
<Box
|
||||||
</Tooltip>
|
component="img"
|
||||||
</Stack>
|
src={previewModal.url}
|
||||||
</Box>
|
alt={`Chart: ${previewModal.title}`}
|
||||||
);
|
sx={{
|
||||||
})}
|
maxWidth: "100%",
|
||||||
</Stack>
|
maxHeight: "70vh",
|
||||||
) : (
|
objectFit: "contain",
|
||||||
<Box sx={{ py: 3, textAlign: "center" }}>
|
p: 2,
|
||||||
<BarChartIcon sx={{ fontSize: 36, color: "#cbd5e1", mb: 1 }} />
|
}}
|
||||||
<Typography variant="body2" sx={{ color: "#64748b", fontSize: "0.8rem" }}>
|
/>
|
||||||
No chart data yet. Add chart data to scenes to generate B-roll visuals.
|
</DialogContent>
|
||||||
</Typography>
|
</>
|
||||||
</Box>
|
)}
|
||||||
)}
|
</Dialog>
|
||||||
</Paper>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -85,6 +85,8 @@ export type Scene = {
|
|||||||
imagePrompt?: string;
|
imagePrompt?: string;
|
||||||
chart_data?: Record<string, any>;
|
chart_data?: Record<string, any>;
|
||||||
broll_preview_url?: string;
|
broll_preview_url?: string;
|
||||||
|
broll_video_url?: string;
|
||||||
|
chart_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Script = {
|
export type Script = {
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ const DEFAULT_KNOBS: Knobs = {
|
|||||||
voice_speed: 1,
|
voice_speed: 1,
|
||||||
voice_id: "Wise_Woman",
|
voice_id: "Wise_Woman",
|
||||||
custom_voice_id: undefined,
|
custom_voice_id: undefined,
|
||||||
|
is_voice_clone: undefined,
|
||||||
|
voice_sample_url: undefined,
|
||||||
|
voice_clone_engine: undefined,
|
||||||
resolution: "720p",
|
resolution: "720p",
|
||||||
scene_length_target: 45,
|
scene_length_target: 45,
|
||||||
sample_rate: 24000,
|
sample_rate: 24000,
|
||||||
@@ -443,7 +446,7 @@ export const usePodcastProjectState = () => {
|
|||||||
scriptData: dbProject.script_data,
|
scriptData: dbProject.script_data,
|
||||||
bible: dbProject.bible,
|
bible: dbProject.bible,
|
||||||
renderJobs: dbProject.render_jobs || [],
|
renderJobs: dbProject.render_jobs || [],
|
||||||
knobs: dbProject.knobs || DEFAULT_KNOBS,
|
knobs: { ...DEFAULT_KNOBS, ...(dbProject.knobs || {}) },
|
||||||
researchProvider: dbProject.research_provider || 'exa',
|
researchProvider: dbProject.research_provider || 'exa',
|
||||||
budgetCap: dbProject.budget_cap || 50,
|
budgetCap: dbProject.budget_cap || 50,
|
||||||
showScriptEditor: dbProject.show_script_editor || false,
|
showScriptEditor: dbProject.show_script_editor || false,
|
||||||
|
|||||||
@@ -626,11 +626,14 @@ export const podcastApi = {
|
|||||||
text: textToUse,
|
text: textToUse,
|
||||||
voice_id: params.voiceId || "Wise_Woman",
|
voice_id: params.voiceId || "Wise_Woman",
|
||||||
custom_voice_id: params.customVoiceId || null,
|
custom_voice_id: params.customVoiceId || null,
|
||||||
speed: params.speed ?? 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
|
use_voice_clone: params.useVoiceClone || false,
|
||||||
|
voice_sample_url: params.voiceSampleUrl || null,
|
||||||
|
voice_clone_engine: params.voiceCloneEngine || null,
|
||||||
|
speed: params.speed ?? 1.0,
|
||||||
volume: params.volume ?? 1.0,
|
volume: params.volume ?? 1.0,
|
||||||
pitch: params.pitch ?? 0.0,
|
pitch: params.pitch ?? 0.0,
|
||||||
emotion: sceneEmotion,
|
emotion: sceneEmotion,
|
||||||
english_normalization: params.englishNormalization ?? true, // Better number reading for statistics
|
english_normalization: params.englishNormalization ?? true,
|
||||||
sample_rate: params.sampleRate || null,
|
sample_rate: params.sampleRate || null,
|
||||||
bitrate: params.bitrate || null,
|
bitrate: params.bitrate || null,
|
||||||
channel: params.channel || null,
|
channel: params.channel || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user