Podcast Maker: Fix progress modals, research JSON, header stepper, voice/podcastMode chips
This commit is contained in:
666
backend/api/podcast/broll_temp/README.md
Normal file
666
backend/api/podcast/broll_temp/README.md
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
# Programmatic B-Roll Composer
|
||||||
|
|
||||||
|
A layered video composition pipeline that assembles AI-generated images, programmatic data charts, Pillow text overlays, and circular-masked avatar videos into a single output MP4. Driven by structured JSON from an LLM, exposed via a FastAPI server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture overview](#1-architecture-overview)
|
||||||
|
2. [File structure](#2-file-structure)
|
||||||
|
3. [Installation](#3-installation)
|
||||||
|
4. [Core concepts](#4-core-concepts)
|
||||||
|
- 4.1 [The Insight dataclass](#41-the-insight-dataclass)
|
||||||
|
- 4.2 [The SceneAssets dataclass](#42-the-sceneassets-dataclass)
|
||||||
|
- 4.3 [The layer stack](#43-the-layer-stack)
|
||||||
|
- 4.4 [The JSON bridge](#44-the-json-bridge)
|
||||||
|
5. [Asset generators](#5-asset-generators)
|
||||||
|
- 5.1 [Bar chart — make_bar_chart](#51-bar-chart--make_bar_chart)
|
||||||
|
- 5.2 [Line trend — make_line_trend](#52-line-trend--make_line_trend)
|
||||||
|
- 5.3 [Bullet overlay — make_bullet_overlay](#53-bullet-overlay--make_bullet_overlay)
|
||||||
|
- 5.4 [Insight card — make_insight_card](#54-insight-card--make_insight_card)
|
||||||
|
6. [Video effects](#6-video-effects)
|
||||||
|
- 6.1 [Circular avatar mask — apply_circle_mask](#61-circular-avatar-mask--apply_circle_mask)
|
||||||
|
- 6.2 [Ken Burns zoom — ken_burns](#62-ken-burns-zoom--ken_burns)
|
||||||
|
7. [Scene builders](#7-scene-builders)
|
||||||
|
- 7.1 [Data scene — build_data_scene](#71-data-scene--build_data_scene)
|
||||||
|
- 7.2 [Bullet scene — build_bullet_scene](#72-bullet-scene--build_bullet_scene)
|
||||||
|
- 7.3 [Full avatar scene — build_full_avatar_scene](#73-full-avatar-scene--build_full_avatar_scene)
|
||||||
|
8. [Scene dispatcher — dispatch_scene](#8-scene-dispatcher--dispatch_scene)
|
||||||
|
9. [Crossfade transitions](#9-crossfade-transitions)
|
||||||
|
- 9.1 [How crossfade_concat works](#91-how-crossfade_concat-works)
|
||||||
|
- 9.2 [The set_duration gotcha](#92-the-set_duration-gotcha)
|
||||||
|
10. [Master compositor — compose_video](#10-master-compositor--compose_video)
|
||||||
|
11. [FastAPI server](#11-fastapi-server)
|
||||||
|
- 11.1 [Request models](#111-request-models)
|
||||||
|
- 11.2 [Job lifecycle](#112-job-lifecycle)
|
||||||
|
- 11.3 [API endpoints](#113-api-endpoints)
|
||||||
|
12. [Running the project](#12-running-the-project)
|
||||||
|
- 12.1 [Smoke test (no media files needed)](#121-smoke-test-no-media-files-needed)
|
||||||
|
- 12.2 [Full video composition](#122-full-video-composition)
|
||||||
|
- 12.3 [API server](#123-api-server)
|
||||||
|
13. [Calling the API](#13-calling-the-api)
|
||||||
|
14. [Production notes](#14-production-notes)
|
||||||
|
15. [Extending the pipeline](#15-extending-the-pipeline)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture overview
|
||||||
|
|
||||||
|
The pipeline follows a **Layered Composition** model. Rather than generating video in one pass, it assembles independent visual layers — each produced by the cheapest appropriate tool — into a single timeline using MoviePy as the compositor.
|
||||||
|
|
||||||
|
```
|
||||||
|
LLM JSON output
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
dispatch_scene() ← routes visual_cue → builder function
|
||||||
|
│
|
||||||
|
├─ build_data_scene()
|
||||||
|
│ ├─ ImageClip (background) ← AI-generated image
|
||||||
|
│ ├─ ImageClip (chart PNG) ← Matplotlib, transparent bg
|
||||||
|
│ ├─ ImageClip (insight card) ← Pillow RGBA
|
||||||
|
│ └─ VideoFileClip (avatar) ← circular numpy mask
|
||||||
|
│
|
||||||
|
├─ build_bullet_scene()
|
||||||
|
│ ├─ ImageClip (background)
|
||||||
|
│ ├─ ImageClip (bullet overlay) ← Pillow RGBA
|
||||||
|
│ └─ VideoFileClip (avatar)
|
||||||
|
│
|
||||||
|
└─ build_full_avatar_scene()
|
||||||
|
└─ VideoFileClip (full-screen)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
crossfade_concat() ← dissolve between scenes
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
write_videofile() ← H.264 MP4 via ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
The key design decision: charts and text are **never** rendered by a generative model. Matplotlib produces pixel-perfect data graphics from real numbers; Pillow renders crisp, deterministic text. Only the background and the talking-head avatar come from AI generation, minimising both cost and hallucination risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── broll_composer.py # Core library — all composition logic
|
||||||
|
├── api_server.py # FastAPI wrapper — HTTP interface to the pipeline
|
||||||
|
└── requirements.txt # Python dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
`broll_composer.py` has no FastAPI dependency and can be imported and called directly from scripts, notebooks, or other web frameworks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# System dependency — must be on PATH
|
||||||
|
apt-get install ffmpeg
|
||||||
|
|
||||||
|
# Python packages
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**requirements.txt**
|
||||||
|
|
||||||
|
```
|
||||||
|
moviepy==1.0.3
|
||||||
|
Pillow>=10.0
|
||||||
|
matplotlib>=3.8
|
||||||
|
numpy>=1.26
|
||||||
|
fastapi>=0.111
|
||||||
|
uvicorn[standard]>=0.29
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
```
|
||||||
|
|
||||||
|
MoviePy 1.0.3 is pinned because 2.x introduced breaking API changes to `CompositeVideoClip` and the effects interface. The rest can float within the specified lower bounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Core concepts
|
||||||
|
|
||||||
|
### 4.1 The Insight dataclass
|
||||||
|
|
||||||
|
Every scene is driven by a single `Insight` object. This is the contract between the LLM and the composition pipeline:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class Insight:
|
||||||
|
key_insight: str # Headline text rendered on the insight card
|
||||||
|
supporting_stat: str # Sub-headline rendered below the headline
|
||||||
|
visual_cue: str # Selects which scene builder to use (see §8)
|
||||||
|
audio_tone: str # Passed through for downstream TTS / audio selection
|
||||||
|
chart_data: dict # Data payload consumed by chart generators (see §5)
|
||||||
|
duration: float # Scene length in seconds, default 10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The `audio_tone` field is not used by the video pipeline itself — it is metadata for whatever system generates or selects the voiceover audio track for the scene.
|
||||||
|
|
||||||
|
### 4.2 The SceneAssets dataclass
|
||||||
|
|
||||||
|
`SceneAssets` carries file paths to the media assets for a given scene:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class SceneAssets:
|
||||||
|
background_img: str # Required — path to JPEG or PNG background
|
||||||
|
chart_img: Optional[str] # Populated by dispatch_scene after chart generation
|
||||||
|
avatar_video: Optional[str] # Optional — path to MP4 avatar clip
|
||||||
|
bullet_img: Optional[str] # Reserved for pre-rendered bullet overlays
|
||||||
|
```
|
||||||
|
|
||||||
|
`chart_img` starts as `None` and is written to by `dispatch_scene` after it generates the Matplotlib PNG, so the scene builders receive a fully-populated `SceneAssets` by the time they run.
|
||||||
|
|
||||||
|
### 4.3 The layer stack
|
||||||
|
|
||||||
|
Every scene is a `CompositeVideoClip` — a MoviePy object that renders multiple clips on a shared canvas by alpha-compositing them bottom-to-top. The layer order is consistent across all scene types:
|
||||||
|
|
||||||
|
| Z-order | Layer | Source | Notes |
|
||||||
|
|---------|-------|--------|-------|
|
||||||
|
| 0 (bottom) | Background | AI image + Ken Burns | Darkened to make overlays legible |
|
||||||
|
| 1 | Chart or bullet overlay | Matplotlib or Pillow PNG | Transparent background; fades in |
|
||||||
|
| 2 | Insight card | Pillow RGBA | Positioned at y=820 (near bottom) |
|
||||||
|
| 3 (top) | Avatar circle | MP4 + numpy mask | Bottom-right corner |
|
||||||
|
|
||||||
|
### 4.4 The JSON bridge
|
||||||
|
|
||||||
|
The LLM is prompted to return a structured JSON object — not prose — so the pipeline can consume it without parsing ambiguity:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key_insight": "AI tools reduced content cycles by 40%",
|
||||||
|
"supporting_stat": "HubSpot 2026 report — 12% lift in CTR",
|
||||||
|
"visual_cue": "bar_chart_comparison",
|
||||||
|
"audio_tone": "authoritative_and_surprising",
|
||||||
|
"duration": 10.0,
|
||||||
|
"chart_data": {
|
||||||
|
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
|
||||||
|
"before": [30, 22, 18, 60],
|
||||||
|
"after": [72, 34, 41, 38]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`pipeline_from_json()` is the single-call entry point that accepts this JSON string, constructs the dataclasses, runs `dispatch_scene`, and writes the output MP4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Asset generators
|
||||||
|
|
||||||
|
These functions produce static image files (PNG with alpha transparency) that are loaded as `ImageClip` objects in the scene builders. They are completely independent of MoviePy and can be called and previewed without assembling any video.
|
||||||
|
|
||||||
|
### 5.1 Bar chart — `make_bar_chart`
|
||||||
|
|
||||||
|
```python
|
||||||
|
make_bar_chart(data: dict, out_path: str, title: str = "") -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces a side-by-side "before vs after" bar chart using Matplotlib. The critical detail is the renderer configuration and save parameters:
|
||||||
|
|
||||||
|
```python
|
||||||
|
matplotlib.use("Agg") # Non-interactive backend — no display required
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||||
|
ax.set_facecolor("none") # Transparent axes background
|
||||||
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
|
```
|
||||||
|
|
||||||
|
Setting both `facecolor="none"` on the figure and `transparent=True` on `savefig` is necessary because they control different things: the figure background and the PNG alpha channel respectively. Without both, a white box appears behind the chart when it is composited over the video background.
|
||||||
|
|
||||||
|
**Expected `data` shape:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"labels": ["Category A", "Category B"], # X-axis labels
|
||||||
|
"before": [30, 22], # Bar heights (left bars)
|
||||||
|
"after": [72, 34] # Bar heights (right bars)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Line trend — `make_line_trend`
|
||||||
|
|
||||||
|
```python
|
||||||
|
make_line_trend(data: dict, out_path: str, title: str = "") -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces a time-series line chart with a translucent fill under the curve (`alpha=0.12`). Suited for growth trends, adoption curves, and any metric tracked over sequential time periods.
|
||||||
|
|
||||||
|
**Expected `data` shape:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"x": [2021, 2022, 2023, 2024, 2025], # X-axis values (numeric or strings)
|
||||||
|
"y": [10, 18, 30, 45, 72] # Y-axis values
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Bullet overlay — `make_bullet_overlay`
|
||||||
|
|
||||||
|
```python
|
||||||
|
make_bullet_overlay(lines: list[str], out_path: str,
|
||||||
|
width: int = 900, font_size: int = 32) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders a list of bullet-point strings onto a semi-transparent dark rounded rectangle using Pillow. The image height is computed dynamically from the number of lines:
|
||||||
|
|
||||||
|
```python
|
||||||
|
img_h = padding * 2 + len(lines) * line_h + 12
|
||||||
|
```
|
||||||
|
|
||||||
|
The fill colour `(10, 10, 10, 185)` gives roughly 73% opacity — dark enough for text legibility over any background, light enough that the background remains visible. The bullet character (`•`) is prepended in Python rather than in the font, so no special Unicode font support is required.
|
||||||
|
|
||||||
|
Font loading tries the DejaVu Sans Bold path common on Debian/Ubuntu systems, falling back to Pillow's built-in bitmap font if the TTF is absent.
|
||||||
|
|
||||||
|
### 5.4 Insight card — `make_insight_card`
|
||||||
|
|
||||||
|
```python
|
||||||
|
make_insight_card(insight: str, stat: str, out_path: str,
|
||||||
|
width: int = 960, height: int = 200) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders a two-line card: a large bold headline (`font_size=34`) and a smaller supporting stat line (`font_size=20`). A solid red rectangle (`#E63946`) is drawn as a left-edge accent bar — a visual device borrowed from print editorial design that gives the card a distinct identity when overlaid on varied backgrounds.
|
||||||
|
|
||||||
|
The card uses `fill=(10, 10, 10, 200)` — approximately 78% opacity — slightly more opaque than the bullet overlay because the headline text is denser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Video effects
|
||||||
|
|
||||||
|
### 6.1 Circular avatar mask — `apply_circle_mask`
|
||||||
|
|
||||||
|
```python
|
||||||
|
apply_circle_mask(clip: VideoFileClip, diameter: int) -> VideoFileClip
|
||||||
|
```
|
||||||
|
|
||||||
|
Takes an MP4 avatar clip and returns it with a circular alpha mask applied, so only the circle region is visible when the clip is composited over other layers.
|
||||||
|
|
||||||
|
The mask is built using NumPy's `ogrid`, which creates coordinate arrays without materialising a full mesh:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Y, X = np.ogrid[:h, :w]
|
||||||
|
cx, cy = w / 2, h / 2
|
||||||
|
mask_arr = ((X - cx)**2 + (Y - cy)**2 <= (min(w, h) / 2)**2).astype(float)
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces a 2D float array (values 0.0 or 1.0) where all pixels within the inscribed circle are 1 (opaque) and all pixels outside are 0 (transparent). MoviePy requires mask arrays in this float format — it does not accept uint8 or boolean arrays directly.
|
||||||
|
|
||||||
|
The mask array is wrapped in an `ImageClip` with `ismask=True` and the duration is set to match the source clip before calling `clip.set_mask()`.
|
||||||
|
|
||||||
|
**Why not use imagemagick or a pre-made circular PNG?** The numpy approach has no subprocess dependency, works for any input resolution, and the mask is computed once and reused for every frame without disk I/O.
|
||||||
|
|
||||||
|
### 6.2 Ken Burns zoom — `ken_burns`
|
||||||
|
|
||||||
|
```python
|
||||||
|
ken_burns(clip: ImageClip, zoom_ratio: float = 0.08) -> ImageClip
|
||||||
|
```
|
||||||
|
|
||||||
|
Applies a slow continuous zoom-in to a static image clip, creating the illusion of camera movement. This prevents the background from looking visually "dead" during the scene.
|
||||||
|
|
||||||
|
The implementation uses `clip.fl()`, MoviePy's frame-level transform function, which receives both `get_frame` (a callable that returns the frame array at time `t`) and the current time `t`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def zoom_frame(get_frame, t):
|
||||||
|
frame = get_frame(t)
|
||||||
|
frac = 1 + zoom_ratio * (t / clip.duration) # grows from 1.0 to 1+zoom_ratio
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
new_h, new_w = int(h / frac), int(w / frac) # shrink crop window
|
||||||
|
y1 = (h - new_h) // 2 # center the crop
|
||||||
|
x1 = (w - new_w) // 2
|
||||||
|
cropped = frame[y1:y1 + new_h, x1:x1 + new_w]
|
||||||
|
return np.array(Image.fromarray(cropped).resize((w, h), Image.LANCZOS))
|
||||||
|
```
|
||||||
|
|
||||||
|
At `t=0`, `frac=1.0` so the crop is the full frame. At `t=duration`, `frac=1+zoom_ratio` so the crop is slightly smaller, and upscaling it back to full resolution creates the zoom effect. `zoom_ratio=0.08` means an 8% zoom over the full duration — perceptible but not distracting.
|
||||||
|
|
||||||
|
`apply_to=["mask"]` passes the same transform to the mask channel if one is present, keeping the mask geometrically in sync with the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Scene builders
|
||||||
|
|
||||||
|
Scene builders assemble the layers for a given `visual_cue` type into a `CompositeVideoClip`. Each builder follows the same pattern: build layers bottom-to-top, append to a list, return `CompositeVideoClip(layers, size=bg.size).set_duration(d)`.
|
||||||
|
|
||||||
|
The explicit `.set_duration(d)` on the return value is mandatory — see [§9.2](#92-the-set_duration-gotcha) for why.
|
||||||
|
|
||||||
|
### 7.1 Data scene — `build_data_scene`
|
||||||
|
|
||||||
|
Used for `visual_cue` values `bar_chart_comparison` and `line_trend`. The most information-dense layout:
|
||||||
|
|
||||||
|
- **Background**: full-canvas `ImageClip`, Ken Burns zoom at 8%, brightness reduced by 40 units via `vfx.lum_contrast(0, -40)`.
|
||||||
|
- **Chart**: resized to 700px wide, centred horizontally, positioned 180px from the top. Fades in over 0.6s starting at `t=0.5` and fades out over 0.4s at the end.
|
||||||
|
- **Insight card**: centred horizontally at y=820 (approximately the lower fifth of a 1080p frame). Fades in over 0.5s.
|
||||||
|
- **Avatar**: circular-masked at 240px diameter, positioned 40px from the bottom-right corner (`bg.w - 280, bg.h - 280`).
|
||||||
|
|
||||||
|
### 7.2 Bullet scene — `build_bullet_scene`
|
||||||
|
|
||||||
|
Used for `visual_cue` value `bullet_points`. A simpler layout suited to lists of supporting facts:
|
||||||
|
|
||||||
|
- **Background**: Ken Burns at 5% zoom (slower than the data scene — more contemplative pacing), brightness reduced by 50 units.
|
||||||
|
- **Bullet overlay**: rendered by `make_bullet_overlay`, centred both horizontally and vertically, fades in over 0.7s.
|
||||||
|
- **Avatar**: circular-masked at 200px diameter (slightly smaller than in the data scene), positioned 40px from the bottom-right corner.
|
||||||
|
|
||||||
|
If `bullet_lines` is not provided by the caller, the builder falls back to using `insight.key_insight` and `insight.supporting_stat` as two bullet points.
|
||||||
|
|
||||||
|
### 7.3 Full avatar scene — `build_full_avatar_scene`
|
||||||
|
|
||||||
|
Used for `visual_cue` value `full_avatar`. The "Hook" scene — designed to open a piece with a direct-to-camera delivery that grabs attention before the data arrives. No overlays; the avatar fills the entire frame:
|
||||||
|
|
||||||
|
```python
|
||||||
|
avatar = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||||
|
return avatar.resize(height=1080).set_duration(d)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the only scene type that does not use a `CompositeVideoClip` — it returns a `VideoFileClip` directly. The explicit `.set_duration(d)` is still applied (see §9.2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Scene dispatcher — `dispatch_scene`
|
||||||
|
|
||||||
|
```python
|
||||||
|
dispatch_scene(insight: Insight, assets: SceneAssets,
|
||||||
|
bullet_lines: Optional[list[str]] = None) -> CompositeVideoClip
|
||||||
|
```
|
||||||
|
|
||||||
|
The dispatcher is the JSON bridge's execution layer. It reads `insight.visual_cue` and routes to the correct builder, generating any intermediate assets (charts) along the way:
|
||||||
|
|
||||||
|
```
|
||||||
|
visual_cue value Action
|
||||||
|
─────────────────────────────────────────────────────
|
||||||
|
"full_avatar" → build_full_avatar_scene()
|
||||||
|
"bar_chart_comparison" → make_bar_chart() → build_data_scene()
|
||||||
|
"line_trend" → make_line_trend() → build_data_scene()
|
||||||
|
"bullet_points" → build_bullet_scene()
|
||||||
|
<anything else> → build_data_scene() with no chart (fallback)
|
||||||
|
```
|
||||||
|
|
||||||
|
Chart PNGs are written to `/tmp/chart.png`. This is intentionally a fixed path — each call overwrites the previous chart, which is fine because `dispatch_scene` is called sequentially per scene. If scenes are ever parallelised, use a `job_id`-prefixed temp path instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Crossfade transitions
|
||||||
|
|
||||||
|
### 9.1 How `crossfade_concat` works
|
||||||
|
|
||||||
|
```python
|
||||||
|
def crossfade_concat(scenes: list, fade_dur: float = 0.5) -> CompositeVideoClip:
|
||||||
|
faded = []
|
||||||
|
for i, clip in enumerate(scenes):
|
||||||
|
c = clip
|
||||||
|
if i > 0:
|
||||||
|
c = c.fx(vfx.crossfadein, fade_dur)
|
||||||
|
faded.append(c)
|
||||||
|
return concatenate_videoclips(faded, padding=-fade_dur, method="compose")
|
||||||
|
```
|
||||||
|
|
||||||
|
`vfx.crossfadein` makes a clip's opacity ramp from 0 to 1 over `fade_dur` seconds from its start point. This handles the incoming side of the dissolve.
|
||||||
|
|
||||||
|
`padding=-fade_dur` is the critical parameter. By default, `concatenate_videoclips` places each clip immediately after the previous one ends. A negative padding shifts each clip left by `fade_dur` seconds, so it starts while the previous clip is still playing. The overlap window is exactly `fade_dur` seconds, which matches the duration of the `crossfadein` effect — this is what produces a dissolve rather than a hard cut or a gap.
|
||||||
|
|
||||||
|
`method="compose"` tells MoviePy to use `CompositeVideoClip` internally for the overlapping portions rather than trying to blend frames at the pixel level, which is how the alpha ramp from `crossfadein` is correctly respected.
|
||||||
|
|
||||||
|
The default `fade_dur` of `0.5s` is appropriate for fast-paced content. Increase to `0.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`.
|
||||||
229
backend/api/podcast/broll_temp/api_server.py
Normal file
229
backend/api/podcast/broll_temp/api_server.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""
|
||||||
|
FastAPI wrapper for the B-Roll Composer pipeline.
|
||||||
|
POST /compose → triggers scene assembly, returns video download URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, HTTPException
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from broll_composer import (
|
||||||
|
Insight, SceneAssets, dispatch_scene, compose_video,
|
||||||
|
make_bar_chart, make_line_trend, make_bullet_overlay,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="B-Roll Composer API",
|
||||||
|
description="Programmatic video composition: Background + Chart + Avatar Circle",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
WORK_DIR = Path("/tmp/broll_jobs")
|
||||||
|
WORK_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request / Response models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class InsightPayload(BaseModel):
|
||||||
|
key_insight: str = Field(..., example="AI tools reduced content cycles by 40% in 2025.")
|
||||||
|
supporting_stat: str = Field(..., example="HubSpot 2026 report cites a 12% lift in CTR.")
|
||||||
|
visual_cue: str = Field(
|
||||||
|
...,
|
||||||
|
example="bar_chart_comparison",
|
||||||
|
description="bar_chart_comparison | line_trend | bullet_points | full_avatar",
|
||||||
|
)
|
||||||
|
audio_tone: str = Field(..., example="authoritative_and_surprising")
|
||||||
|
duration: float = Field(default=10.0, ge=3.0, le=60.0)
|
||||||
|
chart_data: dict = Field(default_factory=dict)
|
||||||
|
bullet_lines: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeRequest(BaseModel):
|
||||||
|
insights: List[InsightPayload]
|
||||||
|
fps: int = Field(default=24, ge=12, le=60)
|
||||||
|
fade_dur: float = Field(default=0.5, ge=0.0, le=2.0,
|
||||||
|
description="Crossfade duration in seconds between scenes")
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
status: str # queued | processing | done | error
|
||||||
|
output_url: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-memory job store (replace with Redis in production)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_jobs: dict[str, JobStatus] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Background task: composition worker
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _compose_worker(
|
||||||
|
job_id: str,
|
||||||
|
request: ComposeRequest,
|
||||||
|
bg_path: str,
|
||||||
|
avatar_path: Optional[str],
|
||||||
|
):
|
||||||
|
job = _jobs[job_id]
|
||||||
|
job.status = "processing"
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
out_path = str(WORK_DIR / f"{job_id}.mp4")
|
||||||
|
|
||||||
|
def _sync_compose():
|
||||||
|
scenes = []
|
||||||
|
for i, payload in enumerate(request.insights):
|
||||||
|
insight = Insight(
|
||||||
|
key_insight=payload.key_insight,
|
||||||
|
supporting_stat=payload.supporting_stat,
|
||||||
|
visual_cue=payload.visual_cue,
|
||||||
|
audio_tone=payload.audio_tone,
|
||||||
|
chart_data=payload.chart_data,
|
||||||
|
duration=payload.duration,
|
||||||
|
)
|
||||||
|
assets = SceneAssets(
|
||||||
|
background_img=bg_path,
|
||||||
|
avatar_video=avatar_path,
|
||||||
|
)
|
||||||
|
scene = dispatch_scene(insight, assets, payload.bullet_lines)
|
||||||
|
scenes.append(scene)
|
||||||
|
|
||||||
|
compose_video(scenes, output_path=out_path, fps=request.fps,
|
||||||
|
fade_dur=request.fade_dur)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
await loop.run_in_executor(None, _sync_compose)
|
||||||
|
job.status = "done"
|
||||||
|
job.output_url = f"/download/{job_id}"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
job.status = "error"
|
||||||
|
job.error = str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.post("/compose", response_model=JobStatus, status_code=202)
|
||||||
|
async def start_compose(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
payload: str = Form(..., description="JSON string matching ComposeRequest schema"),
|
||||||
|
background: UploadFile = File(..., description="Background image (JPEG/PNG)"),
|
||||||
|
avatar: Optional[UploadFile] = File(None, description="Avatar video (MP4) — optional"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Kick off a video composition job.
|
||||||
|
- **payload**: JSON body (ComposeRequest)
|
||||||
|
- **background**: background image file
|
||||||
|
- **avatar**: optional avatar video file
|
||||||
|
Returns a job_id — poll GET /status/{job_id} for progress.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
request = ComposeRequest(**json.loads(payload))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Invalid payload: {e}")
|
||||||
|
|
||||||
|
job_id = uuid.uuid4().hex
|
||||||
|
|
||||||
|
# Save uploads
|
||||||
|
job_dir = WORK_DIR / job_id
|
||||||
|
job_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
bg_path = str(job_dir / background.filename)
|
||||||
|
with open(bg_path, "wb") as f:
|
||||||
|
f.write(await background.read())
|
||||||
|
|
||||||
|
avatar_path = None
|
||||||
|
if avatar:
|
||||||
|
avatar_path = str(job_dir / avatar.filename)
|
||||||
|
with open(avatar_path, "wb") as f:
|
||||||
|
f.write(await avatar.read())
|
||||||
|
|
||||||
|
# Register job
|
||||||
|
job = JobStatus(job_id=job_id, status="queued")
|
||||||
|
_jobs[job_id] = job
|
||||||
|
|
||||||
|
# Launch background worker
|
||||||
|
background_tasks.add_task(
|
||||||
|
_compose_worker, job_id, request, bg_path, avatar_path
|
||||||
|
)
|
||||||
|
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status/{job_id}", response_model=JobStatus)
|
||||||
|
async def get_status(job_id: str):
|
||||||
|
"""Poll composition job status."""
|
||||||
|
job = _jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/download/{job_id}")
|
||||||
|
async def download_video(job_id: str):
|
||||||
|
"""Download the finished video."""
|
||||||
|
job = _jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
if job.status != "done":
|
||||||
|
raise HTTPException(status_code=409, detail=f"Job status: {job.status}")
|
||||||
|
|
||||||
|
out_path = WORK_DIR / f"{job_id}.mp4"
|
||||||
|
if not out_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Output file missing")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(out_path),
|
||||||
|
media_type="video/mp4",
|
||||||
|
filename=f"broll_{job_id}.mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/preview/chart")
|
||||||
|
async def preview_chart(
|
||||||
|
chart_data: dict,
|
||||||
|
title: str = "",
|
||||||
|
chart_type: str = "bar_chart_comparison",
|
||||||
|
):
|
||||||
|
"""Generate and return a chart PNG for preview (no video assembly)."""
|
||||||
|
out = str(WORK_DIR / f"preview_{uuid.uuid4().hex}.png")
|
||||||
|
if chart_type == "bar_chart_comparison":
|
||||||
|
make_bar_chart(chart_data, out, title)
|
||||||
|
else:
|
||||||
|
make_line_trend(chart_data, out, title)
|
||||||
|
return FileResponse(path=out, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
456
backend/api/podcast/broll_temp/broll_composer.py
Normal file
456
backend/api/podcast/broll_temp/broll_composer.py
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
"""
|
||||||
|
Programmatic B-Roll Composer
|
||||||
|
Layered composition pipeline: Background + Chart + Avatar Circle + Text Overlays
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.patches as mpatches
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from moviepy.editor import (
|
||||||
|
VideoFileClip, ImageClip, CompositeVideoClip,
|
||||||
|
TextClip, ColorClip, concatenate_videoclips,
|
||||||
|
)
|
||||||
|
import moviepy.video.fx.all as vfx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Crossfade concat (Option 1: crossfadein + negative padding)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def crossfade_concat(scenes: list, fade_dur: float = 0.5) -> CompositeVideoClip:
|
||||||
|
"""
|
||||||
|
Concatenate scenes with a dissolve transition between each pair.
|
||||||
|
|
||||||
|
Each clip (except the first) gets a crossfadein effect.
|
||||||
|
padding=-fade_dur overlaps consecutive clips so the fade actually fires
|
||||||
|
instead of creating a black gap. set_duration on every scene is
|
||||||
|
mandatory — CompositeVideoClip.duration can be ambiguous without it,
|
||||||
|
which makes the overlap math wrong.
|
||||||
|
"""
|
||||||
|
faded = []
|
||||||
|
for i, clip in enumerate(scenes):
|
||||||
|
c = clip
|
||||||
|
if i > 0:
|
||||||
|
c = c.fx(vfx.crossfadein, fade_dur)
|
||||||
|
faded.append(c)
|
||||||
|
return concatenate_videoclips(faded, padding=-fade_dur, method="compose")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data structures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Insight:
|
||||||
|
key_insight: str
|
||||||
|
supporting_stat: str
|
||||||
|
visual_cue: str # bar_chart_comparison | line_trend | bullet_points | full_avatar
|
||||||
|
audio_tone: str
|
||||||
|
chart_data: dict = field(default_factory=dict)
|
||||||
|
duration: float = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SceneAssets:
|
||||||
|
background_img: str
|
||||||
|
chart_img: Optional[str] = None
|
||||||
|
avatar_video: Optional[str] = None
|
||||||
|
bullet_img: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chart generator (Matplotlib → PNG with transparency)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CHART_STYLE = {
|
||||||
|
"bg": "#0D0D0D",
|
||||||
|
"bar_before": "#2E4057",
|
||||||
|
"bar_after": "#E63946",
|
||||||
|
"text": "#F1F1EF",
|
||||||
|
"grid": "#2A2A2A",
|
||||||
|
"accent": "#E63946",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_bar_chart(data: dict, out_path: str, title: str = "") -> str:
|
||||||
|
"""Render a side-by-side comparison bar chart. Returns output path."""
|
||||||
|
labels = data.get("labels", [])
|
||||||
|
before = data.get("before", [])
|
||||||
|
after = data.get("after", [])
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||||
|
ax.set_facecolor("none")
|
||||||
|
|
||||||
|
x = np.arange(len(labels))
|
||||||
|
w = 0.35
|
||||||
|
bars_b = ax.bar(x - w / 2, before, w, color=CHART_STYLE["bar_before"],
|
||||||
|
label="Before", zorder=3, edgecolor="none")
|
||||||
|
bars_a = ax.bar(x + w / 2, after, w, color=CHART_STYLE["bar_after"],
|
||||||
|
label="After", zorder=3, edgecolor="none")
|
||||||
|
|
||||||
|
ax.set_xticks(x)
|
||||||
|
ax.set_xticklabels(labels, color=CHART_STYLE["text"], fontsize=11)
|
||||||
|
ax.tick_params(axis="y", colors=CHART_STYLE["text"])
|
||||||
|
ax.spines[:].set_visible(False)
|
||||||
|
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||||
|
ax.set_axisbelow(True)
|
||||||
|
|
||||||
|
# Value labels on bars
|
||||||
|
for bar in [*bars_b, *bars_a]:
|
||||||
|
h = bar.get_height()
|
||||||
|
ax.text(bar.get_x() + bar.get_width() / 2, h + 0.5, f"{h:.0f}%",
|
||||||
|
ha="center", va="bottom", color=CHART_STYLE["text"], fontsize=9,
|
||||||
|
fontweight="bold")
|
||||||
|
|
||||||
|
legend = ax.legend(frameon=False, labelcolor=CHART_STYLE["text"],
|
||||||
|
fontsize=10, loc="upper left")
|
||||||
|
if title:
|
||||||
|
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||||
|
fontweight="bold", pad=12)
|
||||||
|
|
||||||
|
fig.tight_layout(pad=0.5)
|
||||||
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def make_line_trend(data: dict, out_path: str, title: str = "") -> str:
|
||||||
|
"""Render a trend line chart. Returns output path."""
|
||||||
|
x_vals = data.get("x", [])
|
||||||
|
y_vals = data.get("y", [])
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||||
|
ax.set_facecolor("none")
|
||||||
|
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
|
||||||
|
linewidth=2.5, marker="o", markersize=7, zorder=3)
|
||||||
|
ax.fill_between(x_vals, y_vals, alpha=0.12, color=CHART_STYLE["accent"])
|
||||||
|
ax.spines[:].set_visible(False)
|
||||||
|
ax.tick_params(colors=CHART_STYLE["text"])
|
||||||
|
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||||
|
if title:
|
||||||
|
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||||
|
fontweight="bold", pad=12)
|
||||||
|
fig.tight_layout(pad=0.5)
|
||||||
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Text / Bullet overlay (Pillow → PNG)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def make_bullet_overlay(lines: list[str], out_path: str,
|
||||||
|
width: int = 900, font_size: int = 32) -> str:
|
||||||
|
"""Render bullet points on a semi-transparent dark pill. Returns path."""
|
||||||
|
padding = 32
|
||||||
|
line_h = font_size + 16
|
||||||
|
img_h = padding * 2 + len(lines) * line_h + 12
|
||||||
|
img = Image.new("RGBA", (width, img_h), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Semi-transparent background pill
|
||||||
|
draw.rounded_rectangle([0, 0, width - 1, img_h - 1],
|
||||||
|
radius=18, fill=(10, 10, 10, 185))
|
||||||
|
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
font_size)
|
||||||
|
except OSError:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
y = padding
|
||||||
|
for line in lines:
|
||||||
|
draw.text((padding + 18, y), f"• {line}", font=font, fill=(241, 241, 239, 255))
|
||||||
|
y += line_h
|
||||||
|
|
||||||
|
img.save(out_path, format="PNG")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def make_insight_card(insight: str, stat: str, out_path: str,
|
||||||
|
width: int = 960, height: int = 200) -> str:
|
||||||
|
"""Render a bold insight card (headline + supporting stat). Returns path."""
|
||||||
|
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.rounded_rectangle([0, 0, width - 1, height - 1],
|
||||||
|
radius=14, fill=(10, 10, 10, 200))
|
||||||
|
|
||||||
|
# Red accent bar
|
||||||
|
draw.rectangle([28, 24, 36, height - 24], fill=(230, 57, 70, 255))
|
||||||
|
|
||||||
|
try:
|
||||||
|
font_lg = ImageFont.truetype(
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
|
||||||
|
font_sm = ImageFont.truetype(
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||||
|
except OSError:
|
||||||
|
font_lg = font_sm = ImageFont.load_default()
|
||||||
|
|
||||||
|
draw.text((58, 36), insight, font=font_lg, fill=(241, 241, 239, 255))
|
||||||
|
draw.text((58, 90), stat, font=font_sm, fill=(180, 180, 178, 230))
|
||||||
|
|
||||||
|
img.save(out_path, format="PNG")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Circular avatar mask
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def apply_circle_mask(clip: VideoFileClip, diameter: int) -> VideoFileClip:
|
||||||
|
"""Resize clip and apply a circular alpha mask."""
|
||||||
|
clip = clip.resize(height=diameter)
|
||||||
|
w, h = clip.size
|
||||||
|
|
||||||
|
# Build a circular mask array (1 = opaque, 0 = transparent)
|
||||||
|
Y, X = np.ogrid[:h, :w]
|
||||||
|
cx, cy = w / 2, h / 2
|
||||||
|
mask_arr = ((X - cx) ** 2 + (Y - cy) ** 2 <= (min(w, h) / 2) ** 2).astype(float)
|
||||||
|
|
||||||
|
mask_clip = ImageClip(mask_arr, ismask=True).set_duration(clip.duration)
|
||||||
|
return clip.set_mask(mask_clip)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ken Burns zoom effect
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def ken_burns(clip: ImageClip, zoom_ratio: float = 0.08) -> ImageClip:
|
||||||
|
"""Apply a slow zoom-in over the clip duration."""
|
||||||
|
def zoom_frame(get_frame, t):
|
||||||
|
frame = get_frame(t)
|
||||||
|
frac = 1 + zoom_ratio * (t / clip.duration)
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
new_h, new_w = int(h / frac), int(w / frac)
|
||||||
|
y1 = (h - new_h) // 2
|
||||||
|
x1 = (w - new_w) // 2
|
||||||
|
cropped = frame[y1:y1 + new_h, x1:x1 + new_w]
|
||||||
|
return np.array(Image.fromarray(cropped).resize((w, h), Image.LANCZOS))
|
||||||
|
|
||||||
|
return clip.fl(zoom_frame, apply_to=["mask"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scene builders (one per visual_cue type)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_data_scene(assets: SceneAssets, insight: Insight) -> CompositeVideoClip:
|
||||||
|
"""
|
||||||
|
Layout: Background (Ken Burns) + Chart (fade-in) + Avatar circle (corner) + Insight card
|
||||||
|
"""
|
||||||
|
d = insight.duration
|
||||||
|
layers = []
|
||||||
|
|
||||||
|
# 1. Background
|
||||||
|
bg = (ImageClip(assets.background_img)
|
||||||
|
.set_duration(d)
|
||||||
|
.resize(height=1080))
|
||||||
|
bg = ken_burns(bg)
|
||||||
|
bg = bg.fx(vfx.lum_contrast, 0, -40) # darken 40 units
|
||||||
|
layers.append(bg)
|
||||||
|
|
||||||
|
# 2. Programmatic chart
|
||||||
|
if assets.chart_img:
|
||||||
|
chart = (ImageClip(assets.chart_img)
|
||||||
|
.set_duration(d - 1.5)
|
||||||
|
.set_start(0.5)
|
||||||
|
.resize(width=700)
|
||||||
|
.set_position(("center", 180))
|
||||||
|
.fx(vfx.fadein, 0.6)
|
||||||
|
.fx(vfx.fadeout, 0.4))
|
||||||
|
layers.append(chart)
|
||||||
|
|
||||||
|
# 3. Insight card at bottom
|
||||||
|
card_path = "/tmp/insight_card.png"
|
||||||
|
make_insight_card(insight.key_insight, insight.supporting_stat, card_path)
|
||||||
|
card = (ImageClip(card_path)
|
||||||
|
.set_duration(d - 1)
|
||||||
|
.set_start(0.5)
|
||||||
|
.set_position(("center", 820))
|
||||||
|
.fx(vfx.fadein, 0.5))
|
||||||
|
layers.append(card)
|
||||||
|
|
||||||
|
# 4. Avatar circle (bottom-right corner)
|
||||||
|
if assets.avatar_video:
|
||||||
|
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||||
|
avatar = apply_circle_mask(avatar_raw, diameter=240)
|
||||||
|
avatar = avatar.set_position((bg.w - 280, bg.h - 280))
|
||||||
|
layers.append(avatar)
|
||||||
|
|
||||||
|
# set_duration is required: CompositeVideoClip infers duration from its
|
||||||
|
# constituent clips, which can be ambiguous when sub-clips have set_start
|
||||||
|
# offsets. Without this, crossfade_concat's overlap math goes wrong.
|
||||||
|
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
|
||||||
|
|
||||||
|
|
||||||
|
def build_bullet_scene(assets: SceneAssets, insight: Insight,
|
||||||
|
bullets: list[str]) -> CompositeVideoClip:
|
||||||
|
"""
|
||||||
|
Layout: AI image (Ken Burns) + Bullet overlay + Avatar circle
|
||||||
|
"""
|
||||||
|
d = insight.duration
|
||||||
|
layers = []
|
||||||
|
|
||||||
|
bg = (ImageClip(assets.background_img)
|
||||||
|
.set_duration(d)
|
||||||
|
.resize(height=1080))
|
||||||
|
bg = ken_burns(bg, zoom_ratio=0.05)
|
||||||
|
bg = bg.fx(vfx.lum_contrast, 0, -50)
|
||||||
|
layers.append(bg)
|
||||||
|
|
||||||
|
bullet_path = "/tmp/bullets.png"
|
||||||
|
make_bullet_overlay(bullets, bullet_path, width=860)
|
||||||
|
bullets_clip = (ImageClip(bullet_path)
|
||||||
|
.set_duration(d - 1)
|
||||||
|
.set_start(0.5)
|
||||||
|
.set_position(("center", "center"))
|
||||||
|
.fx(vfx.fadein, 0.7))
|
||||||
|
layers.append(bullets_clip)
|
||||||
|
|
||||||
|
if assets.avatar_video:
|
||||||
|
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||||
|
avatar = apply_circle_mask(avatar_raw, diameter=200)
|
||||||
|
avatar = avatar.set_position((bg.w - 240, bg.h - 240))
|
||||||
|
layers.append(avatar)
|
||||||
|
|
||||||
|
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
|
||||||
|
|
||||||
|
|
||||||
|
def build_full_avatar_scene(assets: SceneAssets, insight: Insight) -> VideoFileClip:
|
||||||
|
"""Full-screen avatar — the expensive 'Hook' scene. No overlay."""
|
||||||
|
d = insight.duration
|
||||||
|
avatar = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||||
|
return avatar.resize(height=1080).set_duration(d)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scene dispatcher — maps visual_cue → builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def dispatch_scene(insight: Insight, assets: SceneAssets,
|
||||||
|
bullet_lines: Optional[list[str]] = None) -> CompositeVideoClip:
|
||||||
|
cue = insight.visual_cue
|
||||||
|
|
||||||
|
if cue == "full_avatar":
|
||||||
|
return build_full_avatar_scene(assets, insight)
|
||||||
|
|
||||||
|
elif cue in ("bar_chart_comparison", "line_trend"):
|
||||||
|
chart_path = "/tmp/chart.png"
|
||||||
|
if cue == "bar_chart_comparison":
|
||||||
|
make_bar_chart(insight.chart_data, chart_path,
|
||||||
|
title=insight.key_insight)
|
||||||
|
else:
|
||||||
|
make_line_trend(insight.chart_data, chart_path,
|
||||||
|
title=insight.key_insight)
|
||||||
|
assets.chart_img = chart_path
|
||||||
|
return build_data_scene(assets, insight)
|
||||||
|
|
||||||
|
elif cue == "bullet_points":
|
||||||
|
lines = bullet_lines or [insight.key_insight, insight.supporting_stat]
|
||||||
|
return build_bullet_scene(assets, insight, lines)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Fallback: data scene without chart
|
||||||
|
return build_data_scene(assets, insight)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Master compositor — assembles all scenes into one video
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compose_video(scenes: list, output_path: str = "output.mp4",
|
||||||
|
fps: int = 24, fade_dur: float = 0.5) -> str:
|
||||||
|
"""Concatenate scenes with crossfade transitions and write final video file."""
|
||||||
|
final = crossfade_concat(scenes, fade_dur=fade_dur)
|
||||||
|
final.write_videofile(
|
||||||
|
output_path,
|
||||||
|
fps=fps,
|
||||||
|
codec="libx264",
|
||||||
|
audio_codec="aac",
|
||||||
|
threads=4,
|
||||||
|
preset="fast",
|
||||||
|
logger=None,
|
||||||
|
)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON bridge — LLM insight → assets + scene
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def pipeline_from_json(insight_json: str,
|
||||||
|
background_img: str,
|
||||||
|
avatar_video: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
Full pipeline:
|
||||||
|
1. Parse LLM insight JSON
|
||||||
|
2. Generate chart / overlay assets
|
||||||
|
3. Build scene
|
||||||
|
4. Write video
|
||||||
|
Returns path to output video.
|
||||||
|
"""
|
||||||
|
data = json.loads(insight_json)
|
||||||
|
insight = Insight(**{k: data[k] for k in Insight.__dataclass_fields__ if k in data})
|
||||||
|
assets = SceneAssets(background_img=background_img, avatar_video=avatar_video)
|
||||||
|
scene = dispatch_scene(insight, assets,
|
||||||
|
bullet_lines=data.get("bullet_lines"))
|
||||||
|
out = f"/tmp/scene_{insight.visual_cue}.mp4"
|
||||||
|
compose_video([scene], output_path=out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Demo / smoke-test (no real media files needed for chart generation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# --- Test 1: Chart PNG generation only ---
|
||||||
|
sample_bar_data = {
|
||||||
|
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
|
||||||
|
"before": [30, 22, 18, 60],
|
||||||
|
"after": [72, 34, 41, 38],
|
||||||
|
}
|
||||||
|
chart_out = make_bar_chart(
|
||||||
|
sample_bar_data,
|
||||||
|
"/tmp/demo_chart.png",
|
||||||
|
title="AI Tools Impact: Before vs After (2025)",
|
||||||
|
)
|
||||||
|
print(f"Chart saved → {chart_out}")
|
||||||
|
|
||||||
|
# --- Test 2: Bullet overlay PNG ---
|
||||||
|
bullets = [
|
||||||
|
"AI reduced content cycles by 40% in 2025",
|
||||||
|
"HubSpot: 12% lift in CTR with AI-assisted copy",
|
||||||
|
"Video production cost down 3x with hybrid pipeline",
|
||||||
|
]
|
||||||
|
bullet_out = make_bullet_overlay(bullets, "/tmp/demo_bullets.png")
|
||||||
|
print(f"Bullets saved → {bullet_out}")
|
||||||
|
|
||||||
|
# --- Test 3: Insight card PNG ---
|
||||||
|
card_out = make_insight_card(
|
||||||
|
"AI tools reduced content cycles by 40%",
|
||||||
|
"HubSpot 2026 report — 12% lift in CTR",
|
||||||
|
"/tmp/demo_card.png",
|
||||||
|
)
|
||||||
|
print(f"Insight card saved → {card_out}")
|
||||||
|
|
||||||
|
# --- Test 4: JSON bridge (chart only, no video files required) ---
|
||||||
|
sample_json = json.dumps({
|
||||||
|
"key_insight": "AI reduced production time by 40%",
|
||||||
|
"supporting_stat": "HubSpot 2026: 12% CTR lift",
|
||||||
|
"visual_cue": "bar_chart_comparison",
|
||||||
|
"audio_tone": "authoritative_and_surprising",
|
||||||
|
"duration": 8.0,
|
||||||
|
"chart_data": sample_bar_data,
|
||||||
|
})
|
||||||
|
print("\nSample Insight JSON:\n", sample_json)
|
||||||
|
print("\nAll asset generation tests passed.")
|
||||||
|
print("To run full video composition, supply real background_img and avatar_video paths.")
|
||||||
@@ -51,6 +51,7 @@ async def enhance_podcast_idea(
|
|||||||
# In podcast-only mode, skip bible generation since onboarding is disabled
|
# In podcast-only mode, skip bible generation since onboarding is disabled
|
||||||
bible_context = ""
|
bible_context = ""
|
||||||
if not _is_podcast_only_mode():
|
if not _is_podcast_only_mode():
|
||||||
|
logger.warning(f"[Podcast Enhance] Podcast mode=full — attempting Bible generation for user {user_id}")
|
||||||
try:
|
try:
|
||||||
bible_service = PodcastBibleService()
|
bible_service = PodcastBibleService()
|
||||||
if request.bible:
|
if request.bible:
|
||||||
@@ -65,6 +66,7 @@ async def enhance_podcast_idea(
|
|||||||
logger.warning(f"[Podcast Enhance] Failed to parse or generate bible context: {exc}")
|
logger.warning(f"[Podcast Enhance] Failed to parse or generate bible context: {exc}")
|
||||||
else:
|
else:
|
||||||
# In podcast mode, use the provided bible directly if available
|
# In podcast mode, use the provided bible directly if available
|
||||||
|
logger.warning(f"[Podcast Enhance] Podcast mode=podcast_only — skipping Bible generation for user {user_id}")
|
||||||
if request.bible:
|
if request.bible:
|
||||||
try:
|
try:
|
||||||
from models.podcast_bible_models import PodcastBible
|
from models.podcast_bible_models import PodcastBible
|
||||||
@@ -209,7 +211,11 @@ async def analyze_podcast_idea(
|
|||||||
final_avatar_url = request.avatar_url
|
final_avatar_url = request.avatar_url
|
||||||
final_avatar_prompt = None
|
final_avatar_prompt = None
|
||||||
|
|
||||||
if not final_avatar_url:
|
# Skip avatar generation for audio_only mode
|
||||||
|
podcast_mode = getattr(request, 'podcast_mode', None) or 'video_only'
|
||||||
|
should_generate_avatar = not final_avatar_url and podcast_mode != 'audio_only'
|
||||||
|
|
||||||
|
if should_generate_avatar:
|
||||||
logger.info(f"[Podcast Analyze] No avatar_url provided, generating one for user {user_id}")
|
logger.info(f"[Podcast Analyze] No avatar_url provided, generating one for user {user_id}")
|
||||||
try:
|
try:
|
||||||
# 1. PRE-FLIGHT VALIDATION: Check subscription limits for image generation
|
# 1. PRE-FLIGHT VALIDATION: Check subscription limits for image generation
|
||||||
@@ -240,8 +246,9 @@ async def analyze_podcast_idea(
|
|||||||
if image_result and image_result.image_bytes:
|
if image_result and image_result.image_bytes:
|
||||||
img_id = str(uuid.uuid4())[:8]
|
img_id = str(uuid.uuid4())[:8]
|
||||||
filename = f"presenter_podcast_{user_id}_{img_id}.png"
|
filename = f"presenter_podcast_{user_id}_{img_id}.png"
|
||||||
output_path = PODCAST_IMAGES_DIR / filename
|
avatars_dir = PODCAST_IMAGES_DIR / "avatars"
|
||||||
PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path = avatars_dir / filename
|
||||||
|
|
||||||
with open(output_path, "wb") as f:
|
with open(output_path, "wb") as f:
|
||||||
f.write(image_result.image_bytes)
|
f.write(image_result.image_bytes)
|
||||||
@@ -253,13 +260,14 @@ async def analyze_podcast_idea(
|
|||||||
db=db,
|
db=db,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
asset_type="image",
|
asset_type="image",
|
||||||
file_url=final_avatar_url,
|
source_module="podcast_analysis",
|
||||||
filename=filename,
|
filename=filename,
|
||||||
|
file_url=final_avatar_url,
|
||||||
title=f"Presenter Avatar - {request.idea[:40]}",
|
title=f"Presenter Avatar - {request.idea[:40]}",
|
||||||
description=f"AI-generated podcast presenter for: {request.idea}",
|
description=f"AI-generated podcast presenter for: {request.idea}",
|
||||||
provider=image_result.provider,
|
provider=image_result.provider,
|
||||||
model=image_result.model,
|
model=image_result.model,
|
||||||
cost=image_result.cost
|
cost=0.0 # Cost tracked in generate_image
|
||||||
)
|
)
|
||||||
logger.info(f"[Podcast Analyze] ✅ Generated and saved avatar to {final_avatar_url}")
|
logger.info(f"[Podcast Analyze] ✅ Generated and saved avatar to {final_avatar_url}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
241
backend/api/podcast/handlers/broll.py
Normal file
241
backend/api/podcast/handlers/broll.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
B-Roll Handlers
|
||||||
|
|
||||||
|
API endpoints for B-roll chart preview and video generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from middleware.auth_middleware import get_current_user
|
||||||
|
from api.story_writer.utils.auth import require_authenticated_user
|
||||||
|
from services.podcast.broll_service import get_broll_service
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ChartPreviewRequest(BaseModel):
|
||||||
|
"""Request model for chart preview generation."""
|
||||||
|
chart_data: Dict[str, Any] = Field(..., description="Chart data (labels, before/after, etc.)")
|
||||||
|
chart_type: str = Field(
|
||||||
|
default="bar_comparison",
|
||||||
|
description="bar_comparison | bar_horizontal | line_trend | pie | stacked_bar | bullet"
|
||||||
|
)
|
||||||
|
title: str = Field(default="", description="Chart title")
|
||||||
|
subtitle: Optional[str] = Field(default="", description="Optional subtitle at bottom")
|
||||||
|
|
||||||
|
|
||||||
|
class ChartPreviewResponse(BaseModel):
|
||||||
|
"""Response for chart preview."""
|
||||||
|
preview_url: str
|
||||||
|
chart_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class BrollSceneRequest(BaseModel):
|
||||||
|
"""Request for generating B-roll video for a scene."""
|
||||||
|
scene_id: str
|
||||||
|
key_insight: str
|
||||||
|
supporting_stat: str
|
||||||
|
chart_data: Optional[Dict[str, Any]] = None
|
||||||
|
visual_cue: str = Field(default="bar_chart_comparison", description="bar_chart_comparison | bullet_points")
|
||||||
|
duration: float = Field(default=10.0, ge=3.0, le=60.0)
|
||||||
|
background_image_url: str
|
||||||
|
avatar_video_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BrollSceneResponse(BaseModel):
|
||||||
|
"""Response for B-roll scene generation."""
|
||||||
|
scene_id: str
|
||||||
|
broll_video_url: str
|
||||||
|
broll_video_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class BrollComposeRequest(BaseModel):
|
||||||
|
"""Request for composing multiple B-roll videos."""
|
||||||
|
scene_video_paths: List[str]
|
||||||
|
output_filename: str = "final_broll.mp4"
|
||||||
|
fade_dur: float = Field(default=0.5, ge=0.0, le=2.0)
|
||||||
|
fps: int = Field(default=24, ge=12, le=60)
|
||||||
|
|
||||||
|
|
||||||
|
class BrollComposeResponse(BaseModel):
|
||||||
|
"""Response for B-roll composition."""
|
||||||
|
final_video_url: str
|
||||||
|
final_video_path: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview/chart", response_model=ChartPreviewResponse)
|
||||||
|
async def generate_chart_preview(
|
||||||
|
request: ChartPreviewRequest,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate a chart PNG preview (static image for Write phase).
|
||||||
|
|
||||||
|
This endpoint is called from the Write phase to show users chart previews
|
||||||
|
before they commit to B-roll video generation.
|
||||||
|
"""
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
broll_service = get_broll_service()
|
||||||
|
|
||||||
|
preview_path = broll_service.generate_chart_preview(
|
||||||
|
chart_data=request.chart_data,
|
||||||
|
chart_type=request.chart_type,
|
||||||
|
title=request.title,
|
||||||
|
subtitle=request.subtitle or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not preview_path:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to generate chart preview")
|
||||||
|
|
||||||
|
chart_id = uuid.uuid4().hex[:8]
|
||||||
|
preview_url = f"/api/podcast/broll/preview/{chart_id}/{preview_path.split('/')[-1]}"
|
||||||
|
|
||||||
|
return ChartPreviewResponse(
|
||||||
|
preview_url=preview_url,
|
||||||
|
chart_id=chart_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Broll] Chart preview generation failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Chart preview failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/render/broll-scene", response_model=BrollSceneResponse)
|
||||||
|
async def generate_broll_scene(
|
||||||
|
request: BrollSceneRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate a B-roll video for a single scene.
|
||||||
|
|
||||||
|
This creates a programmatic video with:
|
||||||
|
- Background image with Ken Burns effect
|
||||||
|
- Chart overlay (if chart_data provided)
|
||||||
|
- Avatar circle in corner (if avatar_video_url provided)
|
||||||
|
- Insight card at bottom
|
||||||
|
|
||||||
|
Returns a task_id for polling since video generation can take time.
|
||||||
|
"""
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate visual_cue
|
||||||
|
valid_cues = ["bar_chart_comparison", "bullet_points", "full_avatar"]
|
||||||
|
if request.visual_cue not in valid_cues:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid visual_cue. Must be one of: {valid_cues}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# For now, return a placeholder - full video generation requires
|
||||||
|
# resolving image/video URLs to actual file paths
|
||||||
|
# In V2, this will integrate with the actual video generation
|
||||||
|
|
||||||
|
logger.info(f"[Broll] B-roll scene request for scene: {request.scene_id}")
|
||||||
|
|
||||||
|
return BrollSceneResponse(
|
||||||
|
scene_id=request.scene_id,
|
||||||
|
broll_video_url="",
|
||||||
|
broll_video_path="",
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Broll] B-roll scene generation failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"B-roll generation failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/render/broll-compose", response_model=BrollComposeResponse)
|
||||||
|
async def compose_broll_videos(
|
||||||
|
request: BrollComposeRequest,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Compose multiple B-roll scene videos into a final video.
|
||||||
|
|
||||||
|
Applies crossfade transitions between scenes.
|
||||||
|
"""
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
broll_service = get_broll_service()
|
||||||
|
|
||||||
|
final_path = broll_service.compose_final_video(
|
||||||
|
video_paths=request.scene_video_paths,
|
||||||
|
output_filename=request.output_filename,
|
||||||
|
fade_dur=request.fade_dur,
|
||||||
|
fps=request.fps,
|
||||||
|
)
|
||||||
|
|
||||||
|
final_filename = final_path.split('/')[-1]
|
||||||
|
final_url = f"/api/podcast/broll/final/{final_filename}"
|
||||||
|
|
||||||
|
return BrollComposeResponse(
|
||||||
|
final_video_url=final_url,
|
||||||
|
final_video_path=final_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Broll] Video composition failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Video composition failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preview/{chart_id}/{filename}")
|
||||||
|
async def serve_chart_preview(
|
||||||
|
chart_id: str,
|
||||||
|
filename: str,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Serve chart preview PNG files."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
|
broll_service = get_broll_service()
|
||||||
|
file_path = broll_service.output_dir / f"chart_preview_{chart_id}.png"
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Chart preview not found")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path),
|
||||||
|
media_type="image/png",
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/final/{filename}")
|
||||||
|
async def serve_final_broll(
|
||||||
|
filename: str,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Serve final composed B-roll video files."""
|
||||||
|
user_id = require_authenticated_user(current_user)
|
||||||
|
|
||||||
|
broll_service = get_broll_service()
|
||||||
|
file_path = broll_service.output_dir / filename
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path),
|
||||||
|
media_type="video/mp4",
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def broll_health():
|
||||||
|
"""Health check for B-roll service."""
|
||||||
|
return {"status": "ok", "service": "broll"}
|
||||||
@@ -119,7 +119,7 @@ async def update_project(
|
|||||||
project = service.update_project(user_id, project_id, **updates)
|
project = service.update_project(user_id, project_id, **updates)
|
||||||
|
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||||
|
|
||||||
return PodcastProjectResponse.model_validate(project)
|
return PodcastProjectResponse.model_validate(project)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from ..models import (
|
|||||||
PodcastExaSource,
|
PodcastExaSource,
|
||||||
PodcastExaConfig,
|
PodcastExaConfig,
|
||||||
PodcastResearchInsight,
|
PodcastResearchInsight,
|
||||||
|
PodcastResearchOutput,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -159,43 +160,50 @@ As a podcast research expert, analyze this data and create content that will:
|
|||||||
4. Include a compelling call-to-action for listeners
|
4. Include a compelling call-to-action for listeners
|
||||||
|
|
||||||
REQUIRED OUTPUT (JSON):
|
REQUIRED OUTPUT (JSON):
|
||||||
=======================
|
======================
|
||||||
{{
|
{{
|
||||||
"summary": "2-3 paragraph comprehensive summary in Markdown. Start with a hook that matches the episode intro. Include specific data points, expert quotes, and trends.",
|
"summary": "2-3 paragraph comprehensive summary in Markdown. Start with a hook that matches the episode intro.",
|
||||||
"key_insights": [
|
"key_insights": [
|
||||||
{{
|
{{
|
||||||
"title": "Catchy, engaging title for this insight",
|
"title": "Insight title",
|
||||||
"content": "3-4 sentences with specific facts, quotes, or data. Write in a conversational tone suitable for a podcast host to discuss.",
|
"content": "3-4 sentences with specific facts, quotes, or data for podcast host.",
|
||||||
"source_indices": [1, 2, 3],
|
"source_indices": [1, 2],
|
||||||
"podcast_talking_points": ["Point 1 host can expand on", "Counter-point or follow-up", "Question to ask guest"]
|
"podcast_talking_points": ["Point host can expand on", "Counter-point"]
|
||||||
}}
|
}}
|
||||||
],
|
],
|
||||||
"expert_quotes": [
|
"expert_quotes": [
|
||||||
{{
|
{{
|
||||||
"quote": "Direct quote from source",
|
"quote": "Direct quote from source text",
|
||||||
"source_index": 1,
|
"source_index": 1,
|
||||||
"context": "Why this quote matters for the podcast"
|
"context": "Why this quote matters for the podcast"
|
||||||
}}
|
}}
|
||||||
],
|
],
|
||||||
"listener_cta_suggestions": ["Specific action listener can take", "Resource to share", "Next episode preview"]
|
"listener_cta_suggestions": ["Action listener can take", "Resource to share", "Next episode preview"],
|
||||||
|
"mapped_angles": [
|
||||||
|
{{
|
||||||
|
"title": "Content angle title",
|
||||||
|
"why": "Why compelling for audience",
|
||||||
|
"mapped_fact_ids": [1, 2]
|
||||||
|
}}
|
||||||
|
]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
IMPORTANT: You must include ALL fields above with valid data. expert_quotes, listener_cta_suggestions, and mapped_angles must have content - do NOT leave them empty!
|
||||||
|
|
||||||
QUALITY STANDARDS:
|
QUALITY STANDARDS:
|
||||||
==================
|
=================
|
||||||
- INSIGHTS MUST BE DEEP, not superficial - avoid generic statements
|
- Include at least 2 expert_quotes with source_index
|
||||||
- Include SPECIFIC DATA POINTS, percentages, statistics when available
|
- Include at least 2 listener_cta_suggestions
|
||||||
- Extract EXPERT QUOTES that hosts can reference
|
- Include at least 2 mapped_angles
|
||||||
- Identify GAPS in the research where more depth is needed
|
- Include specific data points, percentages, statistics
|
||||||
- Make content naturally flow into the planned episode hook and CTA
|
- Write in conversational tone
|
||||||
- Write in a CONVERSATIONAL tone - how a host would actually speak
|
|
||||||
- Flag any CONTROVERSIAL or debatable claims for host to address
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.warning(f"[Podcast Research] Calling LLM for insight extraction...")
|
logger.warning(f"[Podcast Research] Calling LLM with json_struct...")
|
||||||
llm_response = llm_text_gen(
|
llm_response = llm_text_gen(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
json_struct=None,
|
json_struct=PodcastResearchOutput.model_json_schema(),
|
||||||
preferred_provider=None,
|
preferred_provider=None,
|
||||||
flow_type="premium_tool",
|
flow_type="premium_tool",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class PodcastAnalyzeRequest(BaseModel):
|
|||||||
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
|
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
|
||||||
avatar_url: Optional[str] = Field(None, description="Current avatar URL if selected")
|
avatar_url: Optional[str] = Field(None, description="Current avatar URL if selected")
|
||||||
feedback: Optional[str] = Field(None, description="User feedback for regeneration")
|
feedback: Optional[str] = Field(None, description="User feedback for regeneration")
|
||||||
|
podcast_mode: Optional[str] = Field(None, description="Podcast mode: audio_only, video_only, or audio_video")
|
||||||
|
|
||||||
|
|
||||||
class PodcastAnalyzeResponse(BaseModel):
|
class PodcastAnalyzeResponse(BaseModel):
|
||||||
@@ -171,6 +172,15 @@ class PodcastResearchInsight(BaseModel):
|
|||||||
listener_cta_suggestions: Optional[List[str]] = [] # CTA suggestions
|
listener_cta_suggestions: Optional[List[str]] = [] # CTA suggestions
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastResearchOutput(BaseModel):
|
||||||
|
"""Structured JSON output for LLM research extraction using json_struct."""
|
||||||
|
summary: str = ""
|
||||||
|
key_insights: List[PodcastResearchInsight] = []
|
||||||
|
expert_quotes: List[Dict[str, Any]] = [] # [{"quote": str, "source_index": int, "context": str}]
|
||||||
|
listener_cta_suggestions: List[str] = [] # List of CTA suggestions
|
||||||
|
mapped_angles: List[Dict[str, Any]] = [] # [{"title": str, "why": str, "mapped_fact_ids": []}]
|
||||||
|
|
||||||
|
|
||||||
class PodcastExaResearchResponse(BaseModel):
|
class PodcastExaResearchResponse(BaseModel):
|
||||||
sources: List[PodcastExaSource]
|
sources: List[PodcastExaSource]
|
||||||
search_queries: List[str] = []
|
search_queries: List[str] = []
|
||||||
@@ -180,6 +190,9 @@ class PodcastExaResearchResponse(BaseModel):
|
|||||||
search_type: Optional[str] = None
|
search_type: Optional[str] = None
|
||||||
provider: str = "exa"
|
provider: str = "exa"
|
||||||
content: Optional[str] = None # Raw aggregated content (deprecated)
|
content: Optional[str] = None # Raw aggregated content (deprecated)
|
||||||
|
mapped_angles: List[Dict[str, Any]] = [] # Content angles for the episode
|
||||||
|
expert_quotes: List[Dict[str, Any]] = [] # Expert quotes from research
|
||||||
|
listener_cta_suggestions: List[str] = [] # CTA suggestions
|
||||||
|
|
||||||
|
|
||||||
class PodcastScriptResponse(BaseModel):
|
class PodcastScriptResponse(BaseModel):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
Pre-flight check endpoints for operation validation and cost estimation.
|
Pre-flight check endpoints for operation validation and cost estimation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
@@ -34,6 +35,7 @@ async def preflight_check(
|
|||||||
|
|
||||||
Uses caching to minimize DB load (< 100ms with cache hit).
|
Uses caching to minimize DB load (< 100ms with cache hit).
|
||||||
"""
|
"""
|
||||||
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
user_id = get_user_id_from_token(current_user)
|
user_id = get_user_id_from_token(current_user)
|
||||||
|
|
||||||
@@ -229,13 +231,19 @@ async def preflight_check(
|
|||||||
'remaining': max(0, video_limit - video_current) if video_limit > 0 else float('inf')
|
'remaining': max(0, video_limit - video_current) if video_limit > 0 else float('inf')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.warning(f"[PreflightCheck] Completed in {elapsed_ms:.0f}ms for user {user_id}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"data": response_data
|
"data": response_data
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.warning(f"[PreflightCheck] HTTP error after {elapsed_ms:.0f}ms")
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in pre-flight check: {e}", exc_info=True)
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.error(f"[PreflightCheck] Error after {elapsed_ms:.0f}ms: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Pre-flight check failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Pre-flight check failed: {str(e)}")
|
||||||
|
|||||||
@@ -250,10 +250,6 @@ def huggingface_text_response(
|
|||||||
|
|
||||||
logger.info("🚀 Making Hugging Face API call (chat completion)...")
|
logger.info("🚀 Making Hugging Face API call (chat completion)...")
|
||||||
|
|
||||||
# Add rate limiting to prevent expensive API calls
|
|
||||||
import time
|
|
||||||
time.sleep(1) # 1 second delay between API calls
|
|
||||||
|
|
||||||
response = None
|
response = None
|
||||||
last_error = None
|
last_error = None
|
||||||
for candidate_model in _fallback_model_sequence(model):
|
for candidate_model in _fallback_model_sequence(model):
|
||||||
@@ -403,10 +399,6 @@ def huggingface_structured_json_response(
|
|||||||
json_schema_str = json.dumps(schema, indent=2)
|
json_schema_str = json.dumps(schema, indent=2)
|
||||||
messages[-1]["content"] += f"\n\nJSON Schema:\n{json_schema_str}"
|
messages[-1]["content"] += f"\n\nJSON Schema:\n{json_schema_str}"
|
||||||
|
|
||||||
# Add rate limiting to prevent expensive API calls
|
|
||||||
import time
|
|
||||||
time.sleep(1) # 1 second delay between API calls
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = None
|
response = None
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ migrated from the legacy lib/gpt_providers/text_generation/main_text_generation.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -211,7 +212,7 @@ def llm_text_gen(
|
|||||||
provider_enum = APIProvider.MISTRAL # HuggingFace maps to Mistral enum for usage tracking
|
provider_enum = APIProvider.MISTRAL # HuggingFace maps to Mistral enum for usage tracking
|
||||||
actual_provider_name = "huggingface" # Keep actual provider name for logs
|
actual_provider_name = "huggingface" # Keep actual provider name for logs
|
||||||
elif gpt_provider == "wavespeed":
|
elif gpt_provider == "wavespeed":
|
||||||
provider_enum = APIProvider.OPENAI # Map to OpenAI for tracking purposes
|
provider_enum = APIProvider.WAVESPEED
|
||||||
actual_provider_name = "wavespeed"
|
actual_provider_name = "wavespeed"
|
||||||
elif gpt_provider == "openai":
|
elif gpt_provider == "openai":
|
||||||
provider_enum = APIProvider.OPENAI
|
provider_enum = APIProvider.OPENAI
|
||||||
@@ -225,6 +226,8 @@ def llm_text_gen(
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
raise RuntimeError("user_id is required for subscription checking. Please provide Clerk user ID.")
|
raise RuntimeError("user_id is required for subscription checking. Please provide Clerk user ID.")
|
||||||
|
|
||||||
|
sub_check_start = time.time()
|
||||||
|
logger.warning(f"[llm_text_gen][{flow_tag}] Subscription check START for user {user_id}")
|
||||||
try:
|
try:
|
||||||
from services.database import get_session_for_user
|
from services.database import get_session_for_user
|
||||||
from services.subscription import UsageTrackingService, PricingService
|
from services.subscription import UsageTrackingService, PricingService
|
||||||
@@ -286,6 +289,8 @@ def llm_text_gen(
|
|||||||
logger.info(f"[llm_text_gen] Subscription check passed for user {user_id}: provider={actual_provider_name or gpt_provider}, tokens_requested={estimated_total_tokens}, new_user_no_usage_record")
|
logger.info(f"[llm_text_gen] Subscription check passed for user {user_id}: provider={actual_provider_name or gpt_provider}, tokens_requested={estimated_total_tokens}, new_user_no_usage_record")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
sub_check_ms = (time.time() - sub_check_start) * 1000
|
||||||
|
logger.warning(f"[llm_text_gen][{flow_tag}] Subscription check took {sub_check_ms:.0f}ms for user {user_id}")
|
||||||
db.close()
|
db.close()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
# Re-raise HTTPExceptions (e.g., 429 subscription limit) - preserve error details
|
# Re-raise HTTPExceptions (e.g., 429 subscription limit) - preserve error details
|
||||||
@@ -295,7 +300,8 @@ def llm_text_gen(
|
|||||||
raise
|
raise
|
||||||
except Exception as sub_error:
|
except Exception as sub_error:
|
||||||
# STRICT: Fail on subscription check errors
|
# STRICT: Fail on subscription check errors
|
||||||
logger.error(f"[llm_text_gen] Subscription check failed for user {user_id}: {sub_error}")
|
sub_check_ms = (time.time() - sub_check_start) * 1000
|
||||||
|
logger.error(f"[llm_text_gen][{flow_tag}] Subscription check FAILED after {sub_check_ms:.0f}ms for user {user_id}: {sub_error}")
|
||||||
raise RuntimeError(f"Subscription check failed: {str(sub_error)}")
|
raise RuntimeError(f"Subscription check failed: {str(sub_error)}")
|
||||||
|
|
||||||
# Construct the system prompt if not provided
|
# Construct the system prompt if not provided
|
||||||
@@ -366,6 +372,7 @@ def llm_text_gen(
|
|||||||
)
|
)
|
||||||
elif gpt_provider == "wavespeed":
|
elif gpt_provider == "wavespeed":
|
||||||
from services.llm_providers.wavespeed_provider import wavespeed_text_response
|
from services.llm_providers.wavespeed_provider import wavespeed_text_response
|
||||||
|
llm_start = time.time()
|
||||||
response_text = wavespeed_text_response(
|
response_text = wavespeed_text_response(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=model or "openai/gpt-oss-120b",
|
model=model or "openai/gpt-oss-120b",
|
||||||
@@ -374,6 +381,8 @@ def llm_text_gen(
|
|||||||
top_p=top_p,
|
top_p=top_p,
|
||||||
system_prompt=system_instructions
|
system_prompt=system_instructions
|
||||||
)
|
)
|
||||||
|
llm_ms = (time.time() - llm_start) * 1000
|
||||||
|
logger.warning(f"[llm_text_gen][{flow_tag}] LLM API call took {llm_ms:.0f}ms for user {user_id} (wavespeed)")
|
||||||
else:
|
else:
|
||||||
logger.error(f"[llm_text_gen] Unknown provider: {gpt_provider}")
|
logger.error(f"[llm_text_gen] Unknown provider: {gpt_provider}")
|
||||||
raise RuntimeError(f"Unknown LLM provider: {gpt_provider}. Supported providers: google, huggingface, wavespeed")
|
raise RuntimeError(f"Unknown LLM provider: {gpt_provider}. Supported providers: google, huggingface, wavespeed")
|
||||||
|
|||||||
@@ -274,10 +274,6 @@ def wavespeed_text_response(
|
|||||||
|
|
||||||
logger.info("🚀 Making WaveSpeed API call (chat completion)...")
|
logger.info("🚀 Making WaveSpeed API call (chat completion)...")
|
||||||
|
|
||||||
# Add rate limiting to prevent expensive API calls
|
|
||||||
import time
|
|
||||||
time.sleep(1) # 1 second delay between API calls
|
|
||||||
|
|
||||||
# Call exactly the requested model; no retries, no fallbacks, no variants
|
# Call exactly the requested model; no retries, no fallbacks, no variants
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
@@ -426,10 +422,6 @@ def wavespeed_structured_json_response(
|
|||||||
json_schema_str = json.dumps(schema, indent=2)
|
json_schema_str = json.dumps(schema, indent=2)
|
||||||
messages[-1]["content"] += f"\n\nJSON Schema:\n{json_schema_str}"
|
messages[-1]["content"] += f"\n\nJSON Schema:\n{json_schema_str}"
|
||||||
|
|
||||||
# Add rate limiting to prevent expensive API calls
|
|
||||||
import time
|
|
||||||
time.sleep(1) # 1 second delay between API calls
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = None
|
response = None
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|||||||
623
backend/services/podcast/broll_composer.py
Normal file
623
backend/services/podcast/broll_composer.py
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
"""
|
||||||
|
Programmatic B-Roll Composer
|
||||||
|
Layered composition pipeline: Background + Chart + Avatar Circle + Text Overlays
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.patches as mpatches
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from moviepy import (
|
||||||
|
VideoFileClip, ImageClip, CompositeVideoClip,
|
||||||
|
concatenate_videoclips,
|
||||||
|
)
|
||||||
|
import moviepy.video.fx as vfx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Crossfade concat (Option 1: crossfadein + negative padding)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def crossfade_concat(scenes: list, fade_dur: float = 0.5):
|
||||||
|
"""
|
||||||
|
Concatenate scenes with a dissolve transition between each pair.
|
||||||
|
|
||||||
|
Each clip (except the first) gets a crossfadein effect.
|
||||||
|
padding=-fade_dur overlaps consecutive clips so the fade actually fires
|
||||||
|
instead of creating a black gap. set_duration on every scene is
|
||||||
|
mandatory — CompositeVideoClip.duration can be ambiguous without it,
|
||||||
|
which makes the overlap math wrong.
|
||||||
|
"""
|
||||||
|
faded = []
|
||||||
|
for i, clip in enumerate(scenes):
|
||||||
|
c = clip
|
||||||
|
if i > 0:
|
||||||
|
c = c.fx(vfx.CrossFadeIn, fade_dur)
|
||||||
|
faded.append(c)
|
||||||
|
return concatenate_videoclips(faded, padding=-int(fade_dur), method="compose")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data structures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Insight:
|
||||||
|
key_insight: str
|
||||||
|
supporting_stat: str
|
||||||
|
visual_cue: str # bar_chart_comparison | line_trend | bullet_points | full_avatar
|
||||||
|
audio_tone: str
|
||||||
|
chart_data: dict = field(default_factory=dict)
|
||||||
|
duration: float = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SceneAssets:
|
||||||
|
background_img: str
|
||||||
|
chart_img: Optional[str] = None
|
||||||
|
avatar_video: Optional[str] = None
|
||||||
|
bullet_img: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chart generator (Matplotlib → PNG with transparency)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CHART_STYLE = {
|
||||||
|
"bg": "#0D0D0D",
|
||||||
|
"bar_before": "#2E4057",
|
||||||
|
"bar_after": "#E63946",
|
||||||
|
"text": "#F1F1EF",
|
||||||
|
"grid": "#2A2A2A",
|
||||||
|
"accent": "#E63946",
|
||||||
|
"pie_colors": ["#E63946", "#2E4057", "#457B9D", "#A8DADC", "#F4A261", "#2A9D8F"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chart generators (Matplotlib → PNG with transparency)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def make_bar_chart(data: dict, out_path: str, title: str = "",
|
||||||
|
show_legend: bool = True, value_suffix: str = "%",
|
||||||
|
subtitle: str = "") -> str:
|
||||||
|
"""Render a side-by-side comparison bar chart. Returns output path."""
|
||||||
|
labels = data.get("labels", [])
|
||||||
|
before = data.get("before", [])
|
||||||
|
after = data.get("after", [])
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||||
|
ax.set_facecolor("none")
|
||||||
|
|
||||||
|
x = np.arange(len(labels))
|
||||||
|
w = 0.35
|
||||||
|
bars_b = ax.bar(x - w / 2, before, w, color=CHART_STYLE["bar_before"],
|
||||||
|
label="Before", zorder=3, edgecolor="none")
|
||||||
|
bars_a = ax.bar(x + w / 2, after, w, color=CHART_STYLE["bar_after"],
|
||||||
|
label="After", zorder=3, edgecolor="none")
|
||||||
|
|
||||||
|
ax.set_xticks(x)
|
||||||
|
ax.set_xticklabels(labels, color=CHART_STYLE["text"], fontsize=11)
|
||||||
|
ax.tick_params(axis="y", colors=CHART_STYLE["text"])
|
||||||
|
ax.spines[:].set_visible(False)
|
||||||
|
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||||
|
ax.set_axisbelow(True)
|
||||||
|
|
||||||
|
for bar in [*bars_b, *bars_a]:
|
||||||
|
h = bar.get_height()
|
||||||
|
ax.text(bar.get_x() + bar.get_width() / 2, h + 0.5, f"{h:.0f}{value_suffix}",
|
||||||
|
ha="center", va="bottom", color=CHART_STYLE["text"], fontsize=9,
|
||||||
|
fontweight="bold")
|
||||||
|
|
||||||
|
if show_legend:
|
||||||
|
legend = ax.legend(frameon=False, labelcolor=CHART_STYLE["text"],
|
||||||
|
fontsize=10, loc="upper left")
|
||||||
|
|
||||||
|
# Add title and optional subtitle
|
||||||
|
if title:
|
||||||
|
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||||
|
fontweight="bold", pad=12)
|
||||||
|
if subtitle:
|
||||||
|
fig.text(0.5, 0.02, subtitle, ha='center', color=CHART_STYLE["text"],
|
||||||
|
fontsize=10, style='italic')
|
||||||
|
|
||||||
|
fig.tight_layout(pad=0.5, rect=(0, 0.03 if subtitle else 0, 1, 1))
|
||||||
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def make_horizontal_bar(data: dict, out_path: str, title: str = "",
|
||||||
|
value_suffix: str = "%", bar_color: str = None) -> str:
|
||||||
|
"""Render a horizontal bar chart (good for rankings/lists)."""
|
||||||
|
labels = data.get("labels", [])
|
||||||
|
values = data.get("values", data.get("y", []))
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
bar_color = bar_color or CHART_STYLE["bar_after"]
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||||
|
ax.set_facecolor("none")
|
||||||
|
|
||||||
|
y_pos = np.arange(len(labels))
|
||||||
|
bars = ax.barh(y_pos, values, color=bar_color, zorder=3, edgecolor="none", height=0.6)
|
||||||
|
|
||||||
|
ax.set_yticks(y_pos)
|
||||||
|
ax.set_yticklabels(labels, color=CHART_STYLE["text"], fontsize=11)
|
||||||
|
ax.tick_params(axis="x", colors=CHART_STYLE["text"])
|
||||||
|
ax.spines[:].set_visible(False)
|
||||||
|
ax.xaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||||
|
ax.set_axisbelow(True)
|
||||||
|
ax.invert_yaxis()
|
||||||
|
|
||||||
|
for i, bar in enumerate(bars):
|
||||||
|
width = bar.get_width()
|
||||||
|
ax.text(width + 0.5, bar.get_y() + bar.get_height()/2, f"{width:.0f}{value_suffix}",
|
||||||
|
ha="left", va="center", color=CHART_STYLE["text"], fontsize=10,
|
||||||
|
fontweight="bold")
|
||||||
|
|
||||||
|
if title:
|
||||||
|
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||||
|
fontweight="bold", pad=12)
|
||||||
|
|
||||||
|
fig.tight_layout(pad=0.5)
|
||||||
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def make_line_trend(data: dict, out_path: str, title: str = "",
|
||||||
|
show_area: bool = True, show_markers: bool = True) -> str:
|
||||||
|
"""Render a trend line chart."""
|
||||||
|
x_vals = data.get("x", [])
|
||||||
|
y_vals = data.get("y", [])
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||||
|
ax.set_facecolor("none")
|
||||||
|
|
||||||
|
line_style = data.get("line_style", "-")
|
||||||
|
line_width = data.get("line_width", 2.5)
|
||||||
|
|
||||||
|
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
|
||||||
|
linewidth=line_width, linestyle=line_style,
|
||||||
|
marker="o" if show_markers else None, markersize=7, zorder=3)
|
||||||
|
|
||||||
|
if show_area:
|
||||||
|
ax.fill_between(x_vals, y_vals, alpha=0.12, color=CHART_STYLE["accent"])
|
||||||
|
|
||||||
|
ax.spines[:].set_visible(False)
|
||||||
|
ax.tick_params(colors=CHART_STYLE["text"])
|
||||||
|
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||||
|
|
||||||
|
if title:
|
||||||
|
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||||
|
fontweight="bold", pad=12)
|
||||||
|
|
||||||
|
fig.tight_layout(pad=0.5)
|
||||||
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def make_pie_chart(data: dict, out_path: str, title: str = "",
|
||||||
|
show_labels: bool = True, show_percent: bool = True,
|
||||||
|
donut: bool = False) -> str:
|
||||||
|
"""Render a pie chart."""
|
||||||
|
labels = data.get("labels", [])
|
||||||
|
values = data.get("values", data.get("y", []))
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
colors = CHART_STYLE["pie_colors"][:len(values)]
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(6, 4.5), facecolor="none")
|
||||||
|
ax.set_facecolor("none")
|
||||||
|
|
||||||
|
if donut:
|
||||||
|
wedges, texts, autotexts = ax.pie(
|
||||||
|
values, labels=labels if show_labels else None,
|
||||||
|
colors=colors, autopct=lambda p: f'{p:.1f}%' if show_percent else '',
|
||||||
|
startangle=90, pctdistance=0.75,
|
||||||
|
wedgeprops=dict(width=0.5, edgecolor="none")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
wedges, texts, autotexts = ax.pie(
|
||||||
|
values, labels=labels if show_labels else None,
|
||||||
|
colors=colors, autopct=lambda p: f'{p:.1f}%' if show_percent else '',
|
||||||
|
startangle=90, pctdistance=0.8
|
||||||
|
)
|
||||||
|
|
||||||
|
for text in texts:
|
||||||
|
text.set_color(CHART_STYLE["text"])
|
||||||
|
text.set_fontsize(10)
|
||||||
|
|
||||||
|
for autotext in autotexts:
|
||||||
|
autotext.set_color(CHART_STYLE["text"])
|
||||||
|
autotext.set_fontsize(9)
|
||||||
|
autotext.set_fontweight("bold")
|
||||||
|
|
||||||
|
if title:
|
||||||
|
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||||
|
fontweight="bold", pad=12)
|
||||||
|
|
||||||
|
fig.tight_layout(pad=0.5)
|
||||||
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def make_stacked_bar(data: dict, out_path: str, title: str = "",
|
||||||
|
stack_labels: list = None) -> str:
|
||||||
|
"""Render a stacked bar chart."""
|
||||||
|
labels = data.get("labels", [])
|
||||||
|
stacks = data.get("stacks", []) # List of lists, each inner list is a stack
|
||||||
|
|
||||||
|
if not stacks or len(stacks) < 2:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
stack_labels = stack_labels or [f"Series {i+1}" for i in range(len(stacks))]
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||||
|
ax.set_facecolor("none")
|
||||||
|
|
||||||
|
x = np.arange(len(labels))
|
||||||
|
bottom = np.zeros(len(labels))
|
||||||
|
colors = CHART_STYLE["pie_colors"][:len(stacks)]
|
||||||
|
|
||||||
|
for i, stack in enumerate(stacks):
|
||||||
|
bars = ax.bar(x, stack, 0.6, bottom=bottom, color=colors[i],
|
||||||
|
label=stack_labels[i], zorder=3, edgecolor="none")
|
||||||
|
|
||||||
|
for j, bar in enumerate(bars):
|
||||||
|
height = bar.get_height()
|
||||||
|
if height > 5: # Only show label if segment is big enough
|
||||||
|
ax.text(bar.get_x() + bar.get_width()/2,
|
||||||
|
bottom[j] + height/2,
|
||||||
|
f"{height:.0f}", ha="center", va="center",
|
||||||
|
color=CHART_STYLE["text"], fontsize=8, fontweight="bold")
|
||||||
|
|
||||||
|
bottom = bottom + np.array(stack)
|
||||||
|
|
||||||
|
ax.set_xticks(x)
|
||||||
|
ax.set_xticklabels(labels, color=CHART_STYLE["text"], fontsize=11)
|
||||||
|
ax.tick_params(axis="y", colors=CHART_STYLE["text"])
|
||||||
|
ax.spines[:].set_visible(False)
|
||||||
|
ax.legend(frameon=False, labelcolor=CHART_STYLE["text"], fontsize=9, loc="upper left")
|
||||||
|
|
||||||
|
if title:
|
||||||
|
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||||
|
fontweight="bold", pad=12)
|
||||||
|
|
||||||
|
fig.tight_layout(pad=0.5)
|
||||||
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def make_line_trend(data: dict, out_path: str, title: str = "") -> str:
|
||||||
|
"""Render a trend line chart. Returns output path."""
|
||||||
|
x_vals = data.get("x", [])
|
||||||
|
y_vals = data.get("y", [])
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 4.5), facecolor="none")
|
||||||
|
ax.set_facecolor("none")
|
||||||
|
ax.plot(x_vals, y_vals, color=CHART_STYLE["accent"],
|
||||||
|
linewidth=2.5, marker="o", markersize=7, zorder=3)
|
||||||
|
ax.fill_between(x_vals, y_vals, alpha=0.12, color=CHART_STYLE["accent"])
|
||||||
|
ax.spines[:].set_visible(False)
|
||||||
|
ax.tick_params(colors=CHART_STYLE["text"])
|
||||||
|
ax.yaxis.grid(True, color=CHART_STYLE["grid"], linewidth=0.6, zorder=0)
|
||||||
|
if title:
|
||||||
|
ax.set_title(title, color=CHART_STYLE["text"], fontsize=13,
|
||||||
|
fontweight="bold", pad=12)
|
||||||
|
fig.tight_layout(pad=0.5)
|
||||||
|
fig.savefig(out_path, dpi=150, transparent=True, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Text / Bullet overlay (Pillow → PNG)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def make_bullet_overlay(lines: list[str], out_path: str,
|
||||||
|
width: int = 900, font_size: int = 32) -> str:
|
||||||
|
"""Render bullet points on a semi-transparent dark pill. Returns path."""
|
||||||
|
padding = 32
|
||||||
|
line_h = font_size + 16
|
||||||
|
img_h = padding * 2 + len(lines) * line_h + 12
|
||||||
|
img = Image.new("RGBA", (width, img_h), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
draw.rounded_rectangle([0, 0, width - 1, img_h - 1],
|
||||||
|
radius=18, fill=(10, 10, 10, 185))
|
||||||
|
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
font_size)
|
||||||
|
except OSError:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
y = padding
|
||||||
|
for line in lines:
|
||||||
|
draw.text((padding + 18, y), f"• {line}", font=font, fill=(241, 241, 239, 255))
|
||||||
|
y += line_h
|
||||||
|
|
||||||
|
img.save(out_path, format="PNG")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def make_insight_card(insight: str, stat: str, out_path: str,
|
||||||
|
width: int = 960, height: int = 200) -> str:
|
||||||
|
"""Render a bold insight card (headline + supporting stat). Returns path."""
|
||||||
|
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.rounded_rectangle([0, 0, width - 1, height - 1],
|
||||||
|
radius=14, fill=(10, 10, 10, 200))
|
||||||
|
|
||||||
|
draw.rectangle([28, 24, 36, height - 24], fill=(230, 57, 70, 255))
|
||||||
|
|
||||||
|
try:
|
||||||
|
font_lg = ImageFont.truetype(
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
|
||||||
|
font_sm = ImageFont.truetype(
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||||
|
except OSError:
|
||||||
|
font_lg = font_sm = ImageFont.load_default()
|
||||||
|
|
||||||
|
draw.text((58, 36), insight, font=font_lg, fill=(241, 241, 239, 255))
|
||||||
|
draw.text((58, 90), stat, font=font_sm, fill=(180, 180, 178, 230))
|
||||||
|
|
||||||
|
img.save(out_path, format="PNG")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Circular avatar mask
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def apply_circle_mask(clip: VideoFileClip, diameter: int) -> VideoFileClip:
|
||||||
|
"""Resize clip and apply a circular alpha mask."""
|
||||||
|
clip = clip.resize(height=diameter)
|
||||||
|
w, h = clip.size
|
||||||
|
|
||||||
|
Y, X = np.ogrid[:h, :w]
|
||||||
|
cx, cy = w / 2, h / 2
|
||||||
|
mask_arr = ((X - cx) ** 2 + (Y - cy) ** 2 <= (min(w, h) / 2) ** 2).astype(float)
|
||||||
|
|
||||||
|
mask_clip = ImageClip(mask_arr, ismask=True).set_duration(clip.duration)
|
||||||
|
return clip.set_mask(mask_clip)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ken Burns zoom effect
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def ken_burns(clip: ImageClip, zoom_ratio: float = 0.08) -> ImageClip:
|
||||||
|
"""Apply a slow zoom-in over the clip duration."""
|
||||||
|
def zoom_frame(get_frame, t):
|
||||||
|
frame = get_frame(t)
|
||||||
|
frac = 1 + zoom_ratio * (t / clip.duration)
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
new_h, new_w = int(h / frac), int(w / frac)
|
||||||
|
y1 = (h - new_h) // 2
|
||||||
|
x1 = (w - new_w) // 2
|
||||||
|
cropped = frame[y1:y1 + new_h, x1:x1 + new_w]
|
||||||
|
return np.array(Image.fromarray(cropped).resize((w, h), Image.LANCZOS))
|
||||||
|
|
||||||
|
return clip.fl(zoom_frame, apply_to=["mask"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scene builders (one per visual_cue type)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_data_scene(assets: SceneAssets, insight: Insight) -> CompositeVideoClip:
|
||||||
|
"""
|
||||||
|
Layout: Background (Ken Burns) + Chart (fade-in) + Avatar circle (corner) + Insight card
|
||||||
|
"""
|
||||||
|
d = insight.duration
|
||||||
|
layers = []
|
||||||
|
|
||||||
|
bg = (ImageClip(assets.background_img)
|
||||||
|
.set_duration(d)
|
||||||
|
.resize(height=1080))
|
||||||
|
bg = ken_burns(bg)
|
||||||
|
bg = bg.fx(vfx.lum_contrast, 0, -40)
|
||||||
|
layers.append(bg)
|
||||||
|
|
||||||
|
if assets.chart_img:
|
||||||
|
chart = (ImageClip(assets.chart_img)
|
||||||
|
.set_duration(d - 1.5)
|
||||||
|
.set_start(0.5)
|
||||||
|
.resize(width=700)
|
||||||
|
.set_position(("center", 180))
|
||||||
|
.fx(vfx.fadein, 0.6)
|
||||||
|
.fx(vfx.fadeout, 0.4))
|
||||||
|
layers.append(chart)
|
||||||
|
|
||||||
|
card_path = "/tmp/insight_card.png"
|
||||||
|
make_insight_card(insight.key_insight, insight.supporting_stat, card_path)
|
||||||
|
card = (ImageClip(card_path)
|
||||||
|
.set_duration(d - 1)
|
||||||
|
.set_start(0.5)
|
||||||
|
.set_position(("center", 820))
|
||||||
|
.fx(vfx.fadein, 0.5))
|
||||||
|
layers.append(card)
|
||||||
|
|
||||||
|
if assets.avatar_video:
|
||||||
|
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||||
|
avatar = apply_circle_mask(avatar_raw, diameter=240)
|
||||||
|
avatar = avatar.set_position((bg.w - 280, bg.h - 280))
|
||||||
|
layers.append(avatar)
|
||||||
|
|
||||||
|
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
|
||||||
|
|
||||||
|
|
||||||
|
def build_bullet_scene(assets: SceneAssets, insight: Insight,
|
||||||
|
bullets: list[str]) -> CompositeVideoClip:
|
||||||
|
"""
|
||||||
|
Layout: AI image (Ken Burns) + Bullet overlay + Avatar circle
|
||||||
|
"""
|
||||||
|
d = insight.duration
|
||||||
|
layers = []
|
||||||
|
|
||||||
|
bg = (ImageClip(assets.background_img)
|
||||||
|
.set_duration(d)
|
||||||
|
.resize(height=1080))
|
||||||
|
bg = ken_burns(bg, zoom_ratio=0.05)
|
||||||
|
bg = bg.fx(vfx.lum_contrast, 0, -50)
|
||||||
|
layers.append(bg)
|
||||||
|
|
||||||
|
bullet_path = "/tmp/bullets.png"
|
||||||
|
make_bullet_overlay(bullets, bullet_path, width=860)
|
||||||
|
bullets_clip = (ImageClip(bullet_path)
|
||||||
|
.set_duration(d - 1)
|
||||||
|
.set_start(0.5)
|
||||||
|
.set_position(("center", "center"))
|
||||||
|
.fx(vfx.fadein, 0.7))
|
||||||
|
layers.append(bullets_clip)
|
||||||
|
|
||||||
|
if assets.avatar_video:
|
||||||
|
avatar_raw = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||||
|
avatar = apply_circle_mask(avatar_raw, diameter=200)
|
||||||
|
avatar = avatar.set_position((bg.w - 240, bg.h - 240))
|
||||||
|
layers.append(avatar)
|
||||||
|
|
||||||
|
return CompositeVideoClip(layers, size=bg.size).set_duration(d)
|
||||||
|
|
||||||
|
|
||||||
|
def build_full_avatar_scene(assets: SceneAssets, insight: Insight) -> VideoFileClip:
|
||||||
|
"""Full-screen avatar — the expensive 'Hook' scene. No overlay."""
|
||||||
|
d = insight.duration
|
||||||
|
avatar = VideoFileClip(assets.avatar_video).subclip(0, d)
|
||||||
|
return avatar.resize(height=1080).set_duration(d)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scene dispatcher — maps visual_cue → builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def dispatch_scene(insight: Insight, assets: SceneAssets,
|
||||||
|
bullet_lines: Optional[list[str]] = None):
|
||||||
|
"""Dispatch scene based on visual_cue type."""
|
||||||
|
cue = insight.visual_cue
|
||||||
|
|
||||||
|
if cue == "full_avatar":
|
||||||
|
return build_full_avatar_scene(assets, insight)
|
||||||
|
|
||||||
|
elif cue in ("bar_chart_comparison", "line_trend"):
|
||||||
|
chart_path = "/tmp/chart.png"
|
||||||
|
if cue == "bar_chart_comparison":
|
||||||
|
make_bar_chart(insight.chart_data, chart_path,
|
||||||
|
title=insight.key_insight)
|
||||||
|
else:
|
||||||
|
make_line_trend(insight.chart_data, chart_path,
|
||||||
|
title=insight.key_insight)
|
||||||
|
assets.chart_img = chart_path
|
||||||
|
return build_data_scene(assets, insight)
|
||||||
|
|
||||||
|
elif cue == "bullet_points":
|
||||||
|
lines = bullet_lines or [insight.key_insight, insight.supporting_stat]
|
||||||
|
return build_bullet_scene(assets, insight, lines)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return build_data_scene(assets, insight)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Master compositor — assembles all scenes into one video
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compose_video(scenes: list, output_path: str = "output.mp4",
|
||||||
|
fps: int = 24, fade_dur: float = 0.5) -> str:
|
||||||
|
"""Concatenate scenes with crossfade transitions and write final video file."""
|
||||||
|
final = crossfade_concat(scenes, fade_dur=fade_dur)
|
||||||
|
final.write_videofile(
|
||||||
|
output_path,
|
||||||
|
fps=fps,
|
||||||
|
codec="libx264",
|
||||||
|
audio_codec="aac",
|
||||||
|
threads=4,
|
||||||
|
preset="fast",
|
||||||
|
logger=None,
|
||||||
|
)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON bridge — LLM insight → assets + scene
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def pipeline_from_json(insight_json: str,
|
||||||
|
background_img: str,
|
||||||
|
avatar_video: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
Full pipeline:
|
||||||
|
1. Parse LLM insight JSON
|
||||||
|
2. Generate chart / overlay assets
|
||||||
|
3. Build scene
|
||||||
|
4. Write video
|
||||||
|
Returns path to output video.
|
||||||
|
"""
|
||||||
|
data = json.loads(insight_json)
|
||||||
|
insight = Insight(**{k: data[k] for k in Insight.__dataclass_fields__ if k in data})
|
||||||
|
assets = SceneAssets(background_img=background_img, avatar_video=avatar_video)
|
||||||
|
scene = dispatch_scene(insight, assets,
|
||||||
|
bullet_lines=data.get("bullet_lines"))
|
||||||
|
out = f"/tmp/scene_{insight.visual_cue}.mp4"
|
||||||
|
compose_video([scene], output_path=out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Demo / smoke-test (no real media files needed for chart generation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sample_bar_data = {
|
||||||
|
"labels": ["Content Velocity", "CTR", "Engagement", "Cost/Lead"],
|
||||||
|
"before": [30, 22, 18, 60],
|
||||||
|
"after": [72, 34, 41, 38],
|
||||||
|
}
|
||||||
|
chart_out = make_bar_chart(
|
||||||
|
sample_bar_data,
|
||||||
|
"/tmp/demo_chart.png",
|
||||||
|
title="AI Tools Impact: Before vs After (2025)",
|
||||||
|
)
|
||||||
|
print(f"Chart saved → {chart_out}")
|
||||||
|
|
||||||
|
bullets = [
|
||||||
|
"AI reduced content cycles by 40% in 2025",
|
||||||
|
"HubSpot: 12% lift in CTR with AI-assisted copy",
|
||||||
|
"Video production cost down 3x with hybrid pipeline",
|
||||||
|
]
|
||||||
|
bullet_out = make_bullet_overlay(bullets, "/tmp/demo_bullets.png")
|
||||||
|
print(f"Bullets saved → {bullet_out}")
|
||||||
|
|
||||||
|
card_out = make_insight_card(
|
||||||
|
"AI tools reduced content cycles by 40%",
|
||||||
|
"HubSpot 2026 report — 12% lift in CTR",
|
||||||
|
"/tmp/demo_card.png",
|
||||||
|
)
|
||||||
|
print(f"Insight card saved → {card_out}")
|
||||||
|
|
||||||
|
sample_json = json.dumps({
|
||||||
|
"key_insight": "AI reduced production time by 40%",
|
||||||
|
"supporting_stat": "HubSpot 2026: 12% CTR lift",
|
||||||
|
"visual_cue": "bar_chart_comparison",
|
||||||
|
"audio_tone": "authoritative_and_surprising",
|
||||||
|
"duration": 8.0,
|
||||||
|
"chart_data": sample_bar_data,
|
||||||
|
})
|
||||||
|
print("\nSample Insight JSON:\n", sample_json)
|
||||||
|
print("\nAll asset generation tests passed.")
|
||||||
|
print("To run full video composition, supply real background_img and avatar_video paths.")
|
||||||
253
backend/services/podcast/broll_service.py
Normal file
253
backend/services/podcast/broll_service.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
B-Roll Service - Orchestrator for programmatic B-roll video composition.
|
||||||
|
|
||||||
|
This service handles:
|
||||||
|
- Chart data extraction from research
|
||||||
|
- Individual scene B-roll video generation
|
||||||
|
- Final video composition from multiple B-roll scenes
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Import chart generators directly
|
||||||
|
from services.podcast.broll_composer import (
|
||||||
|
make_bar_chart,
|
||||||
|
make_horizontal_bar,
|
||||||
|
make_line_trend,
|
||||||
|
make_pie_chart,
|
||||||
|
make_stacked_bar,
|
||||||
|
make_bullet_overlay,
|
||||||
|
make_insight_card,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BrollService:
|
||||||
|
"""Orchestrates B-roll composition for podcast scenes."""
|
||||||
|
|
||||||
|
def __init__(self, output_dir: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize B-roll service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Base directory for B-roll output. Defaults to temp directory.
|
||||||
|
"""
|
||||||
|
if output_dir:
|
||||||
|
self.output_dir = Path(output_dir)
|
||||||
|
else:
|
||||||
|
self.output_dir = Path(tempfile.gettempdir()) / "broll_output"
|
||||||
|
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info(f"[BrollService] Initialized with output directory: {self.output_dir}")
|
||||||
|
|
||||||
|
def get_output_path(self, filename: str) -> Path:
|
||||||
|
"""Get output path for a file."""
|
||||||
|
return self.output_dir / filename
|
||||||
|
|
||||||
|
def generate_chart_preview(
|
||||||
|
self,
|
||||||
|
chart_data: Dict[str, Any],
|
||||||
|
chart_type: str = "bar_comparison",
|
||||||
|
title: str = "",
|
||||||
|
subtitle: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a chart PNG preview (static, for Write phase).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chart_data: Chart data dict with labels, before/after, etc.
|
||||||
|
chart_type: Type of chart (bar_comparison, bar_horizontal, line_trend, pie, stacked_bar, bullet)
|
||||||
|
title: Title for the chart
|
||||||
|
subtitle: Optional subtitle at bottom
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to generated PNG file
|
||||||
|
"""
|
||||||
|
chart_id = uuid.uuid4().hex[:8]
|
||||||
|
out_path = str(self.get_output_path(f"chart_preview_{chart_id}.png"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if chart_type == "bar_comparison":
|
||||||
|
make_bar_chart(chart_data, out_path, title, subtitle=subtitle)
|
||||||
|
elif chart_type == "bar_horizontal":
|
||||||
|
make_horizontal_bar(chart_data, out_path, title)
|
||||||
|
elif chart_type == "line_trend":
|
||||||
|
make_line_trend(chart_data, out_path, title)
|
||||||
|
elif chart_type == "pie":
|
||||||
|
make_pie_chart(chart_data, out_path, title)
|
||||||
|
elif chart_type == "pie":
|
||||||
|
make_pie_chart(chart_data, out_path, title)
|
||||||
|
elif chart_type == "stacked_bar":
|
||||||
|
make_stacked_bar(chart_data, out_path, title)
|
||||||
|
elif chart_type == "bullet":
|
||||||
|
bullet_points = chart_data.get("bullet_points", [])
|
||||||
|
if bullet_points:
|
||||||
|
make_bullet_overlay(bullet_points, out_path)
|
||||||
|
else:
|
||||||
|
logger.warning("[BrollService] No bullet points provided")
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
logger.warning(f"[BrollService] Unknown chart type: {chart_type}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
logger.info(f"[BrollService] Chart preview generated: {out_path}")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[BrollService] Failed to generate chart preview: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def generate_scene_broll(
|
||||||
|
self,
|
||||||
|
scene_id: str,
|
||||||
|
key_insight: str,
|
||||||
|
supporting_stat: str,
|
||||||
|
chart_data: Optional[Dict[str, Any]],
|
||||||
|
visual_cue: str, # bar_chart_comparison, bullet_points, full_avatar
|
||||||
|
duration: float,
|
||||||
|
background_img_path: str,
|
||||||
|
avatar_video_path: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a B-roll video for a single scene.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scene_id: Scene identifier
|
||||||
|
key_insight: Main insight text for overlay
|
||||||
|
supporting_stat: Supporting statistic text
|
||||||
|
chart_data: Chart data dict (optional)
|
||||||
|
visual_cue: Type of scene to build
|
||||||
|
duration: Scene duration in seconds
|
||||||
|
background_img_path: Path to background image
|
||||||
|
avatar_video_path: Path to avatar video (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to generated video file
|
||||||
|
"""
|
||||||
|
scene_id_safe = scene_id.replace(" ", "_").replace("/", "_")
|
||||||
|
out_path = str(self.get_output_path(f"broll_{scene_id_safe}.mp4"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
insight = Insight(
|
||||||
|
key_insight=key_insight,
|
||||||
|
supporting_stat=supporting_stat,
|
||||||
|
visual_cue=visual_cue,
|
||||||
|
audio_tone="neutral",
|
||||||
|
chart_data=chart_data or {},
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
assets = SceneAssets(
|
||||||
|
background_img=background_img_path,
|
||||||
|
avatar_video=avatar_video_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate the scene
|
||||||
|
scene = dispatch_scene(insight, assets)
|
||||||
|
|
||||||
|
# Write video
|
||||||
|
compose_video([scene], output_path=out_path)
|
||||||
|
|
||||||
|
logger.info(f"[BrollService] B-roll scene generated: {out_path}")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[BrollService] Failed to generate B-roll scene: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def compose_final_video(
|
||||||
|
self,
|
||||||
|
video_paths: List[str],
|
||||||
|
output_filename: str,
|
||||||
|
fade_dur: float = 0.5,
|
||||||
|
fps: int = 24,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Compose multiple B-roll scene videos into final video.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_paths: List of video file paths to compose
|
||||||
|
output_filename: Output filename
|
||||||
|
fade_dur: Crossfade duration between scenes
|
||||||
|
fps: Output FPS
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to final composed video
|
||||||
|
"""
|
||||||
|
out_path = str(self.get_output_path(output_filename))
|
||||||
|
|
||||||
|
try:
|
||||||
|
scenes = []
|
||||||
|
for video_path in video_paths:
|
||||||
|
from moviepy import VideoFileClip
|
||||||
|
clip = VideoFileClip(video_path)
|
||||||
|
scenes.append(clip)
|
||||||
|
|
||||||
|
if not scenes:
|
||||||
|
raise ValueError("No video clips provided")
|
||||||
|
|
||||||
|
# Use crossfade_concat from broll_composer
|
||||||
|
from services.podcast.broll_composer import crossfade_concat
|
||||||
|
|
||||||
|
final = crossfade_concat(scenes, fade_dur=fade_dur)
|
||||||
|
|
||||||
|
final.write_videofile(
|
||||||
|
out_path,
|
||||||
|
fps=fps,
|
||||||
|
codec="libx264",
|
||||||
|
audio_codec="aac",
|
||||||
|
threads=4,
|
||||||
|
preset="fast",
|
||||||
|
logger=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Close clips
|
||||||
|
for clip in scenes:
|
||||||
|
clip.close()
|
||||||
|
|
||||||
|
logger.info(f"[BrollService] Final video composed: {out_path}")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[BrollService] Failed to compose final video: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def cleanup(self, file_paths: List[str] = None):
|
||||||
|
"""
|
||||||
|
Clean up temporary B-roll files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_paths: Specific files to delete. If None, cleans output directory.
|
||||||
|
"""
|
||||||
|
if file_paths:
|
||||||
|
for path in file_paths:
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
logger.debug(f"[BrollService] Removed: {path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[BrollService] Failed to remove {path}: {e}")
|
||||||
|
else:
|
||||||
|
# Clean entire output directory
|
||||||
|
for file in self.output_dir.glob("*"):
|
||||||
|
try:
|
||||||
|
file.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[BrollService] Failed to remove {file}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance for reuse
|
||||||
|
_broll_service_instance: Optional[BrollService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_broll_service(output_dir: Optional[str] = None) -> BrollService:
|
||||||
|
"""Get or create B-roll service singleton."""
|
||||||
|
global _broll_service_instance
|
||||||
|
if _broll_service_instance is None:
|
||||||
|
_broll_service_instance = BrollService(output_dir=output_dir)
|
||||||
|
return _broll_service_instance
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import time
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from services.product_marketing.personalization_service import PersonalizationService
|
from services.product_marketing.personalization_service import PersonalizationService
|
||||||
from models.podcast_bible_models import (
|
from models.podcast_bible_models import (
|
||||||
@@ -11,9 +13,14 @@ from models.podcast_bible_models import (
|
|||||||
ShowRules
|
ShowRules
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_BIBLE_CACHE_TTL_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
class PodcastBibleService:
|
class PodcastBibleService:
|
||||||
"""Service for generating and managing the Podcast Bible."""
|
"""Service for generating and managing the Podcast Bible."""
|
||||||
|
|
||||||
|
_bible_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
try:
|
try:
|
||||||
from services.product_marketing.personalization_service import PersonalizationService
|
from services.product_marketing.personalization_service import PersonalizationService
|
||||||
@@ -22,19 +29,40 @@ class PodcastBibleService:
|
|||||||
logger.warning(f"Failed to initialize PersonalizationService: {e}")
|
logger.warning(f"Failed to initialize PersonalizationService: {e}")
|
||||||
self.personalization_service = None
|
self.personalization_service = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_user_cache(cls, user_id: str) -> int:
|
||||||
|
"""Clear cached Bible data for a specific user. Returns number of entries cleared."""
|
||||||
|
keys_to_remove = [key for key in cls._bible_cache if key.startswith(f"{user_id}:")]
|
||||||
|
for key in keys_to_remove:
|
||||||
|
del cls._bible_cache[key]
|
||||||
|
if keys_to_remove:
|
||||||
|
logger.info(f"[BibleCache] Cleared {len(keys_to_remove)} cache entries for user {user_id}")
|
||||||
|
return len(keys_to_remove)
|
||||||
|
|
||||||
def generate_bible(self, user_id: str, project_id: str) -> PodcastBible:
|
def generate_bible(self, user_id: str, project_id: str) -> PodcastBible:
|
||||||
"""Generate a Podcast Bible from onboarding data."""
|
"""Generate a Podcast Bible from onboarding data."""
|
||||||
|
bible_start = time.time()
|
||||||
|
|
||||||
|
cache_key = f"{user_id}:{project_id}"
|
||||||
|
cached = self._bible_cache.get(cache_key)
|
||||||
|
if cached and cached.get('expires_at') and cached['expires_at'] > datetime.utcnow():
|
||||||
|
elapsed_ms = (time.time() - bible_start) * 1000
|
||||||
|
logger.warning(f"[BibleCache] HIT for {user_id} — saved 7 DB queries, overhead {elapsed_ms:.0f}ms")
|
||||||
|
return cached['bible']
|
||||||
|
|
||||||
logger.info(f"Generating Podcast Bible for user {user_id}")
|
logger.info(f"Generating Podcast Bible for user {user_id}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.personalization_service:
|
if not self.personalization_service:
|
||||||
logger.warning("PersonalizationService not available, using default bible")
|
elapsed_ms = (time.time() - bible_start) * 1000
|
||||||
|
logger.warning(f"[BibleCache] MISS (fallback) for {user_id} — PersonalizationService unavailable, {elapsed_ms:.0f}ms")
|
||||||
return self._get_default_bible(project_id)
|
return self._get_default_bible(project_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
preferences = self.personalization_service.get_user_preferences(user_id)
|
preferences = self.personalization_service.get_user_preferences(user_id)
|
||||||
except Exception as pref_err:
|
except Exception as pref_err:
|
||||||
logger.warning(f"Failed to get user preferences: {pref_err}, using defaults")
|
elapsed_ms = (time.time() - bible_start) * 1000
|
||||||
|
logger.warning(f"[BibleCache] MISS (fallback) for {user_id} — get_user_preferences failed ({pref_err}), {elapsed_ms:.0f}ms")
|
||||||
return self._get_default_bible(project_id)
|
return self._get_default_bible(project_id)
|
||||||
|
|
||||||
if not preferences:
|
if not preferences:
|
||||||
@@ -131,6 +159,12 @@ class PodcastBibleService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Podcast Bible generated successfully for project {project_id}")
|
logger.info(f"Podcast Bible generated successfully for project {project_id}")
|
||||||
|
elapsed_ms = (time.time() - bible_start) * 1000
|
||||||
|
logger.warning(f"[BibleCache] MISS — generated in {elapsed_ms:.0f}ms (7 DB queries), cached for {_BIBLE_CACHE_TTL_SECONDS}s")
|
||||||
|
self._bible_cache[cache_key] = {
|
||||||
|
'bible': bible,
|
||||||
|
'expires_at': datetime.utcnow() + timedelta(seconds=_BIBLE_CACHE_TTL_SECONDS),
|
||||||
|
}
|
||||||
return bible
|
return bible
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -176,8 +210,12 @@ class PodcastBibleService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def serialize_bible(self, bible: PodcastBible) -> str:
|
def serialize_bible(self, bible: PodcastBible) -> str:
|
||||||
"""Serialize the Bible into a prompt-friendly text block."""
|
"""Serialize the Bible into a prompt-friendly text block. Results are cached by project_id."""
|
||||||
return f"""
|
cache_key = f"serialized:{bible.project_id}"
|
||||||
|
cached = self._bible_cache.get(cache_key)
|
||||||
|
if cached and cached.get('expires_at') and cached['expires_at'] > datetime.utcnow() and isinstance(cached.get('serialized'), str):
|
||||||
|
return cached['serialized']
|
||||||
|
serialized = f"""
|
||||||
<podcast_bible>
|
<podcast_bible>
|
||||||
HOST PERSONA:
|
HOST PERSONA:
|
||||||
- Name: {bible.host.name}
|
- Name: {bible.host.name}
|
||||||
@@ -212,3 +250,8 @@ SHOW RULES & STRUCTURE:
|
|||||||
- Constraints: {', '.join(bible.show_rules.constraints)}
|
- Constraints: {', '.join(bible.show_rules.constraints)}
|
||||||
</podcast_bible>
|
</podcast_bible>
|
||||||
"""
|
"""
|
||||||
|
self._bible_cache[cache_key] = {
|
||||||
|
'serialized': serialized,
|
||||||
|
'expires_at': datetime.utcnow() + timedelta(seconds=_BIBLE_CACHE_TTL_SECONDS),
|
||||||
|
}
|
||||||
|
return serialized
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ Podcast Service
|
|||||||
Service layer for managing podcast project persistence.
|
Service layer for managing podcast project persistence.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import desc, and_, or_
|
from sqlalchemy import desc, and_, or_
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
|
||||||
|
|
||||||
from models.podcast_models import PodcastProject
|
from models.podcast_models import PodcastProject
|
||||||
from services.podcast_bible_service import PodcastBibleService
|
from services.podcast_bible_service import PodcastBibleService
|
||||||
@@ -32,8 +32,14 @@ class PodcastService:
|
|||||||
**kwargs
|
**kwargs
|
||||||
) -> PodcastProject:
|
) -> PodcastProject:
|
||||||
"""Create a new podcast project."""
|
"""Create a new podcast project."""
|
||||||
# Generate Podcast Bible automatically from onboarding data
|
# Generate Podcast Bible in full mode only — skip in podcast-only mode
|
||||||
bible = self.bible_service.generate_bible(user_id, project_id)
|
bible_data = None
|
||||||
|
if os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() != "podcast":
|
||||||
|
try:
|
||||||
|
bible = self.bible_service.generate_bible(user_id, project_id)
|
||||||
|
bible_data = bible.model_dump() if bible else None
|
||||||
|
except Exception:
|
||||||
|
pass # Bible is optional, project creation continues regardless
|
||||||
|
|
||||||
project = PodcastProject(
|
project = PodcastProject(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -42,7 +48,7 @@ class PodcastService:
|
|||||||
duration=duration,
|
duration=duration,
|
||||||
speakers=speakers,
|
speakers=speakers,
|
||||||
budget_cap=budget_cap,
|
budget_cap=budget_cap,
|
||||||
bible=bible.model_dump() if bible else None,
|
bible=bible_data,
|
||||||
status="draft",
|
status="draft",
|
||||||
current_step="create",
|
current_step="create",
|
||||||
**kwargs
|
**kwargs
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Handles subscription limit checking and validation logic.
|
|||||||
Extracted from pricing_service.py for better modularity.
|
Extracted from pricing_service.py for better modularity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
from typing import Dict, Any, Optional, List, Tuple, TYPE_CHECKING
|
from typing import Dict, Any, Optional, List, Tuple, TYPE_CHECKING
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -32,9 +33,11 @@ class LimitValidator:
|
|||||||
self.db = pricing_service.db
|
self.db = pricing_service.db
|
||||||
|
|
||||||
def check_usage_limits(self, user_id: str, provider: APIProvider,
|
def check_usage_limits(self, user_id: str, provider: APIProvider,
|
||||||
tokens_requested: int = 0, actual_provider_name: Optional[str] = None) -> Tuple[bool, str, Dict[str, Any]]:
|
tokens_requested: int = 0, actual_provider_name: Optional[str] = None) -> Tuple[bool, str, Dict[str, Any]]:
|
||||||
"""Check if user can make an API call within their limits.
|
"""Check if user can make an API call within their limits.
|
||||||
|
|
||||||
|
Delegates to LimitValidator for actual validation logic.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User ID
|
user_id: User ID
|
||||||
provider: APIProvider enum (may be MISTRAL for HuggingFace)
|
provider: APIProvider enum (may be MISTRAL for HuggingFace)
|
||||||
@@ -44,6 +47,7 @@ class LimitValidator:
|
|||||||
Returns:
|
Returns:
|
||||||
(can_proceed, error_message, usage_info)
|
(can_proceed, error_message, usage_info)
|
||||||
"""
|
"""
|
||||||
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
# Use actual_provider_name if provided, otherwise use enum value
|
# Use actual_provider_name if provided, otherwise use enum value
|
||||||
# This fixes cases where HuggingFace maps to MISTRAL enum but should show as "huggingface" in errors
|
# This fixes cases where HuggingFace maps to MISTRAL enum but should show as "huggingface" in errors
|
||||||
@@ -51,12 +55,14 @@ class LimitValidator:
|
|||||||
|
|
||||||
logger.debug(f"[Subscription Check] Starting limit check for user {user_id}, provider {display_provider_name}, tokens {tokens_requested}")
|
logger.debug(f"[Subscription Check] Starting limit check for user {user_id}, provider {display_provider_name}, tokens {tokens_requested}")
|
||||||
|
|
||||||
|
logger.warning(f"[Subscription Check] START for user {user_id}, provider {provider.value}")
|
||||||
# Short TTL cache to reduce DB reads under sustained traffic
|
# Short TTL cache to reduce DB reads under sustained traffic
|
||||||
cache_key = f"{user_id}:{provider.value}"
|
cache_key = f"{user_id}:{provider.value}"
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
cached = self.pricing_service._limits_cache.get(cache_key)
|
cached = self.pricing_service._limits_cache.get(cache_key)
|
||||||
if cached and cached.get('expires_at') and cached['expires_at'] > now:
|
if cached and cached.get('expires_at') and cached['expires_at'] > now:
|
||||||
logger.debug(f"[Subscription Check] Using cached result for {user_id}:{provider.value}")
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.warning(f"[Subscription Check] Cache hit for {user_id}:{provider.value} — completed in {elapsed_ms:.0f}ms")
|
||||||
return tuple(cached['result']) # type: ignore
|
return tuple(cached['result']) # type: ignore
|
||||||
|
|
||||||
# Get user subscription first to check expiration
|
# Get user subscription first to check expiration
|
||||||
@@ -139,12 +145,15 @@ class LimitValidator:
|
|||||||
return False, "No subscription plan found. Please subscribe to a plan.", {}
|
return False, "No subscription plan found. Please subscribe to a plan.", {}
|
||||||
|
|
||||||
# Get current usage for this billing period with error handling
|
# Get current usage for this billing period with error handling
|
||||||
# CRITICAL: Use fresh queries to avoid SQLAlchemy cache after renewal
|
# Use targeted expiry instead of expire_all() to avoid nuking the entire session cache
|
||||||
try:
|
try:
|
||||||
current_period = self.pricing_service.get_current_billing_period(user_id) or datetime.now().strftime("%Y-%m")
|
current_period = self.pricing_service.get_current_billing_period(user_id) or datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
# Expire all objects to force fresh read from DB (critical after renewal)
|
# Only expire specific objects that might have changed after renewal
|
||||||
self.db.expire_all()
|
# (subscription was already checked above; plan was expired above)
|
||||||
|
# The usage record is the main object we need fresh, and we query it directly below
|
||||||
|
if subscription:
|
||||||
|
self.db.expire(subscription)
|
||||||
|
|
||||||
# Use raw SQL query first to bypass ORM cache, fallback to ORM if SQL fails
|
# Use raw SQL query first to bypass ORM cache, fallback to ORM if SQL fails
|
||||||
usage = None
|
usage = None
|
||||||
@@ -367,14 +376,18 @@ class LimitValidator:
|
|||||||
'result': result,
|
'result': result,
|
||||||
'expires_at': now + timedelta(seconds=30)
|
'expires_at': now + timedelta(seconds=30)
|
||||||
}
|
}
|
||||||
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.warning(f"[Subscription Check] Completed in {elapsed_ms:.0f}ms for user {user_id}, provider {display_provider_name} — within limits (calls: {current_call_count}/{call_limit_value})")
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calculating usage percentages: {e}")
|
logger.error(f"Error calculating usage percentages: {e}")
|
||||||
# Return basic success
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.warning(f"[Subscription Check] Completed in {elapsed_ms:.0f}ms for user {user_id}, provider {display_provider_name} — within limits (basic check)")
|
||||||
return True, "Within limits", {}
|
return True, "Within limits", {}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in check_usage_limits for {user_id}: {e}")
|
elapsed_ms = (time.time() - start_time) * 1000
|
||||||
|
logger.error(f"[Subscription Check] Failed for user {user_id} after {elapsed_ms:.0f}ms: {e}")
|
||||||
# STRICT: Fail closed - deny requests if subscription system fails
|
# STRICT: Fail closed - deny requests if subscription system fails
|
||||||
return False, f"Subscription check error: {str(e)}", {}
|
return False, f"Subscription check error: {str(e)}", {}
|
||||||
|
|
||||||
@@ -417,9 +430,7 @@ class LimitValidator:
|
|||||||
except Exception as schema_err:
|
except Exception as schema_err:
|
||||||
logger.warning(f"Schema check failed, will retry on query error: {schema_err}")
|
logger.warning(f"Schema check failed, will retry on query error: {schema_err}")
|
||||||
|
|
||||||
# Explicitly expire any cached objects and refresh from DB to ensure fresh data
|
# Explicitly refresh usage from DB to ensure fresh data (targeted instead of expire_all)
|
||||||
self.db.expire_all()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
usage = self.db.query(UsageSummary).filter(
|
usage = self.db.query(UsageSummary).filter(
|
||||||
UsageSummary.user_id == user_id,
|
UsageSummary.user_id == user_id,
|
||||||
@@ -438,7 +449,12 @@ class LimitValidator:
|
|||||||
schema_utils._checked_usage_summaries_columns = False
|
schema_utils._checked_usage_summaries_columns = False
|
||||||
from services.subscription.schema_utils import ensure_usage_summaries_columns
|
from services.subscription.schema_utils import ensure_usage_summaries_columns
|
||||||
ensure_usage_summaries_columns(self.db)
|
ensure_usage_summaries_columns(self.db)
|
||||||
self.db.expire_all()
|
# After schema migration, only expire UsageSummary to force re-query
|
||||||
|
# (no need to expire the entire session)
|
||||||
|
for obj in self.db.query(UsageSummary).filter(
|
||||||
|
UsageSummary.user_id == user_id
|
||||||
|
).all():
|
||||||
|
self.db.expire(obj)
|
||||||
# Retry the query
|
# Retry the query
|
||||||
usage = self.db.query(UsageSummary).filter(
|
usage = self.db.query(UsageSummary).filter(
|
||||||
UsageSummary.user_id == user_id,
|
UsageSummary.user_id == user_id,
|
||||||
@@ -594,8 +610,9 @@ class LimitValidator:
|
|||||||
# Method 2: Fallback to fresh ORM query if raw SQL fails
|
# Method 2: Fallback to fresh ORM query if raw SQL fails
|
||||||
if not query_succeeded:
|
if not query_succeeded:
|
||||||
try:
|
try:
|
||||||
# Expire all cached objects and do fresh query
|
# Only refresh usage object, don't expire entire session
|
||||||
self.db.expire_all()
|
if usage:
|
||||||
|
self.db.refresh(usage)
|
||||||
fresh_usage = self.db.query(UsageSummary).filter(
|
fresh_usage = self.db.query(UsageSummary).filter(
|
||||||
UsageSummary.user_id == user_id,
|
UsageSummary.user_id == user_id,
|
||||||
UsageSummary.billing_period == current_period
|
UsageSummary.billing_period == current_period
|
||||||
@@ -792,7 +809,11 @@ class LimitValidator:
|
|||||||
schema_utils._checked_usage_summaries_columns = False
|
schema_utils._checked_usage_summaries_columns = False
|
||||||
from services.subscription.schema_utils import ensure_usage_summaries_columns
|
from services.subscription.schema_utils import ensure_usage_summaries_columns
|
||||||
ensure_usage_summaries_columns(self.db)
|
ensure_usage_summaries_columns(self.db)
|
||||||
self.db.expire_all()
|
# Only expire UsageSummary after schema migration, not entire session
|
||||||
|
for obj in self.db.query(UsageSummary).filter(
|
||||||
|
UsageSummary.user_id == user_id
|
||||||
|
).all():
|
||||||
|
self.db.expire(obj)
|
||||||
|
|
||||||
# Retry the query
|
# Retry the query
|
||||||
usage = self.db.query(UsageSummary).filter(
|
usage = self.db.query(UsageSummary).filter(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { ResearchMode, ResearchProvider } from '../services/blogWriterApi';
|
import { ResearchMode, ResearchProvider } from '../services/blogWriterApi';
|
||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
import { isPodcastOnlyDemoMode } from '../utils/demoMode';
|
||||||
|
|
||||||
export interface ProviderAvailability {
|
export interface ProviderAvailability {
|
||||||
google_available: boolean;
|
google_available: boolean;
|
||||||
@@ -129,6 +130,11 @@ let pendingConfigRequest: Promise<ResearchConfigResponse> | null = null;
|
|||||||
* and research persona from the unified /api/research/config endpoint.
|
* and research persona from the unified /api/research/config endpoint.
|
||||||
*/
|
*/
|
||||||
export const getResearchConfig = async (): Promise<ResearchConfigResponse> => {
|
export const getResearchConfig = async (): Promise<ResearchConfigResponse> => {
|
||||||
|
// Skip in podcast-only mode — backend always provides AI-generated research_queries
|
||||||
|
if (isPodcastOnlyDemoMode()) {
|
||||||
|
throw new Error('Research config not available in podcast-only mode');
|
||||||
|
}
|
||||||
|
|
||||||
// If a request is already in flight, return the same promise
|
// If a request is already in flight, return the same promise
|
||||||
if (pendingConfigRequest) {
|
if (pendingConfigRequest) {
|
||||||
console.log('[researchConfig] Reusing pending request to avoid duplicate API call');
|
console.log('[researchConfig] Reusing pending request to avoid duplicate API call');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CopilotKit } from "@copilotkit/react-core";
|
|||||||
import { CopilotKitHealthProvider } from '../../contexts/CopilotKitHealthContext';
|
import { CopilotKitHealthProvider } from '../../contexts/CopilotKitHealthContext';
|
||||||
import CopilotKitDegradedBanner from '../shared/CopilotKitDegradedBanner';
|
import CopilotKitDegradedBanner from '../shared/CopilotKitDegradedBanner';
|
||||||
import ErrorBoundary from '../shared/ErrorBoundary';
|
import ErrorBoundary from '../shared/ErrorBoundary';
|
||||||
|
import { isPodcastOnlyDemoMode } from '../../utils/demoMode';
|
||||||
|
|
||||||
interface ConditionalCopilotKitProps {
|
interface ConditionalCopilotKitProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -23,7 +24,8 @@ export const AuthenticatedCopilotWrapper: React.FC<AuthenticatedCopilotWrapperPr
|
|||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding');
|
const isPodcastOnly = isPodcastOnlyDemoMode();
|
||||||
|
const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding') || isPodcastOnly;
|
||||||
|
|
||||||
if (shouldExcludeCopilot) {
|
if (shouldExcludeCopilot) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface AnalysisPanelProps {
|
|||||||
idea?: string;
|
idea?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
speakers?: number;
|
speakers?: number;
|
||||||
|
voiceName?: string;
|
||||||
|
podcastMode?: "audio_only" | "video_only" | "audio_video";
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
avatarPrompt?: string | null;
|
avatarPrompt?: string | null;
|
||||||
bible?: PodcastBible | null;
|
bible?: PodcastBible | null;
|
||||||
@@ -62,6 +64,8 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
|||||||
idea,
|
idea,
|
||||||
duration,
|
duration,
|
||||||
speakers,
|
speakers,
|
||||||
|
voiceName,
|
||||||
|
podcastMode,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
avatarPrompt,
|
avatarPrompt,
|
||||||
bible,
|
bible,
|
||||||
@@ -423,6 +427,8 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
|||||||
idea={idea}
|
idea={idea}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
speakers={speakers}
|
speakers={speakers}
|
||||||
|
voiceName={voiceName}
|
||||||
|
podcastMode={podcastMode}
|
||||||
avatarUrl={avatarUrl}
|
avatarUrl={avatarUrl}
|
||||||
avatarPrompt={avatarPrompt}
|
avatarPrompt={avatarPrompt}
|
||||||
avatarBlobUrl={avatarBlobUrl}
|
avatarBlobUrl={avatarBlobUrl}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||||
|
import { PodcastAnalysis, PodcastEstimate, PodcastBible } from "../types";
|
||||||
|
|
||||||
|
export type TabId = "inputs" | "audience" | "outline" | "details" | "takeaways" | "guest";
|
||||||
|
|
||||||
|
interface AnalysisPanelContextType {
|
||||||
|
activeTab: TabId;
|
||||||
|
setActiveTab: (tab: TabId) => void;
|
||||||
|
analysis: PodcastAnalysis | null;
|
||||||
|
estimate: PodcastEstimate | null;
|
||||||
|
idea?: string;
|
||||||
|
duration?: number;
|
||||||
|
speakers?: number;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
avatarPrompt?: string | null;
|
||||||
|
bible?: PodcastBible | null;
|
||||||
|
isEditing: boolean;
|
||||||
|
setIsEditing: (editing: boolean) => void;
|
||||||
|
editedAnalysis: PodcastAnalysis | null;
|
||||||
|
setEditedAnalysis: React.Dispatch<React.SetStateAction<PodcastAnalysis | null>>;
|
||||||
|
currentAnalysis: PodcastAnalysis | null;
|
||||||
|
handleRemoveKeyword: (keyword: string) => void;
|
||||||
|
handleAddKeyword: (keyword: string) => void;
|
||||||
|
handleRemoveTitle: (title: string) => void;
|
||||||
|
handleAddTitle: (title: string) => void;
|
||||||
|
handleUpdateOutline: (id: string | number, field: 'title' | 'segments', value: any) => void;
|
||||||
|
onRegenerate?: () => void;
|
||||||
|
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
|
||||||
|
onUpdateBible?: (updatedBible: PodcastBible) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnalysisPanelContext = createContext<AnalysisPanelContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface AnalysisPanelProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
analysis: PodcastAnalysis | null;
|
||||||
|
estimate: PodcastEstimate | null;
|
||||||
|
idea?: string;
|
||||||
|
duration?: number;
|
||||||
|
speakers?: number;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
avatarPrompt?: string | null;
|
||||||
|
bible?: PodcastBible | null;
|
||||||
|
onRegenerate?: () => void;
|
||||||
|
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
|
||||||
|
onUpdateBible?: (updatedBible: PodcastBible) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnalysisPanelProvider: React.FC<AnalysisPanelProviderProps> = ({
|
||||||
|
children,
|
||||||
|
analysis,
|
||||||
|
estimate,
|
||||||
|
idea,
|
||||||
|
duration,
|
||||||
|
speakers,
|
||||||
|
avatarUrl,
|
||||||
|
avatarPrompt,
|
||||||
|
bible,
|
||||||
|
onRegenerate,
|
||||||
|
onUpdateAnalysis,
|
||||||
|
onUpdateBible,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>("inputs");
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedAnalysis, setEditedAnalysis] = useState<PodcastAnalysis | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (analysis && !editedAnalysis) {
|
||||||
|
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
|
||||||
|
}
|
||||||
|
}, [analysis, editedAnalysis]);
|
||||||
|
|
||||||
|
const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis;
|
||||||
|
|
||||||
|
const handleAddKeyword = (keyword: string) => {
|
||||||
|
if (!editedAnalysis || !keyword.trim()) return;
|
||||||
|
if (editedAnalysis.topKeywords.includes(keyword.trim())) return;
|
||||||
|
setEditedAnalysis({
|
||||||
|
...editedAnalysis,
|
||||||
|
topKeywords: [...editedAnalysis.topKeywords, keyword.trim()]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveKeyword = (keyword: string) => {
|
||||||
|
if (!editedAnalysis) return;
|
||||||
|
setEditedAnalysis({
|
||||||
|
...editedAnalysis,
|
||||||
|
topKeywords: editedAnalysis.topKeywords.filter(k => k !== keyword)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTitle = (title: string) => {
|
||||||
|
if (!editedAnalysis || !title.trim()) return;
|
||||||
|
setEditedAnalysis({
|
||||||
|
...editedAnalysis,
|
||||||
|
titleSuggestions: [...editedAnalysis.titleSuggestions, title.trim()]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTitle = (title: string) => {
|
||||||
|
if (!editedAnalysis) return;
|
||||||
|
setEditedAnalysis({
|
||||||
|
...editedAnalysis,
|
||||||
|
titleSuggestions: editedAnalysis.titleSuggestions.filter(t => t !== title)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateOutline = (id: string | number, field: 'title' | 'segments', value: any) => {
|
||||||
|
if (!editedAnalysis) return;
|
||||||
|
setEditedAnalysis({
|
||||||
|
...editedAnalysis,
|
||||||
|
suggestedOutlines: editedAnalysis.suggestedOutlines.map(o =>
|
||||||
|
o.id === id ? { ...o, [field]: value } : o
|
||||||
|
)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AnalysisPanelContextType = {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
analysis,
|
||||||
|
estimate,
|
||||||
|
idea,
|
||||||
|
duration,
|
||||||
|
speakers,
|
||||||
|
avatarUrl,
|
||||||
|
avatarPrompt,
|
||||||
|
bible,
|
||||||
|
isEditing,
|
||||||
|
setIsEditing,
|
||||||
|
editedAnalysis,
|
||||||
|
setEditedAnalysis,
|
||||||
|
currentAnalysis,
|
||||||
|
handleRemoveKeyword,
|
||||||
|
handleAddKeyword,
|
||||||
|
handleRemoveTitle,
|
||||||
|
handleAddTitle,
|
||||||
|
handleUpdateOutline,
|
||||||
|
onRegenerate,
|
||||||
|
onUpdateAnalysis,
|
||||||
|
onUpdateBible,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnalysisPanelContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AnalysisPanelContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAnalysisPanel = (): AnalysisPanelContextType => {
|
||||||
|
const context = useContext(AnalysisPanelContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAnalysisPanel must be used within AnalysisPanelProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Stack, Typography, Chip, Button, Divider } from "@mui/material";
|
||||||
|
import { Psychology as PsychologyIcon, Refresh as RefreshIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Mic as MicIcon } from "@mui/icons-material";
|
||||||
|
import { GlassyCard, glassyCardSx, SecondaryButton } from "../ui";
|
||||||
|
import { useAnalysisPanel, TabId } from "./AnalysisPanelContext";
|
||||||
|
import { PodcastEstimate } from "../types";
|
||||||
|
|
||||||
|
interface TabConfig {
|
||||||
|
id: TabId;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabButtonStyles = (isActive: boolean) => ({
|
||||||
|
background: isActive
|
||||||
|
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||||
|
: "#f8fafc",
|
||||||
|
color: isActive ? "#fff" : "#475569",
|
||||||
|
border: isActive
|
||||||
|
? "none"
|
||||||
|
: "1px solid #e2e8f0",
|
||||||
|
borderRadius: 2.5,
|
||||||
|
px: 2.5,
|
||||||
|
py: 1.25,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "none" as const,
|
||||||
|
transition: "all 0.25s ease",
|
||||||
|
boxShadow: isActive
|
||||||
|
? "0 4px 12px rgba(102, 126, 234, 0.3)"
|
||||||
|
: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||||
|
"&:hover": {
|
||||||
|
background: isActive
|
||||||
|
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
|
||||||
|
: "#e2e8f0",
|
||||||
|
transform: isActive ? "translateY(-1px)" : "none",
|
||||||
|
boxShadow: isActive
|
||||||
|
? "0 6px 16px rgba(102, 126, 234, 0.35)"
|
||||||
|
: "0 2px 4px rgba(0, 0, 0, 0.08)",
|
||||||
|
},
|
||||||
|
"&:active": {
|
||||||
|
transform: "translateY(0)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AnalysisPanelLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
isEditing,
|
||||||
|
setIsEditing,
|
||||||
|
editedAnalysis,
|
||||||
|
setEditedAnalysis,
|
||||||
|
analysis,
|
||||||
|
estimate,
|
||||||
|
onRegenerate,
|
||||||
|
onUpdateAnalysis,
|
||||||
|
} = useAnalysisPanel();
|
||||||
|
|
||||||
|
const tabs: TabConfig[] = [
|
||||||
|
{ id: "inputs", label: "Your Inputs", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>📥</Box> },
|
||||||
|
{ id: "audience", label: "Audience & Keywords", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>👥</Box> },
|
||||||
|
{ id: "outline", label: "Outline", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>📋</Box> },
|
||||||
|
{ id: "details", label: "Titles, Hook & CTA", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>📄</Box> },
|
||||||
|
{ id: "takeaways", label: "Takeaways", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>💡</Box> },
|
||||||
|
{ id: "guest", label: "Guest Talking Points", icon: <Box component="span" sx={{ display: "flex", alignItems: "center" }}>👤</Box> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (editedAnalysis && onUpdateAnalysis) {
|
||||||
|
onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis)));
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassyCard
|
||||||
|
sx={{
|
||||||
|
...glassyCardSx,
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid rgba(0,0,0,0.06)",
|
||||||
|
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
|
||||||
|
color: "#111827",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{/* Header Section */}
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
|
||||||
|
<Stack direction="row" alignItems="center" gap={1.5} flex={1}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PsychologyIcon sx={{ color: "#fff", fontSize: 22 }} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, color: "#1e293b", fontSize: "1.1rem" }}>
|
||||||
|
Personalize Your Podcast
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Estimate Display */}
|
||||||
|
{estimate && (
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ ml: 2 }}>
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ height: 24, alignSelf: 'center', borderColor: "rgba(0,0,0,0.1)" }} />
|
||||||
|
<Typography variant="subtitle2" fontWeight={700} sx={{ color: "#4f46e5" }}>
|
||||||
|
Est. Cost: ${estimate.total.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
{estimate.voiceName && (
|
||||||
|
<Chip
|
||||||
|
icon={<PsychologyIcon sx={{ fontSize: "12px !important" }} />}
|
||||||
|
label={estimate.voiceName}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: estimate.isCustomVoice ? "#10b981" : "#6366f1",
|
||||||
|
borderColor: estimate.isCustomVoice ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)",
|
||||||
|
bgcolor: estimate.isCustomVoice ? "rgba(16, 185, 129, 0.05)" : "rgba(99, 102, 241, 0.05)",
|
||||||
|
'& .MuiChip-icon': { color: estimate.isCustomVoice ? "#10b981" : "#6366f1" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', lg: 'flex' } }}>
|
||||||
|
<Chip
|
||||||
|
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`Research: $${estimate.researchCost.toFixed(2)}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
|
{/* Regenerate Button */}
|
||||||
|
<SecondaryButton
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={onRegenerate}
|
||||||
|
sx={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
color: "#475569",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
px: 2,
|
||||||
|
py: 0.75,
|
||||||
|
"&:hover": {
|
||||||
|
background: "#f8fafc",
|
||||||
|
borderColor: "#cbd5e1",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</SecondaryButton>
|
||||||
|
|
||||||
|
{/* Edit/Save/Cancel Buttons */}
|
||||||
|
{isEditing ? (
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
startIcon={<CloseIcon />}
|
||||||
|
onClick={handleCancel}
|
||||||
|
sx={{
|
||||||
|
color: "#64748b",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
px: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
sx={{
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
px: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
startIcon={<EditIcon />}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
sx={{
|
||||||
|
color: "#667eea",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
px: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Box
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
sx={tabButtonStyles(activeTab === tab.id)}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
{tab.icon}
|
||||||
|
<Box>{tab.label}</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Content Area - Render children (tab content) */}
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</GlassyCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { AnalysisPanelLayout } from "./AnalysisPanelLayout";
|
||||||
|
export { AnalysisPanelProvider, useAnalysisPanel } from "./AnalysisPanelContext";
|
||||||
|
export { AnalysisPanelInputsTab } from "./parts/AnalysisPanelInputsTab";
|
||||||
|
export { AnalysisPanelAudienceTab } from "./parts/AnalysisPanelAudienceTab";
|
||||||
|
export { AnalysisPanelOutlineTab } from "./parts/AnalysisPanelOutlineTab";
|
||||||
|
export { AnalysisPanelDetailsTab } from "./parts/AnalysisPanelDetailsTab";
|
||||||
|
export { AnalysisPanelTakeawaysTab } from "./parts/AnalysisPanelTakeawaysTab";
|
||||||
|
export { AnalysisPanelGuestTab } from "./parts/AnalysisPanelGuestTab";
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Box, Typography, Chip, TextField, Divider } from "@mui/material";
|
||||||
|
import { Groups as GroupsIcon, Search as SearchIcon } from "@mui/icons-material";
|
||||||
|
import { useAnalysisPanel } from "../AnalysisPanelContext";
|
||||||
|
|
||||||
|
const inputStyles = {
|
||||||
|
'& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 },
|
||||||
|
'& .MuiInputLabel-root': { color: '#4b5563 !important' },
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: '#ffffff !important',
|
||||||
|
'& fieldset': { borderColor: '#d1d5db !important' },
|
||||||
|
'&:hover fieldset': { borderColor: '#4f46e5 !important' },
|
||||||
|
'&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnalysisTabContent: React.FC<{ title: string; icon?: React.ReactNode; children: React.ReactNode }> = ({ title, icon, children }) => (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center" mb={2}>
|
||||||
|
{icon && <Box sx={{ color: "#6366f1" }}>{icon}</Box>}
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AnalysisPanelAudienceTab: React.FC = () => {
|
||||||
|
const { currentAnalysis, isEditing, setEditedAnalysis, editedAnalysis, handleRemoveKeyword, handleAddKeyword, handleRemoveTitle, handleAddTitle } = useAnalysisPanel();
|
||||||
|
|
||||||
|
if (!currentAnalysis) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, textAlign: "center" }}>
|
||||||
|
<Typography variant="body1" sx={{ color: "#64748b" }}>
|
||||||
|
No analysis data available. Please generate analysis first.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysis = currentAnalysis;
|
||||||
|
|
||||||
|
const handleAudienceChange = (value: string) => {
|
||||||
|
if (editedAnalysis) {
|
||||||
|
setEditedAnalysis({ ...editedAnalysis, audience: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentTypeChange = (value: string) => {
|
||||||
|
if (editedAnalysis) {
|
||||||
|
setEditedAnalysis({ ...editedAnalysis, contentType: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnalysisTabContent title="Target Audience" icon={<GroupsIcon />}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||||
|
Audience Description
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
size="small"
|
||||||
|
value={analysis.audience || ""}
|
||||||
|
onChange={(e) => handleAudienceChange(e.target.value)}
|
||||||
|
placeholder="Describe your target audience..."
|
||||||
|
sx={inputStyles}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#0f172a" }}>
|
||||||
|
{analysis.audience}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
|
||||||
|
Content Type
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={analysis.contentType || ""}
|
||||||
|
onChange={(e) => handleContentTypeChange(e.target.value)}
|
||||||
|
placeholder="e.g. Interview, Narrative, Solo..."
|
||||||
|
sx={inputStyles}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip label={analysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#4f46e5", border: "1px solid rgba(79,70,229,0.2)" }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
|
||||||
|
Top Keywords
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||||
|
{analysis.topKeywords?.map((k: string) => (
|
||||||
|
<Chip
|
||||||
|
key={k}
|
||||||
|
label={k}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onDelete={isEditing ? () => handleRemoveKeyword?.(k) : undefined}
|
||||||
|
sx={{
|
||||||
|
borderColor: isEditing ? "#ef4444" : "rgba(0,0,0,0.15)",
|
||||||
|
color: isEditing ? "#dc2626" : "#0f172a",
|
||||||
|
background: isEditing ? "#fef2f2" : "#f8fafc",
|
||||||
|
fontWeight: 500,
|
||||||
|
"& .MuiChip-deleteIcon": {
|
||||||
|
color: "#ef4444",
|
||||||
|
"&:hover": {
|
||||||
|
color: "#dc2626",
|
||||||
|
backgroundColor: "#fee2e2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{isEditing && (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Add keyword and press Enter..."
|
||||||
|
sx={inputStyles}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
handleAddKeyword?.(input.value);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{analysis.exaSuggestedConfig && (
|
||||||
|
<Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
|
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||||
|
Exa Research Config
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" flexWrap="wrap" useFlexGap>
|
||||||
|
{analysis.exaSuggestedConfig.exa_search_type && (
|
||||||
|
<Chip label={`Search: ${analysis.exaSuggestedConfig.exa_search_type}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||||
|
)}
|
||||||
|
{analysis.exaSuggestedConfig.exa_category && (
|
||||||
|
<Chip label={`Category: ${analysis.exaSuggestedConfig.exa_category}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||||
|
)}
|
||||||
|
{analysis.exaSuggestedConfig.date_range && (
|
||||||
|
<Chip label={`Date: ${analysis.exaSuggestedConfig.date_range}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||||
|
)}
|
||||||
|
{analysis.exaSuggestedConfig.max_sources && (
|
||||||
|
<Chip label={`Max: ${analysis.exaSuggestedConfig.max_sources}`} size="small" sx={{ background: "#eef2ff", color: "#0f172a" }} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
|
||||||
|
Title Suggestions
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||||
|
{analysis.titleSuggestions?.map((t: string) => (
|
||||||
|
<Chip
|
||||||
|
key={t}
|
||||||
|
label={t}
|
||||||
|
size="small"
|
||||||
|
onDelete={isEditing ? () => handleRemoveTitle?.(t) : undefined}
|
||||||
|
sx={{
|
||||||
|
color: isEditing ? "#dc2626" : "#0f172a",
|
||||||
|
background: isEditing ? "#fef2f2" : "#f8fafc",
|
||||||
|
border: isEditing ? "1px solid #ef4444" : "1px solid #e2e8f0",
|
||||||
|
maxWidth: "100%",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
fontWeight: 500,
|
||||||
|
"& .MuiChip-deleteIcon": {
|
||||||
|
color: "#ef4444",
|
||||||
|
"&:hover": {
|
||||||
|
color: "#dc2626",
|
||||||
|
backgroundColor: "#fee2e2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: "auto",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{isEditing && (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Add title suggestion..."
|
||||||
|
sx={inputStyles}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
handleAddTitle?.(input.value);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</AnalysisTabContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Box, Typography, Chip, TextField, IconButton, Paper, Divider } from "@mui/material";
|
||||||
|
import { EditNote as EditNoteIcon, Add as AddIcon, AutoAwesome as AutoAwesomeIcon, CallToAction as CTAIcon } from "@mui/icons-material";
|
||||||
|
import { useAnalysisPanel } from "../AnalysisPanelContext";
|
||||||
|
|
||||||
|
const inputStyles = {
|
||||||
|
'& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 },
|
||||||
|
'& .MuiInputLabel-root': { color: '#4b5563 !important' },
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: '#ffffff !important',
|
||||||
|
'& fieldset': { borderColor: '#d1d5db !important' },
|
||||||
|
'&:hover fieldset': { borderColor: '#4f46e5 !important' },
|
||||||
|
'&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalysisPanelDetailsTab: React.FC = () => {
|
||||||
|
const { currentAnalysis, isEditing, handleAddTitle, handleRemoveTitle } = useAnalysisPanel();
|
||||||
|
|
||||||
|
if (!currentAnalysis) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, textAlign: "center" }}>
|
||||||
|
<Typography variant="body1" sx={{ color: "#64748b" }}>
|
||||||
|
No analysis data available. Please generate analysis first.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysis = currentAnalysis;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
{/* Titles Section */}
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<EditNoteIcon sx={{ color: "#4f46e5", fontSize: 20 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#1e293b", fontWeight: 700 }}>
|
||||||
|
Episode Titles
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ gap: 1 }}>
|
||||||
|
{analysis.titleSuggestions?.map((title: string, idx: number) => (
|
||||||
|
<Chip
|
||||||
|
key={idx}
|
||||||
|
label={title}
|
||||||
|
size="small"
|
||||||
|
onDelete={isEditing ? () => handleRemoveTitle?.(title) : undefined}
|
||||||
|
sx={{
|
||||||
|
color: isEditing ? "#dc2626" : "#0f172a",
|
||||||
|
background: isEditing ? "#fef2f2" : "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)",
|
||||||
|
border: isEditing ? "1px solid #ef4444" : "1px solid #e2e8f0",
|
||||||
|
maxWidth: "100%",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
height: "auto",
|
||||||
|
py: 0.5,
|
||||||
|
fontWeight: 500,
|
||||||
|
"& .MuiChip-deleteIcon": {
|
||||||
|
color: "#ef4444",
|
||||||
|
"&:hover": {
|
||||||
|
color: "#dc2626",
|
||||||
|
backgroundColor: "#fee2e2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"&:hover": { background: isEditing ? "#fee2e2" : "#e2e8f0" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{isEditing && (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Add title suggestion..."
|
||||||
|
sx={{ ...inputStyles, mt: 2 }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
handleAddTitle?.(input.value);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||||
|
|
||||||
|
{/* Hook Section */}
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<AutoAwesomeIcon sx={{ color: "#4f46e5", fontSize: 20 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#1e293b", fontWeight: 700 }}>
|
||||||
|
Episode Hook
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{analysis.episode_hook ? (
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, bgcolor: "#f0f9ff", border: "1px solid rgba(59,130,246,0.2)", borderRadius: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#0369a1", fontStyle: "italic", lineHeight: 1.6 }}>
|
||||||
|
"{analysis.episode_hook}"
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#94a3b8", fontStyle: "italic" }}>
|
||||||
|
No episode hook generated yet.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: "#94a3b8", mt: 1, display: "block" }}>
|
||||||
|
A 15-30 second opening hook to grab listener attention.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<CTAIcon sx={{ color: "#4f46e5", fontSize: 20 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#1e293b", fontWeight: 700 }}>
|
||||||
|
Listener CTA
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{analysis.listener_cta ? (
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, bgcolor: "#fff7ed", border: "1px solid rgba(249,115,22,0.2)", borderRadius: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#c2410c", fontWeight: 500, lineHeight: 1.6 }}>
|
||||||
|
{analysis.listener_cta}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#94a3b8", fontStyle: "italic" }}>
|
||||||
|
No listener call-to-action generated yet.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: "#94a3b8", mt: 1, display: "block" }}>
|
||||||
|
A call-to-action for listeners after the episode.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
|
||||||
|
import { Quiz as TalkIcon } from "@mui/icons-material";
|
||||||
|
import { useAnalysisPanel } from "../AnalysisPanelContext";
|
||||||
|
|
||||||
|
export const AnalysisPanelGuestTab: React.FC = () => {
|
||||||
|
const { analysis: ctxAnalysis } = useAnalysisPanel();
|
||||||
|
|
||||||
|
const guestTalkingPoints = ctxAnalysis?.guest_talking_points;
|
||||||
|
|
||||||
|
if (!guestTalkingPoints || guestTalkingPoints.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, textAlign: "center" }}>
|
||||||
|
<Typography variant="body1" sx={{ color: "#64748b" }}>
|
||||||
|
No guest talking points generated yet. Add a guest speaker to get interview questions.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center", mb: 2 }}>
|
||||||
|
<TalkIcon sx={{ color: "#6366f1" }} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
|
||||||
|
Guest Talking Points
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{guestTalkingPoints.map((point: string, idx: number) => (
|
||||||
|
<Paper key={idx} elevation={0} sx={{ p: 2, bgcolor: "#faf5ff", border: "1px solid rgba(168,85,247,0.2)", borderRadius: 2, display: "flex", alignItems: "flex-start", gap: 1.5 }}>
|
||||||
|
<Chip label="Q" size="small" sx={{ minWidth: 24, bgcolor: "#a855f7", color: "#fff" }} />
|
||||||
|
<Typography variant="body2" sx={{ color: "#6b21a8" }}>
|
||||||
|
{point}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Stack, Typography, Chip, Paper, alpha } from "@mui/material";
|
||||||
|
import { Input as InputIcon, Mic as MicIcon } from "@mui/icons-material";
|
||||||
|
import { useAnalysisPanel } from "../AnalysisPanelContext";
|
||||||
|
|
||||||
|
interface AnalysisTabContentProps {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnalysisTabContent: React.FC<AnalysisTabContentProps> = ({ title, icon, children }) => (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center" mb={2}>
|
||||||
|
{icon && <Box sx={{ color: "#6366f1" }}>{icon}</Box>}
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AnalysisPanelInputsTab: React.FC = () => {
|
||||||
|
const { idea, duration, speakers, avatarUrl, avatarPrompt, estimate } = useAnalysisPanel();
|
||||||
|
|
||||||
|
if (!idea && !duration && !speakers && !avatarUrl && !avatarPrompt) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, textAlign: "center" }}>
|
||||||
|
<Typography variant="body1" sx={{ color: "#64748b" }}>
|
||||||
|
No analysis data available. Please generate analysis first.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnalysisTabContent title="Your Inputs" icon={<InputIcon />}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", md: avatarUrl ? "1fr 1fr" : "1fr" },
|
||||||
|
gap: 3,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{idea && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||||
|
Podcast Idea
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#0f172a", wordBreak: "break-word" }}>
|
||||||
|
{idea}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||||
|
{estimate?.voiceName && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||||
|
Voice
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
|
||||||
|
label={estimate.voiceName}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
background: estimate.isCustomVoice ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
|
||||||
|
color: estimate.isCustomVoice ? "#10b981" : "#6366f1",
|
||||||
|
border: `1px solid ${estimate.isCustomVoice ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
|
||||||
|
'& .MuiChip-icon': { color: estimate.isCustomVoice ? "#10b981" : "#6366f1" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{duration !== undefined && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||||
|
Duration
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`${duration} minutes`}
|
||||||
|
size="small"
|
||||||
|
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{speakers !== undefined && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||||
|
Speakers
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={speakers === 1 ? "Solo" : `${speakers} speakers`}
|
||||||
|
size="small"
|
||||||
|
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
{avatarUrl && (
|
||||||
|
<Paper sx={{ p: 2, background: "#f8fafc", border: "1px solid rgba(0,0,0,0.08)" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 1 }}>
|
||||||
|
Avatar Preview
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Avatar"
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 120,
|
||||||
|
height: "auto",
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid rgba(0,0,0,0.1)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{avatarPrompt && (
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", mt: 1, display: "block" }}>
|
||||||
|
Prompt: {avatarPrompt}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</AnalysisTabContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Typography, Chip } from "@mui/material";
|
||||||
|
import { useAnalysisPanel } from "../AnalysisPanelContext";
|
||||||
|
|
||||||
|
const AnalysisTabContent: React.FC<{ title: string; icon?: React.ReactNode; children: React.ReactNode }> = ({ title, icon, children }) => (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center", mb: 2 }}>
|
||||||
|
{icon}
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AnalysisPanelOutlineTab: React.FC = () => {
|
||||||
|
const { currentAnalysis, isEditing, handleUpdateOutline } = useAnalysisPanel();
|
||||||
|
|
||||||
|
if (!currentAnalysis || !currentAnalysis.suggestedOutlines) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, textAlign: "center" }}>
|
||||||
|
<Typography variant="body1" sx={{ color: "#64748b" }}>
|
||||||
|
No outline available. Please generate analysis first.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysis = currentAnalysis;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center", mb: 2 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
|
||||||
|
Episode Outline
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{analysis.suggestedOutlines?.map((outline: { id?: string | number; title: string; segments: string[] }, idx: number) => (
|
||||||
|
<Box key={outline.id || idx} sx={{ p: 2, bgcolor: "#f8fafc", borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)", mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 700, mb: 1.5 }}>
|
||||||
|
Option {idx + 1}: {outline.title}
|
||||||
|
</Typography>
|
||||||
|
{outline.segments?.map((segment: string, sIdx: number) => (
|
||||||
|
<Box key={sIdx} sx={{ display: "flex", alignItems: "flex-start", gap: 1, mb: 1 }}>
|
||||||
|
<Chip label={sIdx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#4f46e5", color: "#fff" }} />
|
||||||
|
<Typography variant="body2" sx={{ color: "#475569" }}>
|
||||||
|
{segment}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Box, Typography, Chip, Paper } from "@mui/material";
|
||||||
|
import { Lightbulb as TipsIcon } from "@mui/icons-material";
|
||||||
|
import { useAnalysisPanel } from "../AnalysisPanelContext";
|
||||||
|
|
||||||
|
export const AnalysisPanelTakeawaysTab: React.FC = () => {
|
||||||
|
const { analysis: ctxAnalysis } = useAnalysisPanel();
|
||||||
|
|
||||||
|
const keyTakeaways = ctxAnalysis?.key_takeaways;
|
||||||
|
|
||||||
|
if (!keyTakeaways || keyTakeaways.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, textAlign: "center" }}>
|
||||||
|
<Typography variant="body1" sx={{ color: "#64748b" }}>
|
||||||
|
No key takeaways generated yet.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center", mb: 2 }}>
|
||||||
|
<TipsIcon sx={{ color: "#6366f1" }} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, color: "#0f172a" }}>
|
||||||
|
Key Takeaways
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{keyTakeaways.map((takeaway: string, idx: number) => (
|
||||||
|
<Paper key={idx} elevation={0} sx={{ p: 2, bgcolor: "#f0fdf4", border: "1px solid rgba(34,197,94,0.2)", borderRadius: 2, display: "flex", alignItems: "flex-start", gap: 1.5 }}>
|
||||||
|
<Chip label={idx + 1} size="small" sx={{ minWidth: 24, bgcolor: "#22c55e", color: "#fff" }} />
|
||||||
|
<Typography variant="body2" sx={{ color: "#166534" }}>
|
||||||
|
{takeaway}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,8 @@ interface InputsTabProps {
|
|||||||
idea?: string;
|
idea?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
speakers?: number;
|
speakers?: number;
|
||||||
|
voiceName?: string;
|
||||||
|
podcastMode?: "audio_only" | "video_only" | "audio_video";
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
avatarPrompt?: string | null;
|
avatarPrompt?: string | null;
|
||||||
avatarBlobUrl?: string | null;
|
avatarBlobUrl?: string | null;
|
||||||
@@ -14,8 +16,8 @@ interface InputsTabProps {
|
|||||||
avatarError?: boolean;
|
avatarError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers, avatarUrl, avatarPrompt, avatarBlobUrl, avatarLoading, avatarError }) => {
|
export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers, voiceName, podcastMode, avatarUrl, avatarPrompt, avatarBlobUrl, avatarLoading, avatarError }) => {
|
||||||
if (!idea && !duration && !speakers && !avatarUrl && !avatarPrompt) {
|
if (!idea && !duration && !speakers && !voiceName && !podcastMode && !avatarUrl && !avatarPrompt) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers,
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: { xs: "1fr", md: avatarUrl ? "1fr 1fr" : "1fr" },
|
gridTemplateColumns: { xs: "1fr", md: avatarUrl && podcastMode !== "audio_only" ? "1fr 1fr" : "1fr" },
|
||||||
gap: 3,
|
gap: 3,
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
}}
|
}}
|
||||||
@@ -65,6 +67,38 @@ export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers,
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{voiceName && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||||
|
Voice
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={voiceName}
|
||||||
|
size="small"
|
||||||
|
sx={{ background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", color: "#fff", fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{podcastMode && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||||
|
Podcast Mode
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={podcastMode === "audio_only" ? "Audio Only" : podcastMode === "video_only" ? "Video" : "Audio + Video"}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
background: podcastMode === "audio_only"
|
||||||
|
? "#10b981"
|
||||||
|
: podcastMode === "video_only"
|
||||||
|
? "#f97316"
|
||||||
|
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{avatarPrompt && (
|
{avatarPrompt && (
|
||||||
@@ -112,7 +146,7 @@ export const InputsTab: React.FC<InputsTabProps> = ({ idea, duration, speakers,
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{avatarUrl && (
|
{podcastMode !== "audio_only" && avatarUrl && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import { Stack, Paper, Box } from "@mui/material";
|
import { Stack, Paper, Box, Chip, Typography } from "@mui/material";
|
||||||
import { CreateProjectPayload, Knobs } from "./types";
|
import { CreateProjectPayload, Knobs, PodcastMode } from "./types";
|
||||||
import { useSubscription } from "../../contexts/SubscriptionContext";
|
import { useSubscription } from "../../contexts/SubscriptionContext";
|
||||||
import { podcastApi } from "../../services/podcastApi";
|
import { podcastApi } from "../../services/podcastApi";
|
||||||
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
|
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
|
||||||
@@ -26,9 +26,10 @@ interface CreateModalProps {
|
|||||||
defaultKnobs: Knobs;
|
defaultKnobs: Knobs;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
announcement?: string;
|
announcement?: string;
|
||||||
|
onAnnouncementClear?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false, announcement }) => {
|
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false, announcement, onAnnouncementClear }) => {
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
const [topicInput, setTopicInput] = useState("");
|
const [topicInput, setTopicInput] = useState("");
|
||||||
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
|
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
|
||||||
@@ -50,6 +51,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false);
|
const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false);
|
||||||
const [brandAvatarFromDb, setBrandAvatarFromDb] = useState<string | null>(null);
|
const [brandAvatarFromDb, setBrandAvatarFromDb] = useState<string | null>(null);
|
||||||
const [cameraSelfieOpen, setCameraSelfieOpen] = useState(false);
|
const [cameraSelfieOpen, setCameraSelfieOpen] = useState(false);
|
||||||
|
const [podcastMode, setPodcastMode] = useState<PodcastMode>("audio_video");
|
||||||
|
|
||||||
// Enhanced topic choices state
|
// Enhanced topic choices state
|
||||||
const [enhancedChoices, setEnhancedChoices] = useState<string[]>([]);
|
const [enhancedChoices, setEnhancedChoices] = useState<string[]>([]);
|
||||||
@@ -286,11 +288,15 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
const hasDuration = Boolean(duration > 0 && duration <= 10);
|
const hasDuration = Boolean(duration > 0 && duration <= 10);
|
||||||
const hasSpeakers = Boolean(speakers >= 1 && speakers <= 2);
|
const hasSpeakers = Boolean(speakers >= 1 && speakers <= 2);
|
||||||
|
|
||||||
const canSubmit = Boolean(hasTopic && hasAvatar && hasVoice && hasDuration && hasSpeakers);
|
const canSubmit = Boolean(hasTopic && (podcastMode === "audio_only" || hasAvatar) && hasVoice && hasDuration && hasSpeakers);
|
||||||
|
|
||||||
const submit = async () => {
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = useCallback(async () => {
|
||||||
if (!canSubmit || isSubmitting) return;
|
if (!canSubmit || isSubmitting) return;
|
||||||
|
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
// Determine if input is idea or URL
|
// Determine if input is idea or URL
|
||||||
// For URL, we extract the first URL found or use the whole string if it's a direct URL
|
// For URL, we extract the first URL found or use the whole string if it's a direct URL
|
||||||
let finalIdea = "";
|
let finalIdea = "";
|
||||||
@@ -332,16 +338,22 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
voice_id: selectedVoiceId,
|
voice_id: selectedVoiceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
onCreate({
|
try {
|
||||||
ideaOrUrl: finalUrl || finalIdea,
|
await onCreate({
|
||||||
speakers,
|
ideaOrUrl: finalUrl || finalIdea,
|
||||||
duration,
|
speakers,
|
||||||
knobs: finalKnobs,
|
duration,
|
||||||
budgetCap,
|
knobs: finalKnobs,
|
||||||
files: { voiceFile, avatarFile },
|
budgetCap,
|
||||||
avatarUrl: finalAvatarUrl,
|
files: { voiceFile, avatarFile },
|
||||||
});
|
avatarUrl: finalAvatarUrl,
|
||||||
};
|
podcastMode,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[CreateModal] Submit error:", err);
|
||||||
|
setSubmitError(err?.message || String(err) || "Failed to create project");
|
||||||
|
}
|
||||||
|
}, [canSubmit, isSubmitting, isUrl, topicInput, avatarFile, avatarUrl, knobs, selectedVoiceId, speakers, duration, budgetCap, podcastMode, onCreate]);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setTopicInput("");
|
setTopicInput("");
|
||||||
@@ -358,6 +370,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
setKnobs({ ...defaultKnobs });
|
setKnobs({ ...defaultKnobs });
|
||||||
setSelectedVoiceId("Wise_Woman");
|
setSelectedVoiceId("Wise_Woman");
|
||||||
setPlaceholderIndex(0);
|
setPlaceholderIndex(0);
|
||||||
|
setPodcastMode("audio_video");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAvatarSelectFromLibrary = React.useCallback((url: string) => {
|
const handleAvatarSelectFromLibrary = React.useCallback((url: string) => {
|
||||||
@@ -560,6 +573,8 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
setDuration={setDuration}
|
setDuration={setDuration}
|
||||||
speakers={speakers}
|
speakers={speakers}
|
||||||
setSpeakers={setSpeakers}
|
setSpeakers={setSpeakers}
|
||||||
|
podcastMode={podcastMode}
|
||||||
|
setPodcastMode={setPodcastMode}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -583,6 +598,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
brandAvatarBlobUrl={brandAvatarBlobUrl}
|
brandAvatarBlobUrl={brandAvatarBlobUrl}
|
||||||
cameraSelfieOpen={cameraSelfieOpen}
|
cameraSelfieOpen={cameraSelfieOpen}
|
||||||
setCameraSelfieOpen={setCameraSelfieOpen}
|
setCameraSelfieOpen={setCameraSelfieOpen}
|
||||||
|
podcastMode={podcastMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VoiceSelector
|
<VoiceSelector
|
||||||
@@ -597,6 +613,8 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
announcement={announcement}
|
announcement={announcement}
|
||||||
|
onAnnouncementClear={onAnnouncementClear}
|
||||||
|
error={submitError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Enhanced Topic Choices Modal */}
|
{/* Enhanced Topic Choices Modal */}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { AvatarAssetBrowser } from "../AvatarAssetBrowser";
|
import { AvatarAssetBrowser } from "../AvatarAssetBrowser";
|
||||||
import { CameraSelfie } from "../CameraSelfie";
|
import { CameraSelfie } from "../CameraSelfie";
|
||||||
import { SecondaryButton } from "../ui";
|
import { SecondaryButton } from "../ui";
|
||||||
|
import { PodcastMode } from "../types";
|
||||||
|
|
||||||
interface AvatarSelectorProps {
|
interface AvatarSelectorProps {
|
||||||
avatarTab: number;
|
avatarTab: number;
|
||||||
@@ -34,6 +35,7 @@ interface AvatarSelectorProps {
|
|||||||
brandAvatarBlobUrl?: string | null;
|
brandAvatarBlobUrl?: string | null;
|
||||||
cameraSelfieOpen: boolean;
|
cameraSelfieOpen: boolean;
|
||||||
setCameraSelfieOpen: (open: boolean) => void;
|
setCameraSelfieOpen: (open: boolean) => void;
|
||||||
|
podcastMode?: PodcastMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
||||||
@@ -55,6 +57,7 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
|||||||
brandAvatarBlobUrl,
|
brandAvatarBlobUrl,
|
||||||
cameraSelfieOpen,
|
cameraSelfieOpen,
|
||||||
setCameraSelfieOpen,
|
setCameraSelfieOpen,
|
||||||
|
podcastMode,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
@@ -65,6 +68,35 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
|||||||
? ["Brand", "Library", "Selfie", "Upload"]
|
? ["Brand", "Library", "Selfie", "Upload"]
|
||||||
: ["Use Brand Avatar", "Asset Library", "Take Selfie", "Upload Your Photo"];
|
: ["Use Brand Avatar", "Asset Library", "Take Selfie", "Upload Your Photo"];
|
||||||
|
|
||||||
|
if (podcastMode === "audio_only") {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
p: { xs: 1.5, sm: 2.5 },
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "#f8fafc",
|
||||||
|
border: "1px dashed rgba(15, 23, 42, 0.12)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1} alignItems="center" sx={{ textAlign: "center" }}>
|
||||||
|
<PersonIcon sx={{ color: "#94a3b8", fontSize: 40 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#64748b", fontWeight: 600 }}>
|
||||||
|
No avatar needed for audio-only
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "#94a3b8" }}>
|
||||||
|
Avatar is only used for video podcasts. Switch to Video or Both to enable.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
IconButton,
|
||||||
|
Typography,
|
||||||
|
Stack,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
alpha,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Close as CloseIcon, Mic as MicIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Settings as SettingsIcon } from "@mui/icons-material";
|
||||||
|
|
||||||
|
export const DurationInfoModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
color: "white",
|
||||||
|
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
⏱️ Episode Duration Guide
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.8, mt: 1 }}>
|
||||||
|
Recommended durations based on content type and audience
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#4ade80' }}>
|
||||||
|
Recommended Durations
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Stack direction="row" justifyContent="space-between">
|
||||||
|
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)" }}>1-3 minutes</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>Quick tips • Social media • Teasers</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" justifyContent="space-between">
|
||||||
|
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)" }}>5-10 minutes</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>Standard podcast • Deep dives</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#fb923c' }}>
|
||||||
|
Cost vs Duration
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• <strong>1-3 min:</strong> $0.50 - $2.00 (Audio) / $3 - $6 (Video)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• <strong>5 min:</strong> $1 - $3 (Audio) / $5 - $12 (Video)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• <strong>10 min:</strong> $2 - $6 (Audio) / $10 - $20 (Video)
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#a78bfa' }}>
|
||||||
|
💡 Pro Tips
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
|
||||||
|
• Start short (1-3 min) for YouTube algorithm
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
|
||||||
|
• Deep content works best at 5-10 minutes
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
|
||||||
|
• Longest single call: 10 minutes max
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpeakersInfoModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
color: "white",
|
||||||
|
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
👥 Number of Speakers Guide
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.8, mt: 1 }}>
|
||||||
|
Choose the right format for your content
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#4ade80' }}>
|
||||||
|
🎤 1 Speaker (Solo)
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Best for: Tutorials, tips, personal stories
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Simpler script • Lower cost
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Full creative control
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#fb923c' }}>
|
||||||
|
👥 2 Speakers (Host + Guest)
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Best for: Interviews, debates, Q&A
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• More engaging • Broader perspectives
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Requires guest coordination
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#a78bfa' }}>
|
||||||
|
💡 Production Notes
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
|
||||||
|
• 2 speakers = 2x script sections = ~2x word count
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
|
||||||
|
• Audio-only mode works best for interviews
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
|
||||||
|
• Video mode requires avatar setup for each speaker
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VoiceInfoModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
color: "white",
|
||||||
|
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
🎤 Voice Selection Guide
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.8, mt: 1 }}>
|
||||||
|
Choose the right voice for your podcast
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<AutoAwesomeIcon sx={{ color: '#4ade80', fontSize: 18 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#4ade80' }}>
|
||||||
|
Voice Clone
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Your own voice - cloned from audio sample
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Most authentic and personalized
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Requires voice cloning setup in settings
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Best for: Brand podcasts, testimonials
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<MicIcon sx={{ color: '#fb923c', fontSize: 18 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#fb923c' }}>
|
||||||
|
System Voices
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• <strong>Female:</strong> Wise Woman, Friendly, Calm, Lively, Inspirational
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• <strong>Male:</strong> Deep Voice, Casual, Patient, Determined
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Instant selection - no setup required
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<PersonIcon sx={{ color: '#a78bfa', fontSize: 18 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#a78bfa' }}>
|
||||||
|
Voice Personalities
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• <strong>Professional:</strong> Corporate, educational content
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• <strong>Happy/Energetic:</strong> Entertainment, announcements
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• <strong>Calm:</strong> Meditation, sensitive topics
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• <strong>Storytelling:</strong> Narratives, books, experiences
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<SettingsIcon sx={{ color: '#38bdf8', fontSize: 18 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#38bdf8' }}>
|
||||||
|
💡 Tips
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
|
||||||
|
• Match voice personality to your content type
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
|
||||||
|
• Use preview button to hear each voice
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)" }}>
|
||||||
|
• Filter by gender/mood to find voices faster
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Alert,
|
|
||||||
Typography,
|
Typography,
|
||||||
|
Chip,
|
||||||
|
Tooltip,
|
||||||
|
Alert,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Box,
|
||||||
alpha,
|
alpha,
|
||||||
Collapse,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Box,
|
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Chip,
|
|
||||||
Divider,
|
Divider,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useTheme,
|
useTheme,
|
||||||
|
Collapse,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Info as InfoIcon,
|
Info as InfoIcon,
|
||||||
@@ -38,8 +40,9 @@ import {
|
|||||||
Headphones as HeadphonesIcon,
|
Headphones as HeadphonesIcon,
|
||||||
Article as ArticleIcon,
|
Article as ArticleIcon,
|
||||||
Campaign as CampaignIcon,
|
Campaign as CampaignIcon,
|
||||||
Groups as GroupsIcon,
|
Groups as GroupsIcon,
|
||||||
School as SchoolIcon,
|
School as SchoolIcon,
|
||||||
|
Error as ErrorIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||||
|
|
||||||
@@ -49,6 +52,8 @@ interface CreateActionsProps {
|
|||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
announcement?: string;
|
announcement?: string;
|
||||||
|
onAnnouncementClear?: () => void;
|
||||||
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANALYSIS_FEATURES = [
|
const ANALYSIS_FEATURES = [
|
||||||
@@ -374,7 +379,7 @@ const WhatYoullGetView: React.FC<{ isMobile?: boolean }> = ({ isMobile }) => (
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, canSubmit, isSubmitting, announcement }) => {
|
export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, canSubmit, isSubmitting, announcement, onAnnouncementClear, error }) => {
|
||||||
const [showInfo, setShowInfo] = useState(true);
|
const [showInfo, setShowInfo] = useState(true);
|
||||||
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
||||||
const [analysisStarted, setAnalysisStarted] = useState(false);
|
const [analysisStarted, setAnalysisStarted] = useState(false);
|
||||||
@@ -387,6 +392,31 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Close modal when analysis completes OR when there's an error
|
||||||
|
// Use a ref to track previous isSubmitting to detect the transition from true to false
|
||||||
|
const prevIsSubmittingRef = useRef(isSubmitting);
|
||||||
|
useEffect(() => {
|
||||||
|
// Detect transition from submitting to not submitting (analysis complete)
|
||||||
|
const wasSubmitting = prevIsSubmittingRef.current;
|
||||||
|
const nowNotSubmitting = !isSubmitting;
|
||||||
|
|
||||||
|
if (showAnalysisModal && analysisStarted && wasSubmitting && nowNotSubmitting) {
|
||||||
|
console.warn('[CreateActions] Analysis complete — closing modal and clearing announcement');
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowAnalysisModal(false);
|
||||||
|
onAnnouncementClear?.();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ref for next render
|
||||||
|
prevIsSubmittingRef.current = isSubmitting;
|
||||||
|
|
||||||
|
// If there's an error, also ensure modal is usable
|
||||||
|
if (error && showAnalysisModal) {
|
||||||
|
console.warn('[CreateActions] Error detected:', error);
|
||||||
|
}
|
||||||
|
}, [isSubmitting, showAnalysisModal, analysisStarted, onAnnouncementClear, error]);
|
||||||
|
|
||||||
// Sequential progress - increment every few seconds
|
// Sequential progress - increment every few seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSubmitting || !analysisStarted) {
|
if (!isSubmitting || !analysisStarted) {
|
||||||
@@ -408,11 +438,11 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
|||||||
if (canSubmit && !isSubmitting) setShowAnalysisModal(true);
|
if (canSubmit && !isSubmitting) setShowAnalysisModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartAnalysis = () => {
|
const handleStartAnalysis = useCallback(() => {
|
||||||
setAnalysisStarted(true);
|
setAnalysisStarted(true);
|
||||||
setProgressIndex(0);
|
setProgressIndex(0);
|
||||||
submit();
|
submit();
|
||||||
};
|
}, [submit]);
|
||||||
|
|
||||||
const showProgressInModal = showAnalysisModal && (analysisStarted || isSubmitting);
|
const showProgressInModal = showAnalysisModal && (analysisStarted || isSubmitting);
|
||||||
|
|
||||||
@@ -465,6 +495,22 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
|||||||
<DialogContent sx={{ ...styles.dialogContent, ...(isMobile ? { px: 2, py: 2 } : {}) }}>
|
<DialogContent sx={{ ...styles.dialogContent, ...(isMobile ? { px: 2, py: 2 } : {}) }}>
|
||||||
{showProgressInModal ? (
|
{showProgressInModal ? (
|
||||||
<AnalysisProgressView currentMessage={announcement} progressIndex={progressIndex} />
|
<AnalysisProgressView currentMessage={announcement} progressIndex={progressIndex} />
|
||||||
|
) : error ? (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Alert
|
||||||
|
severity="error"
|
||||||
|
icon={<ErrorIcon />}
|
||||||
|
sx={{ bgcolor: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.3)", color: "#fecaca" }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontWeight={600} sx={{ color: "#fecaca" }}>
|
||||||
|
Error creating project
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "#fecaca", display: "block", mt: 1 }}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
<WhatYoullGetView isMobile={isMobile} />
|
||||||
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<WhatYoullGetView isMobile={isMobile} />
|
<WhatYoullGetView isMobile={isMobile} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Stack, Box, Typography, TextField, ToggleButton, ToggleButtonGroup, alpha } from "@mui/material";
|
import { Stack, Box, Typography, TextField, ToggleButton, ToggleButtonGroup, alpha, IconButton, Tooltip } from "@mui/material";
|
||||||
import { Person as PersonIcon, Group as GroupIcon, Settings as SettingsIcon } from "@mui/icons-material";
|
import { Person as PersonIcon, Group as GroupIcon, Settings as SettingsIcon, HelpOutline as HelpOutlineIcon, Headphones as HeadphonesIcon, Videocam as VideocamIcon } from "@mui/icons-material";
|
||||||
|
import { PodcastMode } from "../types";
|
||||||
|
import { PodcastModeInfoModal } from "./PodcastModeInfoModal";
|
||||||
|
|
||||||
interface PodcastConfigurationProps {
|
interface PodcastConfigurationProps {
|
||||||
duration: number;
|
duration: number;
|
||||||
setDuration: (value: number) => void;
|
setDuration: (value: number) => void;
|
||||||
speakers: number;
|
speakers: number;
|
||||||
setSpeakers: (value: number) => void;
|
setSpeakers: (value: number) => void;
|
||||||
|
podcastMode: PodcastMode;
|
||||||
|
setPodcastMode: (mode: PodcastMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
|
export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
|
||||||
@@ -14,7 +18,11 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
|
|||||||
setDuration,
|
setDuration,
|
||||||
speakers,
|
speakers,
|
||||||
setSpeakers,
|
setSpeakers,
|
||||||
|
podcastMode,
|
||||||
|
setPodcastMode,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [modeInfoOpen, setModeInfoOpen] = useState(false);
|
||||||
|
|
||||||
const handleDurationChange = (value: number) => {
|
const handleDurationChange = (value: number) => {
|
||||||
const clamped = Math.min(10, Math.max(1, value));
|
const clamped = Math.min(10, Math.max(1, value));
|
||||||
setDuration(clamped);
|
setDuration(clamped);
|
||||||
@@ -29,6 +37,21 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleModeChange = (
|
||||||
|
event: React.MouseEvent<HTMLElement>,
|
||||||
|
newValue: PodcastMode | null
|
||||||
|
) => {
|
||||||
|
if (newValue !== null) {
|
||||||
|
setPodcastMode(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const podcastModes: { value: PodcastMode; label: string; icon: React.ReactNode; color: string; desc: string }[] = [
|
||||||
|
{ value: "audio_only", label: "Audio", icon: <HeadphonesIcon fontSize="small" />, color: "#10b981", desc: "Audio podcast only" },
|
||||||
|
{ value: "video_only", label: "Video", icon: <VideocamIcon fontSize="small" />, color: "#f97316", desc: "AI avatar video" },
|
||||||
|
{ value: "audio_video", label: "Both", icon: <><HeadphonesIcon fontSize="small" /><VideocamIcon fontSize="small" /></>, color: "#8b5cf6", desc: "Audio + Video" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -98,6 +121,70 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
|
{/* Podcast Mode */}
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ display: "block", color: "#64748b", fontWeight: 600, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
|
Podcast Mode
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Learn about podcast modes">
|
||||||
|
<IconButton size="small" onClick={() => setModeInfoOpen(true)} sx={{ color: "#94a3b8", p: 0.25, "&:hover": { color: "#667eea" } }}>
|
||||||
|
<HelpOutlineIcon sx={{ fontSize: "0.9rem" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={podcastMode}
|
||||||
|
exclusive
|
||||||
|
onChange={handleModeChange}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
border: "2px solid rgba(102, 126, 234, 0.2)",
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 0.5,
|
||||||
|
"& .MuiToggleButton-root": {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
color: "#64748b",
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
py: 1,
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: alpha("#667eea", 0.08),
|
||||||
|
},
|
||||||
|
"&.Mui-selected": {
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.3)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{podcastModes.map((mode) => (
|
||||||
|
<ToggleButton key={mode.value} value={mode.value} aria-label={mode.label}>
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
|
{mode.icon}
|
||||||
|
<Typography variant="body2">{mode.label}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
<Typography variant="caption" sx={{ display: "block", mt: 1, color: podcastModes.find(m => m.value === podcastMode)?.color || "#64748b", fontSize: "0.75rem", fontWeight: 500 }}>
|
||||||
|
{podcastModes.find(m => m.value === podcastMode)?.desc}
|
||||||
|
{podcastMode === "audio_only" && " • No avatar needed • Lowest cost"}
|
||||||
|
{podcastMode === "video_only" && " • Requires avatar • Medium cost"}
|
||||||
|
{podcastMode === "audio_video" && " • Both formats • Highest cost"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Duration Input */}
|
{/* Duration Input */}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ display: "block", mb: 1, color: "#64748b", fontWeight: 600, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
<Typography variant="caption" sx={{ display: "block", mb: 1, color: "#64748b", fontWeight: 600, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
@@ -201,6 +288,8 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<PodcastModeInfoModal open={modeInfoOpen} onClose={() => setModeInfoOpen(false)} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
IconButton,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Stack,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
alpha,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Close as CloseIcon, HelpOutline as HelpOutlineIcon } from "@mui/icons-material";
|
||||||
|
|
||||||
|
export const PodcastModeInfoModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
color: "white",
|
||||||
|
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||||
|
Choosing Your Podcast Mode
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.8 }}>
|
||||||
|
Understand cost, duration, and best use cases for each mode
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.7, mt: 1 }}>
|
||||||
|
Select the right podcast mode based on your content type, target audience, and budget.
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={3} sx={{ mt: 1 }}>
|
||||||
|
{/* Mode Overview */}
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
🎙️ Podcast Modes Explained
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ color: "rgba(255,255,255,0.5)" }}>
|
||||||
|
<HelpOutlineIcon fontSize="small" />
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{[
|
||||||
|
{ mode: 'audio_only', icon: '🎧', label: 'Audio Only', desc: 'AI voice podcast. No video. Best for audio platforms.',
|
||||||
|
bg: '#f0fdf4', borderColor: '#10b981', textColor: '#166534' },
|
||||||
|
{ mode: 'video_only', icon: '🎬', label: 'Video Only', desc: 'AI avatar video. Best for YouTube and social media.',
|
||||||
|
bg: '#fff7ed', borderColor: '#f97316', textColor: '#9a3412' },
|
||||||
|
{ mode: 'audio_video', icon: '🎧+🎬', label: 'Both (Audio + Video)', desc: 'Generates both versions. Best for multi-platform distribution.',
|
||||||
|
bg: '#f5f3ff', borderColor: '#8b5cf6', textColor: '#6b21a8' },
|
||||||
|
].map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.mode}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `1px solid ${item.borderColor}`,
|
||||||
|
background: alpha(item.bg, 0.95),
|
||||||
|
opacity: 0.95
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||||
|
<Typography sx={{ fontSize: '1.5rem' }}>{item.icon}</Typography>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: item.textColor }}>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: item.textColor, fontSize: '0.875rem', opacity: 0.9 }}>
|
||||||
|
{item.desc}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.2)" }} />
|
||||||
|
|
||||||
|
{/* AI API Costs */}
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
💰 AI API Costs (Estimated per 5-min episode)
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{
|
||||||
|
p: 2,
|
||||||
|
background: alpha("#ffffff", 0.1),
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)'
|
||||||
|
}}>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.5, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'white' }}>Audio Only</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>TTS API (voice generation)</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip label="$0.50 - $1.50" size="small" sx={{ background: '#10b981', color: '#fff', fontWeight: 600 }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.5, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'white' }}>Video Only</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>TTS + Image Generation APIs</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip label="$3.00 - $8.00" size="small" sx={{ background: '#f97316', color: '#fff', fontWeight: 600 }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.5 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'white' }}>Both</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)" }}>TTS + Image + Video combination</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip label="$4.00 - $12.00" size="small" sx={{ background: '#8b5cf6', color: '#fff', fontWeight: 600 }} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block', fontStyle: 'italic' }}>
|
||||||
|
* Actual costs vary based on scene count, image resolution, and API provider
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.2)" }} />
|
||||||
|
|
||||||
|
{/* Max Duration & Optimization */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||||
|
⏱️ Maximum Duration & AI Optimization
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
|
||||||
|
<Chip label="Audio Only" size="small" sx={{ background: '#10b981', color: '#fff' }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#4ade80' }}>
|
||||||
|
Max 3-4 scenes per episode • Optimized with fewer API calls
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block', mb: 0.5 }}>
|
||||||
|
• Each scene: 800-1200 words (~1.5 min audio)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block', mb: 0.5 }}>
|
||||||
|
• Fewer API calls = lower cost
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• Rich, content-dense script for audio-only listening
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
|
||||||
|
<Chip label="Video Only" size="small" sx={{ background: '#f97316', color: '#fff' }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#fb923c' }}>
|
||||||
|
Max 5-6 scenes per episode • More visuals = more image costs
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block', mb: 0.5 }}>
|
||||||
|
• Each scene: 300-500 words (~30-45 sec)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block', mb: 0.5 }}>
|
||||||
|
• Shorter scripts for visual pacing + image rendering time
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.9)", display: 'block' }}>
|
||||||
|
• More scenes = more image generation API calls
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.2)" }} />
|
||||||
|
|
||||||
|
{/* When to Use */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||||
|
🎯 When to Choose Each Mode
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#4ade80', mb: 1 }}>
|
||||||
|
🎧 Choose Audio Only For:
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ gap: 0.5 }}>
|
||||||
|
{['Spotify & Podcasts', 'Low budget', 'Evergreen content', 'Commute listeners', 'Deep content'].map((item) => (
|
||||||
|
<Chip key={item} label={item} size="small" variant="outlined" sx={{ borderColor: '#4ade80', color: '#4ade80' }} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#fb923c', mb: 1 }}>
|
||||||
|
🎬 Choose Video Only For:
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ gap: 0.5 }}>
|
||||||
|
{['YouTube', 'Social Media', 'Personal Brand', 'Visual Content', 'Tutorials'].map((item) => (
|
||||||
|
<Chip key={item} label={item} size="small" variant="outlined" sx={{ borderColor: '#fb923c', color: '#fb923c' }} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2, background: alpha("#ffffff", 0.1), borderRadius: 2, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#a78bfa', mb: 1 }}>
|
||||||
|
🎧+🎬 Choose Both For:
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ gap: 0.5 }}>
|
||||||
|
{['Multi-platform', 'Max Reach', 'Content Repurpose', 'Premium Podcast'].map((item) => (
|
||||||
|
<Chip key={item} label={item} size="small" variant="outlined" sx={{ borderColor: '#a78bfa', color: '#a78bfa' }} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -112,7 +112,20 @@ const PodcastDashboard: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Header onShowProjects={() => setShowProjectList(true)} onNewEpisode={handleNewEpisode} />
|
<Header
|
||||||
|
onShowProjects={() => setShowProjectList(true)}
|
||||||
|
onNewEpisode={handleNewEpisode}
|
||||||
|
activeStep={workflow.activeStep}
|
||||||
|
completedSteps={[
|
||||||
|
...(analysis ? [0] : []),
|
||||||
|
...(research ? [1] : []),
|
||||||
|
...(scriptData ? [2] : []),
|
||||||
|
...(renderJobs.some(j => j.status === "completed") ? [3] : []),
|
||||||
|
]}
|
||||||
|
onStepClick={(step) => {
|
||||||
|
// Handle step clicks - could navigate to different views
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||||
|
|
||||||
@@ -232,6 +245,8 @@ const PodcastDashboard: React.FC = () => {
|
|||||||
idea={project?.idea}
|
idea={project?.idea}
|
||||||
duration={project?.duration}
|
duration={project?.duration}
|
||||||
speakers={project?.speakers}
|
speakers={project?.speakers}
|
||||||
|
voiceName={estimate?.voiceName}
|
||||||
|
podcastMode={project?.podcastMode}
|
||||||
avatarUrl={project?.avatarUrl}
|
avatarUrl={project?.avatarUrl}
|
||||||
avatarPrompt={project?.avatarPrompt}
|
avatarPrompt={project?.avatarPrompt}
|
||||||
bible={bible}
|
bible={bible}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Stack, Typography, Box, IconButton, Menu, MenuItem, Divider, ListItemIcon, ListItemText } from "@mui/material";
|
import { Stack, Typography, Box, IconButton, Menu, MenuItem, Divider, ListItemIcon, ListItemText, Collapse } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Mic as MicIcon,
|
Mic as MicIcon,
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
@@ -14,13 +14,17 @@ import {
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { PrimaryButton } from "../ui";
|
import { PrimaryButton } from "../ui";
|
||||||
import HeaderControls from "../../shared/HeaderControls";
|
import HeaderControls from "../../shared/HeaderControls";
|
||||||
|
import { ProgressStepper } from "./ProgressStepper";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onShowProjects: () => void;
|
onShowProjects: () => void;
|
||||||
onNewEpisode: () => void;
|
onNewEpisode: () => void;
|
||||||
|
activeStep?: number;
|
||||||
|
completedSteps?: number[];
|
||||||
|
onStepClick?: (stepIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode }) => {
|
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, activeStep = -1, completedSteps = [], onStepClick }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const isMenuOpen = Boolean(anchorEl);
|
const isMenuOpen = Boolean(anchorEl);
|
||||||
@@ -230,6 +234,17 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
|
|||||||
</Menu>
|
</Menu>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{/* Progress Stepper - integrated into header when active */}
|
||||||
|
<Collapse in={activeStep >= 0} timeout={400}>
|
||||||
|
<Box sx={{ mt: 1.5 }}>
|
||||||
|
<ProgressStepper
|
||||||
|
activeStep={activeStep}
|
||||||
|
completedSteps={completedSteps}
|
||||||
|
onStepClick={onStepClick}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, Paper, Stepper, Step, StepLabel, Typography, alpha } from "@mui/material";
|
import { Box, Stepper, Step, StepLabel, Typography, alpha } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Psychology as PsychologyIcon,
|
Psychology as PsychologyIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
@@ -10,22 +10,21 @@ import {
|
|||||||
|
|
||||||
interface ProgressStepperProps {
|
interface ProgressStepperProps {
|
||||||
activeStep: number;
|
activeStep: number;
|
||||||
completedSteps?: number[]; // Steps that have been completed (have data)
|
completedSteps?: number[];
|
||||||
onStepClick?: (stepIndex: number) => void;
|
onStepClick?: (stepIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
|
{ label: "Analyze", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
|
||||||
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
|
{ label: "Gather", icon: <SearchIcon />, description: "Gather facts and citations" },
|
||||||
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
|
{ label: "Write", icon: <EditNoteIcon />, description: "Edit and approve script" },
|
||||||
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
|
{ label: "Produce", icon: <PlayArrowIcon />, description: "Generate audio & video" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, completedSteps = [], onStepClick }) => {
|
export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, completedSteps = [], onStepClick }) => {
|
||||||
if (activeStep < 0) return null;
|
if (activeStep < 0) return null;
|
||||||
|
|
||||||
const handleStepClick = (stepIndex: number) => {
|
const handleStepClick = (stepIndex: number) => {
|
||||||
// Allow navigation to any completed step (has data), not just steps before active step
|
|
||||||
const isCompleted = completedSteps.includes(stepIndex);
|
const isCompleted = completedSteps.includes(stepIndex);
|
||||||
if (isCompleted && onStepClick) {
|
if (isCompleted && onStepClick) {
|
||||||
onStepClick(stepIndex);
|
onStepClick(stepIndex);
|
||||||
@@ -33,73 +32,60 @@ export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, co
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Stepper activeStep={activeStep} orientation="horizontal" sx={{ px: 1 }}>
|
||||||
sx={{
|
{steps.map((step, index) => {
|
||||||
p: 2.5,
|
const isCompleted = completedSteps.includes(index);
|
||||||
background: "#f8fafc",
|
const isClickable = isCompleted && onStepClick !== undefined;
|
||||||
border: "1px solid rgba(0,0,0,0.08)",
|
|
||||||
borderRadius: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stepper activeStep={activeStep} orientation="horizontal">
|
|
||||||
{steps.map((step, index) => {
|
|
||||||
const isCompleted = completedSteps.includes(index);
|
|
||||||
const isClickable = isCompleted && onStepClick !== undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Step key={step.label} completed={isCompleted}>
|
|
||||||
<StepLabel
|
|
||||||
onClick={() => handleStepClick(index)}
|
|
||||||
sx={{
|
|
||||||
cursor: isClickable ? "pointer" : "default",
|
|
||||||
"&:hover": isClickable
|
|
||||||
? {
|
|
||||||
"& .MuiStepLabel-label": {
|
|
||||||
color: "#667eea",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
}}
|
|
||||||
StepIconComponent={({ active, completed }) => (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "50%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: completed
|
|
||||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
|
||||||
: active
|
|
||||||
? alpha("#667eea", 0.15)
|
|
||||||
: "#e2e8f0",
|
|
||||||
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
|
|
||||||
color: completed || active ? "#fff" : "#64748b",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
...(isClickable && {
|
|
||||||
cursor: "pointer",
|
|
||||||
"&:hover": {
|
|
||||||
transform: "scale(1.05)",
|
|
||||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{completed ? <CheckCircleIcon /> : step.icon}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2">{step.label}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{step.description}
|
|
||||||
</Typography>
|
|
||||||
</StepLabel>
|
|
||||||
</Step>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stepper>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Step key={step.label} completed={isCompleted}>
|
||||||
|
<StepLabel
|
||||||
|
onClick={() => handleStepClick(index)}
|
||||||
|
sx={{
|
||||||
|
cursor: isClickable ? "pointer" : "default",
|
||||||
|
"&:hover": isClickable
|
||||||
|
? {
|
||||||
|
"& .MuiStepLabel-label": {
|
||||||
|
color: "#667eea",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
}}
|
||||||
|
StepIconComponent={({ active, completed }) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: completed
|
||||||
|
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||||
|
: active
|
||||||
|
? alpha("#667eea", 0.15)
|
||||||
|
: "#e2e8f0",
|
||||||
|
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
|
||||||
|
color: completed || active ? "#fff" : "#64748b",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
...(isClickable && {
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": {
|
||||||
|
transform: "scale(1.08)",
|
||||||
|
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{completed ? <CheckCircleIcon sx={{ fontSize: 20 }} /> : React.cloneElement(step.icon, { fontSize: "small" })}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: "0.75rem" }}>{step.label}</Typography>
|
||||||
|
</StepLabel>
|
||||||
|
</Step>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stepper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ListItem,
|
ListItem,
|
||||||
ListItemButton,
|
ListItemButton,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
@@ -22,12 +23,33 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
TextField,
|
TextField,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
CircularProgress,
|
||||||
|
LinearProgress,
|
||||||
|
Divider,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, Edit as EditIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Help as HelpIcon } from "@mui/icons-material";
|
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, Edit as EditIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Help as HelpIcon, TrendingUp as TrendingUpIcon, Psychology as PsychologyIcon, FactCheck as FactCheckIcon, MenuBook as MenuBookIcon } from "@mui/icons-material";
|
||||||
import { ResearchProvider } from "../../../services/blogWriterApi";
|
import { ResearchProvider } from "../../../services/blogWriterApi";
|
||||||
import { Query } from "../types";
|
import { Query } from "../types";
|
||||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "../ui";
|
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "../ui";
|
||||||
|
|
||||||
|
const RESEARCH_FEATURES = [
|
||||||
|
{ icon: <TrendingUpIcon />, text: "Latest trends & statistics from the web" },
|
||||||
|
{ icon: <FactCheckIcon />, text: "Verified facts with source citations" },
|
||||||
|
{ icon: <MenuBookIcon />, text: "Case studies & real-world examples" },
|
||||||
|
{ icon: <PsychologyIcon />, text: "Audience insights & pain points" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESEARCH_MESSAGES = [
|
||||||
|
{ title: "Connecting to Research Engine", message: "Initializing neural search to gather fresh insights..." },
|
||||||
|
{ title: "Searching the Web", message: "Scanning thousands of sources for relevant data..." },
|
||||||
|
{ title: "Analyzing Content", message: "Extracting key facts, statistics, and trends..." },
|
||||||
|
{ title: "Verifying Information", message: "Cross-referencing sources to ensure accuracy..." },
|
||||||
|
{ title: "Synthesizing Insights", message: "Compiling findings into actionable research cards..." },
|
||||||
|
{ title: "Finalizing Research", message: "Organizing insights for your podcast episode..." },
|
||||||
|
];
|
||||||
|
|
||||||
interface QuerySelectionProps {
|
interface QuerySelectionProps {
|
||||||
queries: Query[];
|
queries: Query[];
|
||||||
selectedQueries: Set<string>;
|
selectedQueries: Set<string>;
|
||||||
@@ -41,6 +63,7 @@ interface QuerySelectionProps {
|
|||||||
onDeleteQuery: (id: string) => void;
|
onDeleteQuery: (id: string) => void;
|
||||||
analysis: any;
|
analysis: any;
|
||||||
idea: string;
|
idea: string;
|
||||||
|
researchAnnouncement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||||
@@ -56,7 +79,46 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
|||||||
onDeleteQuery,
|
onDeleteQuery,
|
||||||
analysis,
|
analysis,
|
||||||
idea,
|
idea,
|
||||||
|
researchAnnouncement,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [showResearchModal, setShowResearchModal] = useState(false);
|
||||||
|
const [researchStarted, setResearchStarted] = useState(false);
|
||||||
|
const [progressIndex, setProgressIndex] = useState(0);
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const prevIsResearchingRef = useRef(isResearching);
|
||||||
|
|
||||||
|
// Close modal only when research actually completes (transitions from true to false)
|
||||||
|
useEffect(() => {
|
||||||
|
const wasResearching = prevIsResearchingRef.current;
|
||||||
|
const nowNotResearching = !isResearching;
|
||||||
|
|
||||||
|
if (showResearchModal && researchStarted && wasResearching && nowNotResearching) {
|
||||||
|
setTimeout(() => setShowResearchModal(false), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevIsResearchingRef.current = isResearching;
|
||||||
|
}, [isResearching, showResearchModal, researchStarted]);
|
||||||
|
|
||||||
|
// Progress message cycling
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isResearching || !researchStarted) {
|
||||||
|
setProgressIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setProgressIndex((prev) => (prev < RESEARCH_MESSAGES.length - 1 ? prev + 1 : prev));
|
||||||
|
}, 4000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isResearching, researchStarted]);
|
||||||
|
|
||||||
|
const handleStartResearch = () => {
|
||||||
|
setResearchStarted(true);
|
||||||
|
setProgressIndex(0);
|
||||||
|
onRunResearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showProgressInModal = showResearchModal && (researchStarted || isResearching);
|
||||||
const [showRegenDialog, setShowRegenDialog] = useState(false);
|
const [showRegenDialog, setShowRegenDialog] = useState(false);
|
||||||
const [regenFeedback, setRegenFeedback] = useState("");
|
const [regenFeedback, setRegenFeedback] = useState("");
|
||||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
@@ -281,7 +343,7 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={onRunResearch}
|
onClick={() => setShowResearchModal(true)}
|
||||||
disabled={selectedCount === 0 || isResearching}
|
disabled={selectedCount === 0 || isResearching}
|
||||||
loading={isResearching}
|
loading={isResearching}
|
||||||
startIcon={<SearchIcon />}
|
startIcon={<SearchIcon />}
|
||||||
@@ -291,7 +353,7 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
|||||||
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
|
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isResearching ? "Running Research..." : selectedCount === 0 ? "Next: Select Query" : "Run Research"}
|
{isResearching ? "Running Research..." : selectedCount === 0 ? "Next: Select Query" : "Start Neural Research"}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -357,8 +419,152 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
|||||||
Generate New Queries
|
Generate New Queries
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Research Progress Modal */}
|
||||||
|
<Dialog
|
||||||
|
open={showResearchModal}
|
||||||
|
onClose={() => !isResearching && setShowResearchModal(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={isMobile}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||||
|
border: "1px solid rgba(96, 165, 250, 0.3)",
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1, fontSize: isMobile ? "1rem" : "1.25rem" }}>
|
||||||
|
{isResearching ? (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<CircularProgress size={20} sx={{ color: "#60a5fa" }} />
|
||||||
|
Neural Research in Progress
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<SearchIcon sx={{ color: "#60a5fa" }} />
|
||||||
|
What You'll Get
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent sx={{ color: "rgba(255,255,255,0.8)", minHeight: 200, py: 2, px: { xs: 2, sm: 3 }, maxHeight: { xs: "80vh", sm: "70vh" }, overflowY: "auto" }}>
|
||||||
|
{showProgressInModal ? (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ textAlign: "center" }}>
|
||||||
|
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", mb: 2 }}>
|
||||||
|
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#60a5fa" }} />
|
||||||
|
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||||
|
<SearchIcon sx={{ color: "#60a5fa", fontSize: isMobile ? 20 : 24 }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ color: "#60a5fa", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
|
||||||
|
{RESEARCH_MESSAGES[Math.min(progressIndex, RESEARCH_MESSAGES.length - 1)].title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
|
||||||
|
{RESEARCH_MESSAGES[Math.min(progressIndex, RESEARCH_MESSAGES.length - 1)].message}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{researchAnnouncement && researchAnnouncement !== RESEARCH_MESSAGES[Math.min(progressIndex, RESEARCH_MESSAGES.length - 1)].message && (
|
||||||
|
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
|
||||||
|
{researchAnnouncement}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: "rgba(255,255,255,0.1)",
|
||||||
|
mt: 2,
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: "#60a5fa", borderRadius: 2 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
|
||||||
|
Step {Math.min(progressIndex, RESEARCH_MESSAGES.length - 1) + 1} of {RESEARCH_MESSAGES.length}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Why Neural Research?
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{[
|
||||||
|
"Fresh web data — bypasses LLM training cutoff",
|
||||||
|
"Reduces AI hallucinations with verified sources",
|
||||||
|
"Real-time trends and current statistics",
|
||||||
|
"Citation-backed facts for credibility",
|
||||||
|
].map((item, idx) => (
|
||||||
|
<Box key={idx} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<CheckCircleIcon sx={{ fontSize: 14, color: "#10b981" }} />
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.75rem" }}>
|
||||||
|
{item}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="body2" sx={{ mb: 2, color: "rgba(255,255,255,0.7)", fontSize: isMobile ? "0.85rem" : "0.9rem" }}>
|
||||||
|
Click "Start Research" to gather AI-powered insights. Here's what we'll find for you:
|
||||||
|
</Typography>
|
||||||
|
<List>
|
||||||
|
{RESEARCH_FEATURES.map((feature, index) => (
|
||||||
|
<ListItem key={index} sx={{ px: 0, py: 0.5 }}>
|
||||||
|
<ListItemIcon sx={{ minWidth: 36, color: "#60a5fa" }}>{feature.icon}</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={feature.text}
|
||||||
|
primaryTypographyProps={{ sx: { color: "rgba(255,255,255,0.9)", fontSize: isMobile ? "0.8rem" : "0.9rem" } }}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(16, 185, 129, 0.1)", border: "1px solid rgba(16, 185, 129, 0.2)" }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<CheckCircleIcon sx={{ color: "#10b981", fontSize: 18, mt: 0.25 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ color: "#10b981", fontWeight: 600, fontSize: "0.85rem" }}>
|
||||||
|
Research Benefits
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.75rem", display: "block", mt: 0.5 }}>
|
||||||
|
• Up-to-date information beyond LLM training data<br/>
|
||||||
|
• Reduces fact-checking time significantly<br/>
|
||||||
|
• Credible sources boost listener trust<br/>
|
||||||
|
• Helps AI script sound expert and authoritative
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||||
|
{showProgressInModal ? null : (
|
||||||
|
<>
|
||||||
|
<SecondaryButton onClick={() => setShowResearchModal(false)}>Cancel</SecondaryButton>
|
||||||
|
<PrimaryButton onClick={handleStartResearch} startIcon={<SearchIcon />}>
|
||||||
|
Start Research
|
||||||
|
</PrimaryButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</GlassyCard>
|
</GlassyCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
LinearProgress,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
AutoAwesome as AutoAwesomeIcon,
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
Psychology as PsychologyIcon,
|
||||||
|
Insights as InsightsIcon,
|
||||||
|
Article as ArticleIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
VolumeUp as VolumeUpIcon,
|
||||||
|
VideoLibrary as VideoLibraryIcon,
|
||||||
|
Lightbulb as LightbulbIcon,
|
||||||
|
Search as SearchIcon,
|
||||||
|
FactCheck as FactCheckIcon,
|
||||||
|
School as SchoolIcon,
|
||||||
|
Update as UpdateIcon,
|
||||||
|
Bolt as BoltIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
|
const RESEARCH_MESSAGES = [
|
||||||
|
{ title: "Starting Research", message: "Preparing research queries and configuring search parameters..." },
|
||||||
|
{ title: "Searching Web", message: "Searching the web for relevant content, statistics, and latest developments..." },
|
||||||
|
{ title: "Analyzing Results", message: "Analyzing search results for key insights and factual information..." },
|
||||||
|
{ title: "Extracting Insights", message: "Extracting valuable insights, quotes, and data from verified sources..." },
|
||||||
|
{ title: "Validating Facts", message: "Cross-referencing information to ensure accuracy and credibility..." },
|
||||||
|
{ title: "Final Review", message: "Finalizing research data and preparing comprehensive summary..." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESEARCH_BENEFITS = [
|
||||||
|
{
|
||||||
|
title: "Prevents AI Hallucinations",
|
||||||
|
description: "Research provides factual grounding so AI doesn't make up information",
|
||||||
|
icon: <BoltIcon />,
|
||||||
|
color: "#f59e0b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Latest Information",
|
||||||
|
description: "Gets up-to-date facts, statistics, and developments beyond AI's training date",
|
||||||
|
icon: <UpdateIcon />,
|
||||||
|
color: "#3b82f6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Credible Sources",
|
||||||
|
description: "Cites authoritative sources to build trust with your audience",
|
||||||
|
icon: <SchoolIcon />,
|
||||||
|
color: "#10b981",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESEARCH_STATS_CONFIG = [
|
||||||
|
{ label: "Queries", key: "searchQueries", icon: <SearchIcon />, color: "#a78bfa" },
|
||||||
|
{ label: "Sources", key: "sourceCount", icon: <ArticleIcon />, color: "#34d399", isNumber: true },
|
||||||
|
{ label: "Insights", key: "keyInsights", icon: <InsightsIcon />, color: "#f59e0b" },
|
||||||
|
{ label: "Facts", key: "factCards", icon: <FactCheckIcon />, color: "#60a5fa" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PODCAST_CREATION_JOURNEY = [
|
||||||
|
{
|
||||||
|
phase: "Analyze",
|
||||||
|
icon: <AutoAwesomeIcon />,
|
||||||
|
color: "#a78bfa",
|
||||||
|
description: "AI understands your topic and target audience",
|
||||||
|
benefit: "Identifies key themes and angles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Research",
|
||||||
|
icon: <SearchIcon />,
|
||||||
|
color: "#60a5fa",
|
||||||
|
description: "Gathers facts, statistics, and latest insights",
|
||||||
|
benefit: "Evidence-based content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Generate Script",
|
||||||
|
icon: <EditIcon />,
|
||||||
|
color: "#34d399",
|
||||||
|
description: "Transforms research into structured script",
|
||||||
|
benefit: "Factual, engaging content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Final Render",
|
||||||
|
icon: <VideoLibraryIcon />,
|
||||||
|
color: "#ef4444",
|
||||||
|
description: "Your ready-to-publish podcast episode",
|
||||||
|
benefit: "Professional output"
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ResearchProgressViewProps {
|
||||||
|
currentMessage?: string;
|
||||||
|
progressIndex: number;
|
||||||
|
searchQueries?: string[];
|
||||||
|
provider?: string;
|
||||||
|
searchType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResearchProgressView: React.FC<ResearchProgressViewProps> = ({
|
||||||
|
currentMessage,
|
||||||
|
progressIndex,
|
||||||
|
searchQueries,
|
||||||
|
provider,
|
||||||
|
searchType,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const clampedIndex = Math.min(progressIndex, RESEARCH_MESSAGES.length - 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* Current Status */}
|
||||||
|
<Box sx={{ textAlign: "center" }}>
|
||||||
|
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#60a5fa" }} />
|
||||||
|
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||||
|
<SearchIcon sx={{ color: "#60a5fa", fontSize: isMobile ? 20 : 24 }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ color: "#60a5fa", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
|
||||||
|
{RESEARCH_MESSAGES[clampedIndex].title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
|
||||||
|
{currentMessage || RESEARCH_MESSAGES[clampedIndex].message}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{currentMessage && (
|
||||||
|
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
|
||||||
|
{currentMessage}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: "rgba(255,255,255,0.1)",
|
||||||
|
mt: 2,
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: "#60a5fa", borderRadius: 2 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
|
||||||
|
Step {clampedIndex + 1} of {RESEARCH_MESSAGES.length}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
{/* Why Research Matters */}
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Why Research Matters
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{RESEARCH_BENEFITS.map((benefit, idx) => (
|
||||||
|
<Box key={idx} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<Box sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: `${benefit.color}20`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{React.cloneElement(benefit.icon, { sx: { color: benefit.color, fontSize: 14 } })}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.75rem", display: "block" }}>
|
||||||
|
{benefit.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>
|
||||||
|
{benefit.description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
{/* Search Info */}
|
||||||
|
{(provider || searchType || searchQueries) && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Research Configuration
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
{provider && (
|
||||||
|
<Box sx={{ flex: "1 1 auto", minWidth: 80, p: 1, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", textAlign: "center" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>Provider</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem", textTransform: "uppercase" }}>
|
||||||
|
{provider}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{searchType && (
|
||||||
|
<Box sx={{ flex: "1 1 auto", minWidth: 80, p: 1, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", textAlign: "center" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>Search Type</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem", textTransform: "capitalize" }}>
|
||||||
|
{searchType}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{searchQueries && (
|
||||||
|
<Box sx={{ flex: "1 1 auto", minWidth: 80, p: 1, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", textAlign: "center" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>Queries</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
|
||||||
|
{searchQueries.length}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sequential Progress Steps */}
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Research Progress
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
{RESEARCH_MESSAGES.map((msg, idx) => {
|
||||||
|
const isCompleted = idx < clampedIndex;
|
||||||
|
const isCurrent = idx === clampedIndex;
|
||||||
|
return (
|
||||||
|
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<Box sx={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: isCompleted ? "#10b981" : isCurrent ? "#60a5fa" : "rgba(255,255,255,0.1)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircleIcon sx={{ fontSize: 12, color: "#fff" }} />
|
||||||
|
) : isCurrent ? (
|
||||||
|
<CircularProgress size={10} sx={{ color: "#fff" }} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: 4, height: 4, borderRadius: "50%", bgcolor: "rgba(255,255,255,0.3)" }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{
|
||||||
|
color: isCompleted ? "rgba(255,255,255,0.5)" : isCurrent ? "#60a5fa" : "rgba(255,255,255,0.6)",
|
||||||
|
fontWeight: isCurrent ? 600 : 400,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
textDecoration: isCompleted ? "line-through" : "none",
|
||||||
|
}}>
|
||||||
|
{msg.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
{/* Journey Overview */}
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Your Podcast Journey
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{PODCAST_CREATION_JOURNEY.map((phase, idx) => (
|
||||||
|
<Box key={idx} sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<Box sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: `${phase.color}20`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{React.cloneElement(phase.icon, { sx: { color: phase.color, fontSize: 16 } })}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
|
||||||
|
{phase.phase}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.7rem", display: "block" }}>
|
||||||
|
{phase.description}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: phase.color, fontSize: "0.65rem", display: "block", mt: 0.25 }}>
|
||||||
|
✓ {phase.benefit}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -47,6 +47,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
setRenderJobs,
|
setRenderJobs,
|
||||||
initializeProject,
|
initializeProject,
|
||||||
setBible,
|
setBible,
|
||||||
|
setBackendProjectCreated,
|
||||||
} = projectState;
|
} = projectState;
|
||||||
|
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
@@ -123,21 +124,55 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
|
|
||||||
let dbProject: any = null;
|
let dbProject: any = null;
|
||||||
try {
|
try {
|
||||||
dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
|
if (project) {
|
||||||
} catch (initError: any) {
|
// Existing project - mark as DB-created (it was loaded from DB)
|
||||||
const errorStr = initError?.message || "";
|
setBackendProjectCreated(true);
|
||||||
if (errorStr.includes("DUPLICATE_IDEA")) {
|
dbProject = null;
|
||||||
try {
|
} else {
|
||||||
const dupData = JSON.parse(errorStr);
|
dbProject = await initializeProject(payload, projectId, avatarUrl);
|
||||||
const existingId = dupData.existing_project_id;
|
|
||||||
const existingIdea = dupData.existing_idea;
|
|
||||||
setAnnouncement("");
|
|
||||||
// Throw error to trigger UI modal
|
|
||||||
throw new Error(`DUPLICATE_IDEA:${existingId}:${existingIdea}`);
|
|
||||||
} catch (parseErr) {
|
|
||||||
console.error("Failed to parse duplicate idea error:", parseErr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (initError: any) {
|
||||||
|
setBackendProjectCreated(false);
|
||||||
|
const errorStr = initError?.message || initError?.toString() || "";
|
||||||
|
if (errorStr.includes("DUPLICATE_IDEA") || errorStr.includes("existing_project_id") || errorStr.includes("409")) {
|
||||||
|
setAnnouncement("");
|
||||||
|
// Parse error message to extract existing project info
|
||||||
|
// Format: "DUPLICATE_IDEA:podcast_123:Some idea..." or the full error response
|
||||||
|
let existingId = "";
|
||||||
|
let existingIdea = "";
|
||||||
|
|
||||||
|
// Try extracting from "DUPLICATE_IDEA:projectid:idea" format
|
||||||
|
if (errorStr.includes("DUPLICATE_IDEA:")) {
|
||||||
|
const parts = errorStr.split("DUPLICATE_IDEA:")[1];
|
||||||
|
if (parts) {
|
||||||
|
const colonIdx = parts.indexOf(":");
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
existingId = parts.substring(0, colonIdx).trim();
|
||||||
|
existingIdea = parts.substring(colonIdx + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still empty, try regex on full error response
|
||||||
|
if (!existingId) {
|
||||||
|
const idMatch = errorStr.match(/project_id["']?\s*[:=]\s*["']?([^"'$,\s]+)/);
|
||||||
|
existingId = idMatch ? idMatch[1].trim() : "";
|
||||||
|
}
|
||||||
|
if (!existingIdea) {
|
||||||
|
const ideaMatch = errorStr.match(/idea["']?\s*[:=]\s*["']([^"']+)["']/);
|
||||||
|
existingIdea = ideaMatch ? ideaMatch[1].trim() : "Similar project already exists";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[handleCreate] Duplicate project found:", existingId, existingIdea);
|
||||||
|
// Set the dialog info and show the dialog
|
||||||
|
setShowDuplicateDialog(true);
|
||||||
|
setDuplicateProjectInfo({
|
||||||
|
projectId: existingId || "unknown",
|
||||||
|
idea: existingIdea || "Similar project already exists"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Re-throw other errors to be handled by the outer catch
|
||||||
throw initError;
|
throw initError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,24 +188,37 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the project in database with the analysis results
|
// Update the project in database with the analysis results
|
||||||
try {
|
// If dbProject exists, update it. Otherwise use localStorage fallback
|
||||||
await podcastApi.updateProject(projectId, {
|
if (dbProject) {
|
||||||
analysis: result.analysis,
|
try {
|
||||||
estimate: result.estimate,
|
await podcastApi.updateProject(projectId, {
|
||||||
queries: result.queries,
|
analysis: result.analysis,
|
||||||
selected_queries: [], // Don't auto-select - user must choose manually
|
estimate: result.estimate,
|
||||||
avatar_url: result.avatar_url,
|
queries: result.queries,
|
||||||
avatar_prompt: result.avatar_prompt,
|
selected_queries: [],
|
||||||
});
|
avatar_url: result.avatar_url,
|
||||||
} catch (error) {
|
avatar_prompt: result.avatar_prompt,
|
||||||
console.error('Failed to update project with analysis results:', error);
|
});
|
||||||
|
setBackendProjectCreated(true);
|
||||||
|
console.log("[handleCreate] DB project created and updated successfully");
|
||||||
|
} catch (updateErr) {
|
||||||
|
console.warn("[handleCreate] updateProject failed, using localStorage fallback:", updateErr);
|
||||||
|
// Fall back to localStorage only
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// DB not created (initializeProject failed or returned null) - use localStorage only
|
||||||
|
console.warn("[handleCreate] DB project not created - using localStorage only");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as created in local state so sync doesn't try to create later
|
||||||
|
setBackendProjectCreated(true);
|
||||||
|
|
||||||
setProject({
|
setProject({
|
||||||
id: projectId,
|
id: projectId,
|
||||||
idea: payload.ideaOrUrl,
|
idea: payload.ideaOrUrl,
|
||||||
duration: payload.duration,
|
duration: payload.duration,
|
||||||
speakers: payload.speakers,
|
speakers: payload.speakers,
|
||||||
|
podcastMode: payload.podcastMode,
|
||||||
avatarUrl: result.avatar_url || avatarUrl,
|
avatarUrl: result.avatar_url || avatarUrl,
|
||||||
avatarPrompt: result.avatar_prompt || null,
|
avatarPrompt: result.avatar_prompt || null,
|
||||||
avatarPersonaId: null,
|
avatarPersonaId: null,
|
||||||
@@ -184,8 +232,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
setBudgetCap(payload.budgetCap);
|
setBudgetCap(payload.budgetCap);
|
||||||
|
|
||||||
// Generate presenters AFTER analysis completes (to use analysis insights)
|
// Generate presenters AFTER analysis completes (to use analysis insights)
|
||||||
// This happens only if no avatar was uploaded
|
// Only if no avatar was uploaded AND analysis didn't already generate one AND not audio_only
|
||||||
if (!avatarUrl && payload.speakers > 0 && result.analysis) {
|
if (payload.podcastMode !== "audio_only" && !avatarUrl && !result.avatar_url && payload.speakers > 0 && result.analysis) {
|
||||||
try {
|
try {
|
||||||
setAnnouncement("Generating presenter avatars using AI insights...");
|
setAnnouncement("Generating presenter avatars using AI insights...");
|
||||||
const presentersResponse = await podcastApi.generatePresenters(
|
const presentersResponse = await podcastApi.generatePresenters(
|
||||||
@@ -204,6 +252,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
idea: payload.ideaOrUrl,
|
idea: payload.ideaOrUrl,
|
||||||
duration: payload.duration,
|
duration: payload.duration,
|
||||||
speakers: payload.speakers,
|
speakers: payload.speakers,
|
||||||
|
podcastMode: payload.podcastMode,
|
||||||
avatarUrl: firstAvatar.avatar_url,
|
avatarUrl: firstAvatar.avatar_url,
|
||||||
avatarPrompt: prompt,
|
avatarPrompt: prompt,
|
||||||
avatarPersonaId: firstAvatar.persona_id || presentersResponse.persona_id || null,
|
avatarPersonaId: firstAvatar.persona_id || presentersResponse.persona_id || null,
|
||||||
@@ -216,7 +265,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
// Continue without presenters - can generate later
|
// Continue without presenters - can generate later
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setAnnouncement("Analysis complete");
|
const audioOnlyNote = payload.podcastMode === "audio_only" ? " (audio-only mode)" : "";
|
||||||
|
setAnnouncement(`Analysis complete${audioOnlyNote}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Handle duplicate idea error
|
// Handle duplicate idea error
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Typography, Paper, Box, Button, CircularProgress, LinearProgress, Alert, alpha } from "@mui/material";
|
||||||
|
import {
|
||||||
|
VideoLibrary as VideoLibraryIcon,
|
||||||
|
Download as DownloadIcon,
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { Script } from "../types";
|
||||||
|
|
||||||
|
interface RenderQueueFinalExportPanelProps {
|
||||||
|
script: Script;
|
||||||
|
allVideosReady: boolean;
|
||||||
|
finalVideoUrl: string | null;
|
||||||
|
finalVideoBlobUrl: string | null;
|
||||||
|
combiningVideos: boolean;
|
||||||
|
combiningProgress: { progress: number; message: string } | null;
|
||||||
|
onCombineFinalVideo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RenderQueueFinalExportPanel: React.FC<RenderQueueFinalExportPanelProps> = ({
|
||||||
|
script,
|
||||||
|
allVideosReady,
|
||||||
|
finalVideoUrl,
|
||||||
|
finalVideoBlobUrl,
|
||||||
|
combiningVideos,
|
||||||
|
combiningProgress,
|
||||||
|
onCombineFinalVideo,
|
||||||
|
}) => {
|
||||||
|
if (!allVideosReady) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
mt: 4,
|
||||||
|
p: 4,
|
||||||
|
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(6, 182, 212, 0.05) 100%)",
|
||||||
|
border: "2px solid",
|
||||||
|
borderColor: finalVideoUrl ? "success.main" : "info.light",
|
||||||
|
borderRadius: 3,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: "4px",
|
||||||
|
background: finalVideoUrl
|
||||||
|
? "linear-gradient(90deg, #10b981 0%, #06b6d4 100%)"
|
||||||
|
: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Header */}
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: finalVideoUrl
|
||||||
|
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||||
|
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{finalVideoUrl ? (
|
||||||
|
<CheckCircleIcon sx={{ color: "white", fontSize: 32 }} />
|
||||||
|
) : (
|
||||||
|
<VideoLibraryIcon sx={{ color: "white", fontSize: 32 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#0f172a",
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{finalVideoUrl ? "Final Podcast Ready!" : "Final Podcast Export"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||||
|
{finalVideoUrl
|
||||||
|
? "Your complete podcast video is ready to download"
|
||||||
|
: `Combine ${script.scenes.length} scene videos into one final podcast`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{finalVideoUrl ? (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Alert
|
||||||
|
severity="success"
|
||||||
|
icon={<CheckCircleIcon />}
|
||||||
|
sx={{
|
||||||
|
background: alpha("#10b981", 0.1),
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: alpha("#10b981", 0.3),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
Your final podcast video has been created successfully!
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Video Preview */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 900,
|
||||||
|
mx: "auto",
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: alpha("#10b981", 0.2),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
src={finalVideoBlobUrl || finalVideoUrl}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "block",
|
||||||
|
backgroundColor: "#000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Your browser does not support video playback.
|
||||||
|
</video>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Download Button */}
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="center" sx={{ pt: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
if (finalVideoBlobUrl) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = finalVideoBlobUrl;
|
||||||
|
link.download = `podcast-final-${Date.now()}.mp4`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 4,
|
||||||
|
py: 1.5,
|
||||||
|
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
|
||||||
|
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.4)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "linear-gradient(135deg, #059669 0%, #047857 100%)",
|
||||||
|
boxShadow: "0 6px 16px rgba(16, 185, 129, 0.5)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download Final Podcast
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
sx={{
|
||||||
|
background: alpha("#3b82f6", 0.08),
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: alpha("#3b82f6", 0.2),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>Ready to export!</strong> Click below to combine all {script.scenes.length} scene videos into your final podcast video.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{combiningVideos && (
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: "#0f172a" }}>
|
||||||
|
{combiningProgress?.message || "Combining videos..."}
|
||||||
|
</Typography>
|
||||||
|
{combiningProgress && (
|
||||||
|
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600 }}>
|
||||||
|
{combiningProgress.progress.toFixed(0)}%
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<LinearProgress
|
||||||
|
variant={combiningProgress ? "determinate" : "indeterminate"}
|
||||||
|
value={combiningProgress?.progress || 0}
|
||||||
|
sx={{
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: alpha("#667eea", 0.1),
|
||||||
|
"& .MuiLinearProgress-bar": {
|
||||||
|
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{combiningProgress && combiningProgress.progress < 100 && (
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", mt: 0.5, display: "block" }}>
|
||||||
|
Video encoding in progress. This may take a few minutes...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
startIcon={combiningVideos ? <CircularProgress size={20} sx={{ color: "white" }} /> : <VideoLibraryIcon />}
|
||||||
|
onClick={onCombineFinalVideo}
|
||||||
|
disabled={combiningVideos}
|
||||||
|
sx={{
|
||||||
|
py: 2,
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.4)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%)",
|
||||||
|
boxShadow: "0 6px 16px rgba(102, 126, 234, 0.5)",
|
||||||
|
},
|
||||||
|
"&:disabled": {
|
||||||
|
background: alpha("#667eea", 0.5),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{combiningVideos ? "Combining Videos..." : "Combine Scenes into Final Video"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Typography, Paper, Box, alpha } from "@mui/material";
|
||||||
|
import {
|
||||||
|
PlayArrow as PlayArrowIcon,
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { Script, Job } from "../types";
|
||||||
|
|
||||||
|
interface RenderQueueStatusDashboardProps {
|
||||||
|
script: Script;
|
||||||
|
allVideosReady: boolean;
|
||||||
|
allScenesCompleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RenderQueueStatusDashboard: React.FC<RenderQueueStatusDashboardProps> = ({
|
||||||
|
script,
|
||||||
|
allVideosReady,
|
||||||
|
allScenesCompleted,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mb: 3,
|
||||||
|
p: 2,
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid rgba(0,0,0,0.08)",
|
||||||
|
borderRadius: 3,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.02)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||||
|
{/* Status Chips */}
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
|
{/* Scenes Count */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: alpha("#6366f1", 0.08),
|
||||||
|
color: "#4f46e5",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: alpha("#6366f1", 0.2),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" fontWeight={700} sx={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
|
Scenes
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800}>
|
||||||
|
{script.scenes.length}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Audio Status */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: script.scenes.every(s => s.audioUrl)
|
||||||
|
? alpha("#10b981", 0.1)
|
||||||
|
: alpha("#f59e0b", 0.1),
|
||||||
|
color: script.scenes.every(s => s.audioUrl) ? "#059669" : "#d97706",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: script.scenes.every(s => s.audioUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" fontWeight={700}>
|
||||||
|
Audio
|
||||||
|
</Typography>
|
||||||
|
{script.scenes.every(s => s.audioUrl) ? (
|
||||||
|
<CheckCircleIcon sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<Typography variant="subtitle2" fontWeight={800}>
|
||||||
|
{script.scenes.filter(s => s.audioUrl).length}/{script.scenes.length}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Images Status */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: script.scenes.every(s => s.imageUrl)
|
||||||
|
? alpha("#10b981", 0.1)
|
||||||
|
: alpha("#f59e0b", 0.1),
|
||||||
|
color: script.scenes.every(s => s.imageUrl) ? "#059669" : "#d97706",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: script.scenes.every(s => s.imageUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" fontWeight={700}>
|
||||||
|
Images
|
||||||
|
</Typography>
|
||||||
|
{script.scenes.every(s => s.imageUrl) ? (
|
||||||
|
<CheckCircleIcon sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<Typography variant="subtitle2" fontWeight={800}>
|
||||||
|
{script.scenes.filter(s => s.imageUrl).length}/{script.scenes.length}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dynamic Guidance Message */}
|
||||||
|
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 500, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Box component="span" sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: allVideosReady ? "#10b981" : "#3b82f6",
|
||||||
|
display: "inline-block"
|
||||||
|
}} />
|
||||||
|
{allVideosReady
|
||||||
|
? "All assets ready. You can combine videos below."
|
||||||
|
: !script.scenes.every(s => s.audioUrl)
|
||||||
|
? "Generate audio for all scenes to proceed."
|
||||||
|
: !script.scenes.every(s => s.imageUrl)
|
||||||
|
? "Generate images for video backgrounds."
|
||||||
|
: "Ready to generate scene videos."}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -66,15 +66,15 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
|||||||
const [showAudioModal, setShowAudioModal] = useState(false);
|
const [showAudioModal, setShowAudioModal] = useState(false);
|
||||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||||
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
|
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
|
||||||
voiceId: "Wise_Woman",
|
voiceId: knobs.voice_id || "Wise_Woman",
|
||||||
customVoiceId: undefined,
|
customVoiceId: knobs.custom_voice_id || undefined,
|
||||||
speed: 1.0,
|
speed: knobs.voice_speed ?? 1.0,
|
||||||
volume: 1.0,
|
volume: 1.0,
|
||||||
pitch: 0.0,
|
pitch: 0.0,
|
||||||
emotion: scene.emotion || "neutral",
|
emotion: scene.emotion || knobs.voice_emotion || "neutral",
|
||||||
englishNormalization: true,
|
englishNormalization: true,
|
||||||
sampleRate: 24000,
|
sampleRate: knobs.sample_rate || 24000,
|
||||||
bitrate: 64000,
|
bitrate: knobs.bitrate === 'hd' ? 128000 : 64000,
|
||||||
channel: "1",
|
channel: "1",
|
||||||
format: "mp3",
|
format: "mp3",
|
||||||
languageBoost: "auto",
|
languageBoost: "auto",
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Typography, Stack, Chip } from "@mui/material";
|
||||||
|
import {
|
||||||
|
EditNote as EditNoteIcon,
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
RadioButtonUnchecked as RadioButtonUncheckedIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { Scene } from "../../types";
|
||||||
|
|
||||||
|
interface SceneEditorHeaderProps {
|
||||||
|
scene: Scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneEditorHeader: React.FC<SceneEditorHeaderProps> = ({ scene }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1, color: "#0f172a", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
|
||||||
|
{scene.title}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
||||||
|
<Chip
|
||||||
|
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
|
||||||
|
label={scene.approved ? "Approved" : "Pending Approval"}
|
||||||
|
size="small"
|
||||||
|
color={scene.approved ? "success" : "warning"}
|
||||||
|
sx={{
|
||||||
|
background: scene.approved
|
||||||
|
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
|
||||||
|
: "linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.12) 100%)",
|
||||||
|
color: scene.approved ? "#059669" : "#d97706",
|
||||||
|
border: scene.approved
|
||||||
|
? "1px solid rgba(16, 185, 129, 0.25)"
|
||||||
|
: "1px solid rgba(245, 158, 11, 0.25)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
height: 26,
|
||||||
|
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
|
||||||
|
Duration: {scene.duration}s
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Divider, Stack, Typography, CircularProgress } from "@mui/material";
|
||||||
|
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
|
||||||
|
|
||||||
|
interface SceneEditorMediaPanelProps {
|
||||||
|
hasAudio: boolean;
|
||||||
|
audioBlobUrl?: string | null;
|
||||||
|
isGenerating?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal media panel wrapper extracted for refactor hygiene
|
||||||
|
export const SceneEditorMediaPanel: React.FC<SceneEditorMediaPanelProps> = ({ hasAudio, audioBlobUrl, isGenerating }) => {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 1, p: 2, borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)", background: "#fff" }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||||
|
<VolumeUpIcon sx={{ color: hasAudio ? "#059669" : "#d97706" }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: hasAudio ? "#059669" : "#d97706" }}>
|
||||||
|
{hasAudio ? "Audio Generated" : "Loading Audio..."}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{audioBlobUrl ? (
|
||||||
|
<audio controls src={audioBlobUrl} style={{ width: "100%" }} />
|
||||||
|
) : isGenerating ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : null}
|
||||||
|
<Divider sx={{ mt: 2 }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
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 } from "@mui/material";
|
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, 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 } from "@mui/icons-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 { 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";
|
||||||
@@ -300,6 +300,27 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
|||||||
>
|
>
|
||||||
<EditNoteIcon sx={{ fontSize: "2rem" }} />
|
<EditNoteIcon sx={{ fontSize: "2rem" }} />
|
||||||
Script Editor
|
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>
|
||||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
|
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
|
||||||
Review and refine your podcast script before rendering
|
Review and refine your podcast script before rendering
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||||
|
import { Script, Knobs, Scene, PodcastMode } from "../types";
|
||||||
|
import { podcastApi } from "../../../services/podcastApi";
|
||||||
|
|
||||||
|
interface ScriptEditorContextType {
|
||||||
|
// State
|
||||||
|
script: Script | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
podcastMode: PodcastMode;
|
||||||
|
approvingSceneId: string | null;
|
||||||
|
generatingAudioId: string | null;
|
||||||
|
showScriptFormatInfo: boolean;
|
||||||
|
combiningAudio: boolean;
|
||||||
|
scriptTab: "audio" | "video";
|
||||||
|
combinedAudioResult: { url: string; filename: string; duration: number; sceneCount: number } | null;
|
||||||
|
generatingBatchAudio: boolean;
|
||||||
|
batchAudioProgress: { completed: number; total: number } | null;
|
||||||
|
generatingChartId: string | null; // B-roll: generating chart preview
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
activeScript: Script | null;
|
||||||
|
allApproved: boolean | null;
|
||||||
|
approvedCount: number;
|
||||||
|
totalScenes: number;
|
||||||
|
allScenesHaveAudio: boolean | null;
|
||||||
|
scenesWithAudio: number;
|
||||||
|
allScenesHaveAudioAndImages: boolean | null;
|
||||||
|
needsAudioGeneration: boolean | null;
|
||||||
|
scenesWithCharts: number; // B-roll: count of scenes with chart data
|
||||||
|
|
||||||
|
// Setters for UI state
|
||||||
|
setScript: React.Dispatch<React.SetStateAction<Script | null>>;
|
||||||
|
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
setApprovingSceneId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
setGeneratingAudioId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
setShowScriptFormatInfo: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setCombiningAudio: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setScriptTab: React.Dispatch<React.SetStateAction<"audio" | "video">>;
|
||||||
|
setCombinedAudioResult: React.Dispatch<React.SetStateAction<{ url: string; filename: string; duration: number; sceneCount: number } | null>>;
|
||||||
|
setGeneratingBatchAudio: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setBatchAudioProgress: React.Dispatch<React.SetStateAction<{ completed: number; total: number } | null>>;
|
||||||
|
setGeneratingChartId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
updateScene: (updated: Scene) => void;
|
||||||
|
approveScene: (sceneId: string) => Promise<void>;
|
||||||
|
deleteScene: (sceneId: string) => void;
|
||||||
|
generateAllAudio: () => Promise<void>;
|
||||||
|
combineAudio: () => Promise<void>;
|
||||||
|
emitScriptChange: (next: Script) => void;
|
||||||
|
// B-roll actions
|
||||||
|
generateChartPreviews: () => Promise<void>;
|
||||||
|
regenerateChart: (sceneId: string) => Promise<void>;
|
||||||
|
removeChart: (sceneId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScriptEditorContext = createContext<ScriptEditorContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface ScriptEditorProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
projectId: string;
|
||||||
|
idea: string;
|
||||||
|
rawResearch: any;
|
||||||
|
knobs: Knobs;
|
||||||
|
speakers: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
initialScript: Script | null;
|
||||||
|
initialAudioScript?: Script | null;
|
||||||
|
initialVideoScript?: Script | null;
|
||||||
|
podcastMode?: PodcastMode;
|
||||||
|
analysis?: any;
|
||||||
|
outline?: any;
|
||||||
|
onScriptChange: (script: Script) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
|
||||||
|
children,
|
||||||
|
projectId,
|
||||||
|
idea,
|
||||||
|
rawResearch,
|
||||||
|
knobs,
|
||||||
|
speakers,
|
||||||
|
durationMinutes,
|
||||||
|
initialScript,
|
||||||
|
initialAudioScript,
|
||||||
|
initialVideoScript,
|
||||||
|
podcastMode = "video_only",
|
||||||
|
analysis,
|
||||||
|
outline,
|
||||||
|
onScriptChange,
|
||||||
|
onError,
|
||||||
|
}) => {
|
||||||
|
// Core state
|
||||||
|
const [script, setScript] = useState<Script | null>(initialScript);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||||
|
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
||||||
|
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false);
|
||||||
|
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||||
|
const [scriptTab, setScriptTab] = useState<"audio" | "video">("video");
|
||||||
|
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
duration: number;
|
||||||
|
sceneCount: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [generatingBatchAudio, setGeneratingBatchAudio] = useState(false);
|
||||||
|
const [batchAudioProgress, setBatchAudioProgress] = useState<{ completed: number; total: number } | null>(null);
|
||||||
|
const [generatingChartId, setGeneratingChartId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Emit script changes to parent (deferred to avoid setState during render)
|
||||||
|
const emitScriptChange = useCallback(
|
||||||
|
(next: Script) => {
|
||||||
|
Promise.resolve().then(() => onScriptChange(next));
|
||||||
|
},
|
||||||
|
[onScriptChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine which script to display based on mode and tab
|
||||||
|
const getActiveScript = (): Script | null => {
|
||||||
|
const currentScript = script || null;
|
||||||
|
|
||||||
|
if (podcastMode === "audio_only") {
|
||||||
|
if (currentScript?.audioScript) {
|
||||||
|
return { scenes: currentScript.audioScript };
|
||||||
|
}
|
||||||
|
return initialAudioScript || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (podcastMode === "video_only") {
|
||||||
|
return currentScript || initialVideoScript || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (podcastMode === "audio_video") {
|
||||||
|
if (scriptTab === "audio") {
|
||||||
|
if (currentScript?.audioScript) {
|
||||||
|
return { scenes: currentScript.audioScript };
|
||||||
|
}
|
||||||
|
return initialAudioScript || null;
|
||||||
|
} else {
|
||||||
|
if (currentScript?.videoScript) {
|
||||||
|
return { scenes: currentScript.videoScript };
|
||||||
|
}
|
||||||
|
return currentScript || initialVideoScript || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentScript || initialVideoScript || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeScript = getActiveScript();
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
const allApproved = activeScript && activeScript.scenes.every((s) => s.approved);
|
||||||
|
const approvedCount = activeScript ? activeScript.scenes.filter((s) => s.approved).length : 0;
|
||||||
|
const totalScenes = activeScript ? activeScript.scenes.length : 0;
|
||||||
|
const allScenesHaveAudio = activeScript && activeScript.scenes.every((s) => s.audioUrl);
|
||||||
|
const scenesWithAudio = activeScript ? activeScript.scenes.filter((s) => s.audioUrl).length : 0;
|
||||||
|
const allScenesHaveAudioAndImages = activeScript && (
|
||||||
|
podcastMode === "audio_only"
|
||||||
|
? activeScript.scenes.every((s) => s.audioUrl)
|
||||||
|
: activeScript.scenes.every((s) => s.audioUrl && s.imageUrl)
|
||||||
|
);
|
||||||
|
const needsAudioGeneration = activeScript && !allScenesHaveAudio && activeScript.scenes.some((s) => !s.audioUrl);
|
||||||
|
|
||||||
|
// B-roll computed
|
||||||
|
const scenesWithCharts = activeScript ? activeScript.scenes.filter((s) => s.chart_data && Object.keys(s.chart_data).length > 0).length : 0;
|
||||||
|
|
||||||
|
// Sync with parent state
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialScript) {
|
||||||
|
setScript(initialScript);
|
||||||
|
}
|
||||||
|
}, [initialScript]);
|
||||||
|
|
||||||
|
// Generate script effect - only if not already generated by parent
|
||||||
|
// This prevents duplicate API calls when both parent workflow and this component try to generate
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip if parent already provided script via props
|
||||||
|
if (script || initialScript) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawResearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if podcastMode is audio_only (script should be passed from parent for audio_only)
|
||||||
|
// Parent workflow already generates the script, we just display it here
|
||||||
|
if (podcastMode === "audio_only") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
podcastApi
|
||||||
|
.generateScript({
|
||||||
|
projectId,
|
||||||
|
idea,
|
||||||
|
research: rawResearch,
|
||||||
|
knobs,
|
||||||
|
speakers,
|
||||||
|
durationMinutes,
|
||||||
|
analysis,
|
||||||
|
outline,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (mounted) {
|
||||||
|
setScript(res);
|
||||||
|
emitScriptChange(res);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to generate script";
|
||||||
|
setError(message);
|
||||||
|
onError(message);
|
||||||
|
})
|
||||||
|
.finally(() => mounted && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes, analysis, outline, emitScriptChange, onError, script]);
|
||||||
|
|
||||||
|
const updateScene = (updated: Scene) => {
|
||||||
|
setScript((currentScript) => {
|
||||||
|
if (!currentScript) return currentScript;
|
||||||
|
const updatedScript = {
|
||||||
|
...currentScript,
|
||||||
|
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
|
||||||
|
};
|
||||||
|
emitScriptChange(updatedScript);
|
||||||
|
return updatedScript;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const approveScene = async (sceneId: string) => {
|
||||||
|
try {
|
||||||
|
setApprovingSceneId(sceneId);
|
||||||
|
await podcastApi.approveScene({ projectId, sceneId });
|
||||||
|
setScript((currentScript) => {
|
||||||
|
if (!currentScript) return currentScript;
|
||||||
|
const updatedScript = {
|
||||||
|
...currentScript,
|
||||||
|
scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
|
||||||
|
};
|
||||||
|
emitScriptChange(updatedScript);
|
||||||
|
return updatedScript;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to approve scene";
|
||||||
|
setError(message);
|
||||||
|
onError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setApprovingSceneId((current) => (current === sceneId ? null : current));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteScene = useCallback((sceneId: string) => {
|
||||||
|
if (!activeScript) return;
|
||||||
|
|
||||||
|
if (activeScript.scenes.length <= 1) {
|
||||||
|
onError("Cannot delete the last scene. At least one scene is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sceneToDelete = activeScript.scenes.find(s => s.id === sceneId);
|
||||||
|
if (!sceneToDelete) return;
|
||||||
|
|
||||||
|
const confirmDelete = window.confirm(
|
||||||
|
`Are you sure you want to delete "${sceneToDelete.title}"? This action cannot be undone.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
|
const updatedScenes = activeScript.scenes.filter(s => s.id !== sceneId);
|
||||||
|
const updatedScript = { ...activeScript, scenes: updatedScenes };
|
||||||
|
|
||||||
|
emitScriptChange(updatedScript);
|
||||||
|
setScript(updatedScript);
|
||||||
|
}, [activeScript, emitScriptChange, onError]);
|
||||||
|
|
||||||
|
const generateAllAudio = useCallback(async () => {
|
||||||
|
if (!activeScript || !projectId || !knobs) return;
|
||||||
|
|
||||||
|
const scenesNeedingAudio = activeScript.scenes.filter((s) => !s.audioUrl);
|
||||||
|
if (scenesNeedingAudio.length === 0) {
|
||||||
|
onError("All scenes already have audio generated.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGeneratingBatchAudio(true);
|
||||||
|
setBatchAudioProgress({ completed: 0, total: scenesNeedingAudio.length });
|
||||||
|
|
||||||
|
const sceneData = scenesNeedingAudio.map((scene) => ({
|
||||||
|
id: scene.id,
|
||||||
|
title: scene.title,
|
||||||
|
lines: scene.lines.map((line) => ({ text: line.text })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await podcastApi.generateBatchAudio({
|
||||||
|
scenes: sceneData,
|
||||||
|
voiceId: knobs.voice_id,
|
||||||
|
customVoiceId: knobs.custom_voice_id,
|
||||||
|
speed: knobs.voice_speed,
|
||||||
|
emotion: knobs.voice_emotion,
|
||||||
|
englishNormalization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedScenes = activeScript.scenes.map((scene) => {
|
||||||
|
const batchResult = result.results.find((r: any) => r.sceneId === scene.id);
|
||||||
|
if (batchResult) {
|
||||||
|
return { ...scene, audioUrl: batchResult.audioUrl };
|
||||||
|
}
|
||||||
|
return scene;
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitScriptChange({ ...activeScript, scenes: updatedScenes });
|
||||||
|
setBatchAudioProgress({ completed: scenesNeedingAudio.length, total: scenesNeedingAudio.length });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Batch audio generation failed:", error);
|
||||||
|
onError(`Failed to generate audio: ${error.message || error}`);
|
||||||
|
} finally {
|
||||||
|
setGeneratingBatchAudio(false);
|
||||||
|
setBatchAudioProgress(null);
|
||||||
|
}
|
||||||
|
}, [activeScript, projectId, knobs, emitScriptChange, onError]);
|
||||||
|
|
||||||
|
const combineAudio = useCallback(async () => {
|
||||||
|
if (!activeScript || !projectId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCombiningAudio(true);
|
||||||
|
|
||||||
|
const sceneIds: string[] = [];
|
||||||
|
const sceneAudioUrls: string[] = [];
|
||||||
|
|
||||||
|
activeScript.scenes.forEach((scene) => {
|
||||||
|
if (scene.audioUrl) {
|
||||||
|
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
|
||||||
|
if (audioUrl) {
|
||||||
|
sceneIds.push(scene.id);
|
||||||
|
sceneAudioUrls.push(audioUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sceneIds.length === 0) {
|
||||||
|
onError("No audio files found to combine.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await podcastApi.combineAudio({
|
||||||
|
projectId,
|
||||||
|
sceneIds,
|
||||||
|
sceneAudioUrls,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCombinedAudioResult({
|
||||||
|
url: result.combined_audio_url,
|
||||||
|
filename: result.combined_audio_filename,
|
||||||
|
duration: result.total_duration,
|
||||||
|
sceneCount: result.scene_count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to combine audio";
|
||||||
|
onError(`Failed to combine audio: ${message}`);
|
||||||
|
} finally {
|
||||||
|
setCombiningAudio(false);
|
||||||
|
}
|
||||||
|
}, [activeScript, projectId, onError]);
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// B-Roll Actions
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
const generateChartPreviews = useCallback(async () => {
|
||||||
|
if (!activeScript) return;
|
||||||
|
|
||||||
|
const scenesWithData = activeScript.scenes.filter(
|
||||||
|
(s) => s.chart_data && Object.keys(s.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(
|
||||||
|
activeScript.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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...scene,
|
||||||
|
broll_preview_url: result.preview_url,
|
||||||
|
chart_id: result.chart_id,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to generate chart for scene ${scene.id}:`, err);
|
||||||
|
return scene;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedScript = { ...activeScript, 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);
|
||||||
|
}
|
||||||
|
}, [activeScript, emitScriptChange, onError]);
|
||||||
|
|
||||||
|
const regenerateChart = useCallback(async (sceneId: string) => {
|
||||||
|
if (!activeScript) return;
|
||||||
|
|
||||||
|
const scene = activeScript.scenes.find((s) => s.id === sceneId);
|
||||||
|
if (!scene || !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 updatedScenes = activeScript.scenes.map((s) =>
|
||||||
|
s.id === sceneId
|
||||||
|
? { ...s, broll_preview_url: result.preview_url, chart_id: result.chart_id }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedScript = { ...activeScript, scenes: updatedScenes };
|
||||||
|
setScript(updatedScript);
|
||||||
|
emitScriptChange(updatedScript);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Chart regeneration failed:", error);
|
||||||
|
onError(`Failed to regenerate chart: ${error.message || error}`);
|
||||||
|
} finally {
|
||||||
|
setGeneratingChartId(null);
|
||||||
|
}
|
||||||
|
}, [activeScript, emitScriptChange, onError]);
|
||||||
|
|
||||||
|
const removeChart = useCallback((sceneId: string) => {
|
||||||
|
if (!activeScript) return;
|
||||||
|
|
||||||
|
const updatedScenes = activeScript.scenes.map((s) =>
|
||||||
|
s.id === sceneId
|
||||||
|
? { ...s, chart_data: undefined, broll_preview_url: undefined, broll_video_url: undefined }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedScript = { ...activeScript, scenes: updatedScenes };
|
||||||
|
setScript(updatedScript);
|
||||||
|
emitScriptChange(updatedScript);
|
||||||
|
}, [activeScript, emitScriptChange]);
|
||||||
|
|
||||||
|
const value: ScriptEditorContextType = {
|
||||||
|
// State
|
||||||
|
script,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
podcastMode,
|
||||||
|
approvingSceneId,
|
||||||
|
generatingAudioId,
|
||||||
|
showScriptFormatInfo,
|
||||||
|
combiningAudio,
|
||||||
|
scriptTab,
|
||||||
|
combinedAudioResult,
|
||||||
|
generatingBatchAudio,
|
||||||
|
batchAudioProgress,
|
||||||
|
generatingChartId,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
activeScript,
|
||||||
|
allApproved,
|
||||||
|
approvedCount,
|
||||||
|
totalScenes,
|
||||||
|
allScenesHaveAudio,
|
||||||
|
scenesWithAudio,
|
||||||
|
allScenesHaveAudioAndImages,
|
||||||
|
needsAudioGeneration,
|
||||||
|
scenesWithCharts,
|
||||||
|
|
||||||
|
// Setters for UI state
|
||||||
|
setScript,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
setApprovingSceneId,
|
||||||
|
setGeneratingAudioId,
|
||||||
|
setShowScriptFormatInfo,
|
||||||
|
setCombiningAudio,
|
||||||
|
setScriptTab,
|
||||||
|
setCombinedAudioResult,
|
||||||
|
setGeneratingBatchAudio,
|
||||||
|
setBatchAudioProgress,
|
||||||
|
setGeneratingChartId,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
updateScene,
|
||||||
|
approveScene,
|
||||||
|
deleteScene,
|
||||||
|
generateAllAudio,
|
||||||
|
combineAudio,
|
||||||
|
emitScriptChange,
|
||||||
|
// B-roll actions
|
||||||
|
generateChartPreviews,
|
||||||
|
regenerateChart,
|
||||||
|
removeChart,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScriptEditorContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ScriptEditorContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useScriptEditor = (): ScriptEditorContextType => {
|
||||||
|
const context = useContext(ScriptEditorContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useScriptEditor must be used within ScriptEditorProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Stack, Typography, Tabs, Tab, Chip } from "@mui/material";
|
||||||
|
import { EditNote as EditNoteIcon, ArrowBack as ArrowBackIcon, AudioFile as AudioFileIcon, Videocam as VideocamIcon, Mic as MicIcon } from "@mui/icons-material";
|
||||||
|
import { PodcastMode, Knobs } from "../types";
|
||||||
|
import { SecondaryButton } from "../ui";
|
||||||
|
import { useScriptEditor } from "./ScriptEditorContext";
|
||||||
|
|
||||||
|
interface ScriptEditorLayoutProps {
|
||||||
|
onBackToResearch: () => void;
|
||||||
|
knobs?: Knobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get voice display name
|
||||||
|
const getVoiceDisplayName = (voiceId?: string): string => {
|
||||||
|
if (!voiceId) return "Default";
|
||||||
|
if (voiceId === "Wise_Woman") return "Wise Woman";
|
||||||
|
if (voiceId === "Friendly_Person") return "Friendly Person";
|
||||||
|
if (voiceId === "Deep_Voice_Man") return "Deep Voice Man";
|
||||||
|
if (voiceId === "Calm_Woman") return "Calm Woman";
|
||||||
|
return voiceId.replace(/_/g, " ");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScriptEditorLayout: React.FC<ScriptEditorLayoutProps> = ({ onBackToResearch, knobs }) => {
|
||||||
|
const { podcastMode, scriptTab, setScriptTab } = useScriptEditor();
|
||||||
|
|
||||||
|
const showTabs = podcastMode === "audio_video";
|
||||||
|
const voiceId = knobs?.voice_id;
|
||||||
|
const isCustomVoice = Boolean(voiceId && !voiceId.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(voiceId));
|
||||||
|
const voiceName = isCustomVoice ? "My Voice Clone" : getVoiceDisplayName(voiceId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
|
||||||
|
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
|
||||||
|
Back to Research
|
||||||
|
</SecondaryButton>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<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
|
||||||
|
{voiceId && (
|
||||||
|
<Chip
|
||||||
|
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
|
||||||
|
label={`Active Voice: ${voiceName}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
ml: 2,
|
||||||
|
background: isCustomVoice ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
|
||||||
|
color: isCustomVoice ? "#10b981" : "#6366f1",
|
||||||
|
border: `1px solid ${isCustomVoice ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
|
||||||
|
'& .MuiChip-icon': { color: isCustomVoice ? "#10b981" : "#6366f1" },
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showTabs && (
|
||||||
|
<Tabs
|
||||||
|
value={scriptTab}
|
||||||
|
onChange={(_, v) => setScriptTab(v)}
|
||||||
|
sx={{
|
||||||
|
ml: 3,
|
||||||
|
minHeight: 32,
|
||||||
|
'& .MuiTab-root': {
|
||||||
|
minHeight: 32,
|
||||||
|
py: 0.5,
|
||||||
|
px: 2,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
value="audio"
|
||||||
|
label="Audio Script"
|
||||||
|
icon={<AudioFileIcon sx={{ fontSize: '1rem' }} />}
|
||||||
|
iconPosition="start"
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="video"
|
||||||
|
label="Video Script"
|
||||||
|
icon={<VideocamIcon sx={{ fontSize: '1rem' }} />}
|
||||||
|
iconPosition="start"
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
|
||||||
|
Review and refine your podcast script before rendering
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
LinearProgress,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
AutoAwesome as AutoAwesomeIcon,
|
||||||
|
CheckCircle as CheckCircleIcon,
|
||||||
|
Psychology as PsychologyIcon,
|
||||||
|
Insights as InsightsIcon,
|
||||||
|
Article as ArticleIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
VolumeUp as VolumeUpIcon,
|
||||||
|
VideoLibrary as VideoLibraryIcon,
|
||||||
|
Lightbulb as LightbulbIcon,
|
||||||
|
Search as SearchIcon,
|
||||||
|
FactCheck as FactCheckIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { Research } from "../types";
|
||||||
|
|
||||||
|
const SCRIPT_GENERATION_MESSAGES = [
|
||||||
|
{ title: "Processing Research", message: "Extracting key insights, statistics, and quotes from your research data..." },
|
||||||
|
{ title: "Analyzing Your Topic", message: "Using your topic to shape the episode narrative and content structure..." },
|
||||||
|
{ title: "Structuring Scenes", message: "Creating scene-by-scene breakdown based on research findings..." },
|
||||||
|
{ title: "Writing Dialogue", message: "Generating natural conversation that flows from your insights..." },
|
||||||
|
{ title: "Adding Transitions", message: "Creating smooth flow between scenes and topics..." },
|
||||||
|
{ title: "Optimizing Pacing", message: "Ensuring engaging rhythm throughout the episode..." },
|
||||||
|
{ title: "Final Review", message: "Validating script quality and preparing for editing..." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESEARCH_STATS_CONFIG = [
|
||||||
|
{ label: "Key Insights", key: "keyInsights", icon: <InsightsIcon />, color: "#a78bfa" },
|
||||||
|
{ label: "Fact Cards", key: "factCards", icon: <FactCheckIcon />, color: "#34d399" },
|
||||||
|
{ label: "Angles", key: "mappedAngles", icon: <LightbulbIcon />, color: "#f59e0b" },
|
||||||
|
{ label: "Sources", key: "sourceCount", icon: <SearchIcon />, color: "#60a5fa", isNumber: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PODCAST_CREATION_JOURNEY = [
|
||||||
|
{
|
||||||
|
phase: "Generate Script",
|
||||||
|
icon: <AutoAwesomeIcon />,
|
||||||
|
color: "#a78bfa",
|
||||||
|
description: "AI transforms research into a structured podcast script",
|
||||||
|
benefit: "Professional script based on your research insights"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Edit Scenes",
|
||||||
|
icon: <EditIcon />,
|
||||||
|
color: "#34d399",
|
||||||
|
description: "Review and refine each scene in the Script Editor",
|
||||||
|
benefit: "Full control over your content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Approve Content",
|
||||||
|
icon: <CheckCircleIcon />,
|
||||||
|
color: "#10b981",
|
||||||
|
description: "Mark scenes as approved before audio generation",
|
||||||
|
benefit: "Ensures content isexactly as you want"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Generate Audio",
|
||||||
|
icon: <VolumeUpIcon />,
|
||||||
|
color: "#f59e0b",
|
||||||
|
description: "Convert script to natural-sounding podcast audio",
|
||||||
|
benefit: "Ready-to-use audio narration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Final Render",
|
||||||
|
icon: <VideoLibraryIcon />,
|
||||||
|
color: "#ef4444",
|
||||||
|
description: "Combine into your final podcast episode",
|
||||||
|
benefit: "Download or share your episode"
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ScriptProgressViewProps {
|
||||||
|
currentMessage?: string;
|
||||||
|
progressIndex: number;
|
||||||
|
research?: Research | null;
|
||||||
|
idea?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScriptProgressView: React.FC<ScriptProgressViewProps> = ({
|
||||||
|
currentMessage,
|
||||||
|
progressIndex,
|
||||||
|
research,
|
||||||
|
idea,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const clampedIndex = Math.min(progressIndex, SCRIPT_GENERATION_MESSAGES.length - 1);
|
||||||
|
|
||||||
|
const getResearchValue = (key: string, isNumber?: boolean) => {
|
||||||
|
if (!research) return 0;
|
||||||
|
const value = (research as any)[key];
|
||||||
|
if (isNumber) return research.sourceCount || 0;
|
||||||
|
return Array.isArray(value) ? value.length : value || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* Current Status */}
|
||||||
|
<Box sx={{ textAlign: "center" }}>
|
||||||
|
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#a78bfa" }} />
|
||||||
|
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||||
|
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: isMobile ? 20 : 24 }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ color: "#a78bfa", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
|
||||||
|
{SCRIPT_GENERATION_MESSAGES[clampedIndex].title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
|
||||||
|
{currentMessage || SCRIPT_GENERATION_MESSAGES[clampedIndex].message}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{currentMessage && (
|
||||||
|
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
|
||||||
|
{currentMessage}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: "rgba(255,255,255,0.1)",
|
||||||
|
mt: 2,
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: "#a78bfa", borderRadius: 2 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
|
||||||
|
Step {clampedIndex + 1} of {SCRIPT_GENERATION_MESSAGES.length}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
{/* Research Stats */}
|
||||||
|
{research && (
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Using Your Research
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
{RESEARCH_STATS_CONFIG.map((stat, idx) => {
|
||||||
|
const value = getResearchValue(stat.key, stat.isNumber);
|
||||||
|
return (
|
||||||
|
<Box key={idx} sx={{
|
||||||
|
flex: "1 1 auto",
|
||||||
|
minWidth: 80,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: "rgba(255,255,255,0.05)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.1)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}>
|
||||||
|
<Box sx={{ color: stat.color, mb: 0.25 }}>{stat.icon}</Box>
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 700, fontSize: "1rem" }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem" }}>
|
||||||
|
{stat.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
{/* Sequential Progress Steps */}
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Script Generation Progress
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
{SCRIPT_GENERATION_MESSAGES.map((msg, idx) => {
|
||||||
|
const isCompleted = idx < clampedIndex;
|
||||||
|
const isCurrent = idx === clampedIndex;
|
||||||
|
return (
|
||||||
|
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<Box sx={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: isCompleted ? "#10b981" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.1)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircleIcon sx={{ fontSize: 12, color: "#fff" }} />
|
||||||
|
) : isCurrent ? (
|
||||||
|
<CircularProgress size={10} sx={{ color: "#fff" }} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: 4, height: 4, borderRadius: "50%", bgcolor: "rgba(255,255,255,0.3)" }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{
|
||||||
|
color: isCompleted ? "rgba(255,255,255,0.5)" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.6)",
|
||||||
|
fontWeight: isCurrent ? 600 : 400,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
textDecoration: isCompleted ? "line-through" : "none",
|
||||||
|
}}>
|
||||||
|
{msg.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
{/* Journey Overview */}
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Your Podcast Journey
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{PODCAST_CREATION_JOURNEY.map((phase, idx) => (
|
||||||
|
<Box key={idx} sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<Box sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: `${phase.color}20`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{React.cloneElement(phase.icon, { sx: { color: phase.color, fontSize: 16 } })}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
|
||||||
|
{phase.phase}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.7rem", display: "block" }}>
|
||||||
|
{phase.description}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: phase.color, fontSize: "0.65rem", display: "block", mt: 0.25 }}>
|
||||||
|
✓ {phase.benefit}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Box, Typography, Alert, Paper, Button, CircularProgress, Chip } from "@mui/material";
|
||||||
|
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon } from "@mui/icons-material";
|
||||||
|
import { useScriptEditor } from "../ScriptEditorContext";
|
||||||
|
|
||||||
|
export const BrollInfoPanel: React.FC = () => {
|
||||||
|
const {
|
||||||
|
activeScript,
|
||||||
|
generatingChartId,
|
||||||
|
setGeneratingChartId,
|
||||||
|
generateChartPreviews,
|
||||||
|
regenerateChart,
|
||||||
|
removeChart,
|
||||||
|
scenesWithCharts
|
||||||
|
} = useScriptEditor();
|
||||||
|
|
||||||
|
if (!activeScript || activeScript.scenes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenesWithData = activeScript.scenes.filter(s => s.chart_data && Object.keys(s.chart_data).length > 0);
|
||||||
|
const hasChartData = scenesWithData.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%)",
|
||||||
|
border: "1px solid rgba(34, 197, 94, 0.15)",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ width: 40, height: 40, borderRadius: "50%", background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<BarChartIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||||
|
B-Roll Charts
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
|
||||||
|
Programmatic charts extracted from research data
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{hasChartData && (
|
||||||
|
<Chip
|
||||||
|
label={`${scenesWithData.length} scene${scenesWithData.length > 1 ? 's' : ''} with charts`}
|
||||||
|
size="small"
|
||||||
|
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a", fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!hasChartData ? (
|
||||||
|
<Alert severity="info" sx={{ background: "rgba(34, 197, 94, 0.06)", border: "1px solid rgba(34, 197, 94, 0.15)", "& .MuiAlert-icon": { color: "#22c55e" } }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
|
||||||
|
<strong style={{ fontWeight: 600 }}>No charts detected.</strong> If your research contains statistics or metrics, the script generation will automatically extract chart data for B-roll visualization.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||||
|
Your script contains <strong style={{ fontWeight: 600 }}>{scenesWithData.length}</strong> scene(s) with chart data.
|
||||||
|
Click below to generate chart previews for the Write phase.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={generatingChartId ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
|
||||||
|
onClick={generateChartPreviews}
|
||||||
|
disabled={!!generatingChartId}
|
||||||
|
sx={{
|
||||||
|
background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)",
|
||||||
|
"&:hover": { background: "linear-gradient(135deg, #16a34a 0%, #059669 100%)" },
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generatingChartId ? "Generating..." : "Generate Chart Previews"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{scenesWithData.map((scene) => (
|
||||||
|
<Box
|
||||||
|
key={scene.id}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
background: "rgba(0,0,0,0.02)",
|
||||||
|
borderRadius: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
{scene.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||||
|
{scene.chart_data?.type || "chart"} • {scene.chart_data?.labels?.length || 0} data points
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
{generatingChartId === scene.id ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : scene.broll_preview_url ? (
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
label="Preview Ready"
|
||||||
|
size="small"
|
||||||
|
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a" }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={() => regenerateChart(scene.id)}
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
onClick={() => removeChart(scene.id)}
|
||||||
|
sx={{ color: "#ef4444" }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Box, Typography, Paper, LinearProgress } from "@mui/material";
|
||||||
|
import { CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon } from "@mui/icons-material";
|
||||||
|
import { Script } from "../../types";
|
||||||
|
import { useScriptEditor } from "../ScriptEditorContext";
|
||||||
|
import { PrimaryButton } from "../../ui";
|
||||||
|
|
||||||
|
interface ScriptEditorApprovalPanelProps {
|
||||||
|
onProceedToRendering: (script: Script) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScriptEditorApprovalPanel: React.FC<ScriptEditorApprovalPanelProps> = ({ onProceedToRendering }) => {
|
||||||
|
const { activeScript, allApproved, approvedCount, totalScenes, allScenesHaveAudioAndImages } = useScriptEditor();
|
||||||
|
const approved = allApproved ?? false;
|
||||||
|
const ready = allScenesHaveAudioAndImages ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3.5, background: approved ? "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%)" : "#ffffff", border: approved ? "2px solid rgba(16, 185, 129, 0.25)" : "1px solid rgba(15, 23, 42, 0.08)", borderRadius: 3 }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||||
|
<CheckCircleIcon fontSize="small" sx={{ color: approved ? "#10b981" : "#94a3b8", fontSize: "1.25rem" }} />Approval Status
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
|
||||||
|
{approvedCount} of {totalScenes} scenes approved{!approved && " — Approve all scenes first"}
|
||||||
|
</Typography>
|
||||||
|
{!ready && <LinearProgress variant="determinate" value={ready ? 100 : (activeScript ? (activeScript.scenes.filter((s) => s.audioUrl && s.imageUrl).length / totalScenes) * 100 : 0)} sx={{ mt: 1, height: 6, borderRadius: 3 }} />}
|
||||||
|
</Box>
|
||||||
|
<PrimaryButton onClick={() => activeScript && onProceedToRendering(activeScript)} disabled={!ready} startIcon={<PlayArrowIcon />}>
|
||||||
|
Proceed to Rendering
|
||||||
|
</PrimaryButton>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Box, Typography, Paper, LinearProgress } from "@mui/material";
|
||||||
|
import { AudioFile as AudioFileIcon } from "@mui/icons-material";
|
||||||
|
import { useScriptEditor } from "../ScriptEditorContext";
|
||||||
|
import { PrimaryButton } from "../../ui";
|
||||||
|
|
||||||
|
export const ScriptEditorAudioPanel: React.FC = () => {
|
||||||
|
const { activeScript, needsAudioGeneration, generatingBatchAudio, batchAudioProgress, generateAllAudio } = useScriptEditor();
|
||||||
|
|
||||||
|
if (!(needsAudioGeneration ?? false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)",
|
||||||
|
border: "1px solid rgba(16, 185, 129, 0.2)",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ color: "#059669", fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<AudioFileIcon /> Generate All Audio
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5 }}>
|
||||||
|
{activeScript && `${activeScript.scenes.filter(s => !s.audioUrl).length} scenes need audio`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={generateAllAudio}
|
||||||
|
disabled={generatingBatchAudio}
|
||||||
|
loading={generatingBatchAudio}
|
||||||
|
startIcon={<AudioFileIcon />}
|
||||||
|
sx={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
|
||||||
|
>
|
||||||
|
{generatingBatchAudio
|
||||||
|
? (batchAudioProgress ? `Generating ${batchAudioProgress.completed}/${batchAudioProgress.total}...` : "Generating...")
|
||||||
|
: "Generate All Audio"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</Stack>
|
||||||
|
{(batchAudioProgress !== null && batchAudioProgress !== undefined) && (
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(batchAudioProgress.completed / batchAudioProgress.total) * 100}
|
||||||
|
sx={{ mt: 2, height: 8, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Typography, Paper, Alert, alpha } from "@mui/material";
|
||||||
|
import { Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
|
||||||
|
import { useScriptEditor } from "../ScriptEditorContext";
|
||||||
|
import { PrimaryButton, SecondaryButton } from "../../ui";
|
||||||
|
import { InlineAudioPlayer } from "../../InlineAudioPlayer";
|
||||||
|
import { aiApiClient } from "../../../../api/client";
|
||||||
|
|
||||||
|
interface ScriptEditorDownloadPanelProps {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScriptEditorDownloadPanel: React.FC<ScriptEditorDownloadPanelProps> = ({ projectId }) => {
|
||||||
|
const { allScenesHaveAudio, scenesWithAudio, combiningAudio, combinedAudioResult, combineAudio, setCombinedAudioResult } = useScriptEditor();
|
||||||
|
|
||||||
|
if (!(allScenesHaveAudio ?? false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadAgain = async () => {
|
||||||
|
if (!combinedAudioResult) return;
|
||||||
|
try {
|
||||||
|
let audioPath = combinedAudioResult.url.startsWith('/') ? combinedAudioResult.url : `/${combinedAudioResult.url}`;
|
||||||
|
if (!audioPath.includes('/api/podcast/audio/')) {
|
||||||
|
const filename = audioPath.split('/').pop() || combinedAudioResult.filename;
|
||||||
|
audioPath = `/api/podcast/audio/${filename}`;
|
||||||
|
}
|
||||||
|
audioPath = audioPath.split('?')[0];
|
||||||
|
const response = await aiApiClient.get(audioPath, { responseType: 'blob' });
|
||||||
|
const blob = response.data;
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = combinedAudioResult.filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download audio:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3, background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)", border: "1px solid rgba(102, 126, 234, 0.15)", borderRadius: 2 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600 }}>Download Audio-Only Podcast</Typography>
|
||||||
|
{!combinedAudioResult ? (
|
||||||
|
<>
|
||||||
|
<PrimaryButton onClick={combineAudio} disabled={combiningAudio} loading={combiningAudio} startIcon={<DownloadIcon />} sx={{ minWidth: 280, fontSize: "1rem", py: 1.5, background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" }}>
|
||||||
|
{combiningAudio ? "Combining Audio..." : "Download Audio-Only Podcast"}
|
||||||
|
</PrimaryButton>
|
||||||
|
<Typography variant="caption" sx={{ color: "#64748b", fontStyle: "italic" }}>This will combine all {scenesWithAudio} scene audio files into one complete podcast episode.</Typography>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Alert severity="success" sx={{ background: alpha("#10b981", 0.1), border: "1px solid rgba(16,185,129,0.3)", "& .MuiAlert-icon": { color: "#10b981" } }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>✅ Combined audio generated successfully! ({combinedAudioResult.sceneCount} scenes, {Math.round(combinedAudioResult.duration)}s)</Typography>
|
||||||
|
</Alert>
|
||||||
|
<InlineAudioPlayer audioUrl={combinedAudioResult.url} title="Complete Podcast Episode" />
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<SecondaryButton onClick={handleDownloadAgain} startIcon={<DownloadIcon />}>Download Again</SecondaryButton>
|
||||||
|
<SecondaryButton onClick={() => { setCombinedAudioResult(null); combineAudio(); }} disabled={combiningAudio} loading={combiningAudio} startIcon={<RefreshIcon />}>Regenerate</SecondaryButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Box, Typography, Alert, Paper } from "@mui/material";
|
||||||
|
import { Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from "@mui/icons-material";
|
||||||
|
import { useScriptEditor } from "../ScriptEditorContext";
|
||||||
|
|
||||||
|
interface FormatItem {
|
||||||
|
num: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatItems: FormatItem[] = [
|
||||||
|
{ num: "1", title: "Natural Pauses & Rhythm", desc: "The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns and conversation flow." },
|
||||||
|
{ num: "2", title: "Emphasis Markers", desc: "Lines marked with emphasis help highlight important points, statistics, or key insights." },
|
||||||
|
{ num: "3", title: "Short, Conversational Sentences", desc: "The script uses shorter sentences written in a conversational style that matches how people actually speak." },
|
||||||
|
{ num: "4", title: "Scene-Specific Emotions", desc: "Each scene has an emotional tone that guides the AI voice's delivery." },
|
||||||
|
{ num: "5", title: "Optimized for Podcast Narration", desc: "The script is optimized with slightly slower pacing and natural pronunciation settings." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ScriptEditorInfoPanel: React.FC = () => {
|
||||||
|
const { showScriptFormatInfo, setShowScriptFormatInfo } = useScriptEditor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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" }}>
|
||||||
|
<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>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
color: "#6366f1",
|
||||||
|
cursor: "pointer",
|
||||||
|
p: 0.5,
|
||||||
|
borderRadius: 1,
|
||||||
|
"&:hover": { background: "rgba(99, 102, 241, 0.1)" },
|
||||||
|
}}
|
||||||
|
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
|
||||||
|
>
|
||||||
|
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{showScriptFormatInfo && (
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8 }}>
|
||||||
|
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>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{formatItems.map((item) => (
|
||||||
|
<Box key={item.num} 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" }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>{item.num}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>{item.title}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>{item.desc}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Alert severity="info" sx={{ 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.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -47,6 +47,8 @@ export type Research = {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
cost?: number;
|
cost?: number;
|
||||||
sourceCount?: number;
|
sourceCount?: number;
|
||||||
|
expertQuotes?: { quote: string; source_index: number }[];
|
||||||
|
listenerCta?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Line = {
|
export type Line = {
|
||||||
@@ -63,14 +65,18 @@ export type Scene = {
|
|||||||
duration: number;
|
duration: number;
|
||||||
lines: Line[];
|
lines: Line[];
|
||||||
approved?: boolean;
|
approved?: boolean;
|
||||||
emotion?: string; // Scene-specific emotion
|
emotion?: string;
|
||||||
audioUrl?: string; // Generated audio URL for this scene
|
audioUrl?: string;
|
||||||
imageUrl?: string; // Generated image URL for this scene (for video generation)
|
imageUrl?: string;
|
||||||
imagePrompt?: string; // Original image generation prompt for video context
|
imagePrompt?: string;
|
||||||
|
chart_data?: Record<string, any>;
|
||||||
|
broll_preview_url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Script = {
|
export type Script = {
|
||||||
scenes: Scene[];
|
scenes: Scene[];
|
||||||
|
audioScript?: Scene[];
|
||||||
|
videoScript?: Scene[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JobStatus =
|
export type JobStatus =
|
||||||
@@ -129,8 +135,12 @@ export type PodcastEstimate = {
|
|||||||
videoCost: number;
|
videoCost: number;
|
||||||
researchCost: number;
|
researchCost: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
voiceName?: string;
|
||||||
|
isCustomVoice?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PodcastMode = "audio_only" | "video_only" | "audio_video";
|
||||||
|
|
||||||
export type HostPersona = {
|
export type HostPersona = {
|
||||||
name: string;
|
name: string;
|
||||||
background: string;
|
background: string;
|
||||||
@@ -170,6 +180,7 @@ export type CreateProjectPayload = {
|
|||||||
budgetCap: number;
|
budgetCap: number;
|
||||||
files: { voiceFile?: File | null; avatarFile?: File | null };
|
files: { voiceFile?: File | null; avatarFile?: File | null };
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
podcastMode?: PodcastMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateProjectResult = {
|
export type CreateProjectResult = {
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { motion } from "framer-motion";
|
import { Paper, SxProps, Theme } from "@mui/material";
|
||||||
import { Paper, alpha } from "@mui/material";
|
|
||||||
|
|
||||||
export const GlassyCard = motion.create(Paper);
|
interface GlassyCardProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
onClick?: () => void;
|
||||||
|
[key: string]: any; // Allow other props for framer-motion
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlassyCard: React.FC<GlassyCardProps> = ({ children, sx, ...props }) => {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
border: "1px solid rgba(15, 23, 42, 0.06)",
|
||||||
|
background: "#ffffff",
|
||||||
|
p: 3,
|
||||||
|
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04)",
|
||||||
|
color: "#0f172a",
|
||||||
|
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
|
"&:hover": {
|
||||||
|
boxShadow: "0 4px 6px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(15, 23, 42, 0.06)",
|
||||||
|
borderColor: "rgba(15, 23, 42, 0.1)",
|
||||||
|
},
|
||||||
|
...sx
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const glassyCardSx = {
|
export const glassyCardSx = {
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ export interface PodcastProjectState {
|
|||||||
idea: string;
|
idea: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
speakers: number;
|
speakers: number;
|
||||||
|
podcastMode?: "audio_only" | "video_only" | "audio_video";
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
avatarPrompt?: string | null;
|
avatarPrompt?: string | null;
|
||||||
avatarPersonaId?: string | null;
|
avatarPersonaId?: string | null;
|
||||||
@@ -56,6 +57,9 @@ export interface PodcastProjectState {
|
|||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
|
||||||
|
// Backend project creation status — prevents 404 sync calls before project exists
|
||||||
|
backendProjectCreated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_KNOBS: Knobs = {
|
const DEFAULT_KNOBS: Knobs = {
|
||||||
@@ -86,6 +90,7 @@ const DEFAULT_STATE: PodcastProjectState = {
|
|||||||
showScriptEditor: false,
|
showScriptEditor: false,
|
||||||
showRenderQueue: false,
|
showRenderQueue: false,
|
||||||
currentStep: null,
|
currentStep: null,
|
||||||
|
backendProjectCreated: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'podcast_project_state';
|
const STORAGE_KEY = 'podcast_project_state';
|
||||||
@@ -134,7 +139,7 @@ export const usePodcastProjectState = () => {
|
|||||||
|
|
||||||
// Sync to database after major steps (debounced)
|
// Sync to database after major steps (debounced)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.project || !state.project.id) return;
|
if (!state.project || !state.project.id || !state.backendProjectCreated) return;
|
||||||
|
|
||||||
// Capture project ID to avoid closure issues
|
// Capture project ID to avoid closure issues
|
||||||
const projectId = state.project.id;
|
const projectId = state.project.id;
|
||||||
@@ -319,6 +324,10 @@ export const usePodcastProjectState = () => {
|
|||||||
setState((prev) => ({ ...prev, currentStep: step, updatedAt: new Date().toISOString() }));
|
setState((prev) => ({ ...prev, currentStep: step, updatedAt: new Date().toISOString() }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setBackendProjectCreated = useCallback((created: boolean) => {
|
||||||
|
setState((prev) => ({ ...prev, backendProjectCreated: created }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
const resetState = useCallback(() => {
|
const resetState = useCallback(() => {
|
||||||
setState(DEFAULT_STATE);
|
setState(DEFAULT_STATE);
|
||||||
@@ -407,6 +416,7 @@ export const usePodcastProjectState = () => {
|
|||||||
finalVideoUrl: dbProject.final_video_url || null,
|
finalVideoUrl: dbProject.final_video_url || null,
|
||||||
createdAt: dbProject.created_at,
|
createdAt: dbProject.created_at,
|
||||||
updatedAt: dbProject.updated_at,
|
updatedAt: dbProject.updated_at,
|
||||||
|
backendProjectCreated: true,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading project from database:', error);
|
console.error('Error loading project from database:', error);
|
||||||
@@ -436,6 +446,7 @@ export const usePodcastProjectState = () => {
|
|||||||
setShowScriptEditor,
|
setShowScriptEditor,
|
||||||
setShowRenderQueue,
|
setShowRenderQueue,
|
||||||
setCurrentStep,
|
setCurrentStep,
|
||||||
|
setBackendProjectCreated,
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
resetState,
|
resetState,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "../components/PodcastMaker/types";
|
} from "../components/PodcastMaker/types";
|
||||||
import { checkPreflight, PreflightOperation } from "./billingService";
|
import { checkPreflight, PreflightOperation } from "./billingService";
|
||||||
import { TaskStatus } from "./storyWriterApi";
|
import { TaskStatus } from "./storyWriterApi";
|
||||||
|
import { isPodcastOnlyDemoMode } from "../utils/demoMode";
|
||||||
|
|
||||||
const DEFAULT_KNOBS: Knobs = {
|
const DEFAULT_KNOBS: Knobs = {
|
||||||
voice_emotion: "neutral",
|
voice_emotion: "neutral",
|
||||||
@@ -65,6 +66,7 @@ const estimateCosts = ({
|
|||||||
quality,
|
quality,
|
||||||
avatars,
|
avatars,
|
||||||
queryCount = 3,
|
queryCount = 3,
|
||||||
|
voiceId,
|
||||||
}: {
|
}: {
|
||||||
minutes: number;
|
minutes: number;
|
||||||
scenes: number;
|
scenes: number;
|
||||||
@@ -72,6 +74,7 @@ const estimateCosts = ({
|
|||||||
quality: string;
|
quality: string;
|
||||||
avatars: number;
|
avatars: number;
|
||||||
queryCount?: number;
|
queryCount?: number;
|
||||||
|
voiceId?: string;
|
||||||
}): PodcastEstimate => {
|
}): PodcastEstimate => {
|
||||||
const secs = Math.max(60, minutes * 60);
|
const secs = Math.max(60, minutes * 60);
|
||||||
const ttsCost = (chars / 1000) * 0.05;
|
const ttsCost = (chars / 1000) * 0.05;
|
||||||
@@ -80,12 +83,16 @@ const estimateCosts = ({
|
|||||||
const videoCost = secs * videoRate;
|
const videoCost = secs * videoRate;
|
||||||
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2);
|
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2);
|
||||||
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
|
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
|
||||||
|
const isCustomVoice = Boolean(voiceId && !["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(voiceId));
|
||||||
|
const voiceName = isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " "));
|
||||||
return {
|
return {
|
||||||
ttsCost: +ttsCost.toFixed(2),
|
ttsCost: +ttsCost.toFixed(2),
|
||||||
avatarCost: +avatarCost.toFixed(2),
|
avatarCost: +avatarCost.toFixed(2),
|
||||||
videoCost: +videoCost.toFixed(2),
|
videoCost: +videoCost.toFixed(2),
|
||||||
researchCost,
|
researchCost,
|
||||||
total,
|
total,
|
||||||
|
voiceName,
|
||||||
|
isCustomVoice,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,7 +181,6 @@ type ExaResearchResult = {
|
|||||||
|
|
||||||
const mapExaResearchResponse = (response: any): Research => {
|
const mapExaResearchResponse = (response: any): Research => {
|
||||||
const factCards = mapSourcesToFacts(response.sources);
|
const factCards = mapSourcesToFacts(response.sources);
|
||||||
// Use backend summary if available, otherwise use full content (no truncation) or fallback text
|
|
||||||
const summary = response.summary || response.content || "Research completed.";
|
const summary = response.summary || response.content || "Research completed.";
|
||||||
|
|
||||||
const keyInsights = (response.key_insights || []).map((insight: any) => ({
|
const keyInsights = (response.key_insights || []).map((insight: any) => ({
|
||||||
@@ -183,11 +189,26 @@ const mapExaResearchResponse = (response: any): Research => {
|
|||||||
source_indices: insight.source_indices || []
|
source_indices: insight.source_indices || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const expertQuotes = (response.expert_quotes || []).map((eq: any) => ({
|
||||||
|
quote: eq.quote || eq.text || "",
|
||||||
|
source_index: eq.source_index ?? 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const listenerCta = response.listener_cta_suggestions || response.listener_cta || [];
|
||||||
|
|
||||||
|
const mappedAngles = (response.mapped_angles || []).map((angle: any) => ({
|
||||||
|
title: angle.title || "",
|
||||||
|
why: angle.why || angle.rationale || "",
|
||||||
|
mappedFactIds: angle.mapped_fact_ids || angle.mappedFactIds || []
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary,
|
summary,
|
||||||
keyInsights,
|
keyInsights,
|
||||||
factCards,
|
factCards,
|
||||||
mappedAngles: [],
|
mappedAngles,
|
||||||
|
expertQuotes,
|
||||||
|
listenerCta,
|
||||||
searchQueries: response.search_queries,
|
searchQueries: response.search_queries,
|
||||||
searchType: response.search_type,
|
searchType: response.search_type,
|
||||||
provider: response.provider || "exa",
|
provider: response.provider || "exa",
|
||||||
@@ -225,7 +246,8 @@ export const podcastApi = {
|
|||||||
speakers: payload.speakers,
|
speakers: payload.speakers,
|
||||||
bible: bible,
|
bible: bible,
|
||||||
avatar_url: payload.avatarUrl,
|
avatar_url: payload.avatarUrl,
|
||||||
feedback: feedback, // Pass feedback to backend
|
feedback: feedback,
|
||||||
|
podcast_mode: payload.podcastMode, // Pass mode to skip avatar for audio_only
|
||||||
});
|
});
|
||||||
|
|
||||||
const outlines = (analysisResp.data?.suggested_outlines || []).map((o: any, idx: number) => ({
|
const outlines = (analysisResp.data?.suggested_outlines || []).map((o: any, idx: number) => ({
|
||||||
@@ -249,7 +271,7 @@ export const podcastApi = {
|
|||||||
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
|
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const researchConfig = await getResearchConfig().catch(() => null);
|
const researchConfig = isPodcastOnlyDemoMode() ? null : await getResearchConfig();
|
||||||
|
|
||||||
// Use AI-generated queries if available, fallback to legacy mapping
|
// Use AI-generated queries if available, fallback to legacy mapping
|
||||||
let queries: Query[] = [];
|
let queries: Query[] = [];
|
||||||
@@ -275,6 +297,7 @@ export const podcastApi = {
|
|||||||
quality: payload.knobs.bitrate || "standard",
|
quality: payload.knobs.bitrate || "standard",
|
||||||
avatars: payload.speakers,
|
avatars: payload.speakers,
|
||||||
queryCount: queries.length || 3,
|
queryCount: queries.length || 3,
|
||||||
|
voiceId: payload.knobs.voice_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -899,6 +922,34 @@ export const podcastApi = {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async generateBatchAudio(params: {
|
||||||
|
scenes: { id: string; title: string; lines: { text: string }[] }[];
|
||||||
|
voiceId: string;
|
||||||
|
customVoiceId?: string;
|
||||||
|
speed: number;
|
||||||
|
emotion: string;
|
||||||
|
englishNormalization?: boolean;
|
||||||
|
projectId?: string;
|
||||||
|
}): Promise<{ results: any[] }> {
|
||||||
|
await ensurePreflight({
|
||||||
|
provider: "wavespeed",
|
||||||
|
operation_type: "tts_generation",
|
||||||
|
tokens_requested: 1000,
|
||||||
|
actual_provider_name: "wavespeed",
|
||||||
|
});
|
||||||
|
const response = await aiApiClient.post('/api/podcast/audio/batch', params);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateChartPreview(params: {
|
||||||
|
chart_data: Record<string, any>;
|
||||||
|
chart_type: string;
|
||||||
|
title: string;
|
||||||
|
}): Promise<{ image_url: string; preview_url: string; chart_id: string }> {
|
||||||
|
const response = await aiApiClient.post('/api/podcast/chart/preview', params);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PodcastApi = typeof podcastApi;
|
export type PodcastApi = typeof podcastApi;
|
||||||
|
|||||||
Reference in New Issue
Block a user