Spaces:
Paused
Paused
Commit ·
3d2098f
0
Parent(s):
initial commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +51 -0
- .gitignore +28 -0
- README.md +208 -0
- assets/music/.gitkeep +0 -0
- cli.py +179 -0
- core/__init__.py +0 -0
- core/adapters/__init__.py +30 -0
- core/adapters/alignment.py +42 -0
- core/adapters/base.py +119 -0
- core/adapters/gemini_keys.py +68 -0
- core/adapters/llm_gemini.py +166 -0
- core/adapters/music_local.py +37 -0
- core/adapters/refdata_youtube.py +143 -0
- core/adapters/sfx_freesound.py +59 -0
- core/adapters/stock_pexels.py +87 -0
- core/adapters/transcript_youtube.py +72 -0
- core/adapters/tts_edge.py +63 -0
- core/adapters/tts_elevenlabs.py +83 -0
- core/adapters/tts_gemini.py +80 -0
- core/captions.py +313 -0
- core/config.py +107 -0
- core/contracts.py +359 -0
- core/pipeline.py +387 -0
- core/stages/__init__.py +0 -0
- core/stages/clone.py +94 -0
- core/stages/media.py +155 -0
- core/stages/render.py +218 -0
- core/stages/script.py +178 -0
- core/stages/setup.py +146 -0
- core/stages/style.py +158 -0
- core/stages/topics.py +213 -0
- core/stages/voice.py +64 -0
- core/store.py +246 -0
- core/utils.py +115 -0
- projects/.gitkeep +0 -0
- requirements.txt +21 -0
- server/__init__.py +0 -0
- server/app.py +551 -0
- server/jobs.py +71 -0
- tests/__init__.py +0 -0
- tests/mocks.py +213 -0
- tests/test_server.py +180 -0
- tests/test_smoke.py +199 -0
- web/index.html +12 -0
- web/package-lock.json +1680 -0
- web/package.json +19 -0
- web/src/App.jsx +184 -0
- web/src/api.js +42 -0
- web/src/components/CloneBar.jsx +41 -0
- web/src/components/PromptPanel.jsx +63 -0
.env.example
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reel Studio v2 — API keys
|
| 2 |
+
# Copy this file to .env and fill in what you have.
|
| 3 |
+
# The app starts and degrades gracefully with ONLY the first two set.
|
| 4 |
+
|
| 5 |
+
# ── REQUIRED ────────────────────────────────────────────────────────────────
|
| 6 |
+
|
| 7 |
+
# Gemini (LLM — scripts, topics, style). FREE.
|
| 8 |
+
# Sign up: https://aistudio.google.com/apikey
|
| 9 |
+
# IMPORTANT: must be an AI STUDIO key. A Google Cloud Console key tied to a
|
| 10 |
+
# billing project shows free-tier quota 0 and returns 429 immediately.
|
| 11 |
+
#
|
| 12 |
+
# KEY ROTATION: to spread the free-tier rate limit, set several keys. Either
|
| 13 |
+
# put them comma-separated here, or use GEMINI_API_KEYS below. The adapter
|
| 14 |
+
# rotates through them (and drops any that come back invalid/leaked).
|
| 15 |
+
GEMINI_API_KEY=
|
| 16 |
+
# Optional extra keys to rotate (comma-separated). Merged with GEMINI_API_KEY.
|
| 17 |
+
#GEMINI_API_KEYS=key2,key3,key4
|
| 18 |
+
|
| 19 |
+
# Pexels (stock video clips). FREE.
|
| 20 |
+
# Sign up: https://www.pexels.com/api/
|
| 21 |
+
PEXELS_API_KEY=
|
| 22 |
+
|
| 23 |
+
# ── RECOMMENDED ─────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
# YouTube Data API v3 (reference channels: top Shorts, channel lookup). FREE.
|
| 26 |
+
# Enable "YouTube Data API v3" at https://console.cloud.google.com/apis/library
|
| 27 |
+
# then create an API key under Credentials.
|
| 28 |
+
YOUTUBE_API_KEY=
|
| 29 |
+
|
| 30 |
+
# ── OPTIONAL ────────────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
# Freesound (sound effects). FREE. Skipped cleanly when empty.
|
| 33 |
+
# Sign up: https://freesound.org/apiv2/apply/
|
| 34 |
+
FREESOUND_API_KEY=
|
| 35 |
+
|
| 36 |
+
# ElevenLabs (premium voices). Paid/free trial. edge-tts is the free default.
|
| 37 |
+
# Sign up: https://elevenlabs.io/
|
| 38 |
+
ELEVENLABS_API_KEY=
|
| 39 |
+
|
| 40 |
+
# ── ADAPTER SELECTION (defaults shown; change to swap providers) ────────────
|
| 41 |
+
#ADAPTER_LLM=gemini
|
| 42 |
+
#ADAPTER_TTS=edge_tts # edge_tts | gemini_tts | elevenlabs
|
| 43 |
+
#ADAPTER_STOCK=pexels
|
| 44 |
+
#ADAPTER_SFX=freesound
|
| 45 |
+
#ADAPTER_MUSIC=local
|
| 46 |
+
#ADAPTER_REFDATA=youtube
|
| 47 |
+
#ADAPTER_TRANSCRIPT=youtube
|
| 48 |
+
|
| 49 |
+
# ── TUNING ──────────────────────────────────────────────────────────────────
|
| 50 |
+
#GEMINI_MODELS=gemini-2.0-flash,gemini-2.5-flash,gemini-flash-latest # fallback order
|
| 51 |
+
#EDGE_TTS_VOICE=en-US-ChristopherNeural
|
.gitignore
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
|
| 7 |
+
# Secrets — never commit real keys. .env.example IS tracked (it's the template).
|
| 8 |
+
.env
|
| 9 |
+
.env.*
|
| 10 |
+
!.env.example
|
| 11 |
+
*.key
|
| 12 |
+
*.pem
|
| 13 |
+
secrets.json
|
| 14 |
+
|
| 15 |
+
# Per-project data + outputs
|
| 16 |
+
projects/*
|
| 17 |
+
!projects/.gitkeep
|
| 18 |
+
|
| 19 |
+
# User-supplied music (keep the folder, not the files)
|
| 20 |
+
assets/music/*
|
| 21 |
+
!assets/music/.gitkeep
|
| 22 |
+
|
| 23 |
+
# Web
|
| 24 |
+
web/node_modules/
|
| 25 |
+
web/dist/
|
| 26 |
+
|
| 27 |
+
# OS
|
| 28 |
+
.DS_Store
|
README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reel Studio v2
|
| 2 |
+
|
| 3 |
+
An AI short-form video pipeline with a web UI. You define a niche and a few
|
| 4 |
+
reference channels; the app suggests topics from live signals, writes a
|
| 5 |
+
script in the *style of your references*, voices it, gathers stock clips,
|
| 6 |
+
and renders 2–3 draft 9:16 reel variants you can compare, tweak, and
|
| 7 |
+
download. Everything runs locally on free-tier APIs.
|
| 8 |
+
|
| 9 |
+
There's also a **Clone a reel** flow: paste a YouTube Shorts URL and the app
|
| 10 |
+
extracts its topic and structure, then produces a *fresh* video — original
|
| 11 |
+
wording, different clips, different captions. Source footage, audio, and
|
| 12 |
+
verbatim text are never reused.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Setup (≈10 minutes)
|
| 17 |
+
|
| 18 |
+
### 1. Prerequisites
|
| 19 |
+
|
| 20 |
+
- **Python 3.11+** (`python3 --version`)
|
| 21 |
+
- **Node 18+** (`node --version`)
|
| 22 |
+
- **FFmpeg** with ffprobe (`brew install ffmpeg` on macOS)
|
| 23 |
+
|
| 24 |
+
### 2. Install
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
cd reel-maker-v2
|
| 28 |
+
python3 -m venv .venv
|
| 29 |
+
.venv/bin/pip install -r requirements.txt
|
| 30 |
+
cd web && npm install && cd ..
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### 3. API keys
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
cp .env.example .env
|
| 37 |
+
# open .env and paste your keys
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
Only two keys are required — the app degrades gracefully without the rest:
|
| 41 |
+
|
| 42 |
+
| Key | What it powers | Where to get it (free) |
|
| 43 |
+
|---|---|---|
|
| 44 |
+
| `GEMINI_API_KEY` | scripts, topics, style | https://aistudio.google.com/apikey — **must be an AI Studio key**; a Cloud Console key with billing shows free-tier quota 0 and 429s immediately |
|
| 45 |
+
| `PEXELS_API_KEY` | stock video clips | https://www.pexels.com/api/ |
|
| 46 |
+
| `YOUTUBE_API_KEY` | reference channels (top Shorts, transcripts source) | https://console.cloud.google.com/apis/library → enable *YouTube Data API v3* → Credentials → API key |
|
| 47 |
+
| `FREESOUND_API_KEY` | optional sound effects | https://freesound.org/apiv2/apply/ |
|
| 48 |
+
| `ELEVENLABS_API_KEY` | optional premium voices | https://elevenlabs.io/ |
|
| 49 |
+
|
| 50 |
+
The free default voice provider is **edge-tts** (no key needed at all).
|
| 51 |
+
|
| 52 |
+
### 4. Background music (optional, 2 minutes)
|
| 53 |
+
|
| 54 |
+
Music is mixed from the local `assets/music/` folder only. Download a few
|
| 55 |
+
**royalty-free** tracks from [Pixabay Music](https://pixabay.com/music/) or
|
| 56 |
+
the [YouTube Audio Library](https://studio.youtube.com → Audio Library) and
|
| 57 |
+
drop the MP3s into `assets/music/`. No files there = no background music,
|
| 58 |
+
everything else still works. The app never fetches music from the network —
|
| 59 |
+
only royalty-free files you placed yourself are ever mixed in.
|
| 60 |
+
|
| 61 |
+
### 5. Run it
|
| 62 |
+
|
| 63 |
+
Terminal 1 — backend:
|
| 64 |
+
```bash
|
| 65 |
+
.venv/bin/uvicorn server.app:app --port 8000
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
Terminal 2 — frontend:
|
| 69 |
+
```bash
|
| 70 |
+
cd web && npm run dev
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
Open **http://localhost:5173** and follow the wizard:
|
| 74 |
+
**Setup → References → Topics → Script → Media → Render → Review.**
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## Projects and reels
|
| 79 |
+
|
| 80 |
+
A **project** holds your niche, reference channels, style profile, and topic
|
| 81 |
+
pool — all shared. Within it you create any number of **reels**, each its own
|
| 82 |
+
video (topic → script → voice → media → renders). Switch between reels in the
|
| 83 |
+
left sidebar without losing progress on the others; *+ New reel* starts a
|
| 84 |
+
fresh one. References, the style profile, and topics are built once and reused
|
| 85 |
+
across every reel.
|
| 86 |
+
|
| 87 |
+
## Editable prompts (QC every AI step)
|
| 88 |
+
|
| 89 |
+
Every generative step — *Suggest channels*, *Style profile*, *Topics*, and
|
| 90 |
+
*Script* — has a **▸ Show / edit prompt (advanced)** toggle. It reveals the
|
| 91 |
+
exact text sent to the model (with live data already filled in). Edit it to
|
| 92 |
+
steer the output, then run; *↺ Reset to default* restores the original. The
|
| 93 |
+
outputs are editable too: the **style card**, the **topic list** (inline),
|
| 94 |
+
and the **script** (every field). The exact prompt used is saved alongside
|
| 95 |
+
each result for reproducibility.
|
| 96 |
+
|
| 97 |
+
## How the wizard flows
|
| 98 |
+
|
| 99 |
+
1. **Setup** — name the project, describe the niche (topic, keywords,
|
| 100 |
+
subreddits, audience, tone — or hit *✨ Autofill*), pick YouTube Short or
|
| 101 |
+
Instagram Reel (same 9:16 video, different label).
|
| 102 |
+
2. **References** — add YouTube channels (or *✨ Suggest channels*). Then
|
| 103 |
+
*Build style profile*: pulls transcripts of each reference's top recent
|
| 104 |
+
Shorts and distills how they write. Edit the prompt before, the card after.
|
| 105 |
+
3. **Topics** — choose how many, generate, edit inline, then *Create a reel*
|
| 106 |
+
for the one you pick.
|
| 107 |
+
4. **Script** (per reel) — scene-by-scene script in your references' style.
|
| 108 |
+
Pick one of 3 hooks, edit any field, regenerate one scene or the whole
|
| 109 |
+
thing (with prompt editing).
|
| 110 |
+
5. **Media** (per reel) — pick a **voice provider** (Gemini = natural default,
|
| 111 |
+
ElevenLabs if you add a key, edge-tts = free fallback) and voice, synthesize,
|
| 112 |
+
then gather clips. Each scene keeps 4 candidates — *Swap* cycles them.
|
| 113 |
+
6. **Render** (per reel) — 2–3 variants with different clips and caption styles.
|
| 114 |
+
7. **Review** (per reel) — side-by-side players, pick one, tweak (swap clip /
|
| 115 |
+
caption preset) and re-render, then **Download**.
|
| 116 |
+
|
| 117 |
+
**Clone a reel:** the bar at the top of every step. Paste a Shorts URL → it
|
| 118 |
+
creates a *new reel* with a fresh script on the same topic and opens it at the
|
| 119 |
+
Script step. The source URL is stored for attribution. If the video has no
|
| 120 |
+
obtainable transcript, the app refuses with a clear message.
|
| 121 |
+
|
| 122 |
+
## CLI (debug path)
|
| 123 |
+
|
| 124 |
+
The whole pipeline also runs headless, no server needed:
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
.venv/bin/python cli.py init --name "Deep Sea" --niche "deep sea creatures" \
|
| 128 |
+
--keywords "ocean,marine biology" --subreddits "thalassophobia" \
|
| 129 |
+
--refs "@NatGeo"
|
| 130 |
+
.venv/bin/python cli.py run-all <project_id> # everything, end to end
|
| 131 |
+
# …or stage by stage:
|
| 132 |
+
.venv/bin/python cli.py style <project_id>
|
| 133 |
+
.venv/bin/python cli.py topics <project_id> -n 8
|
| 134 |
+
.venv/bin/python cli.py script <project_id> --topic "..."
|
| 135 |
+
.venv/bin/python cli.py voice <project_id>
|
| 136 |
+
.venv/bin/python cli.py media <project_id>
|
| 137 |
+
.venv/bin/python cli.py render <project_id>
|
| 138 |
+
.venv/bin/python cli.py clone <project_id> --url https://www.youtube.com/shorts/XXXX
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
Outputs land in `projects/<id>/renders/`.
|
| 142 |
+
|
| 143 |
+
## Tests
|
| 144 |
+
|
| 145 |
+
Fully offline — the LLM, TTS, stock, and YouTube adapters are mocked with
|
| 146 |
+
local generators, but the real FFmpeg render path runs:
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
.venv/bin/python -m pytest tests/ -q
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
## Architecture
|
| 153 |
+
|
| 154 |
+
```
|
| 155 |
+
core/ pure Python pipeline — importable without FastAPI
|
| 156 |
+
contracts.py every shared dataclass (the only inter-stage language)
|
| 157 |
+
adapters/ one interface + registry per external capability
|
| 158 |
+
stages/ setup, style, topics, script, voice, media, render, clone
|
| 159 |
+
pipeline.py the only place stages are wired together
|
| 160 |
+
server/ FastAPI: routes + background job manager (POST → job_id → poll)
|
| 161 |
+
web/ React + Vite wizard (plain CSS)
|
| 162 |
+
assets/music/ your royalty-free tracks
|
| 163 |
+
projects/ per-project JSON + media outputs (gitignored)
|
| 164 |
+
cli.py headless entry point
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
Rules the code follows:
|
| 168 |
+
|
| 169 |
+
- Stages import only `contracts.py` and adapters — never each other.
|
| 170 |
+
- Every network call fails soft: a missing SFX or one failed clip never
|
| 171 |
+
kills a run; the stage logs, degrades, and continues.
|
| 172 |
+
- All timing downstream of TTS is driven by the **measured** voice-track
|
| 173 |
+
durations (ffprobe), not by the script's estimates.
|
| 174 |
+
- Captions are rendered as transparent PNGs with Pillow and compiled into
|
| 175 |
+
a single qtrle overlay track — one overlay pass for the whole video.
|
| 176 |
+
(FFmpeg `drawtext`/libass are deliberately not used: many builds lack
|
| 177 |
+
them, and per-word overlay filters are pathologically slow.)
|
| 178 |
+
|
| 179 |
+
### Adding a provider
|
| 180 |
+
|
| 181 |
+
1. Create `core/adapters/<capability>_<name>.py`, subclass the interface
|
| 182 |
+
from `core/adapters/base.py`, call `register("<capability>", "<name>", YourClass)`.
|
| 183 |
+
2. Import the module in `core/adapters/__init__.py`.
|
| 184 |
+
3. Select it with `ADAPTER_<CAPABILITY>=<name>` in `.env`.
|
| 185 |
+
|
| 186 |
+
Nothing else changes — stages only know the interface.
|
| 187 |
+
|
| 188 |
+
## Known limitations
|
| 189 |
+
|
| 190 |
+
- **Instagram references are style-only.** They are never fetched or
|
| 191 |
+
scraped: scraping violates Instagram's ToS and there is no public
|
| 192 |
+
content API. Your written notes about the page feed the style prompt
|
| 193 |
+
as text instead.
|
| 194 |
+
- **Trending audio is not embedded.** Platform-licensed sounds (TikTok /
|
| 195 |
+
Reels / Shorts trending audio) are licensed for use *inside those apps
|
| 196 |
+
only*. Add trending audio in the app at posting time; renders here mix
|
| 197 |
+
only your local royalty-free tracks.
|
| 198 |
+
- **Karaoke captions and the default Gemini voice.** Gemini TTS sounds the
|
| 199 |
+
most natural of the free options but emits no word timings, so faster-whisper
|
| 200 |
+
(installed by default) re-transcribes the audio to recover them. Because the
|
| 201 |
+
karaoke words then come from speech recognition, they can differ slightly
|
| 202 |
+
from the script wording; the `minimal` caption preset uses the exact script
|
| 203 |
+
text instead. edge-tts and ElevenLabs provide native timings (no whisper
|
| 204 |
+
needed). The first whisper run downloads a ~75MB model.
|
| 205 |
+
- **Long-form is not built yet.** The format field reserves it; the
|
| 206 |
+
render stage is 9:16 only.
|
| 207 |
+
- Free-tier quotas (Gemini, YouTube, Pexels) are generous for a few reels
|
| 208 |
+
a day but real; the app retries with backoff and falls back where it can.
|
assets/music/.gitkeep
ADDED
|
File without changes
|
cli.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Reel Studio v2 CLI — run the whole pipeline headless (the debug path).
|
| 3 |
+
|
| 4 |
+
Examples:
|
| 5 |
+
python cli.py init --name "Space Facts" --niche "astronomy facts" \
|
| 6 |
+
--keywords "space,nasa,astronomy" --subreddits "space,astronomy" \
|
| 7 |
+
--refs "@besmartchannel,@AstroKobi"
|
| 8 |
+
python cli.py style <project_id>
|
| 9 |
+
python cli.py topics <project_id> -n 8
|
| 10 |
+
python cli.py script <project_id> --topic "Why Venus spins backwards"
|
| 11 |
+
python cli.py voice <project_id> --voice en-US-GuyNeural
|
| 12 |
+
python cli.py media <project_id>
|
| 13 |
+
python cli.py render <project_id>
|
| 14 |
+
python cli.py clone <project_id> --url https://www.youtube.com/shorts/XXXX
|
| 15 |
+
python cli.py run-all <project_id> [--topic "..."]
|
| 16 |
+
python cli.py list
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import json
|
| 23 |
+
import sys
|
| 24 |
+
|
| 25 |
+
from core.contracts import NicheProfile, ReferenceEntry, to_dict
|
| 26 |
+
from core.pipeline import Pipeline
|
| 27 |
+
from core.utils import log
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _progress(msg: str) -> None:
|
| 31 |
+
log.info(">> %s", msg)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _load_project(pipe: Pipeline, project_id: str):
|
| 35 |
+
project = pipe.store.load_project(project_id)
|
| 36 |
+
if not project:
|
| 37 |
+
sys.exit(f"No project {project_id!r}. Run `python cli.py list`.")
|
| 38 |
+
return project
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _print(obj) -> None:
|
| 42 |
+
print(json.dumps(to_dict(obj), indent=2, ensure_ascii=False))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def main() -> None:
|
| 46 |
+
ap = argparse.ArgumentParser(description="Reel Studio v2 — headless pipeline")
|
| 47 |
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
| 48 |
+
|
| 49 |
+
p = sub.add_parser("init", help="create a project")
|
| 50 |
+
p.add_argument("--name", required=True)
|
| 51 |
+
p.add_argument("--niche", required=True, help="niche topic, e.g. 'astronomy facts'")
|
| 52 |
+
p.add_argument("--keywords", default="", help="comma separated")
|
| 53 |
+
p.add_argument("--subreddits", default="", help="comma separated")
|
| 54 |
+
p.add_argument("--audience", default="")
|
| 55 |
+
p.add_argument("--tone", default="")
|
| 56 |
+
p.add_argument("--format", default="youtube_short",
|
| 57 |
+
choices=["youtube_short", "instagram_reel"])
|
| 58 |
+
p.add_argument("--refs", default="",
|
| 59 |
+
help="comma separated YouTube @handles / URLs / names")
|
| 60 |
+
|
| 61 |
+
p = sub.add_parser("suggest-refs", help="AI-suggest reference channels")
|
| 62 |
+
p.add_argument("project_id")
|
| 63 |
+
p.add_argument("-n", type=int, default=5)
|
| 64 |
+
|
| 65 |
+
p = sub.add_parser("style", help="build/refresh the style profile")
|
| 66 |
+
p.add_argument("project_id")
|
| 67 |
+
|
| 68 |
+
p = sub.add_parser("topics", help="generate ranked topics")
|
| 69 |
+
p.add_argument("project_id")
|
| 70 |
+
p.add_argument("-n", type=int, default=8)
|
| 71 |
+
|
| 72 |
+
p = sub.add_parser("reels", help="list reels in a project")
|
| 73 |
+
p.add_argument("project_id")
|
| 74 |
+
|
| 75 |
+
p = sub.add_parser("script", help="generate the script (creates a new reel unless --reel)")
|
| 76 |
+
p.add_argument("project_id")
|
| 77 |
+
p.add_argument("--topic", required=True)
|
| 78 |
+
p.add_argument("--reel", default=None, help="reel id (default: create a new reel)")
|
| 79 |
+
|
| 80 |
+
p = sub.add_parser("voice", help="synthesize the voice track")
|
| 81 |
+
p.add_argument("project_id")
|
| 82 |
+
p.add_argument("--reel", default=None, help="reel id (default: latest reel)")
|
| 83 |
+
p.add_argument("--voice", default="")
|
| 84 |
+
p.add_argument("--provider", default=None, help="tts provider: gemini_tts|elevenlabs|edge_tts")
|
| 85 |
+
|
| 86 |
+
p = sub.add_parser("media", help="gather clips/sfx/music")
|
| 87 |
+
p.add_argument("project_id")
|
| 88 |
+
p.add_argument("--reel", default=None, help="reel id (default: latest reel)")
|
| 89 |
+
|
| 90 |
+
p = sub.add_parser("render", help="render 2-3 variants")
|
| 91 |
+
p.add_argument("project_id")
|
| 92 |
+
p.add_argument("--reel", default=None, help="reel id (default: latest reel)")
|
| 93 |
+
|
| 94 |
+
p = sub.add_parser("clone", help="clone a reel from a YouTube Shorts URL (creates a new reel)")
|
| 95 |
+
p.add_argument("project_id")
|
| 96 |
+
p.add_argument("--url", required=True)
|
| 97 |
+
|
| 98 |
+
p = sub.add_parser("run-all", help="style->topics->script->voice->media->render")
|
| 99 |
+
p.add_argument("project_id")
|
| 100 |
+
p.add_argument("--topic", default=None)
|
| 101 |
+
p.add_argument("--voice", default="")
|
| 102 |
+
|
| 103 |
+
sub.add_parser("list", help="list projects")
|
| 104 |
+
|
| 105 |
+
args = ap.parse_args()
|
| 106 |
+
pipe = Pipeline()
|
| 107 |
+
|
| 108 |
+
if args.cmd == "init":
|
| 109 |
+
refs = [
|
| 110 |
+
ReferenceEntry(kind="youtube", name=r.strip(), url=r.strip() if "/" in r else "")
|
| 111 |
+
for r in args.refs.split(",") if r.strip()
|
| 112 |
+
]
|
| 113 |
+
niche = NicheProfile(
|
| 114 |
+
topic=args.niche,
|
| 115 |
+
keywords=[k.strip() for k in args.keywords.split(",") if k.strip()],
|
| 116 |
+
subreddits=[s.strip() for s in args.subreddits.split(",") if s.strip()],
|
| 117 |
+
audience=args.audience,
|
| 118 |
+
tone=args.tone,
|
| 119 |
+
)
|
| 120 |
+
project = pipe.create_project(args.name, niche, args.format, refs)
|
| 121 |
+
print(f"Created project {project.id}")
|
| 122 |
+
_print(project)
|
| 123 |
+
return
|
| 124 |
+
|
| 125 |
+
if args.cmd == "list":
|
| 126 |
+
for proj in pipe.store.list_projects():
|
| 127 |
+
print(f"{proj.id} {proj.name} [{proj.format}] niche={proj.niche.topic}")
|
| 128 |
+
return
|
| 129 |
+
|
| 130 |
+
project = _load_project(pipe, args.project_id)
|
| 131 |
+
|
| 132 |
+
def resolve_reel(create_topic: str | None = None) -> str:
|
| 133 |
+
"""Use --reel, else the latest reel, else (if create_topic) a new one."""
|
| 134 |
+
if getattr(args, "reel", None):
|
| 135 |
+
return args.reel
|
| 136 |
+
reels = pipe.list_reels(project)
|
| 137 |
+
if reels and create_topic is None:
|
| 138 |
+
return reels[-1].id
|
| 139 |
+
reel = pipe.create_reel(project, topic=create_topic or "")
|
| 140 |
+
print(f"Created reel {reel.id}")
|
| 141 |
+
return reel.id
|
| 142 |
+
|
| 143 |
+
if args.cmd == "suggest-refs":
|
| 144 |
+
_print(pipe.suggest_references(project, n=args.n))
|
| 145 |
+
elif args.cmd == "style":
|
| 146 |
+
_print(pipe.build_style(project, progress=_progress))
|
| 147 |
+
elif args.cmd == "topics":
|
| 148 |
+
_print(pipe.generate_topics(project, n=args.n, progress=_progress))
|
| 149 |
+
elif args.cmd == "reels":
|
| 150 |
+
for r in pipe.list_reels(project):
|
| 151 |
+
print(f"{r.id} {r.name} topic={r.topic!r}")
|
| 152 |
+
elif args.cmd == "script":
|
| 153 |
+
rid = resolve_reel(create_topic=args.topic)
|
| 154 |
+
_print(pipe.generate_script(project, rid, args.topic))
|
| 155 |
+
elif args.cmd == "voice":
|
| 156 |
+
rid = resolve_reel()
|
| 157 |
+
_print(pipe.synthesize_voice(project, rid, voice=args.voice,
|
| 158 |
+
provider=args.provider, progress=_progress))
|
| 159 |
+
elif args.cmd == "media":
|
| 160 |
+
rid = resolve_reel()
|
| 161 |
+
_print(pipe.gather_media(project, rid, _progress))
|
| 162 |
+
elif args.cmd == "render":
|
| 163 |
+
rid = resolve_reel()
|
| 164 |
+
batch = pipe.render(project, rid, progress=_progress)
|
| 165 |
+
_print(batch)
|
| 166 |
+
for r in batch.results:
|
| 167 |
+
print(f"\n{r.variant}: {r.path}")
|
| 168 |
+
elif args.cmd == "clone":
|
| 169 |
+
result = pipe.clone_reel(project, args.url, _progress)
|
| 170 |
+
print(f"Created reel {result['reel'].id}")
|
| 171 |
+
_print(result["script"])
|
| 172 |
+
elif args.cmd == "run-all":
|
| 173 |
+
batch = pipe.run_all(project, topic=args.topic, voice=args.voice, progress=_progress)
|
| 174 |
+
for r in batch.results:
|
| 175 |
+
print(f"{r.variant}: {r.path}")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
if __name__ == "__main__":
|
| 179 |
+
main()
|
core/__init__.py
ADDED
|
File without changes
|
core/adapters/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapter package. Importing it registers every built-in provider.
|
| 2 |
+
|
| 3 |
+
To add a provider: create a module here that subclasses the relevant
|
| 4 |
+
interface from ``base`` and calls ``register(capability, name, factory)``,
|
| 5 |
+
then import it below. Select it via ``ADAPTER_<CAPABILITY>`` in .env.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from core.adapters import ( # noqa: F401 (imports trigger registration)
|
| 9 |
+
llm_gemini,
|
| 10 |
+
music_local,
|
| 11 |
+
refdata_youtube,
|
| 12 |
+
sfx_freesound,
|
| 13 |
+
stock_pexels,
|
| 14 |
+
transcript_youtube,
|
| 15 |
+
tts_edge,
|
| 16 |
+
tts_elevenlabs,
|
| 17 |
+
tts_gemini,
|
| 18 |
+
)
|
| 19 |
+
from core.adapters.base import ( # noqa: F401
|
| 20 |
+
LLMAdapter,
|
| 21 |
+
MusicAdapter,
|
| 22 |
+
ReferenceDataAdapter,
|
| 23 |
+
SFXAdapter,
|
| 24 |
+
StockVideoAdapter,
|
| 25 |
+
TranscriptAdapter,
|
| 26 |
+
TTSAdapter,
|
| 27 |
+
get_adapter,
|
| 28 |
+
register,
|
| 29 |
+
registered,
|
| 30 |
+
)
|
core/adapters/alignment.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Forced-alignment fallback for word timings.
|
| 2 |
+
|
| 3 |
+
When a TTS provider returns no word timings, we try faster-whisper (if
|
| 4 |
+
installed) to recover them from the rendered audio. faster-whisper is an
|
| 5 |
+
OPTIONAL dependency — when missing or failing, return None and the render
|
| 6 |
+
stage degrades to static (per-scene) captions instead of karaoke.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from core.contracts import WordTiming
|
| 14 |
+
from core.utils import log
|
| 15 |
+
|
| 16 |
+
_model = None
|
| 17 |
+
_model_failed = False
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def align_words(audio_path: str, text: str) -> Optional[list[WordTiming]]:
|
| 21 |
+
"""Best-effort word timings for ``audio_path``. None on any failure."""
|
| 22 |
+
global _model, _model_failed
|
| 23 |
+
if _model_failed:
|
| 24 |
+
return None
|
| 25 |
+
try:
|
| 26 |
+
if _model is None:
|
| 27 |
+
from faster_whisper import WhisperModel # type: ignore
|
| 28 |
+
|
| 29 |
+
_model = WhisperModel("tiny", device="cpu", compute_type="int8")
|
| 30 |
+
segments, _info = _model.transcribe(audio_path, word_timestamps=True)
|
| 31 |
+
timings: list[WordTiming] = []
|
| 32 |
+
for seg in segments:
|
| 33 |
+
for w in seg.words or []:
|
| 34 |
+
timings.append(WordTiming(word=w.word.strip(), start=w.start, end=w.end))
|
| 35 |
+
return timings or None
|
| 36 |
+
except ImportError:
|
| 37 |
+
log.info("faster-whisper not installed — captions will be static (pip install faster-whisper to enable karaoke alignment)")
|
| 38 |
+
_model_failed = True
|
| 39 |
+
return None
|
| 40 |
+
except Exception as e:
|
| 41 |
+
log.warning("Forced alignment failed (%s) — falling back to static captions", e)
|
| 42 |
+
return None
|
core/adapters/base.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapter interfaces and the provider registry.
|
| 2 |
+
|
| 3 |
+
One abstract interface per external capability. Concrete providers register
|
| 4 |
+
themselves with :func:`register`; stages obtain instances through
|
| 5 |
+
:func:`get_adapter` using names from :class:`core.config.Config`. Adding a
|
| 6 |
+
provider = one new module + one ``register()`` call.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from abc import ABC, abstractmethod
|
| 12 |
+
from typing import Any, Callable, Optional
|
| 13 |
+
|
| 14 |
+
from core.contracts import ClipCandidate, Topic, WordTiming
|
| 15 |
+
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
# Registry
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
|
| 20 |
+
_REGISTRY: dict[str, dict[str, Callable[..., Any]]] = {}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def register(capability: str, name: str, factory: Callable[..., Any]) -> None:
|
| 24 |
+
"""Register a provider factory under (capability, name).
|
| 25 |
+
|
| 26 |
+
``factory`` receives the :class:`core.config.Config` and returns an
|
| 27 |
+
adapter instance.
|
| 28 |
+
"""
|
| 29 |
+
_REGISTRY.setdefault(capability, {})[name] = factory
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_adapter(capability: str, name: str, config: Any) -> Any:
|
| 33 |
+
"""Instantiate the registered provider, or raise with the known names."""
|
| 34 |
+
providers = _REGISTRY.get(capability, {})
|
| 35 |
+
if name not in providers:
|
| 36 |
+
known = ", ".join(sorted(providers)) or "(none registered)"
|
| 37 |
+
raise KeyError(f"No {capability!r} adapter named {name!r}. Known: {known}")
|
| 38 |
+
return providers[name](config)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def registered(capability: str) -> list[str]:
|
| 42 |
+
return sorted(_REGISTRY.get(capability, {}))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Interfaces
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class LLMAdapter(ABC):
|
| 51 |
+
"""Text + JSON completion with retries and model fallbacks built in."""
|
| 52 |
+
|
| 53 |
+
@abstractmethod
|
| 54 |
+
def complete(self, prompt: str) -> str:
|
| 55 |
+
"""Return the raw text completion."""
|
| 56 |
+
|
| 57 |
+
@abstractmethod
|
| 58 |
+
def complete_json(self, prompt: str) -> Any:
|
| 59 |
+
"""Return parsed JSON (markdown fences stripped, retried on junk)."""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class TTSAdapter(ABC):
|
| 63 |
+
"""Text-to-speech. Returns word timings when the provider supplies them."""
|
| 64 |
+
|
| 65 |
+
@abstractmethod
|
| 66 |
+
def synth(self, text: str, out_path: str, voice: str = "") -> Optional[list[WordTiming]]:
|
| 67 |
+
"""Synthesize ``text`` to ``out_path`` (mp3/wav). Return word timings
|
| 68 |
+
relative to the start of the file, or None if unavailable."""
|
| 69 |
+
|
| 70 |
+
@abstractmethod
|
| 71 |
+
def voices(self) -> list[str]:
|
| 72 |
+
"""Voice names the user can pick from."""
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class StockVideoAdapter(ABC):
|
| 76 |
+
@abstractmethod
|
| 77 |
+
def search(
|
| 78 |
+
self,
|
| 79 |
+
query: str,
|
| 80 |
+
orientation: str = "portrait",
|
| 81 |
+
min_duration: float = 3.0,
|
| 82 |
+
max_duration: float = 30.0,
|
| 83 |
+
per_query: int = 4,
|
| 84 |
+
) -> list[ClipCandidate]:
|
| 85 |
+
"""Return up to ``per_query`` candidates so the UI can offer alternates."""
|
| 86 |
+
|
| 87 |
+
@abstractmethod
|
| 88 |
+
def download(self, candidate: ClipCandidate, out_path: str) -> str:
|
| 89 |
+
"""Download the candidate, set ``local_path`` and return it."""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class SFXAdapter(ABC):
|
| 93 |
+
@abstractmethod
|
| 94 |
+
def fetch(self, query: str, out_path: str, max_duration: float = 4.0) -> Optional[str]:
|
| 95 |
+
"""Download one short sound effect; None if unavailable (fail soft)."""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class MusicAdapter(ABC):
|
| 99 |
+
@abstractmethod
|
| 100 |
+
def pick(self, mood: str = "") -> tuple[str, str]:
|
| 101 |
+
"""Return (path, credit) of a royalty-free background track, or ("", "")."""
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class ReferenceDataAdapter(ABC):
|
| 105 |
+
"""YouTube Data API v3 only. Instagram entries are never fetched."""
|
| 106 |
+
|
| 107 |
+
@abstractmethod
|
| 108 |
+
def resolve_channel(self, name_or_url: str) -> Optional[dict]:
|
| 109 |
+
"""Return {channel_id, title, url, subscribers} or None."""
|
| 110 |
+
|
| 111 |
+
@abstractmethod
|
| 112 |
+
def top_shorts(self, channel_id: str, n: int = 5, since_days: int = 90) -> list[dict]:
|
| 113 |
+
"""Return [{video_id, title, views, likes, published}] sorted by views."""
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class TranscriptAdapter(ABC):
|
| 117 |
+
@abstractmethod
|
| 118 |
+
def fetch(self, video_id: str) -> Optional[str]:
|
| 119 |
+
"""Plain-text transcript of a YouTube video, or None (fail soft)."""
|
core/adapters/gemini_keys.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared Gemini API key rotation + backoff.
|
| 2 |
+
|
| 3 |
+
Used by the TTS adapter (and available to any Gemini-backed adapter). Spreads
|
| 4 |
+
load across multiple keys: rotate on rate-limit/transient errors, drop a key
|
| 5 |
+
that is refused (401/403), back off only once every live key is rate-limited.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import threading
|
| 11 |
+
import time
|
| 12 |
+
from typing import Callable
|
| 13 |
+
|
| 14 |
+
from core.utils import HardAPIError, log
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class KeyRejected(Exception):
|
| 18 |
+
"""A single key was refused (401/403) — try another key."""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class KeyRotator:
|
| 22 |
+
def __init__(self, keys: list[str]):
|
| 23 |
+
self.keys = list(keys)
|
| 24 |
+
self._i = 0
|
| 25 |
+
self._lock = threading.Lock()
|
| 26 |
+
|
| 27 |
+
def _current(self) -> str:
|
| 28 |
+
with self._lock:
|
| 29 |
+
return self.keys[self._i % len(self.keys)]
|
| 30 |
+
|
| 31 |
+
def _rotate(self) -> None:
|
| 32 |
+
with self._lock:
|
| 33 |
+
self._i = (self._i + 1) % len(self.keys)
|
| 34 |
+
|
| 35 |
+
def call(self, do_call: Callable[[str], object], *, label: str,
|
| 36 |
+
rounds: int = 4, base_delay: float = 2.0):
|
| 37 |
+
"""Run ``do_call(key)`` with rotation + backoff.
|
| 38 |
+
|
| 39 |
+
``do_call`` should raise HardAPIError (abort), KeyRejected (drop this
|
| 40 |
+
key), or any other exception (treated as retryable → rotate).
|
| 41 |
+
"""
|
| 42 |
+
rejected: set[str] = set()
|
| 43 |
+
delay = base_delay
|
| 44 |
+
last: Exception | None = None
|
| 45 |
+
for _ in range(rounds):
|
| 46 |
+
for _ in range(len(self.keys)):
|
| 47 |
+
key = self._current()
|
| 48 |
+
if key in rejected:
|
| 49 |
+
self._rotate()
|
| 50 |
+
continue
|
| 51 |
+
try:
|
| 52 |
+
return do_call(key)
|
| 53 |
+
except HardAPIError:
|
| 54 |
+
raise
|
| 55 |
+
except KeyRejected as e:
|
| 56 |
+
log.warning("%s — dropping key …%s for the run", label, key[-6:])
|
| 57 |
+
rejected.add(key)
|
| 58 |
+
self._rotate()
|
| 59 |
+
last = e
|
| 60 |
+
except Exception as e: # 429 / 5xx / network — rotate
|
| 61 |
+
last = e
|
| 62 |
+
self._rotate()
|
| 63 |
+
if len(rejected) >= len(self.keys):
|
| 64 |
+
raise HardAPIError(f"{label}: all Gemini keys were rejected (401/403)")
|
| 65 |
+
log.warning("%s: all live keys rate-limited — backing off %.0fs", label, delay)
|
| 66 |
+
time.sleep(delay)
|
| 67 |
+
delay *= 2
|
| 68 |
+
raise last or RuntimeError(f"{label}: exhausted retries")
|
core/adapters/llm_gemini.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini LLM adapter (free tier, AI Studio keys) with key rotation.
|
| 2 |
+
|
| 3 |
+
Behavior hardened by v1 experience:
|
| 4 |
+
- retry with exponential backoff on 429/500/503,
|
| 5 |
+
- ordered model fallbacks (config.gemini_models),
|
| 6 |
+
- markdown fences stripped before JSON parsing.
|
| 7 |
+
|
| 8 |
+
Key rotation (v2): give it several keys and it spreads load across them.
|
| 9 |
+
- 429/5xx (rate limit / transient): rotate to the next key immediately; only
|
| 10 |
+
after every key has been tried this round do we back off and sleep. This
|
| 11 |
+
turns N keys into roughly N× the free-tier throughput.
|
| 12 |
+
- 401/403 (invalid / leaked / permission): that key is dropped for the run;
|
| 13 |
+
the call retries on the remaining keys. Only when ALL keys are rejected do
|
| 14 |
+
we hard-fail with a clear message.
|
| 15 |
+
- 404 (model not found): advance to the next model, not the next key.
|
| 16 |
+
- 400 (bad request): hard-fail immediately — not a key or model problem.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import threading
|
| 23 |
+
import time
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
import requests
|
| 27 |
+
|
| 28 |
+
from core.adapters.base import LLMAdapter, register
|
| 29 |
+
from core.utils import HardAPIError, extract_json, log
|
| 30 |
+
|
| 31 |
+
_API = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class _KeyRejected(Exception):
|
| 35 |
+
"""A single key was refused (401/403) — try another key."""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class _ModelUnavailable(Exception):
|
| 39 |
+
"""A model was not found (404) — try the next model."""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class _AllKeysRejected(Exception):
|
| 43 |
+
"""Every key was refused — no point trying more models."""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class GeminiLLM(LLMAdapter):
|
| 47 |
+
def __init__(self, config):
|
| 48 |
+
self.keys = list(config.gemini_api_keys)
|
| 49 |
+
if not self.keys:
|
| 50 |
+
raise HardAPIError(
|
| 51 |
+
"No Gemini API key set. Put one in GEMINI_API_KEY (or several, "
|
| 52 |
+
"comma-separated, in GEMINI_API_KEY / GEMINI_API_KEYS to rotate). "
|
| 53 |
+
"Free keys: https://aistudio.google.com/apikey — must be AI Studio "
|
| 54 |
+
"keys; Cloud Console keys show free-tier quota 0 and 429 immediately."
|
| 55 |
+
)
|
| 56 |
+
self.models = list(config.gemini_models)
|
| 57 |
+
self._idx = 0 # rotation cursor, shared across job-manager threads
|
| 58 |
+
self._lock = threading.Lock()
|
| 59 |
+
|
| 60 |
+
# -- key rotation -------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def _current_key(self) -> str:
|
| 63 |
+
with self._lock:
|
| 64 |
+
return self.keys[self._idx % len(self.keys)]
|
| 65 |
+
|
| 66 |
+
def _rotate(self) -> None:
|
| 67 |
+
with self._lock:
|
| 68 |
+
self._idx = (self._idx + 1) % len(self.keys)
|
| 69 |
+
|
| 70 |
+
# -- one HTTP call ------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
def _call(self, model: str, key: str, prompt: str) -> str:
|
| 73 |
+
resp = requests.post(
|
| 74 |
+
_API.format(model=model),
|
| 75 |
+
params={"key": key},
|
| 76 |
+
json={"contents": [{"parts": [{"text": prompt}]}]},
|
| 77 |
+
timeout=120,
|
| 78 |
+
)
|
| 79 |
+
code = resp.status_code
|
| 80 |
+
if code == 400:
|
| 81 |
+
raise HardAPIError(f"Gemini {model} HTTP 400 (bad request): {resp.text[:300]}")
|
| 82 |
+
if code in (401, 403):
|
| 83 |
+
raise _KeyRejected(f"Gemini key …{key[-6:]} HTTP {code}: {resp.text[:200]}")
|
| 84 |
+
if code == 404:
|
| 85 |
+
raise _ModelUnavailable(f"Gemini model {model} not found (404)")
|
| 86 |
+
if code in (429, 500, 503):
|
| 87 |
+
raise RuntimeError(f"Gemini {model} HTTP {code} (retryable)")
|
| 88 |
+
resp.raise_for_status()
|
| 89 |
+
data = resp.json()
|
| 90 |
+
try:
|
| 91 |
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
| 92 |
+
except (KeyError, IndexError) as e:
|
| 93 |
+
raise RuntimeError(f"Gemini {model} returned no text: {json.dumps(data)[:300]}") from e
|
| 94 |
+
|
| 95 |
+
# -- one model, all keys, with backoff ----------------------------------
|
| 96 |
+
|
| 97 |
+
def _complete_model(self, model: str, prompt: str, rejected: set[str]) -> str:
|
| 98 |
+
n = len(self.keys)
|
| 99 |
+
delay = 2.0
|
| 100 |
+
last: Exception | None = None
|
| 101 |
+
for _round in range(4): # backoff rounds once all live keys are rate-limited
|
| 102 |
+
for _ in range(n):
|
| 103 |
+
key = self._current_key()
|
| 104 |
+
if key in rejected: # already known-bad this run
|
| 105 |
+
self._rotate()
|
| 106 |
+
continue
|
| 107 |
+
try:
|
| 108 |
+
return self._call(model, key, prompt)
|
| 109 |
+
except HardAPIError:
|
| 110 |
+
raise
|
| 111 |
+
except _ModelUnavailable:
|
| 112 |
+
raise
|
| 113 |
+
except _KeyRejected as e:
|
| 114 |
+
log.warning("%s — dropping this key for the run", e)
|
| 115 |
+
rejected.add(key)
|
| 116 |
+
self._rotate()
|
| 117 |
+
last = e
|
| 118 |
+
except Exception as e: # 429 / 5xx — rotate to the next key
|
| 119 |
+
last = e
|
| 120 |
+
self._rotate()
|
| 121 |
+
live = [k for k in self.keys if k not in rejected]
|
| 122 |
+
if not live:
|
| 123 |
+
raise _AllKeysRejected("All Gemini keys were rejected (401/403).")
|
| 124 |
+
# Every live key is rate-limited right now — wait, then retry.
|
| 125 |
+
log.warning(
|
| 126 |
+
"All %d live Gemini key(s) rate-limited on %s — backing off %.0fs",
|
| 127 |
+
len(live), model, delay,
|
| 128 |
+
)
|
| 129 |
+
time.sleep(delay)
|
| 130 |
+
delay *= 2
|
| 131 |
+
raise last or RuntimeError(f"Gemini {model} exhausted retries")
|
| 132 |
+
|
| 133 |
+
# -- interface ----------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
def complete(self, prompt: str) -> str:
|
| 136 |
+
rejected: set[str] = set() # keys refused, shared across model attempts
|
| 137 |
+
errors: list[str] = []
|
| 138 |
+
for model in self.models:
|
| 139 |
+
try:
|
| 140 |
+
return self._complete_model(model, prompt, rejected)
|
| 141 |
+
except HardAPIError:
|
| 142 |
+
raise
|
| 143 |
+
except _AllKeysRejected as e:
|
| 144 |
+
raise HardAPIError(str(e)) from e
|
| 145 |
+
except _ModelUnavailable as e:
|
| 146 |
+
log.warning("%s — trying next model", e)
|
| 147 |
+
errors.append(str(e))
|
| 148 |
+
except Exception as e:
|
| 149 |
+
log.warning("Model %s exhausted retries (%s); falling back", model, e)
|
| 150 |
+
errors.append(f"{model}: {e}")
|
| 151 |
+
raise RuntimeError("All Gemini models/keys failed: " + "; ".join(errors))
|
| 152 |
+
|
| 153 |
+
def complete_json(self, prompt: str) -> Any:
|
| 154 |
+
# One extra round trip if the first response isn't valid JSON.
|
| 155 |
+
text = self.complete(prompt)
|
| 156 |
+
try:
|
| 157 |
+
return extract_json(text)
|
| 158 |
+
except ValueError:
|
| 159 |
+
log.warning("LLM response was not JSON; asking it to reformat")
|
| 160 |
+
text = self.complete(
|
| 161 |
+
"Convert the following into VALID JSON only — no prose, no markdown fences:\n\n" + text
|
| 162 |
+
)
|
| 163 |
+
return extract_json(text)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
register("llm", "gemini", GeminiLLM)
|
core/adapters/music_local.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local-folder music adapter.
|
| 2 |
+
|
| 3 |
+
COPYRIGHT RULE: only royalty-free music is ever mixed into a render. The
|
| 4 |
+
user downloads tracks themselves (Pixabay Music, YouTube Audio Library —
|
| 5 |
+
both download-only, no public API) into ``assets/music/``. We never fetch
|
| 6 |
+
arbitrary music from the network.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
|
| 13 |
+
from core.adapters.base import MusicAdapter, register
|
| 14 |
+
from core.utils import log
|
| 15 |
+
|
| 16 |
+
_EXTS = {".mp3", ".m4a", ".wav", ".ogg", ".flac"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class LocalMusic(MusicAdapter):
|
| 20 |
+
def __init__(self, config):
|
| 21 |
+
self.music_dir = config.music_dir
|
| 22 |
+
|
| 23 |
+
def pick(self, mood: str = "") -> tuple[str, str]:
|
| 24 |
+
tracks = sorted(
|
| 25 |
+
p for p in self.music_dir.glob("*") if p.suffix.lower() in _EXTS
|
| 26 |
+
)
|
| 27 |
+
if not tracks:
|
| 28 |
+
log.info("No music files in %s — rendering without background music", self.music_dir)
|
| 29 |
+
return "", ""
|
| 30 |
+
# Deterministic pick keyed on the mood string so re-renders of the
|
| 31 |
+
# same project reuse the same track (Date-free, test-friendly).
|
| 32 |
+
idx = int(hashlib.sha1(mood.encode()).hexdigest(), 16) % len(tracks)
|
| 33 |
+
track = tracks[idx]
|
| 34 |
+
return str(track), f"local: {track.name}"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
register("music", "local", LocalMusic)
|
core/adapters/refdata_youtube.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""YouTube Data API v3 reference-data adapter (free key).
|
| 2 |
+
|
| 3 |
+
This is the ONLY place reference channel data comes from. Instagram
|
| 4 |
+
reference entries are style-only text and are never fetched (ToS + no
|
| 5 |
+
public API) — see core.contracts.ReferenceEntry.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
from datetime import datetime, timedelta, timezone
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
import requests
|
| 15 |
+
|
| 16 |
+
from core.adapters.base import ReferenceDataAdapter, register
|
| 17 |
+
from core.utils import HardAPIError, log, retry_backoff
|
| 18 |
+
|
| 19 |
+
_BASE = "https://www.googleapis.com/youtube/v3"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class YouTubeRefData(ReferenceDataAdapter):
|
| 23 |
+
def __init__(self, config):
|
| 24 |
+
if not config.youtube_api_key:
|
| 25 |
+
raise HardAPIError(
|
| 26 |
+
"YOUTUBE_API_KEY is not set. Free key: enable 'YouTube Data API v3' "
|
| 27 |
+
"at https://console.cloud.google.com/apis/library"
|
| 28 |
+
)
|
| 29 |
+
self.key = config.youtube_api_key
|
| 30 |
+
|
| 31 |
+
def _get(self, path: str, **params) -> dict:
|
| 32 |
+
def _call():
|
| 33 |
+
resp = requests.get(
|
| 34 |
+
f"{_BASE}/{path}", params={"key": self.key, **params}, timeout=30
|
| 35 |
+
)
|
| 36 |
+
if resp.status_code in (400, 401, 403, 404):
|
| 37 |
+
raise HardAPIError(f"YouTube API {path} HTTP {resp.status_code}: {resp.text[:300]}")
|
| 38 |
+
resp.raise_for_status()
|
| 39 |
+
return resp.json()
|
| 40 |
+
|
| 41 |
+
return retry_backoff(_call, attempts=3, label=f"youtube:{path}")
|
| 42 |
+
|
| 43 |
+
# -- interface ----------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
def resolve_channel(self, name_or_url: str) -> Optional[dict]:
|
| 46 |
+
"""Accepts @handles, channel URLs, or plain names."""
|
| 47 |
+
text = name_or_url.strip()
|
| 48 |
+
# URL forms: /channel/UC..., /@handle
|
| 49 |
+
m = re.search(r"youtube\.com/channel/(UC[\w-]+)", text)
|
| 50 |
+
channel_id = m.group(1) if m else ""
|
| 51 |
+
handle = ""
|
| 52 |
+
m = re.search(r"youtube\.com/@([\w.-]+)", text)
|
| 53 |
+
if m:
|
| 54 |
+
handle = m.group(1)
|
| 55 |
+
elif text.startswith("@"):
|
| 56 |
+
handle = text[1:]
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
if channel_id:
|
| 60 |
+
data = self._get("channels", part="snippet,statistics", id=channel_id)
|
| 61 |
+
elif handle:
|
| 62 |
+
data = self._get("channels", part="snippet,statistics", forHandle=handle)
|
| 63 |
+
else:
|
| 64 |
+
# Plain name: search for the channel, take the top hit.
|
| 65 |
+
search = self._get("search", part="snippet", q=text, type="channel", maxResults=1)
|
| 66 |
+
items = search.get("items", [])
|
| 67 |
+
if not items:
|
| 68 |
+
return None
|
| 69 |
+
channel_id = items[0]["snippet"]["channelId"]
|
| 70 |
+
data = self._get("channels", part="snippet,statistics", id=channel_id)
|
| 71 |
+
except HardAPIError:
|
| 72 |
+
raise
|
| 73 |
+
except Exception as e:
|
| 74 |
+
log.warning("resolve_channel(%r) failed: %s", name_or_url, e)
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
items = data.get("items", [])
|
| 78 |
+
if not items:
|
| 79 |
+
return None
|
| 80 |
+
ch = items[0]
|
| 81 |
+
return {
|
| 82 |
+
"channel_id": ch["id"],
|
| 83 |
+
"title": ch["snippet"]["title"],
|
| 84 |
+
"url": f"https://www.youtube.com/channel/{ch['id']}",
|
| 85 |
+
"subscribers": int(ch.get("statistics", {}).get("subscriberCount", 0)),
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
def top_shorts(self, channel_id: str, n: int = 5, since_days: int = 90) -> list[dict]:
|
| 89 |
+
"""Recent videos <= 3.5 min, sorted by views. Uses search + videos."""
|
| 90 |
+
published_after = (
|
| 91 |
+
datetime.now(timezone.utc) - timedelta(days=since_days)
|
| 92 |
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 93 |
+
try:
|
| 94 |
+
search = self._get(
|
| 95 |
+
"search",
|
| 96 |
+
part="id",
|
| 97 |
+
channelId=channel_id,
|
| 98 |
+
type="video",
|
| 99 |
+
order="viewCount",
|
| 100 |
+
publishedAfter=published_after,
|
| 101 |
+
maxResults=25,
|
| 102 |
+
)
|
| 103 |
+
ids = [it["id"]["videoId"] for it in search.get("items", []) if "videoId" in it.get("id", {})]
|
| 104 |
+
if not ids:
|
| 105 |
+
return []
|
| 106 |
+
vids = self._get(
|
| 107 |
+
"videos", part="snippet,statistics,contentDetails", id=",".join(ids)
|
| 108 |
+
)
|
| 109 |
+
except HardAPIError:
|
| 110 |
+
raise
|
| 111 |
+
except Exception as e:
|
| 112 |
+
log.warning("top_shorts(%s) failed: %s", channel_id, e)
|
| 113 |
+
return []
|
| 114 |
+
|
| 115 |
+
out = []
|
| 116 |
+
for v in vids.get("items", []):
|
| 117 |
+
dur = _iso_duration_seconds(v["contentDetails"].get("duration", "PT0S"))
|
| 118 |
+
if dur == 0 or dur > 210: # Shorts-length only (<= 3.5 min)
|
| 119 |
+
continue
|
| 120 |
+
stats = v.get("statistics", {})
|
| 121 |
+
out.append(
|
| 122 |
+
{
|
| 123 |
+
"video_id": v["id"],
|
| 124 |
+
"title": v["snippet"]["title"],
|
| 125 |
+
"views": int(stats.get("viewCount", 0)),
|
| 126 |
+
"likes": int(stats.get("likeCount", 0)),
|
| 127 |
+
"published": v["snippet"].get("publishedAt", ""),
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
+
out.sort(key=lambda x: x["views"], reverse=True)
|
| 131 |
+
return out[:n]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _iso_duration_seconds(iso: str) -> int:
|
| 135 |
+
"""PT1M23S -> 83."""
|
| 136 |
+
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "")
|
| 137 |
+
if not m:
|
| 138 |
+
return 0
|
| 139 |
+
h, mi, s = (int(x) if x else 0 for x in m.groups())
|
| 140 |
+
return h * 3600 + mi * 60 + s
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
register("refdata", "youtube", YouTubeRefData)
|
core/adapters/sfx_freesound.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Freesound SFX adapter (optional free key). Skips cleanly when keyless."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
from core.adapters.base import SFXAdapter, register
|
| 10 |
+
from core.utils import log, retry_backoff
|
| 11 |
+
|
| 12 |
+
_SEARCH = "https://freesound.org/apiv2/search/text/"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class FreesoundSFX(SFXAdapter):
|
| 16 |
+
def __init__(self, config):
|
| 17 |
+
self.key = config.freesound_api_key # may be empty — fetch() then no-ops
|
| 18 |
+
|
| 19 |
+
def fetch(self, query: str, out_path: str, max_duration: float = 4.0) -> Optional[str]:
|
| 20 |
+
if not self.key:
|
| 21 |
+
log.info("Freesound: no key — skipping SFX %r", query)
|
| 22 |
+
return None
|
| 23 |
+
try:
|
| 24 |
+
def _search():
|
| 25 |
+
resp = requests.get(
|
| 26 |
+
_SEARCH,
|
| 27 |
+
params={
|
| 28 |
+
"query": query,
|
| 29 |
+
"token": self.key,
|
| 30 |
+
"filter": f"duration:[0.2 TO {max_duration}]",
|
| 31 |
+
"fields": "id,name,previews,duration",
|
| 32 |
+
"page_size": 5,
|
| 33 |
+
"sort": "score",
|
| 34 |
+
},
|
| 35 |
+
timeout=30,
|
| 36 |
+
)
|
| 37 |
+
resp.raise_for_status()
|
| 38 |
+
return resp.json()
|
| 39 |
+
|
| 40 |
+
data = retry_backoff(_search, attempts=2, label=f"freesound:{query!r}")
|
| 41 |
+
results = data.get("results", [])
|
| 42 |
+
if not results:
|
| 43 |
+
return None
|
| 44 |
+
preview = results[0]["previews"].get("preview-hq-mp3") or results[0]["previews"].get(
|
| 45 |
+
"preview-lq-mp3"
|
| 46 |
+
)
|
| 47 |
+
if not preview:
|
| 48 |
+
return None
|
| 49 |
+
with requests.get(preview, timeout=60) as r:
|
| 50 |
+
r.raise_for_status()
|
| 51 |
+
with open(out_path, "wb") as f:
|
| 52 |
+
f.write(r.content)
|
| 53 |
+
return out_path
|
| 54 |
+
except Exception as e: # SFX is decoration — never kill a run over it
|
| 55 |
+
log.warning("Freesound fetch failed for %r: %s — skipping", query, e)
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
register("sfx", "freesound", FreesoundSFX)
|
core/adapters/stock_pexels.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pexels stock-video adapter (free API key)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import requests
|
| 6 |
+
|
| 7 |
+
from core.adapters.base import StockVideoAdapter, register
|
| 8 |
+
from core.contracts import ClipCandidate
|
| 9 |
+
from core.utils import HardAPIError, log, retry_backoff
|
| 10 |
+
|
| 11 |
+
_SEARCH = "https://api.pexels.com/videos/search"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class PexelsStock(StockVideoAdapter):
|
| 15 |
+
def __init__(self, config):
|
| 16 |
+
if not config.pexels_api_key:
|
| 17 |
+
raise HardAPIError(
|
| 18 |
+
"PEXELS_API_KEY is not set. Free key: https://www.pexels.com/api/"
|
| 19 |
+
)
|
| 20 |
+
self.headers = {"Authorization": config.pexels_api_key}
|
| 21 |
+
|
| 22 |
+
def search(
|
| 23 |
+
self,
|
| 24 |
+
query: str,
|
| 25 |
+
orientation: str = "portrait",
|
| 26 |
+
min_duration: float = 3.0,
|
| 27 |
+
max_duration: float = 30.0,
|
| 28 |
+
per_query: int = 4,
|
| 29 |
+
) -> list[ClipCandidate]:
|
| 30 |
+
def _call():
|
| 31 |
+
resp = requests.get(
|
| 32 |
+
_SEARCH,
|
| 33 |
+
headers=self.headers,
|
| 34 |
+
params={"query": query, "orientation": orientation, "per_page": 15},
|
| 35 |
+
timeout=30,
|
| 36 |
+
)
|
| 37 |
+
if resp.status_code in (400, 401, 403):
|
| 38 |
+
raise HardAPIError(f"Pexels HTTP {resp.status_code}: {resp.text[:200]}")
|
| 39 |
+
resp.raise_for_status()
|
| 40 |
+
return resp.json()
|
| 41 |
+
|
| 42 |
+
data = retry_backoff(_call, attempts=3, label=f"pexels:{query!r}")
|
| 43 |
+
out: list[ClipCandidate] = []
|
| 44 |
+
for video in data.get("videos", []):
|
| 45 |
+
if not (min_duration <= video.get("duration", 0) <= max_duration * 3):
|
| 46 |
+
continue
|
| 47 |
+
files = video.get("video_files", [])
|
| 48 |
+
# Prefer tall HD files; smallest file that is >= 1080 tall, else tallest.
|
| 49 |
+
portrait = [f for f in files if f.get("height", 0) >= f.get("width", 0)]
|
| 50 |
+
pool = portrait or files
|
| 51 |
+
pool = sorted(pool, key=lambda f: f.get("height", 0))
|
| 52 |
+
best = next((f for f in pool if f.get("height", 0) >= 1080), pool[-1] if pool else None)
|
| 53 |
+
if not best:
|
| 54 |
+
continue
|
| 55 |
+
out.append(
|
| 56 |
+
ClipCandidate(
|
| 57 |
+
id=str(video["id"]),
|
| 58 |
+
source="pexels",
|
| 59 |
+
preview_url=video.get("image", ""),
|
| 60 |
+
download_url=best["link"],
|
| 61 |
+
width=best.get("width", 0),
|
| 62 |
+
height=best.get("height", 0),
|
| 63 |
+
duration_sec=float(video.get("duration", 0)),
|
| 64 |
+
credit=f'{video.get("user", {}).get("name", "Pexels")} / Pexels',
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
if len(out) >= per_query:
|
| 68 |
+
break
|
| 69 |
+
if not out:
|
| 70 |
+
log.warning("Pexels: no usable results for %r", query)
|
| 71 |
+
return out
|
| 72 |
+
|
| 73 |
+
def download(self, candidate: ClipCandidate, out_path: str) -> str:
|
| 74 |
+
def _dl():
|
| 75 |
+
with requests.get(candidate.download_url, stream=True, timeout=120) as r:
|
| 76 |
+
r.raise_for_status()
|
| 77 |
+
with open(out_path, "wb") as f:
|
| 78 |
+
for chunk in r.iter_content(chunk_size=1 << 16):
|
| 79 |
+
f.write(chunk)
|
| 80 |
+
return out_path
|
| 81 |
+
|
| 82 |
+
retry_backoff(_dl, attempts=3, label=f"pexels-dl:{candidate.id}")
|
| 83 |
+
candidate.local_path = out_path
|
| 84 |
+
return out_path
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
register("stock", "pexels", PexelsStock)
|
core/adapters/transcript_youtube.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transcript adapter: YouTube captions first, yt-dlp + faster-whisper fallback.
|
| 2 |
+
|
| 3 |
+
Used for two things: style few-shot exemplars and the clone flow. Both fail
|
| 4 |
+
soft — a missing transcript degrades the feature, never crashes a run.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import tempfile
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from core.adapters.base import TranscriptAdapter, register
|
| 14 |
+
from core.utils import log
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class YouTubeTranscript(TranscriptAdapter):
|
| 18 |
+
def __init__(self, config):
|
| 19 |
+
self.config = config
|
| 20 |
+
|
| 21 |
+
def fetch(self, video_id: str) -> Optional[str]:
|
| 22 |
+
text = self._via_captions(video_id)
|
| 23 |
+
if text:
|
| 24 |
+
return text
|
| 25 |
+
log.info("No captions for %s — trying audio transcription fallback", video_id)
|
| 26 |
+
return self._via_whisper(video_id)
|
| 27 |
+
|
| 28 |
+
# -- caption API ---------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
@staticmethod
|
| 31 |
+
def _via_captions(video_id: str) -> Optional[str]:
|
| 32 |
+
try:
|
| 33 |
+
from youtube_transcript_api import YouTubeTranscriptApi
|
| 34 |
+
|
| 35 |
+
api = YouTubeTranscriptApi()
|
| 36 |
+
fetched = api.fetch(video_id, languages=["en", "en-US", "en-GB"])
|
| 37 |
+
return " ".join(snippet.text.strip() for snippet in fetched if snippet.text.strip())
|
| 38 |
+
except Exception as e:
|
| 39 |
+
log.info("youtube-transcript-api failed for %s: %s", video_id, e)
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
# -- yt-dlp + faster-whisper fallback -------------------------------------
|
| 43 |
+
|
| 44 |
+
@staticmethod
|
| 45 |
+
def _via_whisper(video_id: str) -> Optional[str]:
|
| 46 |
+
try:
|
| 47 |
+
import yt_dlp
|
| 48 |
+
from faster_whisper import WhisperModel # optional dep
|
| 49 |
+
except ImportError as e:
|
| 50 |
+
log.info("Transcription fallback unavailable (%s) — skipping %s", e, video_id)
|
| 51 |
+
return None
|
| 52 |
+
try:
|
| 53 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 54 |
+
out = str(Path(tmp) / "audio.%(ext)s")
|
| 55 |
+
ydl_opts = {
|
| 56 |
+
"format": "bestaudio/best",
|
| 57 |
+
"outtmpl": out,
|
| 58 |
+
"quiet": True,
|
| 59 |
+
"no_warnings": True,
|
| 60 |
+
}
|
| 61 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 62 |
+
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
|
| 63 |
+
audio = next(Path(tmp).glob("audio.*"))
|
| 64 |
+
model = WhisperModel("tiny", device="cpu", compute_type="int8")
|
| 65 |
+
segments, _ = model.transcribe(str(audio))
|
| 66 |
+
return " ".join(seg.text.strip() for seg in segments) or None
|
| 67 |
+
except Exception as e:
|
| 68 |
+
log.warning("Whisper transcription failed for %s: %s", video_id, e)
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
register("transcript", "youtube", YouTubeTranscript)
|
core/adapters/tts_edge.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""edge-tts adapter — the free default voice provider.
|
| 2 |
+
|
| 3 |
+
Microsoft Edge's online TTS emits word-boundary events natively, so we get
|
| 4 |
+
karaoke-grade word timings without any alignment step.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from core.adapters.base import TTSAdapter, register
|
| 13 |
+
from core.contracts import WordTiming
|
| 14 |
+
from core.utils import log, retry_backoff
|
| 15 |
+
|
| 16 |
+
# A small curated set; edge-tts has hundreds, these are reliable for narration.
|
| 17 |
+
_VOICES = [
|
| 18 |
+
"en-US-ChristopherNeural",
|
| 19 |
+
"en-US-GuyNeural",
|
| 20 |
+
"en-US-JennyNeural",
|
| 21 |
+
"en-US-AriaNeural",
|
| 22 |
+
"en-GB-RyanNeural",
|
| 23 |
+
"en-AU-NatashaNeural",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class EdgeTTS(TTSAdapter):
|
| 28 |
+
def __init__(self, config):
|
| 29 |
+
self.default_voice = config.edge_voice
|
| 30 |
+
|
| 31 |
+
async def _synth_async(self, text: str, out_path: str, voice: str) -> list[WordTiming]:
|
| 32 |
+
import edge_tts
|
| 33 |
+
|
| 34 |
+
# edge-tts >= 7 defaults to sentence boundaries; we need per-word.
|
| 35 |
+
communicate = edge_tts.Communicate(text, voice, boundary="WordBoundary")
|
| 36 |
+
timings: list[WordTiming] = []
|
| 37 |
+
with open(out_path, "wb") as f:
|
| 38 |
+
async for chunk in communicate.stream():
|
| 39 |
+
if chunk["type"] == "audio":
|
| 40 |
+
f.write(chunk["data"])
|
| 41 |
+
elif chunk["type"] == "WordBoundary":
|
| 42 |
+
start = chunk["offset"] / 10_000_000 # 100-ns ticks -> s
|
| 43 |
+
dur = chunk["duration"] / 10_000_000
|
| 44 |
+
timings.append(WordTiming(word=chunk["text"], start=start, end=start + dur))
|
| 45 |
+
return timings
|
| 46 |
+
|
| 47 |
+
def synth(self, text: str, out_path: str, voice: str = "") -> Optional[list[WordTiming]]:
|
| 48 |
+
voice = voice or self.default_voice
|
| 49 |
+
|
| 50 |
+
def _run():
|
| 51 |
+
return asyncio.run(self._synth_async(text, out_path, voice))
|
| 52 |
+
|
| 53 |
+
timings = retry_backoff(_run, attempts=3, label="edge-tts")
|
| 54 |
+
if not timings:
|
| 55 |
+
log.warning("edge-tts returned no word boundaries for %r", text[:40])
|
| 56 |
+
return None
|
| 57 |
+
return timings
|
| 58 |
+
|
| 59 |
+
def voices(self) -> list[str]:
|
| 60 |
+
return list(_VOICES)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
register("tts", "edge_tts", EdgeTTS)
|
core/adapters/tts_elevenlabs.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ElevenLabs TTS adapter (optional, needs ELEVENLABS_API_KEY).
|
| 2 |
+
|
| 3 |
+
Uses the with-timestamps endpoint so we get character-level timings and
|
| 4 |
+
aggregate them into word timings — no alignment pass needed.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import base64
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
import requests
|
| 13 |
+
|
| 14 |
+
from core.adapters.base import TTSAdapter, register
|
| 15 |
+
from core.contracts import WordTiming
|
| 16 |
+
from core.utils import HardAPIError, log, retry_backoff
|
| 17 |
+
|
| 18 |
+
_BASE = "https://api.elevenlabs.io/v1"
|
| 19 |
+
|
| 20 |
+
# name -> voice_id (a few stock voices)
|
| 21 |
+
_VOICES = {
|
| 22 |
+
"Adam": "pNInz6obpgDQGcFmaJgB",
|
| 23 |
+
"Rachel": "21m00Tcm4TlvDq8ikWAM",
|
| 24 |
+
"Antoni": "ErXwobaYiN019PkySvjV",
|
| 25 |
+
"Bella": "EXAVITQu4vr4xnSDxMaL",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ElevenLabsTTS(TTSAdapter):
|
| 30 |
+
def __init__(self, config):
|
| 31 |
+
if not config.elevenlabs_api_key:
|
| 32 |
+
raise HardAPIError("ELEVENLABS_API_KEY required for elevenlabs adapter")
|
| 33 |
+
self.key = config.elevenlabs_api_key
|
| 34 |
+
|
| 35 |
+
def _call(self, text: str, voice_id: str) -> dict:
|
| 36 |
+
resp = requests.post(
|
| 37 |
+
f"{_BASE}/text-to-speech/{voice_id}/with-timestamps",
|
| 38 |
+
headers={"xi-api-key": self.key},
|
| 39 |
+
json={"text": text, "model_id": "eleven_turbo_v2_5"},
|
| 40 |
+
timeout=120,
|
| 41 |
+
)
|
| 42 |
+
if resp.status_code in (400, 401, 403, 404):
|
| 43 |
+
raise HardAPIError(f"ElevenLabs HTTP {resp.status_code}: {resp.text[:300]}")
|
| 44 |
+
resp.raise_for_status()
|
| 45 |
+
return resp.json()
|
| 46 |
+
|
| 47 |
+
@staticmethod
|
| 48 |
+
def _chars_to_words(alignment: dict) -> list[WordTiming]:
|
| 49 |
+
chars = alignment.get("characters", [])
|
| 50 |
+
starts = alignment.get("character_start_times_seconds", [])
|
| 51 |
+
ends = alignment.get("character_end_times_seconds", [])
|
| 52 |
+
words: list[WordTiming] = []
|
| 53 |
+
cur, w_start, w_end = "", 0.0, 0.0
|
| 54 |
+
for ch, s, e in zip(chars, starts, ends):
|
| 55 |
+
if ch.isspace():
|
| 56 |
+
if cur:
|
| 57 |
+
words.append(WordTiming(word=cur, start=w_start, end=w_end))
|
| 58 |
+
cur = ""
|
| 59 |
+
continue
|
| 60 |
+
if not cur:
|
| 61 |
+
w_start = s
|
| 62 |
+
cur += ch
|
| 63 |
+
w_end = e
|
| 64 |
+
if cur:
|
| 65 |
+
words.append(WordTiming(word=cur, start=w_start, end=w_end))
|
| 66 |
+
return words
|
| 67 |
+
|
| 68 |
+
def synth(self, text: str, out_path: str, voice: str = "") -> Optional[list[WordTiming]]:
|
| 69 |
+
voice_id = _VOICES.get(voice or "Adam", next(iter(_VOICES.values())))
|
| 70 |
+
data = retry_backoff(lambda: self._call(text, voice_id), attempts=3, label="elevenlabs")
|
| 71 |
+
with open(out_path, "wb") as f:
|
| 72 |
+
f.write(base64.b64decode(data["audio_base64"]))
|
| 73 |
+
timings = self._chars_to_words(data.get("alignment") or {})
|
| 74 |
+
if not timings:
|
| 75 |
+
log.warning("ElevenLabs returned no alignment for %r", text[:40])
|
| 76 |
+
return None
|
| 77 |
+
return timings
|
| 78 |
+
|
| 79 |
+
def voices(self) -> list[str]:
|
| 80 |
+
return list(_VOICES)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
register("tts", "elevenlabs", ElevenLabsTTS)
|
core/adapters/tts_gemini.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini TTS adapter (free tier). No native word timings — the voice stage
|
| 2 |
+
recovers them via faster-whisper forced alignment when available."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import base64
|
| 7 |
+
import struct
|
| 8 |
+
import wave
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
from core.adapters.base import TTSAdapter, register
|
| 14 |
+
from core.adapters.gemini_keys import KeyRejected, KeyRotator
|
| 15 |
+
from core.contracts import WordTiming
|
| 16 |
+
from core.utils import HardAPIError
|
| 17 |
+
|
| 18 |
+
_API = (
|
| 19 |
+
"https://generativelanguage.googleapis.com/v1beta/models/"
|
| 20 |
+
"gemini-2.5-flash-preview-tts:generateContent"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
_VOICES = ["Kore", "Puck", "Charon", "Fenrir", "Aoede"]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GeminiTTS(TTSAdapter):
|
| 27 |
+
def __init__(self, config):
|
| 28 |
+
if not config.gemini_api_keys:
|
| 29 |
+
raise HardAPIError("GEMINI_API_KEY required for gemini_tts")
|
| 30 |
+
# Rotate across all configured keys, same as the LLM adapter.
|
| 31 |
+
self.rotator = KeyRotator(config.gemini_api_keys)
|
| 32 |
+
|
| 33 |
+
def _call(self, key: str, text: str, voice: str) -> bytes:
|
| 34 |
+
resp = requests.post(
|
| 35 |
+
_API,
|
| 36 |
+
params={"key": key},
|
| 37 |
+
json={
|
| 38 |
+
"contents": [{"parts": [{"text": text}]}],
|
| 39 |
+
"generationConfig": {
|
| 40 |
+
"responseModalities": ["AUDIO"],
|
| 41 |
+
"speechConfig": {
|
| 42 |
+
"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}
|
| 43 |
+
},
|
| 44 |
+
},
|
| 45 |
+
},
|
| 46 |
+
timeout=120,
|
| 47 |
+
)
|
| 48 |
+
code = resp.status_code
|
| 49 |
+
if code in (400, 404):
|
| 50 |
+
raise HardAPIError(f"Gemini TTS HTTP {code}: {resp.text[:300]}")
|
| 51 |
+
if code in (401, 403):
|
| 52 |
+
raise KeyRejected(f"Gemini TTS key HTTP {code}")
|
| 53 |
+
if code in (429, 500, 503):
|
| 54 |
+
raise RuntimeError(f"Gemini TTS HTTP {code} (retryable)")
|
| 55 |
+
resp.raise_for_status()
|
| 56 |
+
data = resp.json()
|
| 57 |
+
b64 = data["candidates"][0]["content"]["parts"][0]["inlineData"]["data"]
|
| 58 |
+
return base64.b64decode(b64)
|
| 59 |
+
|
| 60 |
+
def synth(self, text: str, out_path: str, voice: str = "") -> Optional[list[WordTiming]]:
|
| 61 |
+
voice = voice or _VOICES[0]
|
| 62 |
+
pcm = self.rotator.call(lambda key: self._call(key, text, voice), label="gemini-tts")
|
| 63 |
+
# API returns raw 16-bit 24kHz mono PCM; wrap it in a WAV container.
|
| 64 |
+
wav_path = out_path if out_path.endswith(".wav") else out_path + ".wav"
|
| 65 |
+
with wave.open(wav_path, "wb") as w:
|
| 66 |
+
w.setnchannels(1)
|
| 67 |
+
w.setsampwidth(2)
|
| 68 |
+
w.setframerate(24000)
|
| 69 |
+
w.writeframes(pcm)
|
| 70 |
+
if wav_path != out_path:
|
| 71 |
+
import shutil
|
| 72 |
+
|
| 73 |
+
shutil.move(wav_path, out_path)
|
| 74 |
+
return None # no native timings — caller runs forced alignment
|
| 75 |
+
|
| 76 |
+
def voices(self) -> list[str]:
|
| 77 |
+
return list(_VOICES)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
register("tts", "gemini_tts", GeminiTTS)
|
core/captions.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Caption rendering: Pillow PNGs compiled into ONE qtrle overlay track.
|
| 2 |
+
|
| 3 |
+
v1 lessons baked in:
|
| 4 |
+
- FFmpeg drawtext/libass are NOT available on the target machine's build —
|
| 5 |
+
captions are rasterized with Pillow instead.
|
| 6 |
+
- One overlay pass for the whole video. Per-word overlay filters are
|
| 7 |
+
pathologically slow; instead every caption state becomes a transparent
|
| 8 |
+
PNG frame and the sequence is compiled into a single qtrle .mov with
|
| 9 |
+
alpha, overlaid once.
|
| 10 |
+
|
| 11 |
+
Karaoke mode (word timings available): words are grouped into small chunks;
|
| 12 |
+
each word boundary produces a frame with the current word highlighted.
|
| 13 |
+
Static mode (no timings): chunks are spread evenly across each line.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 23 |
+
|
| 24 |
+
from core.contracts import VIDEO_HEIGHT, VIDEO_WIDTH, VoiceTrack
|
| 25 |
+
from core.utils import log, run_ffmpeg
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Presets
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class CaptionPreset:
|
| 34 |
+
name: str
|
| 35 |
+
font_size: int
|
| 36 |
+
y_center_frac: float # vertical anchor as fraction of video height
|
| 37 |
+
base_color: tuple
|
| 38 |
+
highlight_color: tuple
|
| 39 |
+
stroke_color: tuple
|
| 40 |
+
stroke_width: int
|
| 41 |
+
uppercase: bool
|
| 42 |
+
karaoke: bool # highlight the current word when timings exist
|
| 43 |
+
box: bool # dark rounded box behind text
|
| 44 |
+
words_per_chunk: int
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
PRESETS: dict[str, CaptionPreset] = {
|
| 48 |
+
"bold-center-karaoke": CaptionPreset(
|
| 49 |
+
name="bold-center-karaoke",
|
| 50 |
+
font_size=88,
|
| 51 |
+
y_center_frac=0.52,
|
| 52 |
+
base_color=(255, 255, 255, 255),
|
| 53 |
+
highlight_color=(255, 214, 10, 255),
|
| 54 |
+
stroke_color=(0, 0, 0, 255),
|
| 55 |
+
stroke_width=8,
|
| 56 |
+
uppercase=True,
|
| 57 |
+
karaoke=True,
|
| 58 |
+
box=False,
|
| 59 |
+
words_per_chunk=3,
|
| 60 |
+
),
|
| 61 |
+
"clean-lower-third": CaptionPreset(
|
| 62 |
+
name="clean-lower-third",
|
| 63 |
+
font_size=58,
|
| 64 |
+
y_center_frac=0.78,
|
| 65 |
+
base_color=(255, 255, 255, 255),
|
| 66 |
+
highlight_color=(120, 220, 255, 255),
|
| 67 |
+
stroke_color=(0, 0, 0, 200),
|
| 68 |
+
stroke_width=3,
|
| 69 |
+
uppercase=False,
|
| 70 |
+
karaoke=True,
|
| 71 |
+
box=True,
|
| 72 |
+
words_per_chunk=5,
|
| 73 |
+
),
|
| 74 |
+
"minimal": CaptionPreset(
|
| 75 |
+
name="minimal",
|
| 76 |
+
font_size=48,
|
| 77 |
+
y_center_frac=0.85,
|
| 78 |
+
base_color=(245, 245, 245, 235),
|
| 79 |
+
highlight_color=(245, 245, 245, 235),
|
| 80 |
+
stroke_color=(0, 0, 0, 160),
|
| 81 |
+
stroke_width=2,
|
| 82 |
+
uppercase=False,
|
| 83 |
+
karaoke=False,
|
| 84 |
+
box=False,
|
| 85 |
+
words_per_chunk=6,
|
| 86 |
+
),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
_FONT_CANDIDATES = [
|
| 90 |
+
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
| 91 |
+
"/System/Library/Fonts/Supplemental/Verdana Bold.ttf",
|
| 92 |
+
"/System/Library/Fonts/Helvetica.ttc",
|
| 93 |
+
"/Library/Fonts/Arial Bold.ttf",
|
| 94 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 95 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _load_font(size: int) -> ImageFont.FreeTypeFont:
|
| 100 |
+
for path in _FONT_CANDIDATES:
|
| 101 |
+
if os.path.exists(path):
|
| 102 |
+
try:
|
| 103 |
+
return ImageFont.truetype(path, size)
|
| 104 |
+
except OSError:
|
| 105 |
+
continue
|
| 106 |
+
log.warning("No system TTF found — using Pillow default bitmap font")
|
| 107 |
+
return ImageFont.load_default(size) # type: ignore[return-value]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
# Event timeline
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@dataclass
|
| 116 |
+
class CaptionEvent:
|
| 117 |
+
start: float # absolute seconds in the final video
|
| 118 |
+
end: float
|
| 119 |
+
words: list[str] # the visible chunk
|
| 120 |
+
highlight: int # index of the highlighted word, -1 for none
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def build_events(voice: VoiceTrack, preset: CaptionPreset) -> list[CaptionEvent]:
|
| 124 |
+
"""Turn the voice track into an absolute-time caption event list."""
|
| 125 |
+
events: list[CaptionEvent] = []
|
| 126 |
+
offset = 0.0
|
| 127 |
+
for line in voice.lines:
|
| 128 |
+
if line.word_timings and preset.karaoke:
|
| 129 |
+
events.extend(_karaoke_events(line, offset, preset))
|
| 130 |
+
else:
|
| 131 |
+
events.extend(_static_events(line, offset, preset))
|
| 132 |
+
offset += line.duration_sec
|
| 133 |
+
return events
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _karaoke_events(line, offset: float, preset: CaptionPreset) -> list[CaptionEvent]:
|
| 137 |
+
timings = line.word_timings
|
| 138 |
+
chunks = [
|
| 139 |
+
timings[i : i + preset.words_per_chunk]
|
| 140 |
+
for i in range(0, len(timings), preset.words_per_chunk)
|
| 141 |
+
]
|
| 142 |
+
events: list[CaptionEvent] = []
|
| 143 |
+
for ci, chunk in enumerate(chunks):
|
| 144 |
+
words = [t.word for t in chunk]
|
| 145 |
+
chunk_end_limit = (
|
| 146 |
+
chunks[ci + 1][0].start if ci + 1 < len(chunks) else line.duration_sec
|
| 147 |
+
)
|
| 148 |
+
for wi, t in enumerate(chunk):
|
| 149 |
+
start = t.start
|
| 150 |
+
end = chunk[wi + 1].start if wi + 1 < len(chunk) else chunk_end_limit
|
| 151 |
+
if end <= start:
|
| 152 |
+
end = start + 0.05
|
| 153 |
+
events.append(
|
| 154 |
+
CaptionEvent(
|
| 155 |
+
start=offset + start,
|
| 156 |
+
end=offset + min(end, line.duration_sec),
|
| 157 |
+
words=words,
|
| 158 |
+
highlight=wi,
|
| 159 |
+
)
|
| 160 |
+
)
|
| 161 |
+
return events
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _static_events(line, offset: float, preset: CaptionPreset) -> list[CaptionEvent]:
|
| 165 |
+
words = line.text.split()
|
| 166 |
+
if not words:
|
| 167 |
+
return []
|
| 168 |
+
chunks = [
|
| 169 |
+
words[i : i + preset.words_per_chunk]
|
| 170 |
+
for i in range(0, len(words), preset.words_per_chunk)
|
| 171 |
+
]
|
| 172 |
+
per = line.duration_sec / len(chunks)
|
| 173 |
+
return [
|
| 174 |
+
CaptionEvent(
|
| 175 |
+
start=offset + i * per,
|
| 176 |
+
end=offset + (i + 1) * per,
|
| 177 |
+
words=chunk,
|
| 178 |
+
highlight=-1,
|
| 179 |
+
)
|
| 180 |
+
for i, chunk in enumerate(chunks)
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
# Frame rendering
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _render_frame(event: CaptionEvent, preset: CaptionPreset, font) -> Image.Image:
|
| 190 |
+
img = Image.new("RGBA", (VIDEO_WIDTH, VIDEO_HEIGHT), (0, 0, 0, 0))
|
| 191 |
+
draw = ImageDraw.Draw(img)
|
| 192 |
+
words = [w.upper() for w in event.words] if preset.uppercase else list(event.words)
|
| 193 |
+
|
| 194 |
+
space_w = draw.textlength(" ", font=font)
|
| 195 |
+
widths = [draw.textlength(w, font=font) for w in words]
|
| 196 |
+
|
| 197 |
+
# Wrap into display lines that fit 90% of the frame width.
|
| 198 |
+
max_w = VIDEO_WIDTH * 0.9
|
| 199 |
+
lines: list[list[int]] = [[]]
|
| 200 |
+
cur_w = 0.0
|
| 201 |
+
for i, w in enumerate(widths):
|
| 202 |
+
add = w if not lines[-1] else w + space_w
|
| 203 |
+
if lines[-1] and cur_w + add > max_w:
|
| 204 |
+
lines.append([i])
|
| 205 |
+
cur_w = w
|
| 206 |
+
else:
|
| 207 |
+
lines[-1].append(i)
|
| 208 |
+
cur_w += add
|
| 209 |
+
|
| 210 |
+
ascent, descent = font.getmetrics()
|
| 211 |
+
line_h = (ascent + descent) * 1.15
|
| 212 |
+
block_h = line_h * len(lines)
|
| 213 |
+
y = preset.y_center_frac * VIDEO_HEIGHT - block_h / 2
|
| 214 |
+
|
| 215 |
+
if preset.box:
|
| 216 |
+
widest = max(sum(widths[i] for i in ln) + space_w * (len(ln) - 1) for ln in lines)
|
| 217 |
+
pad = 28
|
| 218 |
+
box = [
|
| 219 |
+
(VIDEO_WIDTH - widest) / 2 - pad,
|
| 220 |
+
y - pad * 0.5,
|
| 221 |
+
(VIDEO_WIDTH + widest) / 2 + pad,
|
| 222 |
+
y + block_h + pad * 0.5,
|
| 223 |
+
]
|
| 224 |
+
draw.rounded_rectangle(box, radius=18, fill=(10, 10, 14, 175))
|
| 225 |
+
|
| 226 |
+
for ln in lines:
|
| 227 |
+
total_w = sum(widths[i] for i in ln) + space_w * (len(ln) - 1)
|
| 228 |
+
x = (VIDEO_WIDTH - total_w) / 2
|
| 229 |
+
for i in ln:
|
| 230 |
+
color = (
|
| 231 |
+
preset.highlight_color
|
| 232 |
+
if i == event.highlight
|
| 233 |
+
else preset.base_color
|
| 234 |
+
)
|
| 235 |
+
draw.text(
|
| 236 |
+
(x, y),
|
| 237 |
+
words[i],
|
| 238 |
+
font=font,
|
| 239 |
+
fill=color,
|
| 240 |
+
stroke_width=preset.stroke_width,
|
| 241 |
+
stroke_fill=preset.stroke_color,
|
| 242 |
+
)
|
| 243 |
+
x += widths[i] + space_w
|
| 244 |
+
y += line_h
|
| 245 |
+
|
| 246 |
+
return img
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ---------------------------------------------------------------------------
|
| 250 |
+
# Overlay track compilation
|
| 251 |
+
# ---------------------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def build_caption_track(
|
| 255 |
+
voice: VoiceTrack,
|
| 256 |
+
preset_name: str,
|
| 257 |
+
work_dir: str,
|
| 258 |
+
total_duration: float,
|
| 259 |
+
) -> str:
|
| 260 |
+
"""Render all caption frames and compile ONE qtrle overlay .mov.
|
| 261 |
+
|
| 262 |
+
Returns the .mov path, or "" when there is nothing to caption.
|
| 263 |
+
"""
|
| 264 |
+
preset = PRESETS.get(preset_name) or PRESETS["minimal"]
|
| 265 |
+
events = build_events(voice, preset)
|
| 266 |
+
if not events:
|
| 267 |
+
return ""
|
| 268 |
+
|
| 269 |
+
work = Path(work_dir)
|
| 270 |
+
frames_dir = work / f"captions_{preset.name}"
|
| 271 |
+
frames_dir.mkdir(parents=True, exist_ok=True)
|
| 272 |
+
font = _load_font(preset.font_size)
|
| 273 |
+
|
| 274 |
+
blank = Image.new("RGBA", (VIDEO_WIDTH, VIDEO_HEIGHT), (0, 0, 0, 0))
|
| 275 |
+
blank_path = frames_dir / "blank.png"
|
| 276 |
+
blank.save(blank_path)
|
| 277 |
+
|
| 278 |
+
# Build a gap-free ffconcat timeline: blank frames fill silence.
|
| 279 |
+
entries: list[tuple[str, float]] = []
|
| 280 |
+
cursor = 0.0
|
| 281 |
+
for i, ev in enumerate(events):
|
| 282 |
+
if ev.start > cursor + 0.01:
|
| 283 |
+
entries.append((str(blank_path), ev.start - cursor))
|
| 284 |
+
frame_path = frames_dir / f"f{i:04d}.png"
|
| 285 |
+
_render_frame(ev, preset, font).save(frame_path)
|
| 286 |
+
end = min(ev.end, total_duration)
|
| 287 |
+
if end <= ev.start:
|
| 288 |
+
continue
|
| 289 |
+
entries.append((str(frame_path), end - ev.start))
|
| 290 |
+
cursor = end
|
| 291 |
+
if cursor < total_duration:
|
| 292 |
+
entries.append((str(blank_path), total_duration - cursor))
|
| 293 |
+
|
| 294 |
+
concat_path = work / f"captions_{preset.name}.ffconcat"
|
| 295 |
+
lines = ["ffconcat version 1.0"]
|
| 296 |
+
for path, dur in entries:
|
| 297 |
+
lines.append(f"file '{path}'")
|
| 298 |
+
lines.append(f"duration {max(dur, 0.034):.3f}")
|
| 299 |
+
lines.append(f"file '{entries[-1][0]}'") # concat demuxer needs a final repeat
|
| 300 |
+
concat_path.write_text("\n".join(lines))
|
| 301 |
+
|
| 302 |
+
out_mov = str(work / f"captions_{preset.name}.mov")
|
| 303 |
+
run_ffmpeg(
|
| 304 |
+
[
|
| 305 |
+
"-f", "concat", "-safe", "0", "-i", str(concat_path),
|
| 306 |
+
"-vf", "fps=30,format=argb",
|
| 307 |
+
"-c:v", "qtrle",
|
| 308 |
+
out_mov,
|
| 309 |
+
],
|
| 310 |
+
label="caption track",
|
| 311 |
+
)
|
| 312 |
+
log.info("Caption track (%s): %d events -> %s", preset.name, len(events), out_mov)
|
| 313 |
+
return out_mov
|
core/config.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration: .env loading, API keys, and adapter selection.
|
| 2 |
+
|
| 3 |
+
Adapter choice is config-driven: set ``ADAPTER_<CAPABILITY>`` in the
|
| 4 |
+
environment (or .env) to the registry name of the provider you want.
|
| 5 |
+
Defaults are the free-tier providers.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
|
| 16 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 17 |
+
PROJECTS_DIR = REPO_ROOT / "projects"
|
| 18 |
+
MUSIC_DIR = REPO_ROOT / "assets" / "music"
|
| 19 |
+
|
| 20 |
+
load_dotenv(REPO_ROOT / ".env")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def env(name: str, default: str = "") -> str:
|
| 24 |
+
return os.environ.get(name, default).strip()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class Config:
|
| 29 |
+
"""Resolved runtime configuration. Build once via :func:`load_config`."""
|
| 30 |
+
|
| 31 |
+
# API keys (only Gemini + Pexels are required for a useful run).
|
| 32 |
+
# gemini_api_keys holds one or more keys the LLM adapter rotates through
|
| 33 |
+
# to spread free-tier rate limits; gemini_api_key is the first one (kept
|
| 34 |
+
# for back-compat and used by the optional Gemini TTS adapter).
|
| 35 |
+
gemini_api_keys: list[str] = field(default_factory=list)
|
| 36 |
+
pexels_api_key: str = ""
|
| 37 |
+
freesound_api_key: str = ""
|
| 38 |
+
youtube_api_key: str = ""
|
| 39 |
+
elevenlabs_api_key: str = ""
|
| 40 |
+
|
| 41 |
+
# Adapter selection (registry names)
|
| 42 |
+
llm_adapter: str = "gemini"
|
| 43 |
+
# Default to Gemini TTS for natural narration (works with the Gemini key).
|
| 44 |
+
# Word timings come from faster-whisper alignment; edge_tts / elevenlabs
|
| 45 |
+
# are selectable per-reel in the UI.
|
| 46 |
+
tts_adapter: str = "gemini_tts"
|
| 47 |
+
stock_adapter: str = "pexels"
|
| 48 |
+
sfx_adapter: str = "freesound"
|
| 49 |
+
music_adapter: str = "local"
|
| 50 |
+
refdata_adapter: str = "youtube"
|
| 51 |
+
transcript_adapter: str = "youtube"
|
| 52 |
+
|
| 53 |
+
# LLM model fallback chain, tried in order on retryable failures.
|
| 54 |
+
# gemini-flash-latest is an alias that always resolves to a live flash
|
| 55 |
+
# model, so it survives Google retiring specific dated versions.
|
| 56 |
+
gemini_models: list[str] = field(
|
| 57 |
+
default_factory=lambda: ["gemini-2.0-flash", "gemini-2.5-flash", "gemini-flash-latest"]
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# TTS
|
| 61 |
+
edge_voice: str = "en-US-ChristopherNeural"
|
| 62 |
+
|
| 63 |
+
projects_dir: Path = PROJECTS_DIR
|
| 64 |
+
music_dir: Path = MUSIC_DIR
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def gemini_api_key(self) -> str:
|
| 68 |
+
"""First Gemini key (back-compat; used by the Gemini TTS adapter)."""
|
| 69 |
+
return self.gemini_api_keys[0] if self.gemini_api_keys else ""
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _parse_keys(*raw: str) -> list[str]:
|
| 73 |
+
"""Split keys on comma / whitespace / newline, dedupe, keep order."""
|
| 74 |
+
seen: set[str] = set()
|
| 75 |
+
out: list[str] = []
|
| 76 |
+
for chunk in raw:
|
| 77 |
+
for k in chunk.replace("\n", ",").replace(" ", ",").split(","):
|
| 78 |
+
k = k.strip()
|
| 79 |
+
if k and k not in seen:
|
| 80 |
+
seen.add(k)
|
| 81 |
+
out.append(k)
|
| 82 |
+
return out
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def load_config() -> Config:
|
| 86 |
+
models = env("GEMINI_MODELS")
|
| 87 |
+
# Accept one key in GEMINI_API_KEY or many in GEMINI_API_KEYS (or several
|
| 88 |
+
# comma-separated in GEMINI_API_KEY itself). All are merged and rotated.
|
| 89 |
+
gemini_keys = _parse_keys(env("GEMINI_API_KEY"), env("GEMINI_API_KEYS"))
|
| 90 |
+
cfg = Config(
|
| 91 |
+
gemini_api_keys=gemini_keys,
|
| 92 |
+
pexels_api_key=env("PEXELS_API_KEY"),
|
| 93 |
+
freesound_api_key=env("FREESOUND_API_KEY"),
|
| 94 |
+
youtube_api_key=env("YOUTUBE_API_KEY"),
|
| 95 |
+
elevenlabs_api_key=env("ELEVENLABS_API_KEY"),
|
| 96 |
+
llm_adapter=env("ADAPTER_LLM", "gemini"),
|
| 97 |
+
tts_adapter=env("ADAPTER_TTS", "gemini_tts"),
|
| 98 |
+
stock_adapter=env("ADAPTER_STOCK", "pexels"),
|
| 99 |
+
sfx_adapter=env("ADAPTER_SFX", "freesound"),
|
| 100 |
+
music_adapter=env("ADAPTER_MUSIC", "local"),
|
| 101 |
+
refdata_adapter=env("ADAPTER_REFDATA", "youtube"),
|
| 102 |
+
transcript_adapter=env("ADAPTER_TRANSCRIPT", "youtube"),
|
| 103 |
+
edge_voice=env("EDGE_TTS_VOICE", "en-US-ChristopherNeural"),
|
| 104 |
+
)
|
| 105 |
+
if models:
|
| 106 |
+
cfg.gemini_models = [m.strip() for m in models.split(",") if m.strip()]
|
| 107 |
+
return cfg
|
core/contracts.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared data contracts for the Reel Studio v2 pipeline.
|
| 2 |
+
|
| 3 |
+
Every stage and adapter communicates exclusively through the dataclasses
|
| 4 |
+
defined here. Stages never import each other; they import this module and
|
| 5 |
+
the adapter interfaces only.
|
| 6 |
+
|
| 7 |
+
All dataclasses are JSON-serializable via :func:`to_dict` / :func:`from_dict`
|
| 8 |
+
helpers so projects persist as plain JSON files on disk.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import dataclasses
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from typing import Any, Optional
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Serialization helpers
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def to_dict(obj: Any) -> Any:
|
| 23 |
+
"""Recursively convert a dataclass (or container of them) to plain JSON types."""
|
| 24 |
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
| 25 |
+
return {k: to_dict(v) for k, v in dataclasses.asdict(obj).items()}
|
| 26 |
+
if isinstance(obj, list):
|
| 27 |
+
return [to_dict(x) for x in obj]
|
| 28 |
+
if isinstance(obj, dict):
|
| 29 |
+
return {k: to_dict(v) for k, v in obj.items()}
|
| 30 |
+
return obj
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def from_dict(cls: type, data: dict) -> Any:
|
| 34 |
+
"""Build a dataclass instance from a dict, ignoring unknown keys.
|
| 35 |
+
|
| 36 |
+
Nested dataclass fields are reconstructed when the field's type
|
| 37 |
+
annotation is itself a dataclass or a ``list`` of dataclasses
|
| 38 |
+
(resolved via the ``_NESTED`` map each contract declares as needed).
|
| 39 |
+
"""
|
| 40 |
+
nested = getattr(cls, "_NESTED", {})
|
| 41 |
+
kwargs: dict[str, Any] = {}
|
| 42 |
+
for f in dataclasses.fields(cls):
|
| 43 |
+
if f.name not in data:
|
| 44 |
+
continue
|
| 45 |
+
value = data[f.name]
|
| 46 |
+
if f.name in nested and value is not None:
|
| 47 |
+
sub_cls, is_list = nested[f.name]
|
| 48 |
+
if is_list:
|
| 49 |
+
value = [from_dict(sub_cls, v) for v in value]
|
| 50 |
+
else:
|
| 51 |
+
value = from_dict(sub_cls, value)
|
| 52 |
+
kwargs[f.name] = value
|
| 53 |
+
return cls(**kwargs)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# Project setup
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
# Output formats. "youtube_short" and "instagram_reel" are the same 9:16
|
| 61 |
+
# video with a different label. "long_form" is reserved for the future and
|
| 62 |
+
# intentionally not implemented by the render stage.
|
| 63 |
+
FORMATS = ("youtube_short", "instagram_reel", "long_form")
|
| 64 |
+
|
| 65 |
+
VIDEO_WIDTH = 1080
|
| 66 |
+
VIDEO_HEIGHT = 1920
|
| 67 |
+
VIDEO_FPS = 30
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class NicheProfile:
|
| 72 |
+
"""What the project is about. Nothing niche-specific is hardcoded anywhere."""
|
| 73 |
+
|
| 74 |
+
topic: str
|
| 75 |
+
keywords: list[str] = field(default_factory=list)
|
| 76 |
+
subreddits: list[str] = field(default_factory=list)
|
| 77 |
+
audience: str = ""
|
| 78 |
+
tone: str = ""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@dataclass
|
| 82 |
+
class ReferenceEntry:
|
| 83 |
+
"""A reference channel/page that informs topics and style.
|
| 84 |
+
|
| 85 |
+
``kind == "youtube"``: a data source (top Shorts stats, transcripts)
|
| 86 |
+
AND a style source.
|
| 87 |
+
|
| 88 |
+
``kind == "instagram_style"``: style-only. Never fetched or scraped —
|
| 89 |
+
just a URL plus the user's own notes that feed the style prompt as text
|
| 90 |
+
(Instagram ToS forbids scraping and there is no public content API).
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
kind: str # "youtube" | "instagram_style"
|
| 94 |
+
name: str
|
| 95 |
+
url: str = ""
|
| 96 |
+
channel_id: str = "" # resolved YouTube channel id (youtube kind only)
|
| 97 |
+
notes: str = "" # user-written style notes (required for instagram_style)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class Project:
|
| 102 |
+
id: str
|
| 103 |
+
name: str
|
| 104 |
+
niche: NicheProfile
|
| 105 |
+
format: str = "youtube_short" # one of FORMATS
|
| 106 |
+
references: list[ReferenceEntry] = field(default_factory=list)
|
| 107 |
+
created_at: str = ""
|
| 108 |
+
|
| 109 |
+
_NESTED = {
|
| 110 |
+
"niche": (NicheProfile, False),
|
| 111 |
+
"references": (ReferenceEntry, True),
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@dataclass
|
| 116 |
+
class Reel:
|
| 117 |
+
"""One video within a project.
|
| 118 |
+
|
| 119 |
+
References + style profile + the topic pool live at the project level and
|
| 120 |
+
are shared; each Reel owns its own script, voice, media, and renders, so a
|
| 121 |
+
user can work on several topics in parallel without losing progress.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
id: str
|
| 125 |
+
name: str = ""
|
| 126 |
+
topic: str = ""
|
| 127 |
+
created_at: str = ""
|
| 128 |
+
clone_source: str = "" # attribution when this reel came from the clone flow
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
# Style profile
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@dataclass
|
| 137 |
+
class StyleProfile:
|
| 138 |
+
"""Distilled writing style of the reference channels.
|
| 139 |
+
|
| 140 |
+
``style_card`` is an LLM-written brief (hook patterns, pacing, sentence
|
| 141 |
+
length, vocabulary, CTA style). ``exemplars`` keeps 2-3 full transcripts
|
| 142 |
+
for few-shot injection into script generation.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
style_card: str = ""
|
| 146 |
+
exemplars: list[str] = field(default_factory=list)
|
| 147 |
+
instagram_notes: list[str] = field(default_factory=list)
|
| 148 |
+
sources: list[str] = field(default_factory=list) # human-readable provenance
|
| 149 |
+
built_at: str = ""
|
| 150 |
+
prompt_used: str = "" # the exact LLM prompt, for QC / reproducibility
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ---------------------------------------------------------------------------
|
| 154 |
+
# Topics
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@dataclass
|
| 159 |
+
class Topic:
|
| 160 |
+
title: str
|
| 161 |
+
reason: str = "" # one-line why this topic
|
| 162 |
+
# Supporting signals, one source per item (reference Shorts, news, Reddit
|
| 163 |
+
# posts). A list, not a string, since each item is its own source.
|
| 164 |
+
signals: list[str] = field(default_factory=list)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@dataclass
|
| 168 |
+
class TopicBatch:
|
| 169 |
+
topics: list[Topic] = field(default_factory=list)
|
| 170 |
+
generated_at: str = ""
|
| 171 |
+
prompt_used: str = "" # the exact LLM prompt, for QC / reproducibility
|
| 172 |
+
|
| 173 |
+
_NESTED = {"topics": (Topic, True)}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ---------------------------------------------------------------------------
|
| 177 |
+
# Script
|
| 178 |
+
# ---------------------------------------------------------------------------
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@dataclass
|
| 182 |
+
class Scene:
|
| 183 |
+
scene: int
|
| 184 |
+
narration: str
|
| 185 |
+
visual_query: str # concrete 3-5 word stock-footage query
|
| 186 |
+
sfx_query: Optional[str] = None
|
| 187 |
+
duration_sec: float = 5.0
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@dataclass
|
| 191 |
+
class Script:
|
| 192 |
+
title: str
|
| 193 |
+
hook_options: list[str] = field(default_factory=list) # exactly 3
|
| 194 |
+
selected_hook: int = 0 # index into hook_options
|
| 195 |
+
scenes: list[Scene] = field(default_factory=list) # <= 6 scenes
|
| 196 |
+
cta: str = ""
|
| 197 |
+
total_duration_sec: float = 0.0
|
| 198 |
+
topic: str = "" # the topic this script was written for
|
| 199 |
+
clone_source: str = "" # attribution when produced by the clone flow
|
| 200 |
+
prompt_used: str = "" # the exact LLM prompt, for QC / reproducibility
|
| 201 |
+
|
| 202 |
+
_NESTED = {"scenes": (Scene, True)}
|
| 203 |
+
|
| 204 |
+
def narration_lines(self) -> list[str]:
|
| 205 |
+
"""Hook + scene narrations + CTA, in spoken order.
|
| 206 |
+
|
| 207 |
+
The selected hook replaces nothing — it is spoken first, then each
|
| 208 |
+
scene, then the CTA. Voice generation iterates this list.
|
| 209 |
+
"""
|
| 210 |
+
hook = self.hook_options[self.selected_hook] if self.hook_options else ""
|
| 211 |
+
lines = [hook] if hook else []
|
| 212 |
+
lines += [s.narration for s in self.scenes]
|
| 213 |
+
if self.cta:
|
| 214 |
+
lines.append(self.cta)
|
| 215 |
+
return lines
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
# Voice
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@dataclass
|
| 224 |
+
class WordTiming:
|
| 225 |
+
word: str
|
| 226 |
+
start: float # seconds, relative to the line's audio file
|
| 227 |
+
end: float
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@dataclass
|
| 231 |
+
class VoiceLine:
|
| 232 |
+
"""One synthesized narration line (hook, a scene, or the CTA)."""
|
| 233 |
+
|
| 234 |
+
index: int # position in Script.narration_lines()
|
| 235 |
+
text: str
|
| 236 |
+
audio_path: str
|
| 237 |
+
duration_sec: float # true duration from ffprobe — drives ALL timing
|
| 238 |
+
word_timings: list[WordTiming] = field(default_factory=list)
|
| 239 |
+
|
| 240 |
+
_NESTED = {"word_timings": (WordTiming, True)}
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
@dataclass
|
| 244 |
+
class VoiceTrack:
|
| 245 |
+
lines: list[VoiceLine] = field(default_factory=list)
|
| 246 |
+
provider: str = ""
|
| 247 |
+
voice: str = ""
|
| 248 |
+
total_duration_sec: float = 0.0
|
| 249 |
+
|
| 250 |
+
_NESTED = {"lines": (VoiceLine, True)}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# ---------------------------------------------------------------------------
|
| 254 |
+
# Media
|
| 255 |
+
# ---------------------------------------------------------------------------
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@dataclass
|
| 259 |
+
class ClipCandidate:
|
| 260 |
+
id: str
|
| 261 |
+
source: str # e.g. "pexels"
|
| 262 |
+
preview_url: str = ""
|
| 263 |
+
download_url: str = ""
|
| 264 |
+
width: int = 0
|
| 265 |
+
height: int = 0
|
| 266 |
+
duration_sec: float = 0.0
|
| 267 |
+
credit: str = ""
|
| 268 |
+
local_path: str = "" # set once downloaded
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@dataclass
|
| 272 |
+
class SceneMedia:
|
| 273 |
+
"""Clip candidates for one voice line. ``selected`` indexes candidates."""
|
| 274 |
+
|
| 275 |
+
line_index: int
|
| 276 |
+
query: str
|
| 277 |
+
candidates: list[ClipCandidate] = field(default_factory=list)
|
| 278 |
+
selected: int = 0
|
| 279 |
+
sfx_path: str = "" # optional sound effect, empty if none
|
| 280 |
+
|
| 281 |
+
_NESTED = {"candidates": (ClipCandidate, True)}
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
@dataclass
|
| 285 |
+
class MediaManifest:
|
| 286 |
+
scenes: list[SceneMedia] = field(default_factory=list)
|
| 287 |
+
music_path: str = "" # royalty-free local file only — never fetched music
|
| 288 |
+
music_credit: str = ""
|
| 289 |
+
|
| 290 |
+
_NESTED = {"scenes": (SceneMedia, True)}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# ---------------------------------------------------------------------------
|
| 294 |
+
# Render
|
| 295 |
+
# ---------------------------------------------------------------------------
|
| 296 |
+
|
| 297 |
+
# Caption style presets. Names are part of the contract so the UI can offer
|
| 298 |
+
# them and variant specs can reference them.
|
| 299 |
+
CAPTION_PRESETS = ("bold-center-karaoke", "clean-lower-third", "minimal")
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
@dataclass
|
| 303 |
+
class VariantSpec:
|
| 304 |
+
"""How one render variant differs from the others.
|
| 305 |
+
|
| 306 |
+
``clip_offset`` rotates each scene's clip choice through its alternates
|
| 307 |
+
(0 = the selected clip, 1 = next candidate, ...). ``caption_preset`` is
|
| 308 |
+
one of CAPTION_PRESETS.
|
| 309 |
+
"""
|
| 310 |
+
|
| 311 |
+
name: str
|
| 312 |
+
caption_preset: str = "bold-center-karaoke"
|
| 313 |
+
clip_offset: int = 0
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
DEFAULT_VARIANTS = [
|
| 317 |
+
VariantSpec(name="Variant A", caption_preset="bold-center-karaoke", clip_offset=0),
|
| 318 |
+
VariantSpec(name="Variant B", caption_preset="clean-lower-third", clip_offset=1),
|
| 319 |
+
VariantSpec(name="Variant C", caption_preset="minimal", clip_offset=2),
|
| 320 |
+
]
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
@dataclass
|
| 324 |
+
class RenderResult:
|
| 325 |
+
variant: str
|
| 326 |
+
caption_preset: str
|
| 327 |
+
path: str
|
| 328 |
+
duration_sec: float = 0.0
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@dataclass
|
| 332 |
+
class RenderBatch:
|
| 333 |
+
results: list[RenderResult] = field(default_factory=list)
|
| 334 |
+
rendered_at: str = ""
|
| 335 |
+
|
| 336 |
+
_NESTED = {"results": (RenderResult, True)}
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ---------------------------------------------------------------------------
|
| 340 |
+
# Clone flow
|
| 341 |
+
# ---------------------------------------------------------------------------
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@dataclass
|
| 345 |
+
class CloneBrief:
|
| 346 |
+
"""Extracted essence of a source Short, used to write a FRESH script.
|
| 347 |
+
|
| 348 |
+
The brief carries structure and topic only. The script stage is
|
| 349 |
+
explicitly instructed to write original wording; source footage, audio
|
| 350 |
+
and verbatim text are never reused.
|
| 351 |
+
"""
|
| 352 |
+
|
| 353 |
+
source_url: str
|
| 354 |
+
source_title: str = ""
|
| 355 |
+
source_channel: str = ""
|
| 356 |
+
topic: str = ""
|
| 357 |
+
hook_pattern: str = ""
|
| 358 |
+
structure: str = "" # e.g. "hook -> 3 escalating facts -> twist -> CTA"
|
| 359 |
+
style_notes: str = ""
|
core/pipeline.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pipeline orchestrator — the ONLY place stages are wired together.
|
| 2 |
+
|
| 3 |
+
Builds adapters from config (lazily, fail-soft for optional ones), hands
|
| 4 |
+
stages their inputs, and persists every artifact through ProjectStore.
|
| 5 |
+
Used identically by cli.py and the FastAPI server.
|
| 6 |
+
|
| 7 |
+
Project-level (shared): references, style profile, topic pool.
|
| 8 |
+
Reel-level (per video): script, voice, media, renders, clone brief.
|
| 9 |
+
|
| 10 |
+
Every generative step exposes a prepare_*_prompt() that returns the exact
|
| 11 |
+
assembled prompt, and accepts a ``prompt`` override on the run method so the
|
| 12 |
+
prompt can be QC'd / edited in the UI before it is sent.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Callable, Optional
|
| 18 |
+
|
| 19 |
+
import core.adapters # noqa: F401 — registers all providers
|
| 20 |
+
from core.adapters.base import get_adapter, registered
|
| 21 |
+
from core.config import Config, load_config
|
| 22 |
+
from core.contracts import (
|
| 23 |
+
CloneBrief,
|
| 24 |
+
MediaManifest,
|
| 25 |
+
NicheProfile,
|
| 26 |
+
Project,
|
| 27 |
+
Reel,
|
| 28 |
+
ReferenceEntry,
|
| 29 |
+
RenderBatch,
|
| 30 |
+
Script,
|
| 31 |
+
StyleProfile,
|
| 32 |
+
TopicBatch,
|
| 33 |
+
VariantSpec,
|
| 34 |
+
VoiceTrack,
|
| 35 |
+
)
|
| 36 |
+
from core.stages import clone as clone_stage
|
| 37 |
+
from core.stages import media as media_stage
|
| 38 |
+
from core.stages import render as render_stage
|
| 39 |
+
from core.stages import script as script_stage
|
| 40 |
+
from core.stages import setup as setup_stage
|
| 41 |
+
from core.stages import style as style_stage
|
| 42 |
+
from core.stages import topics as topics_stage
|
| 43 |
+
from core.stages import voice as voice_stage
|
| 44 |
+
from core.store import ProjectStore, now_iso
|
| 45 |
+
from core.utils import log
|
| 46 |
+
|
| 47 |
+
Progress = Callable[[str], None]
|
| 48 |
+
|
| 49 |
+
# Which TTS providers need which key (edge_tts is always free/available).
|
| 50 |
+
_TTS_KEY = {"gemini_tts": "gemini_api_key", "elevenlabs": "elevenlabs_api_key", "edge_tts": None}
|
| 51 |
+
|
| 52 |
+
_STYLE_PREP = "style_prep.json" # cached transcripts between prompt-prepare and run
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class Pipeline:
|
| 56 |
+
def __init__(self, config: Optional[Config] = None, store: Optional[ProjectStore] = None):
|
| 57 |
+
self.config = config or load_config()
|
| 58 |
+
self.store = store or ProjectStore(self.config.projects_dir)
|
| 59 |
+
self._adapters: dict[str, object] = {}
|
| 60 |
+
|
| 61 |
+
# -- adapter access (lazy; optional ones fail soft to None) --------------
|
| 62 |
+
|
| 63 |
+
def adapter(self, capability: str, name: Optional[str] = None) -> object:
|
| 64 |
+
"""Required adapter. ``name`` overrides the configured provider."""
|
| 65 |
+
name = name or getattr(self.config, f"{capability}_adapter")
|
| 66 |
+
key = f"{capability}:{name}"
|
| 67 |
+
if key not in self._adapters:
|
| 68 |
+
self._adapters[key] = get_adapter(capability, name, self.config)
|
| 69 |
+
return self._adapters[key]
|
| 70 |
+
|
| 71 |
+
def optional_adapter(self, capability: str, name: Optional[str] = None):
|
| 72 |
+
try:
|
| 73 |
+
return self.adapter(capability, name)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
log.info("Optional %r adapter unavailable: %s", capability, e)
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
# -- TTS provider discovery ---------------------------------------------
|
| 79 |
+
|
| 80 |
+
def available_tts_providers(self) -> list[str]:
|
| 81 |
+
"""Registered TTS providers whose key (if any) is present."""
|
| 82 |
+
out = []
|
| 83 |
+
for name in registered("tts"):
|
| 84 |
+
key_attr = _TTS_KEY.get(name)
|
| 85 |
+
if key_attr is None or getattr(self.config, key_attr, ""):
|
| 86 |
+
out.append(name)
|
| 87 |
+
return out
|
| 88 |
+
|
| 89 |
+
SAMPLE_TEXT = "Hey! This is a quick preview of how I sound. Let's make something great together."
|
| 90 |
+
|
| 91 |
+
def voice_sample(self, provider: Optional[str], voice: str) -> str:
|
| 92 |
+
"""Synthesize (once, then cache) a short sample for a provider+voice.
|
| 93 |
+
|
| 94 |
+
Returns the path to an mp3. Cached under projects/_voice_samples so
|
| 95 |
+
repeated previews don't re-spend TTS quota.
|
| 96 |
+
"""
|
| 97 |
+
from core.utils import run_ffmpeg, slugify
|
| 98 |
+
|
| 99 |
+
provider = provider or self.config.tts_adapter
|
| 100 |
+
if provider not in self.available_tts_providers():
|
| 101 |
+
raise RuntimeError(f"Voice provider {provider!r} is not available")
|
| 102 |
+
samples = self.store.root / "_voice_samples"
|
| 103 |
+
samples.mkdir(parents=True, exist_ok=True)
|
| 104 |
+
mp3 = samples / f"{slugify(provider)}_{slugify(voice or 'default')}.mp3"
|
| 105 |
+
if not mp3.exists():
|
| 106 |
+
raw = samples / f"{mp3.stem}.raw"
|
| 107 |
+
self.adapter("tts", provider).synth(self.SAMPLE_TEXT, str(raw), voice=voice)
|
| 108 |
+
# Transcode whatever the provider produced (mp3/wav) to real mp3.
|
| 109 |
+
run_ffmpeg(["-i", str(raw), str(mp3)], label="voice sample")
|
| 110 |
+
raw.unlink(missing_ok=True)
|
| 111 |
+
return str(mp3)
|
| 112 |
+
|
| 113 |
+
def voices(self, provider: Optional[str] = None) -> tuple[str, list[str]]:
|
| 114 |
+
provider = provider or self.config.tts_adapter
|
| 115 |
+
if provider not in self.available_tts_providers():
|
| 116 |
+
# Fall back to a usable provider rather than erroring.
|
| 117 |
+
avail = self.available_tts_providers()
|
| 118 |
+
provider = avail[0] if avail else "edge_tts"
|
| 119 |
+
tts = self.adapter("tts", provider)
|
| 120 |
+
return provider, tts.voices()
|
| 121 |
+
|
| 122 |
+
# -- stage 1: setup -------------------------------------------------------
|
| 123 |
+
|
| 124 |
+
def create_project(
|
| 125 |
+
self,
|
| 126 |
+
name: str,
|
| 127 |
+
niche: NicheProfile,
|
| 128 |
+
format: str = "youtube_short",
|
| 129 |
+
references: Optional[list[ReferenceEntry]] = None,
|
| 130 |
+
) -> Project:
|
| 131 |
+
project = setup_stage.build_project(name, niche, format, references)
|
| 132 |
+
refdata = self.optional_adapter("refdata")
|
| 133 |
+
if refdata:
|
| 134 |
+
for ref in project.references:
|
| 135 |
+
if ref.kind == "youtube" and not ref.channel_id:
|
| 136 |
+
try:
|
| 137 |
+
resolved = refdata.resolve_channel(ref.url or ref.name)
|
| 138 |
+
if resolved:
|
| 139 |
+
ref.channel_id = resolved["channel_id"]
|
| 140 |
+
ref.url = ref.url or resolved["url"]
|
| 141 |
+
ref.name = resolved["title"]
|
| 142 |
+
except Exception as e:
|
| 143 |
+
log.warning("Could not resolve %r now: %s", ref.name, e)
|
| 144 |
+
return self.store.create_project(project)
|
| 145 |
+
|
| 146 |
+
def suggest_niche_fields(self, topic: str) -> dict:
|
| 147 |
+
return setup_stage.suggest_niche_fields(self.adapter("llm"), topic)
|
| 148 |
+
|
| 149 |
+
def prepare_suggest_prompt(self, project: Project, n: int = 5) -> str:
|
| 150 |
+
return setup_stage.build_suggest_channels_prompt(project.niche, n)
|
| 151 |
+
|
| 152 |
+
def suggest_references(
|
| 153 |
+
self, project: Project, n: int = 5, prompt: Optional[str] = None
|
| 154 |
+
) -> list[ReferenceEntry]:
|
| 155 |
+
return setup_stage.suggest_channels(
|
| 156 |
+
self.adapter("llm"), self.optional_adapter("refdata"), project.niche, n=n, prompt=prompt
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# -- stage 2: style (project-level) --------------------------------------
|
| 160 |
+
|
| 161 |
+
def prepare_style_prompt(self, project: Project, progress: Progress = lambda m: None) -> str:
|
| 162 |
+
"""Fetch transcripts, cache them, and return the assembled style prompt."""
|
| 163 |
+
collected, ig_notes, sources = style_stage.collect_style_inputs(
|
| 164 |
+
project, self.optional_adapter("refdata"), self.optional_adapter("transcript"), progress
|
| 165 |
+
)
|
| 166 |
+
self.store.save_json(
|
| 167 |
+
project.id, _STYLE_PREP,
|
| 168 |
+
{"collected": collected, "ig_notes": ig_notes, "sources": sources},
|
| 169 |
+
)
|
| 170 |
+
self.store.save_project(project) # channel ids may have resolved
|
| 171 |
+
return style_stage.build_style_prompt(project, collected, ig_notes)
|
| 172 |
+
|
| 173 |
+
def build_style(
|
| 174 |
+
self, project: Project, prompt: Optional[str] = None, progress: Progress = lambda m: None
|
| 175 |
+
) -> StyleProfile:
|
| 176 |
+
prep = self.store.load_json(project.id, _STYLE_PREP) if prompt else None
|
| 177 |
+
if prep is not None:
|
| 178 |
+
# Reuse cached transcripts so an edited prompt doesn't re-fetch.
|
| 179 |
+
collected = [tuple(c) for c in prep["collected"]]
|
| 180 |
+
ig_notes, sources = prep["ig_notes"], prep["sources"]
|
| 181 |
+
else:
|
| 182 |
+
collected, ig_notes, sources = style_stage.collect_style_inputs(
|
| 183 |
+
project, self.optional_adapter("refdata"), self.optional_adapter("transcript"), progress
|
| 184 |
+
)
|
| 185 |
+
self.store.save_project(project)
|
| 186 |
+
profile = style_stage.assemble_style_profile(
|
| 187 |
+
self.adapter("llm"), project, collected, ig_notes, sources, prompt=prompt, progress=progress
|
| 188 |
+
)
|
| 189 |
+
profile.built_at = now_iso()
|
| 190 |
+
self.store.save_style(project.id, profile)
|
| 191 |
+
return profile
|
| 192 |
+
|
| 193 |
+
def save_style(self, project: Project, profile: StyleProfile) -> StyleProfile:
|
| 194 |
+
"""Persist a user-edited style profile (editable output)."""
|
| 195 |
+
profile.built_at = now_iso()
|
| 196 |
+
self.store.save_style(project.id, profile)
|
| 197 |
+
return profile
|
| 198 |
+
|
| 199 |
+
# -- stage 3: topics (project-level pool) --------------------------------
|
| 200 |
+
|
| 201 |
+
def prepare_topics_prompt(
|
| 202 |
+
self, project: Project, n: int = 8, progress: Progress = lambda m: None
|
| 203 |
+
) -> str:
|
| 204 |
+
signals = topics_stage.collect_topic_signals(project, self.optional_adapter("refdata"), progress)
|
| 205 |
+
return topics_stage.build_topics_prompt(project, signals, n)
|
| 206 |
+
|
| 207 |
+
def generate_topics(
|
| 208 |
+
self, project: Project, n: int = 8, prompt: Optional[str] = None,
|
| 209 |
+
progress: Progress = lambda m: None,
|
| 210 |
+
) -> TopicBatch:
|
| 211 |
+
batch = topics_stage.generate_topics(
|
| 212 |
+
project, self.adapter("llm"), self.optional_adapter("refdata"),
|
| 213 |
+
n=n, progress=progress, prompt=prompt,
|
| 214 |
+
)
|
| 215 |
+
batch.generated_at = now_iso()
|
| 216 |
+
self.store.save_topics(project.id, batch)
|
| 217 |
+
return batch
|
| 218 |
+
|
| 219 |
+
def save_topics(self, project: Project, batch: TopicBatch) -> TopicBatch:
|
| 220 |
+
self.store.save_topics(project.id, batch)
|
| 221 |
+
return batch
|
| 222 |
+
|
| 223 |
+
# -- reels ----------------------------------------------------------------
|
| 224 |
+
|
| 225 |
+
def create_reel(self, project: Project, name: str = "", topic: str = "") -> Reel:
|
| 226 |
+
return self.store.create_reel(project.id, name=name, topic=topic)
|
| 227 |
+
|
| 228 |
+
def list_reels(self, project: Project) -> list[Reel]:
|
| 229 |
+
return self.store.list_reels(project.id)
|
| 230 |
+
|
| 231 |
+
def delete_reel(self, project: Project, reel_id: str) -> None:
|
| 232 |
+
self.store.delete_reel(project.id, reel_id)
|
| 233 |
+
|
| 234 |
+
def _touch_reel(self, project: Project, reel_id: str, topic: str) -> None:
|
| 235 |
+
reel = self.store.load_reel(project.id, reel_id)
|
| 236 |
+
if reel:
|
| 237 |
+
reel.topic = topic
|
| 238 |
+
if not reel.name or reel.name == "Untitled reel":
|
| 239 |
+
reel.name = topic
|
| 240 |
+
self.store.save_reel(project.id, reel)
|
| 241 |
+
|
| 242 |
+
# -- stage 4: script (reel-level) ----------------------------------------
|
| 243 |
+
|
| 244 |
+
def prepare_script_prompt(
|
| 245 |
+
self, project: Project, reel_id: str, topic: str
|
| 246 |
+
) -> str:
|
| 247 |
+
style = self.store.load_style(project.id)
|
| 248 |
+
clone_brief = self.store.load_clone(project.id, reel_id)
|
| 249 |
+
use_brief = clone_brief if clone_brief and clone_brief.topic == topic else None
|
| 250 |
+
return script_stage.build_script_prompt(project, topic, style, use_brief)
|
| 251 |
+
|
| 252 |
+
def generate_script(
|
| 253 |
+
self, project: Project, reel_id: str, topic: str,
|
| 254 |
+
clone_brief: Optional[CloneBrief] = None, prompt: Optional[str] = None,
|
| 255 |
+
) -> Script:
|
| 256 |
+
style = self.store.load_style(project.id)
|
| 257 |
+
if clone_brief is None:
|
| 258 |
+
saved = self.store.load_clone(project.id, reel_id)
|
| 259 |
+
if saved and saved.topic == topic:
|
| 260 |
+
clone_brief = saved
|
| 261 |
+
script = script_stage.generate_script(
|
| 262 |
+
project, topic, self.adapter("llm"), style=style, clone_brief=clone_brief, prompt=prompt
|
| 263 |
+
)
|
| 264 |
+
self.store.save_script(project.id, reel_id, script)
|
| 265 |
+
self._touch_reel(project, reel_id, topic)
|
| 266 |
+
return script
|
| 267 |
+
|
| 268 |
+
def save_script(self, project: Project, reel_id: str, script: Script) -> Script:
|
| 269 |
+
self.store.save_script(project.id, reel_id, script)
|
| 270 |
+
return script
|
| 271 |
+
|
| 272 |
+
def regenerate_scene(self, project: Project, reel_id: str, scene_number: int) -> Script:
|
| 273 |
+
script = self.store.load_script(project.id, reel_id)
|
| 274 |
+
if not script:
|
| 275 |
+
raise RuntimeError("No script to regenerate — generate one first")
|
| 276 |
+
style = self.store.load_style(project.id)
|
| 277 |
+
script = script_stage.regenerate_scene(
|
| 278 |
+
project, script, scene_number, self.adapter("llm"), style=style
|
| 279 |
+
)
|
| 280 |
+
self.store.save_script(project.id, reel_id, script)
|
| 281 |
+
return script
|
| 282 |
+
|
| 283 |
+
# -- stage 5: voice (reel-level) -----------------------------------------
|
| 284 |
+
|
| 285 |
+
def synthesize_voice(
|
| 286 |
+
self, project: Project, reel_id: str, voice: str = "",
|
| 287 |
+
provider: Optional[str] = None, progress: Progress = lambda m: None,
|
| 288 |
+
) -> VoiceTrack:
|
| 289 |
+
script = self.store.load_script(project.id, reel_id)
|
| 290 |
+
if not script:
|
| 291 |
+
raise RuntimeError("No script — generate one first")
|
| 292 |
+
provider = provider or self.config.tts_adapter
|
| 293 |
+
track = voice_stage.synthesize_voice(
|
| 294 |
+
script,
|
| 295 |
+
self.adapter("tts", provider),
|
| 296 |
+
str(self.store.voice_dir(project.id, reel_id)),
|
| 297 |
+
voice=voice,
|
| 298 |
+
provider_name=provider,
|
| 299 |
+
progress=progress,
|
| 300 |
+
)
|
| 301 |
+
self.store.save_voice(project.id, reel_id, track)
|
| 302 |
+
return track
|
| 303 |
+
|
| 304 |
+
# -- stage 6: media (reel-level) -----------------------------------------
|
| 305 |
+
|
| 306 |
+
def gather_media(self, project: Project, reel_id: str, progress: Progress = lambda m: None) -> MediaManifest:
|
| 307 |
+
script = self.store.load_script(project.id, reel_id)
|
| 308 |
+
voice = self.store.load_voice(project.id, reel_id)
|
| 309 |
+
if not script or not voice:
|
| 310 |
+
raise RuntimeError("Need a script and a voice track before gathering media")
|
| 311 |
+
manifest = media_stage.gather_media(
|
| 312 |
+
script, voice, self.adapter("stock"), self.optional_adapter("sfx"),
|
| 313 |
+
self.optional_adapter("music"), str(self.store.media_dir(project.id, reel_id)), progress,
|
| 314 |
+
)
|
| 315 |
+
self.store.save_media(project.id, reel_id, manifest)
|
| 316 |
+
return manifest
|
| 317 |
+
|
| 318 |
+
def swap_clip(self, project: Project, reel_id: str, line_index: int) -> MediaManifest:
|
| 319 |
+
manifest = self.store.load_media(project.id, reel_id)
|
| 320 |
+
if not manifest:
|
| 321 |
+
raise RuntimeError("No media manifest — gather media first")
|
| 322 |
+
manifest = media_stage.swap_clip(
|
| 323 |
+
manifest, line_index, self.adapter("stock"), str(self.store.media_dir(project.id, reel_id))
|
| 324 |
+
)
|
| 325 |
+
self.store.save_media(project.id, reel_id, manifest)
|
| 326 |
+
return manifest
|
| 327 |
+
|
| 328 |
+
# -- stage 7: render (reel-level) ----------------------------------------
|
| 329 |
+
|
| 330 |
+
def render(
|
| 331 |
+
self, project: Project, reel_id: str, variants: Optional[list[VariantSpec]] = None,
|
| 332 |
+
progress: Progress = lambda m: None,
|
| 333 |
+
) -> RenderBatch:
|
| 334 |
+
voice = self.store.load_voice(project.id, reel_id)
|
| 335 |
+
manifest = self.store.load_media(project.id, reel_id)
|
| 336 |
+
if not voice or not manifest:
|
| 337 |
+
raise RuntimeError("Need voice and media before rendering")
|
| 338 |
+
batch = render_stage.render_variants(
|
| 339 |
+
voice, manifest, str(self.store.renders_dir(project.id, reel_id)),
|
| 340 |
+
variants=variants, stock=self.optional_adapter("stock"), progress=progress,
|
| 341 |
+
)
|
| 342 |
+
batch.rendered_at = now_iso()
|
| 343 |
+
self.store.save_renders(project.id, reel_id, batch)
|
| 344 |
+
return batch
|
| 345 |
+
|
| 346 |
+
# -- stage 8: clone (creates a new reel) ---------------------------------
|
| 347 |
+
|
| 348 |
+
def clone_reel(self, project: Project, url: str, progress: Progress = lambda m: None) -> dict:
|
| 349 |
+
"""URL -> CloneBrief -> a NEW reel with a fresh script."""
|
| 350 |
+
transcripts = self.optional_adapter("transcript")
|
| 351 |
+
if transcripts is None:
|
| 352 |
+
raise clone_stage.CloneError("Transcript support unavailable on this install.")
|
| 353 |
+
progress("Fetching source transcript")
|
| 354 |
+
brief = clone_stage.build_clone_brief(
|
| 355 |
+
url, self.adapter("llm"), transcripts, self.optional_adapter("refdata")
|
| 356 |
+
)
|
| 357 |
+
reel = self.store.create_reel(project.id, name=brief.topic, topic=brief.topic)
|
| 358 |
+
reel.clone_source = url
|
| 359 |
+
self.store.save_reel(project.id, reel)
|
| 360 |
+
self.store.save_clone(project.id, reel.id, brief)
|
| 361 |
+
progress(f"Writing a fresh script on: {brief.topic}")
|
| 362 |
+
script = self.generate_script(project, reel.id, brief.topic, clone_brief=brief)
|
| 363 |
+
return {"reel": reel, "script": script}
|
| 364 |
+
|
| 365 |
+
# -- convenience: full headless run --------------------------------------
|
| 366 |
+
|
| 367 |
+
def run_all(
|
| 368 |
+
self, project: Project, topic: Optional[str] = None, voice: str = "",
|
| 369 |
+
provider: Optional[str] = None, progress: Progress = lambda m: None,
|
| 370 |
+
) -> RenderBatch:
|
| 371 |
+
"""Style -> topics -> reel(script -> voice -> media -> render), end to end."""
|
| 372 |
+
progress("Building style profile")
|
| 373 |
+
self.build_style(project, progress=progress)
|
| 374 |
+
if topic is None:
|
| 375 |
+
progress("Generating topics")
|
| 376 |
+
batch = self.generate_topics(project, n=5, progress=progress)
|
| 377 |
+
topic = batch.topics[0].title
|
| 378 |
+
progress(f"Auto-picked top topic: {topic}")
|
| 379 |
+
reel = self.create_reel(project, topic=topic)
|
| 380 |
+
progress("Writing script")
|
| 381 |
+
self.generate_script(project, reel.id, topic)
|
| 382 |
+
progress("Synthesizing voice")
|
| 383 |
+
self.synthesize_voice(project, reel.id, voice=voice, provider=provider, progress=progress)
|
| 384 |
+
progress("Gathering media")
|
| 385 |
+
self.gather_media(project, reel.id, progress)
|
| 386 |
+
progress("Rendering variants")
|
| 387 |
+
return self.render(project, reel.id, progress=progress)
|
core/stages/__init__.py
ADDED
|
File without changes
|
core/stages/clone.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 8 — "Clone a reel".
|
| 2 |
+
|
| 3 |
+
Given a YouTube Shorts URL: fetch metadata + transcript, have the LLM
|
| 4 |
+
extract the topic / hook pattern / structure, and emit a CloneBrief that
|
| 5 |
+
script.py uses to write a FRESH script.
|
| 6 |
+
|
| 7 |
+
Content policy (enforced here and in the script prompt): the output is
|
| 8 |
+
transformed original content. The source video's footage, audio, and
|
| 9 |
+
verbatim wording are NEVER reused. If a transcript can't be obtained we
|
| 10 |
+
refuse gracefully with a clear message instead of guessing.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
from core.adapters.base import LLMAdapter, ReferenceDataAdapter, TranscriptAdapter
|
| 19 |
+
from core.contracts import CloneBrief
|
| 20 |
+
from core.utils import log
|
| 21 |
+
|
| 22 |
+
_VIDEO_ID_RE = re.compile(
|
| 23 |
+
r"(?:youtube\.com/(?:shorts/|watch\?v=)|youtu\.be/)([\w-]{11})"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class CloneError(Exception):
|
| 28 |
+
"""User-facing clone failure (bad URL, no transcript, ...)."""
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def extract_video_id(url: str) -> str:
|
| 32 |
+
m = _VIDEO_ID_RE.search(url.strip())
|
| 33 |
+
if not m:
|
| 34 |
+
raise CloneError(
|
| 35 |
+
"That doesn't look like a YouTube video URL. "
|
| 36 |
+
"Paste a link like https://www.youtube.com/shorts/VIDEOID"
|
| 37 |
+
)
|
| 38 |
+
return m.group(1)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def build_clone_brief(
|
| 42 |
+
url: str,
|
| 43 |
+
llm: LLMAdapter,
|
| 44 |
+
transcripts: TranscriptAdapter,
|
| 45 |
+
refdata: Optional[ReferenceDataAdapter] = None,
|
| 46 |
+
) -> CloneBrief:
|
| 47 |
+
video_id = extract_video_id(url)
|
| 48 |
+
|
| 49 |
+
transcript = transcripts.fetch(video_id)
|
| 50 |
+
if not transcript or len(transcript.strip()) < 40:
|
| 51 |
+
raise CloneError(
|
| 52 |
+
"Couldn't get a transcript for that video (no captions and audio "
|
| 53 |
+
"transcription unavailable). Cloning needs the spoken content — "
|
| 54 |
+
"try a different video."
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
title, channel = "", ""
|
| 58 |
+
if refdata is not None:
|
| 59 |
+
try:
|
| 60 |
+
# videos.list via the refdata adapter's private getter keeps the
|
| 61 |
+
# YouTube surface in one module.
|
| 62 |
+
data = refdata._get("videos", part="snippet", id=video_id) # type: ignore[attr-defined]
|
| 63 |
+
items = data.get("items", [])
|
| 64 |
+
if items:
|
| 65 |
+
title = items[0]["snippet"]["title"]
|
| 66 |
+
channel = items[0]["snippet"]["channelTitle"]
|
| 67 |
+
except Exception as e:
|
| 68 |
+
log.info("Clone metadata lookup failed (%s) — proceeding with transcript only", e)
|
| 69 |
+
|
| 70 |
+
prompt = f"""Analyze this short-form video transcript and extract its essence
|
| 71 |
+
so a writer can make an ORIGINAL video on the same topic.
|
| 72 |
+
|
| 73 |
+
TITLE: {title or "(unknown)"}
|
| 74 |
+
TRANSCRIPT:
|
| 75 |
+
{transcript[:6000]}
|
| 76 |
+
|
| 77 |
+
Return JSON only:
|
| 78 |
+
{{"topic": "what the video is about, one line",
|
| 79 |
+
"hook_pattern": "the abstract pattern of its hook (e.g. 'shocking stat then challenge to viewer'), NOT its words",
|
| 80 |
+
"structure": "beat-by-beat structure (e.g. 'hook -> 3 escalating examples -> twist -> CTA')",
|
| 81 |
+
"style_notes": "delivery style: pacing, person, tone, devices used"}}"""
|
| 82 |
+
raw = llm.complete_json(prompt)
|
| 83 |
+
brief = CloneBrief(
|
| 84 |
+
source_url=url,
|
| 85 |
+
source_title=title,
|
| 86 |
+
source_channel=channel,
|
| 87 |
+
topic=(raw.get("topic") or "").strip(),
|
| 88 |
+
hook_pattern=(raw.get("hook_pattern") or "").strip(),
|
| 89 |
+
structure=(raw.get("structure") or "").strip(),
|
| 90 |
+
style_notes=(raw.get("style_notes") or "").strip(),
|
| 91 |
+
)
|
| 92 |
+
if not brief.topic:
|
| 93 |
+
raise CloneError("Couldn't extract a usable topic from that video's transcript.")
|
| 94 |
+
return brief
|
core/stages/media.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 6 — media gathering.
|
| 2 |
+
|
| 3 |
+
For each voiced narration line: search stock clips (keeping alternates in
|
| 4 |
+
the manifest so the UI can offer swaps), download the selected candidate,
|
| 5 |
+
optionally fetch an SFX, and pick a background music track. Dedupe rule:
|
| 6 |
+
the same clip id is never used twice in one reel.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Callable, Optional
|
| 13 |
+
|
| 14 |
+
from core.adapters.base import MusicAdapter, SFXAdapter, StockVideoAdapter
|
| 15 |
+
from core.contracts import MediaManifest, SceneMedia, Script, VoiceTrack
|
| 16 |
+
from core.utils import log
|
| 17 |
+
|
| 18 |
+
Progress = Callable[[str], None]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _queries_per_line(script: Script) -> list[str]:
|
| 22 |
+
"""Map each narration line (hook + scenes + cta) to a visual query.
|
| 23 |
+
|
| 24 |
+
The hook reuses the first scene's visuals; the CTA reuses the last's.
|
| 25 |
+
"""
|
| 26 |
+
scene_queries = [s.visual_query for s in script.scenes]
|
| 27 |
+
if not scene_queries:
|
| 28 |
+
return []
|
| 29 |
+
queries = []
|
| 30 |
+
if script.hook_options:
|
| 31 |
+
queries.append(scene_queries[0])
|
| 32 |
+
queries.extend(scene_queries)
|
| 33 |
+
if script.cta:
|
| 34 |
+
queries.append(scene_queries[-1])
|
| 35 |
+
return queries
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _sfx_per_line(script: Script) -> list[Optional[str]]:
|
| 39 |
+
sfx = [s.sfx_query for s in script.scenes]
|
| 40 |
+
out: list[Optional[str]] = []
|
| 41 |
+
if script.hook_options:
|
| 42 |
+
out.append(None)
|
| 43 |
+
out.extend(sfx)
|
| 44 |
+
if script.cta:
|
| 45 |
+
out.append(None)
|
| 46 |
+
return out
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def gather_media(
|
| 50 |
+
script: Script,
|
| 51 |
+
voice: VoiceTrack,
|
| 52 |
+
stock: StockVideoAdapter,
|
| 53 |
+
sfx: Optional[SFXAdapter],
|
| 54 |
+
music: Optional[MusicAdapter],
|
| 55 |
+
out_dir: str,
|
| 56 |
+
progress: Progress = lambda m: None,
|
| 57 |
+
) -> MediaManifest:
|
| 58 |
+
out = Path(out_dir)
|
| 59 |
+
clips_dir = out / "clips"
|
| 60 |
+
sfx_dir = out / "sfx"
|
| 61 |
+
clips_dir.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
sfx_dir.mkdir(parents=True, exist_ok=True)
|
| 63 |
+
|
| 64 |
+
queries = _queries_per_line(script)
|
| 65 |
+
sfx_queries = _sfx_per_line(script)
|
| 66 |
+
if len(queries) != len(voice.lines):
|
| 67 |
+
log.warning(
|
| 68 |
+
"Query count (%d) != voice line count (%d) — padding with last query",
|
| 69 |
+
len(queries), len(voice.lines),
|
| 70 |
+
)
|
| 71 |
+
while len(queries) < len(voice.lines):
|
| 72 |
+
queries.append(queries[-1] if queries else script.topic)
|
| 73 |
+
queries = queries[: len(voice.lines)]
|
| 74 |
+
sfx_queries = (sfx_queries + [None] * len(voice.lines))[: len(voice.lines)]
|
| 75 |
+
|
| 76 |
+
used_clip_ids: set[str] = set()
|
| 77 |
+
scenes: list[SceneMedia] = []
|
| 78 |
+
|
| 79 |
+
for line, query, sfx_query in zip(voice.lines, queries, sfx_queries):
|
| 80 |
+
progress(f"Searching clips for line {line.index + 1}: {query!r}")
|
| 81 |
+
try:
|
| 82 |
+
candidates = stock.search(
|
| 83 |
+
query,
|
| 84 |
+
orientation="portrait",
|
| 85 |
+
min_duration=max(2.0, line.duration_sec * 0.5),
|
| 86 |
+
max_duration=max(30.0, line.duration_sec * 2),
|
| 87 |
+
per_query=4,
|
| 88 |
+
)
|
| 89 |
+
except Exception as e:
|
| 90 |
+
log.warning("Stock search failed for %r: %s — line gets no candidates", query, e)
|
| 91 |
+
candidates = []
|
| 92 |
+
|
| 93 |
+
# Dedupe across the whole reel: drop candidates already used elsewhere,
|
| 94 |
+
# unless that would leave the line with nothing.
|
| 95 |
+
fresh = [c for c in candidates if c.id not in used_clip_ids]
|
| 96 |
+
candidates = fresh or candidates
|
| 97 |
+
|
| 98 |
+
selected = 0
|
| 99 |
+
if candidates:
|
| 100 |
+
try:
|
| 101 |
+
chosen = candidates[selected]
|
| 102 |
+
dest = str(clips_dir / f"line{line.index:02d}_{chosen.id}.mp4")
|
| 103 |
+
progress(f"Downloading clip {chosen.id}")
|
| 104 |
+
stock.download(chosen, dest)
|
| 105 |
+
used_clip_ids.add(chosen.id)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
log.warning("Clip download failed (%s) — line %d left clipless", e, line.index)
|
| 108 |
+
candidates = []
|
| 109 |
+
|
| 110 |
+
sfx_path = ""
|
| 111 |
+
if sfx_query and sfx is not None:
|
| 112 |
+
dest = str(sfx_dir / f"line{line.index:02d}.mp3")
|
| 113 |
+
got = sfx.fetch(sfx_query, dest)
|
| 114 |
+
sfx_path = got or ""
|
| 115 |
+
|
| 116 |
+
scenes.append(
|
| 117 |
+
SceneMedia(
|
| 118 |
+
line_index=line.index,
|
| 119 |
+
query=query,
|
| 120 |
+
candidates=candidates,
|
| 121 |
+
selected=selected,
|
| 122 |
+
sfx_path=sfx_path,
|
| 123 |
+
)
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
music_path, music_credit = "", ""
|
| 127 |
+
if music is not None:
|
| 128 |
+
try:
|
| 129 |
+
music_path, music_credit = music.pick(mood=script.topic)
|
| 130 |
+
except Exception as e:
|
| 131 |
+
log.warning("Music pick failed: %s — rendering without music", e)
|
| 132 |
+
|
| 133 |
+
return MediaManifest(scenes=scenes, music_path=music_path, music_credit=music_credit)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def swap_clip(
|
| 137 |
+
manifest: MediaManifest,
|
| 138 |
+
line_index: int,
|
| 139 |
+
stock: StockVideoAdapter,
|
| 140 |
+
out_dir: str,
|
| 141 |
+
) -> MediaManifest:
|
| 142 |
+
"""Cycle a line's selection to its next downloaded-or-downloadable alternate."""
|
| 143 |
+
sm = next((s for s in manifest.scenes if s.line_index == line_index), None)
|
| 144 |
+
if sm is None or len(sm.candidates) < 2:
|
| 145 |
+
return manifest
|
| 146 |
+
sm.selected = (sm.selected + 1) % len(sm.candidates)
|
| 147 |
+
chosen = sm.candidates[sm.selected]
|
| 148 |
+
if not chosen.local_path:
|
| 149 |
+
dest = str(Path(out_dir) / "clips" / f"line{line_index:02d}_{chosen.id}.mp4")
|
| 150 |
+
try:
|
| 151 |
+
stock.download(chosen, dest)
|
| 152 |
+
except Exception as e:
|
| 153 |
+
log.warning("Swap download failed (%s) — reverting selection", e)
|
| 154 |
+
sm.selected = (sm.selected - 1) % len(sm.candidates)
|
| 155 |
+
return manifest
|
core/stages/render.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 7 — rendering.
|
| 2 |
+
|
| 3 |
+
Per voice line: normalize the chosen clip to 1080x1920@30, loop/trim it to
|
| 4 |
+
the TRUE voice duration, and mix voice + optional SFX. Segments are then
|
| 5 |
+
concatenated, the Pillow caption track is overlaid in a single pass, and
|
| 6 |
+
royalty-free music is mixed at low volume.
|
| 7 |
+
|
| 8 |
+
Produces 2-3 VARIANTS per run (different clip choices via alternates +
|
| 9 |
+
different caption presets) so the user can pick side by side.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Callable, Optional
|
| 16 |
+
|
| 17 |
+
from core.adapters.base import StockVideoAdapter
|
| 18 |
+
from core.captions import build_caption_track
|
| 19 |
+
from core.contracts import (
|
| 20 |
+
DEFAULT_VARIANTS,
|
| 21 |
+
VIDEO_FPS,
|
| 22 |
+
VIDEO_HEIGHT,
|
| 23 |
+
VIDEO_WIDTH,
|
| 24 |
+
MediaManifest,
|
| 25 |
+
RenderBatch,
|
| 26 |
+
RenderResult,
|
| 27 |
+
VariantSpec,
|
| 28 |
+
VoiceTrack,
|
| 29 |
+
)
|
| 30 |
+
from core.utils import ffprobe_duration, log, run_ffmpeg
|
| 31 |
+
|
| 32 |
+
Progress = Callable[[str], None]
|
| 33 |
+
|
| 34 |
+
_VID = f"scale={VIDEO_WIDTH}:{VIDEO_HEIGHT}:force_original_aspect_ratio=increase,crop={VIDEO_WIDTH}:{VIDEO_HEIGHT},fps={VIDEO_FPS},setsar=1,format=yuv420p"
|
| 35 |
+
_AUD = "aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo"
|
| 36 |
+
_ENC = [
|
| 37 |
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
| 38 |
+
"-c:a", "aac", "-b:a", "192k", "-ar", "44100", "-ac", "2",
|
| 39 |
+
]
|
| 40 |
+
_FALLBACK_BG = "color=c=0x16213e:s=%dx%d:r=%d" % (VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _clip_for_line(scene_media, clip_offset: int, stock: Optional[StockVideoAdapter],
|
| 44 |
+
clips_dir: Path) -> str:
|
| 45 |
+
"""Pick this variant's clip for a line, lazily downloading alternates.
|
| 46 |
+
|
| 47 |
+
Falls back: offset candidate -> selected candidate -> "" (solid bg).
|
| 48 |
+
"""
|
| 49 |
+
cands = scene_media.candidates
|
| 50 |
+
if not cands:
|
| 51 |
+
return ""
|
| 52 |
+
order = [(scene_media.selected + clip_offset) % len(cands), scene_media.selected]
|
| 53 |
+
for idx in order:
|
| 54 |
+
cand = cands[idx]
|
| 55 |
+
if cand.local_path and Path(cand.local_path).exists():
|
| 56 |
+
return cand.local_path
|
| 57 |
+
if stock is not None:
|
| 58 |
+
dest = str(clips_dir / f"line{scene_media.line_index:02d}_{cand.id}.mp4")
|
| 59 |
+
try:
|
| 60 |
+
stock.download(cand, dest)
|
| 61 |
+
return dest
|
| 62 |
+
except Exception as e:
|
| 63 |
+
log.warning("Alternate download failed (%s) — trying fallback clip", e)
|
| 64 |
+
return ""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _render_segment(clip_path: str, line, sfx_path: str, out_path: str) -> None:
|
| 68 |
+
"""One normalized 9:16 segment, exactly line.duration_sec long."""
|
| 69 |
+
dur = max(line.duration_sec, 0.2)
|
| 70 |
+
args: list[str] = []
|
| 71 |
+
if clip_path:
|
| 72 |
+
args += ["-stream_loop", "-1", "-i", clip_path]
|
| 73 |
+
video_in = "[0:v]"
|
| 74 |
+
voice_idx, sfx_idx = 1, 2
|
| 75 |
+
else:
|
| 76 |
+
args += ["-f", "lavfi", "-i", _FALLBACK_BG]
|
| 77 |
+
video_in = "[0:v]"
|
| 78 |
+
voice_idx, sfx_idx = 1, 2
|
| 79 |
+
args += ["-i", line.audio_path]
|
| 80 |
+
if sfx_path:
|
| 81 |
+
args += ["-i", sfx_path]
|
| 82 |
+
|
| 83 |
+
filters = [f"{video_in}{_VID}[v]"]
|
| 84 |
+
if sfx_path:
|
| 85 |
+
filters.append(f"[{sfx_idx}:a]volume=0.45,{_AUD}[sfx]")
|
| 86 |
+
filters.append(f"[{voice_idx}:a]{_AUD}[vo]")
|
| 87 |
+
filters.append("[vo][sfx]amix=inputs=2:duration=first:dropout_transition=0[a]")
|
| 88 |
+
else:
|
| 89 |
+
filters.append(f"[{voice_idx}:a]{_AUD}[a]")
|
| 90 |
+
|
| 91 |
+
run_ffmpeg(
|
| 92 |
+
[
|
| 93 |
+
*args,
|
| 94 |
+
"-filter_complex", ";".join(filters),
|
| 95 |
+
"-map", "[v]", "-map", "[a]",
|
| 96 |
+
"-t", f"{dur:.3f}",
|
| 97 |
+
*_ENC,
|
| 98 |
+
out_path,
|
| 99 |
+
],
|
| 100 |
+
label=f"segment line {line.index}",
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def render_variants(
|
| 105 |
+
voice: VoiceTrack,
|
| 106 |
+
manifest: MediaManifest,
|
| 107 |
+
out_dir: str,
|
| 108 |
+
variants: Optional[list[VariantSpec]] = None,
|
| 109 |
+
stock: Optional[StockVideoAdapter] = None,
|
| 110 |
+
progress: Progress = lambda m: None,
|
| 111 |
+
) -> RenderBatch:
|
| 112 |
+
"""Render every requested variant; a failed variant is logged and skipped,
|
| 113 |
+
but at least one must succeed."""
|
| 114 |
+
variants = variants or list(DEFAULT_VARIANTS)
|
| 115 |
+
out = Path(out_dir)
|
| 116 |
+
work = out / "work"
|
| 117 |
+
work.mkdir(parents=True, exist_ok=True)
|
| 118 |
+
clips_dir = work / "clips"
|
| 119 |
+
clips_dir.mkdir(exist_ok=True)
|
| 120 |
+
|
| 121 |
+
media_by_line = {s.line_index: s for s in manifest.scenes}
|
| 122 |
+
results: list[RenderResult] = []
|
| 123 |
+
|
| 124 |
+
for vi, spec in enumerate(variants):
|
| 125 |
+
try:
|
| 126 |
+
progress(f"Rendering {spec.name} ({spec.caption_preset})")
|
| 127 |
+
path = _render_one(
|
| 128 |
+
voice, manifest, media_by_line, spec, work, out, clips_dir, stock, progress
|
| 129 |
+
)
|
| 130 |
+
results.append(
|
| 131 |
+
RenderResult(
|
| 132 |
+
variant=spec.name,
|
| 133 |
+
caption_preset=spec.caption_preset,
|
| 134 |
+
path=path,
|
| 135 |
+
duration_sec=ffprobe_duration(path),
|
| 136 |
+
)
|
| 137 |
+
)
|
| 138 |
+
except Exception as e:
|
| 139 |
+
log.warning("Variant %s failed: %s — continuing with the others", spec.name, e)
|
| 140 |
+
|
| 141 |
+
if not results:
|
| 142 |
+
raise RuntimeError("All render variants failed")
|
| 143 |
+
return RenderBatch(results=results)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _render_one(
|
| 147 |
+
voice: VoiceTrack,
|
| 148 |
+
manifest: MediaManifest,
|
| 149 |
+
media_by_line: dict,
|
| 150 |
+
spec: VariantSpec,
|
| 151 |
+
work: Path,
|
| 152 |
+
out: Path,
|
| 153 |
+
clips_dir: Path,
|
| 154 |
+
stock: Optional[StockVideoAdapter],
|
| 155 |
+
progress: Progress,
|
| 156 |
+
) -> str:
|
| 157 |
+
tag = spec.name.lower().replace(" ", "_")
|
| 158 |
+
|
| 159 |
+
# 1. Per-line segments (loop/trim clip to the voice line's true duration).
|
| 160 |
+
seg_paths = []
|
| 161 |
+
for line in voice.lines:
|
| 162 |
+
sm = media_by_line.get(line.index)
|
| 163 |
+
clip = _clip_for_line(sm, spec.clip_offset, stock, clips_dir) if sm else ""
|
| 164 |
+
sfx = sm.sfx_path if sm and sm.sfx_path and Path(sm.sfx_path).exists() else ""
|
| 165 |
+
seg = str(work / f"{tag}_seg{line.index:02d}.mp4")
|
| 166 |
+
progress(f"{spec.name}: segment {line.index + 1}/{len(voice.lines)}")
|
| 167 |
+
_render_segment(clip, line, sfx, seg)
|
| 168 |
+
seg_paths.append(seg)
|
| 169 |
+
|
| 170 |
+
concat_list = work / f"{tag}_segments.txt"
|
| 171 |
+
concat_list.write_text("\n".join(f"file '{p}'" for p in seg_paths))
|
| 172 |
+
total = sum(line.duration_sec for line in voice.lines)
|
| 173 |
+
|
| 174 |
+
# 2. Caption overlay track (one qtrle pass for the whole video).
|
| 175 |
+
progress(f"{spec.name}: rendering captions")
|
| 176 |
+
captions = build_caption_track(voice, spec.caption_preset, str(work), total)
|
| 177 |
+
|
| 178 |
+
# 3. Final pass: concat + caption overlay + low-volume music, one encode.
|
| 179 |
+
final = str(out / f"{tag}.mp4")
|
| 180 |
+
args = ["-f", "concat", "-safe", "0", "-i", str(concat_list)]
|
| 181 |
+
filter_parts = []
|
| 182 |
+
n_in = 1
|
| 183 |
+
video_label = "[0:v]"
|
| 184 |
+
if captions:
|
| 185 |
+
args += ["-i", captions]
|
| 186 |
+
filter_parts.append(f"{video_label}[{n_in}:v]overlay=0:0:eof_action=pass[v]")
|
| 187 |
+
video_label = "[v]"
|
| 188 |
+
n_in += 1
|
| 189 |
+
|
| 190 |
+
audio_label = "[0:a]"
|
| 191 |
+
music = manifest.music_path if manifest.music_path and Path(manifest.music_path).exists() else ""
|
| 192 |
+
if music:
|
| 193 |
+
args += ["-stream_loop", "-1", "-i", music]
|
| 194 |
+
filter_parts.append(f"[{n_in}:a]volume=0.12,{_AUD}[mus]")
|
| 195 |
+
filter_parts.append(f"{audio_label}[mus]amix=inputs=2:duration=first:dropout_transition=0[a]")
|
| 196 |
+
audio_label = "[a]"
|
| 197 |
+
n_in += 1
|
| 198 |
+
|
| 199 |
+
progress(f"{spec.name}: final encode")
|
| 200 |
+
if filter_parts:
|
| 201 |
+
run_ffmpeg(
|
| 202 |
+
[
|
| 203 |
+
*args,
|
| 204 |
+
"-filter_complex", ";".join(filter_parts),
|
| 205 |
+
"-map", video_label, "-map", audio_label,
|
| 206 |
+
"-t", f"{total:.3f}",
|
| 207 |
+
*_ENC,
|
| 208 |
+
"-movflags", "+faststart",
|
| 209 |
+
final,
|
| 210 |
+
],
|
| 211 |
+
label=f"final {spec.name}",
|
| 212 |
+
)
|
| 213 |
+
else: # no captions, no music — plain concat re-encode
|
| 214 |
+
run_ffmpeg(
|
| 215 |
+
[*args, "-t", f"{total:.3f}", *_ENC, "-movflags", "+faststart", final],
|
| 216 |
+
label=f"final {spec.name}",
|
| 217 |
+
)
|
| 218 |
+
return final
|
core/stages/script.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 4 — scene-structured script generation.
|
| 2 |
+
|
| 3 |
+
Injects the niche profile + style card + exemplar transcripts + topic so
|
| 4 |
+
output mirrors the reference channels rather than generic AI voice.
|
| 5 |
+
Supports full regeneration, single-scene regeneration, and clone briefs.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from core.adapters.base import LLMAdapter
|
| 13 |
+
from core.contracts import CloneBrief, Project, Scene, Script, StyleProfile
|
| 14 |
+
from core.utils import log
|
| 15 |
+
|
| 16 |
+
MAX_SCENES = 6
|
| 17 |
+
MIN_TOTAL_SEC = 30
|
| 18 |
+
MAX_TOTAL_SEC = 45
|
| 19 |
+
|
| 20 |
+
_SCHEMA = """{
|
| 21 |
+
"title": "video title",
|
| 22 |
+
"hook_options": ["hook 1", "hook 2", "hook 3"],
|
| 23 |
+
"scenes": [
|
| 24 |
+
{"scene": 1, "narration": "spoken words", "visual_query": "3-5 word stock footage query",
|
| 25 |
+
"sfx_query": "short sfx query or null", "duration_sec": 6}
|
| 26 |
+
],
|
| 27 |
+
"cta": "closing call to action",
|
| 28 |
+
"total_duration_sec": 38
|
| 29 |
+
}"""
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _style_block(style: Optional[StyleProfile]) -> str:
|
| 33 |
+
if not style:
|
| 34 |
+
return "No style profile available — use a strong default short-form style."
|
| 35 |
+
parts = [f"STYLE CARD:\n{style.style_card}"]
|
| 36 |
+
for i, ex in enumerate(style.exemplars[:2], 1):
|
| 37 |
+
parts.append(f"EXEMPLAR TRANSCRIPT {i} (match this voice, never copy lines):\n{ex[:3000]}")
|
| 38 |
+
if style.instagram_notes:
|
| 39 |
+
parts.append("EXTRA STYLE NOTES:\n" + "\n".join(f"- {n}" for n in style.instagram_notes))
|
| 40 |
+
return "\n\n".join(parts)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def generate_script(
|
| 44 |
+
project: Project,
|
| 45 |
+
topic: str,
|
| 46 |
+
llm: LLMAdapter,
|
| 47 |
+
style: Optional[StyleProfile] = None,
|
| 48 |
+
clone_brief: Optional[CloneBrief] = None,
|
| 49 |
+
prompt: Optional[str] = None,
|
| 50 |
+
) -> Script:
|
| 51 |
+
"""Write a complete script for ``topic`` in the references' style.
|
| 52 |
+
|
| 53 |
+
``prompt`` overrides the default assembled prompt (for QC editing).
|
| 54 |
+
"""
|
| 55 |
+
used_prompt = prompt or build_script_prompt(project, topic, style, clone_brief)
|
| 56 |
+
raw = llm.complete_json(used_prompt)
|
| 57 |
+
script = _parse_script(raw, topic)
|
| 58 |
+
script.prompt_used = used_prompt
|
| 59 |
+
if clone_brief:
|
| 60 |
+
script.clone_source = clone_brief.source_url
|
| 61 |
+
return script
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def build_script_prompt(
|
| 65 |
+
project: Project,
|
| 66 |
+
topic: str,
|
| 67 |
+
style: Optional[StyleProfile] = None,
|
| 68 |
+
clone_brief: Optional[CloneBrief] = None,
|
| 69 |
+
) -> str:
|
| 70 |
+
"""The default scriptwriting prompt (exposed for QC/editing)."""
|
| 71 |
+
clone_block = ""
|
| 72 |
+
if clone_brief:
|
| 73 |
+
clone_block = f"""
|
| 74 |
+
THIS IS A CLONE BRIEF. A reference Short exists on this topic. Reuse ONLY its
|
| 75 |
+
abstract structure — write 100% ORIGINAL wording. Never quote or paraphrase
|
| 76 |
+
its lines closely.
|
| 77 |
+
Hook pattern to emulate: {clone_brief.hook_pattern}
|
| 78 |
+
Structure to follow: {clone_brief.structure}
|
| 79 |
+
Style notes: {clone_brief.style_notes}
|
| 80 |
+
"""
|
| 81 |
+
return f"""You write scripts for 9:16 short-form videos.
|
| 82 |
+
|
| 83 |
+
NICHE: {project.niche.topic} | Audience: {project.niche.audience or "general"} | Tone: {project.niche.tone or "engaging"}
|
| 84 |
+
TOPIC: {topic}
|
| 85 |
+
|
| 86 |
+
{_style_block(style)}
|
| 87 |
+
{clone_block}
|
| 88 |
+
HARD RULES:
|
| 89 |
+
- At most {MAX_SCENES} scenes; total spoken duration {MIN_TOTAL_SEC}-{MAX_TOTAL_SEC} seconds.
|
| 90 |
+
- The hook must land inside the first 2 seconds (start mid-action, no warmup).
|
| 91 |
+
- Conversational spoken English — contractions, short sentences, no headings.
|
| 92 |
+
- Provide exactly 3 alternative hook_options (different angles, same topic).
|
| 93 |
+
- visual_query: concrete 3-5 word stock-footage search ("rain on car window"),
|
| 94 |
+
never abstract ("success concept").
|
| 95 |
+
- sfx_query: a short sound effect cue or null. Use sparingly (0-2 scenes).
|
| 96 |
+
|
| 97 |
+
Return JSON only, exactly this shape:
|
| 98 |
+
{_SCHEMA}"""
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def regenerate_scene(
|
| 102 |
+
project: Project,
|
| 103 |
+
script: Script,
|
| 104 |
+
scene_number: int,
|
| 105 |
+
llm: LLMAdapter,
|
| 106 |
+
style: Optional[StyleProfile] = None,
|
| 107 |
+
) -> Script:
|
| 108 |
+
"""Rewrite a single scene in place, keeping the rest of the script."""
|
| 109 |
+
target = next((s for s in script.scenes if s.scene == scene_number), None)
|
| 110 |
+
if target is None:
|
| 111 |
+
raise ValueError(f"No scene {scene_number} in script")
|
| 112 |
+
context = "\n".join(
|
| 113 |
+
f"Scene {s.scene}: {s.narration}" for s in script.scenes if s.scene != scene_number
|
| 114 |
+
)
|
| 115 |
+
prompt = f"""Rewrite ONE scene of a short-form video script.
|
| 116 |
+
|
| 117 |
+
TOPIC: {script.topic} | NICHE: {project.niche.topic}
|
| 118 |
+
{_style_block(style)}
|
| 119 |
+
|
| 120 |
+
OTHER SCENES (keep continuity with these):
|
| 121 |
+
{context}
|
| 122 |
+
|
| 123 |
+
Rewrite scene {scene_number} (currently: "{target.narration}").
|
| 124 |
+
Keep roughly the same duration ({target.duration_sec}s of speech).
|
| 125 |
+
|
| 126 |
+
Return JSON only:
|
| 127 |
+
{{"narration": "...", "visual_query": "3-5 words", "sfx_query": null, "duration_sec": {target.duration_sec}}}"""
|
| 128 |
+
raw = llm.complete_json(prompt)
|
| 129 |
+
target.narration = (raw.get("narration") or target.narration).strip()
|
| 130 |
+
target.visual_query = (raw.get("visual_query") or target.visual_query).strip()
|
| 131 |
+
target.sfx_query = raw.get("sfx_query") or None
|
| 132 |
+
try:
|
| 133 |
+
target.duration_sec = float(raw.get("duration_sec") or target.duration_sec)
|
| 134 |
+
except (TypeError, ValueError):
|
| 135 |
+
pass
|
| 136 |
+
return script
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _parse_script(raw: dict, topic: str) -> Script:
|
| 140 |
+
scenes = []
|
| 141 |
+
for i, s in enumerate(raw.get("scenes", [])[:MAX_SCENES], 1):
|
| 142 |
+
narration = (s.get("narration") or "").strip()
|
| 143 |
+
if not narration:
|
| 144 |
+
continue
|
| 145 |
+
try:
|
| 146 |
+
duration = float(s.get("duration_sec") or 5.0)
|
| 147 |
+
except (TypeError, ValueError):
|
| 148 |
+
duration = 5.0
|
| 149 |
+
scenes.append(
|
| 150 |
+
Scene(
|
| 151 |
+
scene=int(s.get("scene") or i),
|
| 152 |
+
narration=narration,
|
| 153 |
+
visual_query=(s.get("visual_query") or topic).strip(),
|
| 154 |
+
sfx_query=(s.get("sfx_query") or None),
|
| 155 |
+
duration_sec=duration,
|
| 156 |
+
)
|
| 157 |
+
)
|
| 158 |
+
if not scenes:
|
| 159 |
+
raise RuntimeError("LLM script had no usable scenes")
|
| 160 |
+
|
| 161 |
+
hooks = [h.strip() for h in raw.get("hook_options", []) if isinstance(h, str) and h.strip()]
|
| 162 |
+
while len(hooks) < 3: # contract promises 3 options
|
| 163 |
+
hooks.append(hooks[0] if hooks else f"You won't believe this about {topic}")
|
| 164 |
+
hooks = hooks[:3]
|
| 165 |
+
|
| 166 |
+
total = sum(s.duration_sec for s in scenes)
|
| 167 |
+
if not (MIN_TOTAL_SEC * 0.5 <= total <= MAX_TOTAL_SEC * 1.5):
|
| 168 |
+
log.warning("Script total %ss is far from the %s-%ss target", total, MIN_TOTAL_SEC, MAX_TOTAL_SEC)
|
| 169 |
+
|
| 170 |
+
return Script(
|
| 171 |
+
title=(raw.get("title") or topic).strip(),
|
| 172 |
+
hook_options=hooks,
|
| 173 |
+
selected_hook=0,
|
| 174 |
+
scenes=scenes,
|
| 175 |
+
cta=(raw.get("cta") or "").strip(),
|
| 176 |
+
total_duration_sec=total,
|
| 177 |
+
topic=topic,
|
| 178 |
+
)
|
core/stages/setup.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 1 — project setup: build/validate Project objects and suggest
|
| 2 |
+
reference channels for a niche.
|
| 3 |
+
|
| 4 |
+
Pure functions over contracts; persistence happens in the orchestrator.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from core.adapters.base import LLMAdapter, ReferenceDataAdapter
|
| 10 |
+
from core.contracts import FORMATS, NicheProfile, Project, ReferenceEntry
|
| 11 |
+
from core.utils import log
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build_project(
|
| 15 |
+
name: str,
|
| 16 |
+
niche: NicheProfile,
|
| 17 |
+
format: str = "youtube_short",
|
| 18 |
+
references: list[ReferenceEntry] | None = None,
|
| 19 |
+
) -> Project:
|
| 20 |
+
"""Validate inputs and construct a Project (id assigned on save)."""
|
| 21 |
+
if format not in FORMATS:
|
| 22 |
+
raise ValueError(f"format must be one of {FORMATS}, got {format!r}")
|
| 23 |
+
if format == "long_form":
|
| 24 |
+
raise ValueError("long_form is a future format and not yet supported")
|
| 25 |
+
if not niche.topic.strip():
|
| 26 |
+
raise ValueError("Niche topic is required")
|
| 27 |
+
for ref in references or []:
|
| 28 |
+
validate_reference(ref)
|
| 29 |
+
return Project(id="", name=name.strip() or niche.topic, niche=niche, format=format,
|
| 30 |
+
references=list(references or []))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def validate_reference(ref: ReferenceEntry) -> None:
|
| 34 |
+
if ref.kind not in ("youtube", "instagram_style"):
|
| 35 |
+
raise ValueError(f"Unknown reference kind {ref.kind!r}")
|
| 36 |
+
if ref.kind == "instagram_style" and not (ref.url or ref.notes):
|
| 37 |
+
raise ValueError("instagram_style references need a URL or style notes")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def suggest_niche_fields(llm: LLMAdapter, topic: str) -> dict:
|
| 41 |
+
"""From a bare niche topic, propose the rest of the setup fields.
|
| 42 |
+
|
| 43 |
+
Returns a dict with name/keywords/subreddits/audience/tone. Fails soft:
|
| 44 |
+
on any LLM error returns an empty dict so the UI can degrade gracefully.
|
| 45 |
+
The caller decides which fields to actually apply (e.g. only empty ones).
|
| 46 |
+
"""
|
| 47 |
+
topic = topic.strip()
|
| 48 |
+
if not topic:
|
| 49 |
+
return {}
|
| 50 |
+
prompt = f"""A creator is setting up short-form videos (YouTube Shorts / Reels)
|
| 51 |
+
in this niche: "{topic}".
|
| 52 |
+
|
| 53 |
+
Propose sensible defaults for the rest of their project setup. Subreddits must
|
| 54 |
+
be REAL, active subreddits relevant to the niche (no "r/" prefix, no guesses at
|
| 55 |
+
dead subs). Keep keywords concrete and searchable.
|
| 56 |
+
|
| 57 |
+
Return JSON only:
|
| 58 |
+
{{"name": "a short catchy project name",
|
| 59 |
+
"keywords": ["5-7 search keywords"],
|
| 60 |
+
"subreddits": ["3-5 real subreddit names"],
|
| 61 |
+
"audience": "one phrase describing the target viewer",
|
| 62 |
+
"tone": "2-4 words describing the voice/tone"}}"""
|
| 63 |
+
try:
|
| 64 |
+
raw = llm.complete_json(prompt)
|
| 65 |
+
except Exception as e:
|
| 66 |
+
log.warning("Niche autofill failed: %s", e)
|
| 67 |
+
return {}
|
| 68 |
+
if not isinstance(raw, dict):
|
| 69 |
+
return {}
|
| 70 |
+
return {
|
| 71 |
+
"name": (raw.get("name") or "").strip(),
|
| 72 |
+
"keywords": [str(k).strip() for k in (raw.get("keywords") or []) if str(k).strip()],
|
| 73 |
+
"subreddits": [
|
| 74 |
+
str(s).strip().lstrip("r/").strip("/")
|
| 75 |
+
for s in (raw.get("subreddits") or [])
|
| 76 |
+
if str(s).strip()
|
| 77 |
+
],
|
| 78 |
+
"audience": (raw.get("audience") or "").strip(),
|
| 79 |
+
"tone": (raw.get("tone") or "").strip(),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def build_suggest_channels_prompt(niche: NicheProfile, n: int = 5) -> str:
|
| 84 |
+
"""The default prompt for channel suggestion (exposed for QC/editing)."""
|
| 85 |
+
return f"""Suggest {n + 3} popular YouTube channels that post SHORT-FORM
|
| 86 |
+
(YouTube Shorts) content in this niche. Prefer channels known for high-view
|
| 87 |
+
Shorts, not just long-form.
|
| 88 |
+
|
| 89 |
+
Niche: {niche.topic}
|
| 90 |
+
Keywords: {", ".join(niche.keywords) or "-"}
|
| 91 |
+
Audience: {niche.audience or "-"}
|
| 92 |
+
|
| 93 |
+
Return JSON only: [{{"name": "...", "handle": "@..."}}] — handle may be ""
|
| 94 |
+
if unknown."""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def suggest_channels(
|
| 98 |
+
llm: LLMAdapter,
|
| 99 |
+
refdata: ReferenceDataAdapter | None,
|
| 100 |
+
niche: NicheProfile,
|
| 101 |
+
n: int = 5,
|
| 102 |
+
prompt: str | None = None,
|
| 103 |
+
) -> list[ReferenceEntry]:
|
| 104 |
+
"""LLM proposes channel names for the niche; each is verified via the
|
| 105 |
+
YouTube API and unverifiable ones are dropped. Without a refdata
|
| 106 |
+
adapter (no YOUTUBE_API_KEY) returns unverified suggestions, marked so.
|
| 107 |
+
|
| 108 |
+
``prompt`` overrides the default assembled prompt (for QC editing).
|
| 109 |
+
"""
|
| 110 |
+
prompt = prompt or build_suggest_channels_prompt(niche, n)
|
| 111 |
+
try:
|
| 112 |
+
raw = llm.complete_json(prompt)
|
| 113 |
+
except Exception as e:
|
| 114 |
+
log.warning("Channel suggestion LLM call failed: %s", e)
|
| 115 |
+
return []
|
| 116 |
+
|
| 117 |
+
suggestions = raw if isinstance(raw, list) else raw.get("channels", [])
|
| 118 |
+
out: list[ReferenceEntry] = []
|
| 119 |
+
for item in suggestions:
|
| 120 |
+
if len(out) >= n:
|
| 121 |
+
break
|
| 122 |
+
name = (item.get("name") or "").strip()
|
| 123 |
+
handle = (item.get("handle") or "").strip()
|
| 124 |
+
if not name:
|
| 125 |
+
continue
|
| 126 |
+
if refdata is None:
|
| 127 |
+
out.append(ReferenceEntry(kind="youtube", name=name,
|
| 128 |
+
notes="unverified (no YOUTUBE_API_KEY)"))
|
| 129 |
+
continue
|
| 130 |
+
try:
|
| 131 |
+
resolved = refdata.resolve_channel(handle or name)
|
| 132 |
+
except Exception as e:
|
| 133 |
+
log.warning("Verification failed for %r: %s — dropping", name, e)
|
| 134 |
+
continue
|
| 135 |
+
if not resolved:
|
| 136 |
+
log.info("Dropping unverifiable suggested channel %r", name)
|
| 137 |
+
continue
|
| 138 |
+
out.append(
|
| 139 |
+
ReferenceEntry(
|
| 140 |
+
kind="youtube",
|
| 141 |
+
name=resolved["title"],
|
| 142 |
+
url=resolved["url"],
|
| 143 |
+
channel_id=resolved["channel_id"],
|
| 144 |
+
)
|
| 145 |
+
)
|
| 146 |
+
return out
|
core/stages/style.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 2 — style profile builder.
|
| 2 |
+
|
| 3 |
+
Pulls transcripts of each YouTube reference's top recent Shorts, distills a
|
| 4 |
+
style card with the LLM, and keeps 2-3 full transcripts as few-shot
|
| 5 |
+
exemplars. Instagram references contribute only their user-written notes.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Callable, Optional
|
| 11 |
+
|
| 12 |
+
from core.adapters.base import LLMAdapter, ReferenceDataAdapter, TranscriptAdapter
|
| 13 |
+
from core.contracts import Project, StyleProfile
|
| 14 |
+
from core.utils import log
|
| 15 |
+
|
| 16 |
+
Progress = Callable[[str], None]
|
| 17 |
+
|
| 18 |
+
_MAX_TRANSCRIPTS_PER_CHANNEL = 3
|
| 19 |
+
_MAX_EXEMPLARS = 3
|
| 20 |
+
_EXEMPLAR_CHAR_LIMIT = 4000
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def build_style_profile(
|
| 24 |
+
project: Project,
|
| 25 |
+
llm: LLMAdapter,
|
| 26 |
+
refdata: Optional[ReferenceDataAdapter],
|
| 27 |
+
transcripts: Optional[TranscriptAdapter],
|
| 28 |
+
progress: Progress = lambda m: None,
|
| 29 |
+
) -> StyleProfile:
|
| 30 |
+
"""Distill the writing style of the project's references (collect + assemble).
|
| 31 |
+
|
| 32 |
+
Every external call fails soft: with no YouTube key or no transcripts
|
| 33 |
+
the profile is built from whatever is available (worst case: niche
|
| 34 |
+
fields + Instagram notes only).
|
| 35 |
+
"""
|
| 36 |
+
collected, ig_notes, sources = collect_style_inputs(project, refdata, transcripts, progress)
|
| 37 |
+
return assemble_style_profile(llm, project, collected, ig_notes, sources, progress=progress)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def collect_style_inputs(
|
| 41 |
+
project: Project,
|
| 42 |
+
refdata: Optional[ReferenceDataAdapter],
|
| 43 |
+
transcripts: Optional[TranscriptAdapter],
|
| 44 |
+
progress: Progress = lambda m: None,
|
| 45 |
+
) -> tuple[list[tuple[str, str]], list[str], list[str]]:
|
| 46 |
+
"""Fetch transcripts + Instagram notes — the slow part, run once per refresh.
|
| 47 |
+
|
| 48 |
+
Returns (collected [(label, transcript)], instagram_notes, sources).
|
| 49 |
+
"""
|
| 50 |
+
collected: list[tuple[str, str]] = []
|
| 51 |
+
sources: list[str] = []
|
| 52 |
+
ig_notes: list[str] = []
|
| 53 |
+
|
| 54 |
+
for ref in project.references:
|
| 55 |
+
if ref.kind == "instagram_style":
|
| 56 |
+
note = f"{ref.name}: {ref.notes}".strip(": ")
|
| 57 |
+
if note:
|
| 58 |
+
ig_notes.append(note)
|
| 59 |
+
sources.append(f"instagram_style:{ref.name} (notes only, never fetched)")
|
| 60 |
+
continue
|
| 61 |
+
if refdata is None or transcripts is None:
|
| 62 |
+
log.info("Skipping transcript pull for %s (missing YouTube/transcript adapter)", ref.name)
|
| 63 |
+
continue
|
| 64 |
+
try:
|
| 65 |
+
channel_id = ref.channel_id
|
| 66 |
+
if not channel_id:
|
| 67 |
+
resolved = refdata.resolve_channel(ref.url or ref.name)
|
| 68 |
+
if not resolved:
|
| 69 |
+
log.warning("Could not resolve reference channel %r — skipping", ref.name)
|
| 70 |
+
continue
|
| 71 |
+
channel_id = resolved["channel_id"]
|
| 72 |
+
ref.channel_id = channel_id
|
| 73 |
+
progress(f"Fetching top Shorts of {ref.name}")
|
| 74 |
+
shorts = refdata.top_shorts(channel_id, n=_MAX_TRANSCRIPTS_PER_CHANNEL + 2)
|
| 75 |
+
except Exception as e:
|
| 76 |
+
log.warning("Reference data failed for %s: %s — skipping channel", ref.name, e)
|
| 77 |
+
continue
|
| 78 |
+
|
| 79 |
+
got = 0
|
| 80 |
+
for short in shorts:
|
| 81 |
+
if got >= _MAX_TRANSCRIPTS_PER_CHANNEL:
|
| 82 |
+
break
|
| 83 |
+
text = transcripts.fetch(short["video_id"])
|
| 84 |
+
if not text or len(text) < 100:
|
| 85 |
+
continue
|
| 86 |
+
collected.append((f'{ref.name} — "{short["title"]}" ({short["views"]:,} views)', text))
|
| 87 |
+
sources.append(f'youtube:{ref.name}:{short["video_id"]}')
|
| 88 |
+
got += 1
|
| 89 |
+
if got:
|
| 90 |
+
progress(f"Got {got} transcript(s) from {ref.name}")
|
| 91 |
+
|
| 92 |
+
return collected, ig_notes, sources
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def build_style_prompt(
|
| 96 |
+
project: Project,
|
| 97 |
+
collected: list[tuple[str, str]],
|
| 98 |
+
ig_notes: list[str],
|
| 99 |
+
) -> str:
|
| 100 |
+
"""The default style-card distillation prompt (exposed for QC/editing)."""
|
| 101 |
+
transcript_block = "\n\n".join(
|
| 102 |
+
f"--- {label} ---\n{text[:2500]}" for label, text in collected[:6]
|
| 103 |
+
)
|
| 104 |
+
ig_block = "\n".join(f"- {n}" for n in ig_notes)
|
| 105 |
+
return f"""You are analyzing the style of successful short-form video channels.
|
| 106 |
+
|
| 107 |
+
Niche: {project.niche.topic} (audience: {project.niche.audience or "general"};
|
| 108 |
+
tone: {project.niche.tone or "unspecified"})
|
| 109 |
+
|
| 110 |
+
{"TRANSCRIPTS OF TOP SHORTS:" + chr(10) + transcript_block if transcript_block else "No transcripts available — infer a strong default style for the niche."}
|
| 111 |
+
|
| 112 |
+
{"INSTAGRAM STYLE NOTES (user-written):" + chr(10) + ig_block if ig_block else ""}
|
| 113 |
+
|
| 114 |
+
Write a STYLE CARD (max ~250 words) covering:
|
| 115 |
+
- Hook patterns (how the first 2 seconds grab attention)
|
| 116 |
+
- Pacing and sentence length
|
| 117 |
+
- Vocabulary level and signature phrases
|
| 118 |
+
- How facts/claims are delivered
|
| 119 |
+
- CTA style
|
| 120 |
+
|
| 121 |
+
Plain text, bullet form. This card will steer a scriptwriter."""
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def assemble_style_profile(
|
| 125 |
+
llm: LLMAdapter,
|
| 126 |
+
project: Project,
|
| 127 |
+
collected: list[tuple[str, str]],
|
| 128 |
+
ig_notes: list[str],
|
| 129 |
+
sources: list[str],
|
| 130 |
+
prompt: Optional[str] = None,
|
| 131 |
+
progress: Progress = lambda m: None,
|
| 132 |
+
) -> StyleProfile:
|
| 133 |
+
"""Run the distillation LLM call and build the StyleProfile.
|
| 134 |
+
|
| 135 |
+
``prompt`` overrides the default assembled prompt (for QC editing); the
|
| 136 |
+
exemplars are taken from ``collected`` either way.
|
| 137 |
+
"""
|
| 138 |
+
used_prompt = prompt or build_style_prompt(project, collected, ig_notes)
|
| 139 |
+
progress("Distilling style card")
|
| 140 |
+
try:
|
| 141 |
+
style_card = llm.complete(used_prompt).strip()
|
| 142 |
+
except Exception as e:
|
| 143 |
+
log.warning("Style card distillation failed: %s — using minimal default", e)
|
| 144 |
+
style_card = (
|
| 145 |
+
"Default style: punchy hook in the first sentence, short conversational "
|
| 146 |
+
"sentences, one concrete fact per scene, end with a question CTA."
|
| 147 |
+
)
|
| 148 |
+
exemplars = [
|
| 149 |
+
f"[{label}]\n{text[:_EXEMPLAR_CHAR_LIMIT]}"
|
| 150 |
+
for label, text in collected[:_MAX_EXEMPLARS]
|
| 151 |
+
]
|
| 152 |
+
return StyleProfile(
|
| 153 |
+
style_card=style_card,
|
| 154 |
+
exemplars=exemplars,
|
| 155 |
+
instagram_notes=ig_notes,
|
| 156 |
+
sources=sources,
|
| 157 |
+
prompt_used=used_prompt,
|
| 158 |
+
)
|
core/stages/topics.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 3 — topic generation.
|
| 2 |
+
|
| 3 |
+
Blends three signal sources, all fail-soft:
|
| 4 |
+
(a) titles + stats of the reference channels' top recent Shorts,
|
| 5 |
+
(b) Google News RSS for the niche keywords,
|
| 6 |
+
(c) Reddit top-of-day from the niche subreddits (JSON, RSS fallback).
|
| 7 |
+
The LLM ranks them into N topics, each with a one-line reason and a
|
| 8 |
+
popularity-signal note.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
import xml.etree.ElementTree as ET
|
| 15 |
+
from typing import Callable, Optional
|
| 16 |
+
|
| 17 |
+
import requests
|
| 18 |
+
|
| 19 |
+
from core.adapters.base import LLMAdapter, ReferenceDataAdapter
|
| 20 |
+
from core.contracts import Project, Topic, TopicBatch
|
| 21 |
+
from core.utils import log
|
| 22 |
+
|
| 23 |
+
Progress = Callable[[str], None]
|
| 24 |
+
|
| 25 |
+
_UA = {"User-Agent": "ReelStudio/2.0 (local short-form video research tool)"}
|
| 26 |
+
|
| 27 |
+
# v1-proven filter: drop megathreads / mod housekeeping / daily threads.
|
| 28 |
+
_REDDIT_NOISE = re.compile(
|
| 29 |
+
r"megathread|daily (thread|discussion)|weekly (thread|discussion)|"
|
| 30 |
+
r"open thread|mod ?post|announcement|rules|read first|faq",
|
| 31 |
+
re.IGNORECASE,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Signal collectors (each returns a list of human-readable signal lines)
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _reference_signals(project: Project, refdata: Optional[ReferenceDataAdapter]) -> list[str]:
|
| 41 |
+
if refdata is None:
|
| 42 |
+
return []
|
| 43 |
+
lines = []
|
| 44 |
+
for ref in project.references:
|
| 45 |
+
if ref.kind != "youtube" or not ref.channel_id:
|
| 46 |
+
continue
|
| 47 |
+
try:
|
| 48 |
+
for s in refdata.top_shorts(ref.channel_id, n=5):
|
| 49 |
+
lines.append(
|
| 50 |
+
f'[reference short] {ref.name}: "{s["title"]}" — {s["views"]:,} views'
|
| 51 |
+
)
|
| 52 |
+
except Exception as e:
|
| 53 |
+
log.warning("Reference signals failed for %s: %s", ref.name, e)
|
| 54 |
+
return lines
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _news_signals(project: Project) -> list[str]:
|
| 58 |
+
"""Google News RSS for the niche keywords."""
|
| 59 |
+
query = " OR ".join([project.niche.topic, *project.niche.keywords[:3]])
|
| 60 |
+
url = "https://news.google.com/rss/search"
|
| 61 |
+
try:
|
| 62 |
+
resp = requests.get(url, params={"q": query, "hl": "en-US"}, headers=_UA, timeout=20)
|
| 63 |
+
resp.raise_for_status()
|
| 64 |
+
root = ET.fromstring(resp.content)
|
| 65 |
+
titles = [item.findtext("title") or "" for item in root.iter("item")]
|
| 66 |
+
return [f"[news] {t}" for t in titles[:12] if t]
|
| 67 |
+
except Exception as e:
|
| 68 |
+
log.warning("Google News RSS failed: %s — continuing without news", e)
|
| 69 |
+
return []
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _reddit_signals(project: Project) -> list[str]:
|
| 73 |
+
lines: list[str] = []
|
| 74 |
+
for sub in project.niche.subreddits[:4]:
|
| 75 |
+
sub = sub.lstrip("r/").strip("/")
|
| 76 |
+
if not sub:
|
| 77 |
+
continue
|
| 78 |
+
posts = _reddit_json(sub)
|
| 79 |
+
if posts is None:
|
| 80 |
+
posts = _reddit_rss(sub)
|
| 81 |
+
for title, score in (posts or [])[:8]:
|
| 82 |
+
if _REDDIT_NOISE.search(title):
|
| 83 |
+
continue
|
| 84 |
+
score_txt = f" ({score:,} upvotes)" if score else ""
|
| 85 |
+
lines.append(f"[r/{sub}] {title}{score_txt}")
|
| 86 |
+
return lines
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _reddit_json(sub: str) -> Optional[list[tuple[str, int]]]:
|
| 90 |
+
try:
|
| 91 |
+
resp = requests.get(
|
| 92 |
+
f"https://www.reddit.com/r/{sub}/top.json",
|
| 93 |
+
params={"t": "day", "limit": 15},
|
| 94 |
+
headers=_UA,
|
| 95 |
+
timeout=20,
|
| 96 |
+
)
|
| 97 |
+
resp.raise_for_status()
|
| 98 |
+
out = []
|
| 99 |
+
for child in resp.json().get("data", {}).get("children", []):
|
| 100 |
+
d = child.get("data", {})
|
| 101 |
+
if d.get("stickied") or d.get("distinguished"):
|
| 102 |
+
continue
|
| 103 |
+
out.append((d.get("title", ""), int(d.get("ups", 0))))
|
| 104 |
+
return out
|
| 105 |
+
except Exception as e:
|
| 106 |
+
log.info("Reddit JSON failed for r/%s (%s) — trying RSS", sub, e)
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _reddit_rss(sub: str) -> Optional[list[tuple[str, int]]]:
|
| 111 |
+
try:
|
| 112 |
+
resp = requests.get(f"https://www.reddit.com/r/{sub}/top/.rss?t=day", headers=_UA, timeout=20)
|
| 113 |
+
resp.raise_for_status()
|
| 114 |
+
root = ET.fromstring(resp.content)
|
| 115 |
+
ns = {"atom": "http://www.w3.org/2005/Atom"}
|
| 116 |
+
return [(e.findtext("atom:title", "", ns), 0) for e in root.findall("atom:entry", ns)]
|
| 117 |
+
except Exception as e:
|
| 118 |
+
log.warning("Reddit RSS failed for r/%s: %s — skipping subreddit", sub, e)
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Ranking
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def collect_topic_signals(
|
| 128 |
+
project: Project,
|
| 129 |
+
refdata: Optional[ReferenceDataAdapter],
|
| 130 |
+
progress: Progress = lambda m: None,
|
| 131 |
+
) -> list[str]:
|
| 132 |
+
"""Gather reference / news / Reddit signal lines — the slow part."""
|
| 133 |
+
progress("Collecting reference channel signals")
|
| 134 |
+
signals = _reference_signals(project, refdata)
|
| 135 |
+
progress("Collecting news signals")
|
| 136 |
+
signals += _news_signals(project)
|
| 137 |
+
progress("Collecting Reddit signals")
|
| 138 |
+
signals += _reddit_signals(project)
|
| 139 |
+
if not signals:
|
| 140 |
+
log.info("No external signals available — LLM will rely on the niche profile alone")
|
| 141 |
+
return signals
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def build_topics_prompt(project: Project, signals: list[str], n: int = 8) -> str:
|
| 145 |
+
"""The default topic-ranking prompt (exposed for QC/editing)."""
|
| 146 |
+
signal_block = "\n".join(signals[:60]) or "(no external signals available)"
|
| 147 |
+
return f"""You pick topics for short-form videos (YouTube Shorts / Reels).
|
| 148 |
+
|
| 149 |
+
NICHE: {project.niche.topic}
|
| 150 |
+
Keywords: {", ".join(project.niche.keywords) or "-"}
|
| 151 |
+
Audience: {project.niche.audience or "general"}
|
| 152 |
+
Tone: {project.niche.tone or "engaging"}
|
| 153 |
+
|
| 154 |
+
LIVE SIGNALS (reference channels' top Shorts, news, Reddit):
|
| 155 |
+
{signal_block}
|
| 156 |
+
|
| 157 |
+
Propose exactly {n} video topics ranked best-first. Favor topics supported
|
| 158 |
+
by multiple signals; avoid duplicating a reference Short's exact angle.
|
| 159 |
+
Each topic must work as a 30-45 second vertical video.
|
| 160 |
+
|
| 161 |
+
Return JSON only:
|
| 162 |
+
[{{"title": "...", "reason": "one line on why now",
|
| 163 |
+
"signals": ["one supporting source per item", "..."]}}]"""
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _coerce_signals(it: dict) -> list[str]:
|
| 167 |
+
"""Read the signals list, tolerating the older single-string field."""
|
| 168 |
+
sig = it.get("signals")
|
| 169 |
+
if isinstance(sig, list):
|
| 170 |
+
return [str(s).strip() for s in sig if str(s).strip()]
|
| 171 |
+
if isinstance(sig, str) and sig.strip():
|
| 172 |
+
return [sig.strip()]
|
| 173 |
+
legacy = it.get("popularity_signal") # pre-list format
|
| 174 |
+
if isinstance(legacy, str) and legacy.strip():
|
| 175 |
+
return [legacy.strip()]
|
| 176 |
+
return []
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _parse_topics(raw, n: int) -> list[Topic]:
|
| 180 |
+
items = raw if isinstance(raw, list) else raw.get("topics", [])
|
| 181 |
+
return [
|
| 182 |
+
Topic(
|
| 183 |
+
title=(it.get("title") or "").strip(),
|
| 184 |
+
reason=(it.get("reason") or "").strip(),
|
| 185 |
+
signals=_coerce_signals(it),
|
| 186 |
+
)
|
| 187 |
+
for it in items
|
| 188 |
+
if (it.get("title") or "").strip()
|
| 189 |
+
][:n]
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def generate_topics(
|
| 193 |
+
project: Project,
|
| 194 |
+
llm: LLMAdapter,
|
| 195 |
+
refdata: Optional[ReferenceDataAdapter],
|
| 196 |
+
n: int = 8,
|
| 197 |
+
progress: Progress = lambda m: None,
|
| 198 |
+
prompt: Optional[str] = None,
|
| 199 |
+
) -> TopicBatch:
|
| 200 |
+
"""Generate N ranked, editable topic candidates.
|
| 201 |
+
|
| 202 |
+
``prompt`` overrides the default; when given we skip signal collection
|
| 203 |
+
(the user is re-running an edited prompt) and just rank.
|
| 204 |
+
"""
|
| 205 |
+
if prompt is None:
|
| 206 |
+
signals = collect_topic_signals(project, refdata, progress)
|
| 207 |
+
prompt = build_topics_prompt(project, signals, n)
|
| 208 |
+
progress("Ranking topics with LLM")
|
| 209 |
+
raw = llm.complete_json(prompt)
|
| 210 |
+
topics = _parse_topics(raw, n)
|
| 211 |
+
if not topics:
|
| 212 |
+
raise RuntimeError("LLM returned no usable topics")
|
| 213 |
+
return TopicBatch(topics=topics, prompt_used=prompt)
|
core/stages/voice.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 5 — voice synthesis.
|
| 2 |
+
|
| 3 |
+
One audio file per narration line (hook, scenes, CTA). True durations come
|
| 4 |
+
from ffprobe and drive ALL downstream timing (v1 lesson: never trust the
|
| 5 |
+
script's estimated durations). Word timings come from the TTS provider when
|
| 6 |
+
it emits them, else faster-whisper forced alignment, else None → the render
|
| 7 |
+
stage falls back to static captions.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Callable
|
| 14 |
+
|
| 15 |
+
from core.adapters.alignment import align_words
|
| 16 |
+
from core.adapters.base import TTSAdapter
|
| 17 |
+
from core.contracts import Script, VoiceLine, VoiceTrack
|
| 18 |
+
from core.utils import ffprobe_duration, log
|
| 19 |
+
|
| 20 |
+
Progress = Callable[[str], None]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def synthesize_voice(
|
| 24 |
+
script: Script,
|
| 25 |
+
tts: TTSAdapter,
|
| 26 |
+
out_dir: str,
|
| 27 |
+
voice: str = "",
|
| 28 |
+
provider_name: str = "",
|
| 29 |
+
progress: Progress = lambda m: None,
|
| 30 |
+
) -> VoiceTrack:
|
| 31 |
+
out = Path(out_dir)
|
| 32 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
lines = script.narration_lines()
|
| 34 |
+
if not lines:
|
| 35 |
+
raise ValueError("Script has no narration lines to voice")
|
| 36 |
+
|
| 37 |
+
voice_lines: list[VoiceLine] = []
|
| 38 |
+
for i, text in enumerate(lines):
|
| 39 |
+
progress(f"Voicing line {i + 1}/{len(lines)}")
|
| 40 |
+
path = str(out / f"line_{i:02d}.mp3")
|
| 41 |
+
timings = tts.synth(text, path, voice=voice)
|
| 42 |
+
if timings is None:
|
| 43 |
+
timings = align_words(path, text) # soft fallback, may stay None
|
| 44 |
+
if timings:
|
| 45 |
+
log.info("Recovered %d word timings via forced alignment", len(timings))
|
| 46 |
+
duration = ffprobe_duration(path)
|
| 47 |
+
voice_lines.append(
|
| 48 |
+
VoiceLine(
|
| 49 |
+
index=i,
|
| 50 |
+
text=text,
|
| 51 |
+
audio_path=path,
|
| 52 |
+
duration_sec=duration,
|
| 53 |
+
word_timings=timings or [],
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
total = sum(line.duration_sec for line in voice_lines)
|
| 58 |
+
log.info("Voice track: %d lines, %.1fs total", len(voice_lines), total)
|
| 59 |
+
return VoiceTrack(
|
| 60 |
+
lines=voice_lines,
|
| 61 |
+
provider=provider_name,
|
| 62 |
+
voice=voice,
|
| 63 |
+
total_duration_sec=total,
|
| 64 |
+
)
|
core/store.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSON-on-disk persistence for projects and their reels.
|
| 2 |
+
|
| 3 |
+
Layout (everything under projects/<project_id>/):
|
| 4 |
+
project.json — Project (niche, format, references)
|
| 5 |
+
style.json — StyleProfile (project-level, shared by all reels)
|
| 6 |
+
topics.json — TopicBatch (project-level topic pool)
|
| 7 |
+
reels/<reel_id>/
|
| 8 |
+
reel.json — Reel metadata
|
| 9 |
+
script.json — Script
|
| 10 |
+
voice/ — line_<i>.mp3 + voice.json (VoiceTrack)
|
| 11 |
+
media/ — clips/ sfx/ + media.json (MediaManifest)
|
| 12 |
+
renders/ — variant mp4s + renders.json (RenderBatch)
|
| 13 |
+
clone.json — CloneBrief (when the reel came from a clone)
|
| 14 |
+
|
| 15 |
+
References + style profile + the topic pool are shared at the project level;
|
| 16 |
+
each reel owns its own script/voice/media/renders so multiple topics can be
|
| 17 |
+
worked on in parallel. Stage modules stay pure and never touch this layout
|
| 18 |
+
themselves (audio/video files excepted, via paths the orchestrator hands them).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
import shutil
|
| 25 |
+
import uuid
|
| 26 |
+
from datetime import datetime, timezone
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Optional
|
| 29 |
+
|
| 30 |
+
from core.contracts import (
|
| 31 |
+
CloneBrief,
|
| 32 |
+
MediaManifest,
|
| 33 |
+
Project,
|
| 34 |
+
Reel,
|
| 35 |
+
RenderBatch,
|
| 36 |
+
Script,
|
| 37 |
+
StyleProfile,
|
| 38 |
+
TopicBatch,
|
| 39 |
+
VoiceTrack,
|
| 40 |
+
from_dict,
|
| 41 |
+
to_dict,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def now_iso() -> str:
|
| 46 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _split_legacy_signal(text: str) -> list[str]:
|
| 50 |
+
"""Split an old single-string signal into sources on '),' boundaries.
|
| 51 |
+
|
| 52 |
+
Commas inside the parenthetical quotes (e.g. multiple quoted reactions)
|
| 53 |
+
are preserved; only the separators between sources split.
|
| 54 |
+
"""
|
| 55 |
+
import re
|
| 56 |
+
|
| 57 |
+
parts = re.split(r"\)\s*,\s*", text)
|
| 58 |
+
out = []
|
| 59 |
+
for i, p in enumerate(parts):
|
| 60 |
+
p = (p + ")" if i < len(parts) - 1 else p).strip()
|
| 61 |
+
if p:
|
| 62 |
+
out.append(p)
|
| 63 |
+
return out
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class ProjectStore:
|
| 67 |
+
def __init__(self, projects_dir: Path):
|
| 68 |
+
self.root = Path(projects_dir)
|
| 69 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
|
| 71 |
+
# -- paths ---------------------------------------------------------------
|
| 72 |
+
|
| 73 |
+
def dir(self, project_id: str) -> Path:
|
| 74 |
+
return self.root / project_id
|
| 75 |
+
|
| 76 |
+
def reels_dir(self, project_id: str) -> Path:
|
| 77 |
+
return self._ensure(self.dir(project_id) / "reels")
|
| 78 |
+
|
| 79 |
+
def reel_dir(self, project_id: str, reel_id: str) -> Path:
|
| 80 |
+
return self._ensure(self.reels_dir(project_id) / reel_id)
|
| 81 |
+
|
| 82 |
+
def voice_dir(self, project_id: str, reel_id: str) -> Path:
|
| 83 |
+
return self._ensure(self.reel_dir(project_id, reel_id) / "voice")
|
| 84 |
+
|
| 85 |
+
def media_dir(self, project_id: str, reel_id: str) -> Path:
|
| 86 |
+
return self._ensure(self.reel_dir(project_id, reel_id) / "media")
|
| 87 |
+
|
| 88 |
+
def renders_dir(self, project_id: str, reel_id: str) -> Path:
|
| 89 |
+
return self._ensure(self.reel_dir(project_id, reel_id) / "renders")
|
| 90 |
+
|
| 91 |
+
@staticmethod
|
| 92 |
+
def _ensure(p: Path) -> Path:
|
| 93 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
return p
|
| 95 |
+
|
| 96 |
+
# -- generic json --------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
def _save_path(self, path: Path, obj) -> None:
|
| 99 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
path.write_text(json.dumps(to_dict(obj), indent=2, ensure_ascii=False))
|
| 101 |
+
|
| 102 |
+
def _load_path(self, path: Path, cls):
|
| 103 |
+
if not path.exists():
|
| 104 |
+
return None
|
| 105 |
+
return from_dict(cls, json.loads(path.read_text()))
|
| 106 |
+
|
| 107 |
+
def _save(self, project_id: str, name: str, obj) -> None:
|
| 108 |
+
self._save_path(self.dir(project_id) / name, obj)
|
| 109 |
+
|
| 110 |
+
def _load(self, project_id: str, name: str, cls):
|
| 111 |
+
return self._load_path(self.dir(project_id) / name, cls)
|
| 112 |
+
|
| 113 |
+
def save_json(self, project_id: str, name: str, data) -> None:
|
| 114 |
+
"""Persist a plain dict/list (used for transient prep caches)."""
|
| 115 |
+
path = self.dir(project_id) / name
|
| 116 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 117 |
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
| 118 |
+
|
| 119 |
+
def load_json(self, project_id: str, name: str):
|
| 120 |
+
path = self.dir(project_id) / name
|
| 121 |
+
return json.loads(path.read_text()) if path.exists() else None
|
| 122 |
+
|
| 123 |
+
# -- projects ------------------------------------------------------------
|
| 124 |
+
|
| 125 |
+
def create_project(self, project: Project) -> Project:
|
| 126 |
+
if not project.id:
|
| 127 |
+
project.id = uuid.uuid4().hex[:12]
|
| 128 |
+
if not project.created_at:
|
| 129 |
+
project.created_at = now_iso()
|
| 130 |
+
self.save_project(project)
|
| 131 |
+
return project
|
| 132 |
+
|
| 133 |
+
def save_project(self, project: Project) -> None:
|
| 134 |
+
self._save(project.id, "project.json", project)
|
| 135 |
+
|
| 136 |
+
def load_project(self, project_id: str) -> Optional[Project]:
|
| 137 |
+
return self._load(project_id, "project.json", Project) # type: ignore[return-value]
|
| 138 |
+
|
| 139 |
+
def list_projects(self) -> list[Project]:
|
| 140 |
+
out = []
|
| 141 |
+
for d in sorted(self.root.iterdir()):
|
| 142 |
+
if d.is_dir() and (d / "project.json").exists():
|
| 143 |
+
p = self.load_project(d.name)
|
| 144 |
+
if p:
|
| 145 |
+
out.append(p)
|
| 146 |
+
return out
|
| 147 |
+
|
| 148 |
+
def delete_project(self, project_id: str) -> None:
|
| 149 |
+
d = self.dir(project_id)
|
| 150 |
+
if d.exists():
|
| 151 |
+
shutil.rmtree(d)
|
| 152 |
+
|
| 153 |
+
# -- reels ---------------------------------------------------------------
|
| 154 |
+
|
| 155 |
+
def create_reel(self, project_id: str, name: str = "", topic: str = "") -> Reel:
|
| 156 |
+
reel = Reel(
|
| 157 |
+
id=uuid.uuid4().hex[:12],
|
| 158 |
+
name=name or topic or "Untitled reel",
|
| 159 |
+
topic=topic,
|
| 160 |
+
created_at=now_iso(),
|
| 161 |
+
)
|
| 162 |
+
self.save_reel(project_id, reel)
|
| 163 |
+
return reel
|
| 164 |
+
|
| 165 |
+
def save_reel(self, project_id: str, reel: Reel) -> None:
|
| 166 |
+
self._save_path(self.reel_dir(project_id, reel.id) / "reel.json", reel)
|
| 167 |
+
|
| 168 |
+
def load_reel(self, project_id: str, reel_id: str) -> Optional[Reel]:
|
| 169 |
+
return self._load_path(self.reel_dir(project_id, reel_id) / "reel.json", Reel)
|
| 170 |
+
|
| 171 |
+
def list_reels(self, project_id: str) -> list[Reel]:
|
| 172 |
+
rd = self.dir(project_id) / "reels"
|
| 173 |
+
if not rd.exists():
|
| 174 |
+
return []
|
| 175 |
+
out = []
|
| 176 |
+
for d in sorted(rd.iterdir(), key=lambda p: p.name):
|
| 177 |
+
reel = self._load_path(d / "reel.json", Reel)
|
| 178 |
+
if reel:
|
| 179 |
+
out.append(reel)
|
| 180 |
+
out.sort(key=lambda r: r.created_at)
|
| 181 |
+
return out
|
| 182 |
+
|
| 183 |
+
def delete_reel(self, project_id: str, reel_id: str) -> None:
|
| 184 |
+
d = self.reel_dir(project_id, reel_id)
|
| 185 |
+
if d.exists():
|
| 186 |
+
shutil.rmtree(d)
|
| 187 |
+
|
| 188 |
+
# -- project-level artifacts (shared) ------------------------------------
|
| 189 |
+
|
| 190 |
+
def save_style(self, pid: str, s: StyleProfile) -> None:
|
| 191 |
+
self._save(pid, "style.json", s)
|
| 192 |
+
|
| 193 |
+
def load_style(self, pid: str) -> Optional[StyleProfile]:
|
| 194 |
+
return self._load(pid, "style.json", StyleProfile) # type: ignore[return-value]
|
| 195 |
+
|
| 196 |
+
def save_topics(self, pid: str, t: TopicBatch) -> None:
|
| 197 |
+
self._save(pid, "topics.json", t)
|
| 198 |
+
|
| 199 |
+
def load_topics(self, pid: str) -> Optional[TopicBatch]:
|
| 200 |
+
raw = self.load_json(pid, "topics.json")
|
| 201 |
+
if raw is None:
|
| 202 |
+
return None
|
| 203 |
+
# Normalize signals: migrate the pre-list field, and split any entry
|
| 204 |
+
# that still holds a combined "Source (...), Source (...)" string.
|
| 205 |
+
for t in raw.get("topics", []):
|
| 206 |
+
sigs = t.get("signals")
|
| 207 |
+
if not sigs and t.get("popularity_signal"):
|
| 208 |
+
sigs = [t["popularity_signal"]]
|
| 209 |
+
if sigs:
|
| 210 |
+
t["signals"] = [part for s in sigs for part in _split_legacy_signal(str(s))]
|
| 211 |
+
return from_dict(TopicBatch, raw)
|
| 212 |
+
|
| 213 |
+
# -- reel-level artifacts ------------------------------------------------
|
| 214 |
+
|
| 215 |
+
def save_script(self, pid: str, rid: str, s: Script) -> None:
|
| 216 |
+
self._save_path(self.reel_dir(pid, rid) / "script.json", s)
|
| 217 |
+
|
| 218 |
+
def load_script(self, pid: str, rid: str) -> Optional[Script]:
|
| 219 |
+
return self._load_path(self.reel_dir(pid, rid) / "script.json", Script)
|
| 220 |
+
|
| 221 |
+
def save_voice(self, pid: str, rid: str, v: VoiceTrack) -> None:
|
| 222 |
+
self.voice_dir(pid, rid)
|
| 223 |
+
self._save_path(self.reel_dir(pid, rid) / "voice" / "voice.json", v)
|
| 224 |
+
|
| 225 |
+
def load_voice(self, pid: str, rid: str) -> Optional[VoiceTrack]:
|
| 226 |
+
return self._load_path(self.reel_dir(pid, rid) / "voice" / "voice.json", VoiceTrack)
|
| 227 |
+
|
| 228 |
+
def save_media(self, pid: str, rid: str, m: MediaManifest) -> None:
|
| 229 |
+
self.media_dir(pid, rid)
|
| 230 |
+
self._save_path(self.reel_dir(pid, rid) / "media" / "media.json", m)
|
| 231 |
+
|
| 232 |
+
def load_media(self, pid: str, rid: str) -> Optional[MediaManifest]:
|
| 233 |
+
return self._load_path(self.reel_dir(pid, rid) / "media" / "media.json", MediaManifest)
|
| 234 |
+
|
| 235 |
+
def save_renders(self, pid: str, rid: str, r: RenderBatch) -> None:
|
| 236 |
+
self.renders_dir(pid, rid)
|
| 237 |
+
self._save_path(self.reel_dir(pid, rid) / "renders" / "renders.json", r)
|
| 238 |
+
|
| 239 |
+
def load_renders(self, pid: str, rid: str) -> Optional[RenderBatch]:
|
| 240 |
+
return self._load_path(self.reel_dir(pid, rid) / "renders" / "renders.json", RenderBatch)
|
| 241 |
+
|
| 242 |
+
def save_clone(self, pid: str, rid: str, c: CloneBrief) -> None:
|
| 243 |
+
self._save_path(self.reel_dir(pid, rid) / "clone.json", c)
|
| 244 |
+
|
| 245 |
+
def load_clone(self, pid: str, rid: str) -> Optional[CloneBrief]:
|
| 246 |
+
return self._load_path(self.reel_dir(pid, rid) / "clone.json", CloneBrief)
|
core/utils.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small shared utilities: logging, JSON extraction, retries, ffprobe."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import re
|
| 8 |
+
import subprocess
|
| 9 |
+
import time
|
| 10 |
+
from typing import Any, Callable, Optional
|
| 11 |
+
|
| 12 |
+
log = logging.getLogger("reelstudio")
|
| 13 |
+
if not log.handlers:
|
| 14 |
+
_h = logging.StreamHandler()
|
| 15 |
+
_h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
| 16 |
+
log.addHandler(_h)
|
| 17 |
+
log.setLevel(logging.INFO)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# JSON extraction (LLMs love markdown fences)
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def extract_json(text: str) -> Any:
|
| 28 |
+
"""Parse JSON out of an LLM response, stripping markdown fences.
|
| 29 |
+
|
| 30 |
+
Tries, in order: fenced block, the raw text, and the largest
|
| 31 |
+
{...} / [...] slice. Raises ValueError if nothing parses.
|
| 32 |
+
"""
|
| 33 |
+
candidates = []
|
| 34 |
+
m = _FENCE_RE.search(text)
|
| 35 |
+
if m:
|
| 36 |
+
candidates.append(m.group(1))
|
| 37 |
+
candidates.append(text.strip())
|
| 38 |
+
for opener, closer in (("{", "}"), ("[", "]")):
|
| 39 |
+
start, end = text.find(opener), text.rfind(closer)
|
| 40 |
+
if start != -1 and end > start:
|
| 41 |
+
candidates.append(text[start : end + 1])
|
| 42 |
+
for c in candidates:
|
| 43 |
+
try:
|
| 44 |
+
return json.loads(c)
|
| 45 |
+
except (json.JSONDecodeError, TypeError):
|
| 46 |
+
continue
|
| 47 |
+
raise ValueError(f"No parseable JSON in LLM response: {text[:200]!r}")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Retry with exponential backoff
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class HardAPIError(Exception):
|
| 56 |
+
"""Non-retryable API failure (400/401/403/404 class)."""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def retry_backoff(
|
| 60 |
+
fn: Callable[[], Any],
|
| 61 |
+
attempts: int = 4,
|
| 62 |
+
base_delay: float = 2.0,
|
| 63 |
+
retry_on: tuple = (Exception,),
|
| 64 |
+
label: str = "call",
|
| 65 |
+
) -> Any:
|
| 66 |
+
"""Run ``fn`` with exponential backoff. HardAPIError is never retried."""
|
| 67 |
+
last: Optional[Exception] = None
|
| 68 |
+
for i in range(attempts):
|
| 69 |
+
try:
|
| 70 |
+
return fn()
|
| 71 |
+
except HardAPIError:
|
| 72 |
+
raise
|
| 73 |
+
except retry_on as e: # noqa: PERF203
|
| 74 |
+
last = e
|
| 75 |
+
delay = base_delay * (2**i)
|
| 76 |
+
log.warning("%s failed (attempt %d/%d): %s — retrying in %.1fs", label, i + 1, attempts, e, delay)
|
| 77 |
+
if i < attempts - 1:
|
| 78 |
+
time.sleep(delay)
|
| 79 |
+
raise last # type: ignore[misc]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
# FFmpeg helpers
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def run_ffmpeg(args: list[str], label: str = "ffmpeg") -> None:
|
| 88 |
+
"""Run an ffmpeg command, raising with stderr tail on failure."""
|
| 89 |
+
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", *args]
|
| 90 |
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
| 91 |
+
if proc.returncode != 0:
|
| 92 |
+
tail = (proc.stderr or "")[-2000:]
|
| 93 |
+
raise RuntimeError(f"{label} failed (exit {proc.returncode}): {tail}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def ffprobe_duration(path: str) -> float:
|
| 97 |
+
"""True media duration in seconds via ffprobe."""
|
| 98 |
+
proc = subprocess.run(
|
| 99 |
+
[
|
| 100 |
+
"ffprobe", "-v", "error",
|
| 101 |
+
"-show_entries", "format=duration",
|
| 102 |
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
| 103 |
+
path,
|
| 104 |
+
],
|
| 105 |
+
capture_output=True,
|
| 106 |
+
text=True,
|
| 107 |
+
)
|
| 108 |
+
if proc.returncode != 0:
|
| 109 |
+
raise RuntimeError(f"ffprobe failed for {path}: {proc.stderr[-500:]}")
|
| 110 |
+
return float(proc.stdout.strip())
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def slugify(text: str, max_len: int = 40) -> str:
|
| 114 |
+
s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
| 115 |
+
return s[:max_len] or "untitled"
|
projects/.gitkeep
ADDED
|
File without changes
|
requirements.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core
|
| 2 |
+
fastapi>=0.115
|
| 3 |
+
uvicorn[standard]>=0.30
|
| 4 |
+
python-dotenv>=1.0
|
| 5 |
+
requests>=2.32
|
| 6 |
+
Pillow>=10.4
|
| 7 |
+
|
| 8 |
+
# TTS (free default)
|
| 9 |
+
edge-tts>=6.1
|
| 10 |
+
|
| 11 |
+
# Reference data / transcripts
|
| 12 |
+
youtube-transcript-api>=0.6.2
|
| 13 |
+
yt-dlp>=2024.8.6
|
| 14 |
+
|
| 15 |
+
# Word-timing alignment for karaoke captions. Needed by the default Gemini
|
| 16 |
+
# voice (which has no native timings) and as the transcript fallback when a
|
| 17 |
+
# YouTube video has no captions. First use downloads a small (~75MB) model.
|
| 18 |
+
faster-whisper>=1.0
|
| 19 |
+
|
| 20 |
+
# Tests
|
| 21 |
+
pytest>=8.0
|
server/__init__.py
ADDED
|
File without changes
|
server/app.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI server for Reel Studio v2.
|
| 2 |
+
|
| 3 |
+
Thin HTTP layer over core.pipeline. Long operations run as background jobs:
|
| 4 |
+
the POST returns {job_id} and the frontend polls GET /api/jobs/{id}.
|
| 5 |
+
|
| 6 |
+
Structure mirrors the data model:
|
| 7 |
+
- Project-level (shared): references, style profile, topic pool.
|
| 8 |
+
- Reel-level (per video): script, voice, media, renders, clone.
|
| 9 |
+
|
| 10 |
+
Every generative step has a `/prompt` endpoint returning the exact assembled
|
| 11 |
+
prompt (for QC/editing) and accepts an optional ``prompt`` override on run.
|
| 12 |
+
|
| 13 |
+
Run: .venv/bin/uvicorn server.app:app --port 8000
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from fastapi import FastAPI, HTTPException
|
| 22 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 23 |
+
from fastapi.staticfiles import StaticFiles
|
| 24 |
+
from pydantic import BaseModel
|
| 25 |
+
|
| 26 |
+
from core.contracts import (
|
| 27 |
+
CAPTION_PRESETS,
|
| 28 |
+
DEFAULT_VARIANTS,
|
| 29 |
+
NicheProfile,
|
| 30 |
+
ReferenceEntry,
|
| 31 |
+
Script,
|
| 32 |
+
StyleProfile,
|
| 33 |
+
Topic,
|
| 34 |
+
TopicBatch,
|
| 35 |
+
VariantSpec,
|
| 36 |
+
from_dict,
|
| 37 |
+
to_dict,
|
| 38 |
+
)
|
| 39 |
+
from core.pipeline import Pipeline
|
| 40 |
+
from core.stages.clone import CloneError
|
| 41 |
+
from server.jobs import JobManager
|
| 42 |
+
|
| 43 |
+
app = FastAPI(title="Reel Studio v2")
|
| 44 |
+
app.add_middleware(
|
| 45 |
+
CORSMiddleware,
|
| 46 |
+
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173",
|
| 47 |
+
"http://localhost:5174", "http://127.0.0.1:5174"],
|
| 48 |
+
allow_methods=["*"],
|
| 49 |
+
allow_headers=["*"],
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
pipeline = Pipeline()
|
| 53 |
+
jobs = JobManager()
|
| 54 |
+
|
| 55 |
+
app.mount("/files", StaticFiles(directory=str(pipeline.store.root)), name="files")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _url(path: str) -> str:
|
| 59 |
+
if not path:
|
| 60 |
+
return ""
|
| 61 |
+
try:
|
| 62 |
+
rel = Path(path).resolve().relative_to(pipeline.store.root.resolve())
|
| 63 |
+
except ValueError:
|
| 64 |
+
return ""
|
| 65 |
+
return f"/files/{rel.as_posix()}"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _project_or_404(project_id: str):
|
| 69 |
+
project = pipeline.store.load_project(project_id)
|
| 70 |
+
if not project:
|
| 71 |
+
raise HTTPException(404, f"No project {project_id}")
|
| 72 |
+
return project
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _reel_or_404(project_id: str, reel_id: str):
|
| 76 |
+
reel = pipeline.store.load_reel(project_id, reel_id)
|
| 77 |
+
if not reel:
|
| 78 |
+
raise HTTPException(404, f"No reel {reel_id}")
|
| 79 |
+
return reel
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
# Request models
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class NicheIn(BaseModel):
|
| 88 |
+
topic: str
|
| 89 |
+
keywords: list[str] = []
|
| 90 |
+
subreddits: list[str] = []
|
| 91 |
+
audience: str = ""
|
| 92 |
+
tone: str = ""
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class ReferenceIn(BaseModel):
|
| 96 |
+
kind: str
|
| 97 |
+
name: str
|
| 98 |
+
url: str = ""
|
| 99 |
+
channel_id: str = ""
|
| 100 |
+
notes: str = ""
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class ProjectIn(BaseModel):
|
| 104 |
+
name: str
|
| 105 |
+
niche: NicheIn
|
| 106 |
+
format: str = "youtube_short"
|
| 107 |
+
references: list[ReferenceIn] = []
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class NicheSuggestIn(BaseModel):
|
| 111 |
+
topic: str
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class SuggestIn(BaseModel):
|
| 115 |
+
n: int = 5
|
| 116 |
+
prompt: Optional[str] = None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class StyleIn(BaseModel):
|
| 120 |
+
prompt: Optional[str] = None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class TopicsIn(BaseModel):
|
| 124 |
+
n: int = 8
|
| 125 |
+
prompt: Optional[str] = None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class TopicEditIn(BaseModel):
|
| 129 |
+
topics: list[dict]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class ReelIn(BaseModel):
|
| 133 |
+
name: str = ""
|
| 134 |
+
topic: str = ""
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class ScriptGenIn(BaseModel):
|
| 138 |
+
topic: str
|
| 139 |
+
prompt: Optional[str] = None
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class ScriptPromptIn(BaseModel):
|
| 143 |
+
topic: str
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class SceneRegenIn(BaseModel):
|
| 147 |
+
scene: int
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class VoiceIn(BaseModel):
|
| 151 |
+
voice: str = ""
|
| 152 |
+
provider: Optional[str] = None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class SwapIn(BaseModel):
|
| 156 |
+
line_index: int
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class RenderIn(BaseModel):
|
| 160 |
+
variants: Optional[list[dict]] = None
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class CloneIn(BaseModel):
|
| 164 |
+
url: str
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ---------------------------------------------------------------------------
|
| 168 |
+
# Meta
|
| 169 |
+
# ---------------------------------------------------------------------------
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@app.get("/api/health")
|
| 173 |
+
def health():
|
| 174 |
+
cfg = pipeline.config
|
| 175 |
+
return {
|
| 176 |
+
"ok": True,
|
| 177 |
+
"keys": {
|
| 178 |
+
"gemini": bool(cfg.gemini_api_key),
|
| 179 |
+
"gemini_key_count": len(cfg.gemini_api_keys),
|
| 180 |
+
"pexels": bool(cfg.pexels_api_key),
|
| 181 |
+
"youtube": bool(cfg.youtube_api_key),
|
| 182 |
+
"freesound": bool(cfg.freesound_api_key),
|
| 183 |
+
"elevenlabs": bool(cfg.elevenlabs_api_key),
|
| 184 |
+
},
|
| 185 |
+
"adapters": {"llm": cfg.llm_adapter, "tts": cfg.tts_adapter, "stock": cfg.stock_adapter},
|
| 186 |
+
"tts_providers": pipeline.available_tts_providers(),
|
| 187 |
+
"caption_presets": list(CAPTION_PRESETS),
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@app.get("/api/jobs/{job_id}")
|
| 192 |
+
def get_job(job_id: str):
|
| 193 |
+
job = jobs.get(job_id)
|
| 194 |
+
if not job:
|
| 195 |
+
raise HTTPException(404, "No such job")
|
| 196 |
+
return job.to_dict()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@app.get("/api/voices")
|
| 200 |
+
def voices(provider: Optional[str] = None):
|
| 201 |
+
try:
|
| 202 |
+
used, names = pipeline.voices(provider)
|
| 203 |
+
return {"provider": used, "voices": names, "providers": pipeline.available_tts_providers()}
|
| 204 |
+
except Exception as e:
|
| 205 |
+
raise HTTPException(503, f"TTS adapter unavailable: {e}")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class VoiceSampleIn(BaseModel):
|
| 209 |
+
provider: Optional[str] = None
|
| 210 |
+
voice: str = ""
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@app.post("/api/voices/sample")
|
| 214 |
+
def voice_sample(body: VoiceSampleIn):
|
| 215 |
+
"""Synthesize (and cache) a short preview clip for a provider+voice."""
|
| 216 |
+
try:
|
| 217 |
+
path = pipeline.voice_sample(body.provider, body.voice)
|
| 218 |
+
return {"url": _url(path)}
|
| 219 |
+
except Exception as e:
|
| 220 |
+
raise HTTPException(503, f"Could not generate sample: {e}")
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@app.post("/api/niche/suggest")
|
| 224 |
+
def suggest_niche(body: NicheSuggestIn):
|
| 225 |
+
if not body.topic.strip():
|
| 226 |
+
raise HTTPException(400, "A niche topic is required")
|
| 227 |
+
try:
|
| 228 |
+
return pipeline.suggest_niche_fields(body.topic)
|
| 229 |
+
except Exception as e:
|
| 230 |
+
raise HTTPException(503, f"Autofill unavailable: {e}")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ---------------------------------------------------------------------------
|
| 234 |
+
# Projects
|
| 235 |
+
# ---------------------------------------------------------------------------
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@app.get("/api/projects")
|
| 239 |
+
def list_projects():
|
| 240 |
+
return [to_dict(p) for p in pipeline.store.list_projects()]
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
@app.post("/api/projects")
|
| 244 |
+
def create_project(body: ProjectIn):
|
| 245 |
+
try:
|
| 246 |
+
project = pipeline.create_project(
|
| 247 |
+
body.name, NicheProfile(**body.niche.model_dump()), body.format,
|
| 248 |
+
[ReferenceEntry(**r.model_dump()) for r in body.references],
|
| 249 |
+
)
|
| 250 |
+
except ValueError as e:
|
| 251 |
+
raise HTTPException(400, str(e))
|
| 252 |
+
return to_dict(project)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@app.get("/api/projects/{project_id}")
|
| 256 |
+
def get_project(project_id: str):
|
| 257 |
+
"""Project-level data + the list of reels (metadata only)."""
|
| 258 |
+
project = _project_or_404(project_id)
|
| 259 |
+
return {
|
| 260 |
+
"project": to_dict(project),
|
| 261 |
+
"style": to_dict(pipeline.store.load_style(project_id)),
|
| 262 |
+
"topics": to_dict(pipeline.store.load_topics(project_id)),
|
| 263 |
+
"reels": [to_dict(r) for r in pipeline.store.list_reels(project_id)],
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
@app.patch("/api/projects/{project_id}")
|
| 268 |
+
def update_project(project_id: str, body: ProjectIn):
|
| 269 |
+
project = _project_or_404(project_id)
|
| 270 |
+
project.name = body.name or project.name
|
| 271 |
+
project.niche = NicheProfile(**body.niche.model_dump())
|
| 272 |
+
project.format = body.format
|
| 273 |
+
project.references = [ReferenceEntry(**r.model_dump()) for r in body.references]
|
| 274 |
+
pipeline.store.save_project(project)
|
| 275 |
+
return to_dict(project)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
@app.delete("/api/projects/{project_id}")
|
| 279 |
+
def delete_project(project_id: str):
|
| 280 |
+
_project_or_404(project_id)
|
| 281 |
+
pipeline.store.delete_project(project_id)
|
| 282 |
+
return {"ok": True}
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# ---------------------------------------------------------------------------
|
| 286 |
+
# References / style (project-level)
|
| 287 |
+
# ---------------------------------------------------------------------------
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
@app.post("/api/projects/{project_id}/references/suggest/prompt")
|
| 291 |
+
def suggest_prompt(project_id: str, body: SuggestIn):
|
| 292 |
+
project = _project_or_404(project_id)
|
| 293 |
+
return {"prompt": pipeline.prepare_suggest_prompt(project, n=body.n)}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@app.post("/api/projects/{project_id}/references/suggest")
|
| 297 |
+
def suggest_references(project_id: str, body: SuggestIn):
|
| 298 |
+
project = _project_or_404(project_id)
|
| 299 |
+
job = jobs.submit("suggest", lambda progress: [
|
| 300 |
+
to_dict(r) for r in pipeline.suggest_references(project, n=body.n, prompt=body.prompt)
|
| 301 |
+
])
|
| 302 |
+
return {"job_id": job.id}
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@app.post("/api/projects/{project_id}/style/prompt")
|
| 306 |
+
def style_prompt(project_id: str):
|
| 307 |
+
project = _project_or_404(project_id)
|
| 308 |
+
job = jobs.submit("style-prompt", lambda progress: {"prompt": pipeline.prepare_style_prompt(project, progress)})
|
| 309 |
+
return {"job_id": job.id}
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@app.post("/api/projects/{project_id}/style/refresh")
|
| 313 |
+
def refresh_style(project_id: str, body: StyleIn):
|
| 314 |
+
project = _project_or_404(project_id)
|
| 315 |
+
job = jobs.submit("style", lambda progress: to_dict(pipeline.build_style(project, prompt=body.prompt, progress=progress)))
|
| 316 |
+
return {"job_id": job.id}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@app.put("/api/projects/{project_id}/style")
|
| 320 |
+
def save_style(project_id: str, body: dict):
|
| 321 |
+
"""Persist a user-edited style profile (editable output)."""
|
| 322 |
+
project = _project_or_404(project_id)
|
| 323 |
+
profile = from_dict(StyleProfile, body)
|
| 324 |
+
return to_dict(pipeline.save_style(project, profile))
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# ---------------------------------------------------------------------------
|
| 328 |
+
# Topics (project-level pool)
|
| 329 |
+
# ---------------------------------------------------------------------------
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
@app.post("/api/projects/{project_id}/topics/prompt")
|
| 333 |
+
def topics_prompt(project_id: str, body: TopicsIn):
|
| 334 |
+
project = _project_or_404(project_id)
|
| 335 |
+
n = max(1, min(body.n, 20))
|
| 336 |
+
job = jobs.submit("topics-prompt", lambda progress: {"prompt": pipeline.prepare_topics_prompt(project, n=n, progress=progress)})
|
| 337 |
+
return {"job_id": job.id}
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
@app.post("/api/projects/{project_id}/topics/generate")
|
| 341 |
+
def generate_topics(project_id: str, body: TopicsIn):
|
| 342 |
+
project = _project_or_404(project_id)
|
| 343 |
+
n = max(1, min(body.n, 20))
|
| 344 |
+
job = jobs.submit("topics", lambda progress: to_dict(
|
| 345 |
+
pipeline.generate_topics(project, n=n, prompt=body.prompt, progress=progress)
|
| 346 |
+
))
|
| 347 |
+
return {"job_id": job.id}
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
@app.put("/api/projects/{project_id}/topics")
|
| 351 |
+
def edit_topics(project_id: str, body: TopicEditIn):
|
| 352 |
+
project = _project_or_404(project_id)
|
| 353 |
+
batch = pipeline.store.load_topics(project_id) or TopicBatch()
|
| 354 |
+
batch.topics = [from_dict(Topic, t) for t in body.topics]
|
| 355 |
+
return to_dict(pipeline.save_topics(project, batch))
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
# ---------------------------------------------------------------------------
|
| 359 |
+
# Reels
|
| 360 |
+
# ---------------------------------------------------------------------------
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _media_payload(project_id: str, reel_id: str):
|
| 364 |
+
manifest = pipeline.store.load_media(project_id, reel_id)
|
| 365 |
+
if not manifest:
|
| 366 |
+
return None
|
| 367 |
+
data = to_dict(manifest)
|
| 368 |
+
data["music_url"] = _url(manifest.music_path)
|
| 369 |
+
for scene in data["scenes"]:
|
| 370 |
+
for cand in scene["candidates"]:
|
| 371 |
+
cand["local_url"] = _url(cand["local_path"])
|
| 372 |
+
return data
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _renders_payload(project_id: str, reel_id: str):
|
| 376 |
+
batch = pipeline.store.load_renders(project_id, reel_id)
|
| 377 |
+
if not batch:
|
| 378 |
+
return None
|
| 379 |
+
data = to_dict(batch)
|
| 380 |
+
for r in data["results"]:
|
| 381 |
+
r["url"] = _url(r["path"])
|
| 382 |
+
return data
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _reel_payload(project_id: str, reel_id: str):
|
| 386 |
+
reel = _reel_or_404(project_id, reel_id)
|
| 387 |
+
return {
|
| 388 |
+
"reel": to_dict(reel),
|
| 389 |
+
"script": to_dict(pipeline.store.load_script(project_id, reel_id)),
|
| 390 |
+
"voice": to_dict(pipeline.store.load_voice(project_id, reel_id)),
|
| 391 |
+
"media": _media_payload(project_id, reel_id),
|
| 392 |
+
"renders": _renders_payload(project_id, reel_id),
|
| 393 |
+
"clone": to_dict(pipeline.store.load_clone(project_id, reel_id)),
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
@app.get("/api/projects/{project_id}/reels")
|
| 398 |
+
def list_reels(project_id: str):
|
| 399 |
+
_project_or_404(project_id)
|
| 400 |
+
return [to_dict(r) for r in pipeline.store.list_reels(project_id)]
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
@app.post("/api/projects/{project_id}/reels")
|
| 404 |
+
def create_reel(project_id: str, body: ReelIn):
|
| 405 |
+
project = _project_or_404(project_id)
|
| 406 |
+
return to_dict(pipeline.create_reel(project, name=body.name, topic=body.topic))
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
@app.get("/api/projects/{project_id}/reels/{reel_id}")
|
| 410 |
+
def get_reel(project_id: str, reel_id: str):
|
| 411 |
+
_project_or_404(project_id)
|
| 412 |
+
return _reel_payload(project_id, reel_id)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
@app.delete("/api/projects/{project_id}/reels/{reel_id}")
|
| 416 |
+
def delete_reel(project_id: str, reel_id: str):
|
| 417 |
+
project = _project_or_404(project_id)
|
| 418 |
+
_reel_or_404(project_id, reel_id)
|
| 419 |
+
pipeline.delete_reel(project, reel_id)
|
| 420 |
+
return {"ok": True}
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# -- script (reel-level) -----------------------------------------------------
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
@app.post("/api/projects/{project_id}/reels/{reel_id}/script/prompt")
|
| 427 |
+
def script_prompt(project_id: str, reel_id: str, body: ScriptPromptIn):
|
| 428 |
+
project = _project_or_404(project_id)
|
| 429 |
+
_reel_or_404(project_id, reel_id)
|
| 430 |
+
return {"prompt": pipeline.prepare_script_prompt(project, reel_id, body.topic)}
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
@app.post("/api/projects/{project_id}/reels/{reel_id}/script/generate")
|
| 434 |
+
def generate_script(project_id: str, reel_id: str, body: ScriptGenIn):
|
| 435 |
+
project = _project_or_404(project_id)
|
| 436 |
+
_reel_or_404(project_id, reel_id)
|
| 437 |
+
job = jobs.submit("script", lambda progress: to_dict(
|
| 438 |
+
pipeline.generate_script(project, reel_id, body.topic, prompt=body.prompt)
|
| 439 |
+
))
|
| 440 |
+
return {"job_id": job.id}
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
@app.patch("/api/projects/{project_id}/reels/{reel_id}/script")
|
| 444 |
+
def patch_script(project_id: str, reel_id: str, body: dict):
|
| 445 |
+
project = _project_or_404(project_id)
|
| 446 |
+
_reel_or_404(project_id, reel_id)
|
| 447 |
+
script = from_dict(Script, body)
|
| 448 |
+
if not script.scenes:
|
| 449 |
+
raise HTTPException(400, "Script must keep at least one scene")
|
| 450 |
+
return to_dict(pipeline.save_script(project, reel_id, script))
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
@app.post("/api/projects/{project_id}/reels/{reel_id}/script/regenerate-scene")
|
| 454 |
+
def regenerate_scene(project_id: str, reel_id: str, body: SceneRegenIn):
|
| 455 |
+
project = _project_or_404(project_id)
|
| 456 |
+
_reel_or_404(project_id, reel_id)
|
| 457 |
+
job = jobs.submit("scene", lambda progress: to_dict(
|
| 458 |
+
pipeline.regenerate_scene(project, reel_id, body.scene)
|
| 459 |
+
))
|
| 460 |
+
return {"job_id": job.id}
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
# -- voice / media (reel-level) ----------------------------------------------
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
@app.post("/api/projects/{project_id}/reels/{reel_id}/voice")
|
| 467 |
+
def synthesize_voice(project_id: str, reel_id: str, body: VoiceIn):
|
| 468 |
+
project = _project_or_404(project_id)
|
| 469 |
+
_reel_or_404(project_id, reel_id)
|
| 470 |
+
job = jobs.submit("voice", lambda progress: to_dict(
|
| 471 |
+
pipeline.synthesize_voice(project, reel_id, voice=body.voice, provider=body.provider, progress=progress)
|
| 472 |
+
))
|
| 473 |
+
return {"job_id": job.id}
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
@app.post("/api/projects/{project_id}/reels/{reel_id}/media")
|
| 477 |
+
def gather_media(project_id: str, reel_id: str):
|
| 478 |
+
project = _project_or_404(project_id)
|
| 479 |
+
_reel_or_404(project_id, reel_id)
|
| 480 |
+
|
| 481 |
+
def run(progress):
|
| 482 |
+
pipeline.gather_media(project, reel_id, progress)
|
| 483 |
+
return _media_payload(project_id, reel_id)
|
| 484 |
+
|
| 485 |
+
return {"job_id": jobs.submit("media", run).id}
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
@app.get("/api/projects/{project_id}/reels/{reel_id}/media")
|
| 489 |
+
def get_media(project_id: str, reel_id: str):
|
| 490 |
+
_project_or_404(project_id)
|
| 491 |
+
_reel_or_404(project_id, reel_id)
|
| 492 |
+
return _media_payload(project_id, reel_id)
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@app.post("/api/projects/{project_id}/reels/{reel_id}/media/swap")
|
| 496 |
+
def swap_clip(project_id: str, reel_id: str, body: SwapIn):
|
| 497 |
+
project = _project_or_404(project_id)
|
| 498 |
+
_reel_or_404(project_id, reel_id)
|
| 499 |
+
try:
|
| 500 |
+
pipeline.swap_clip(project, reel_id, body.line_index)
|
| 501 |
+
except RuntimeError as e:
|
| 502 |
+
raise HTTPException(400, str(e))
|
| 503 |
+
return _media_payload(project_id, reel_id)
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
# -- render (reel-level) -----------------------------------------------------
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
@app.post("/api/projects/{project_id}/reels/{reel_id}/render")
|
| 510 |
+
def start_render(project_id: str, reel_id: str, body: RenderIn):
|
| 511 |
+
project = _project_or_404(project_id)
|
| 512 |
+
_reel_or_404(project_id, reel_id)
|
| 513 |
+
if body.variants:
|
| 514 |
+
variants = [from_dict(VariantSpec, v) for v in body.variants]
|
| 515 |
+
for v in variants:
|
| 516 |
+
if v.caption_preset not in CAPTION_PRESETS:
|
| 517 |
+
raise HTTPException(400, f"Unknown caption preset {v.caption_preset!r}")
|
| 518 |
+
else:
|
| 519 |
+
variants = list(DEFAULT_VARIANTS)
|
| 520 |
+
|
| 521 |
+
def run(progress):
|
| 522 |
+
pipeline.render(project, reel_id, variants=variants, progress=progress)
|
| 523 |
+
return _renders_payload(project_id, reel_id)
|
| 524 |
+
|
| 525 |
+
return {"job_id": jobs.submit("render", run).id}
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
@app.get("/api/projects/{project_id}/reels/{reel_id}/render")
|
| 529 |
+
def get_renders(project_id: str, reel_id: str):
|
| 530 |
+
_project_or_404(project_id)
|
| 531 |
+
_reel_or_404(project_id, reel_id)
|
| 532 |
+
return _renders_payload(project_id, reel_id)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
# ---------------------------------------------------------------------------
|
| 536 |
+
# Clone (creates a new reel)
|
| 537 |
+
# ---------------------------------------------------------------------------
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
@app.post("/api/projects/{project_id}/clone")
|
| 541 |
+
def clone_reel(project_id: str, body: CloneIn):
|
| 542 |
+
project = _project_or_404(project_id)
|
| 543 |
+
|
| 544 |
+
def run(progress):
|
| 545 |
+
try:
|
| 546 |
+
result = pipeline.clone_reel(project, body.url, progress)
|
| 547 |
+
return {"reel": to_dict(result["reel"]), "script": to_dict(result["script"])}
|
| 548 |
+
except CloneError as e:
|
| 549 |
+
raise RuntimeError(str(e)) from e
|
| 550 |
+
|
| 551 |
+
return {"job_id": jobs.submit("clone", run).id}
|
server/jobs.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background job manager for long pipeline operations.
|
| 2 |
+
|
| 3 |
+
POST handlers enqueue a callable and return a job_id immediately; the
|
| 4 |
+
frontend polls GET /jobs/{id} for status, per-step progress messages, and
|
| 5 |
+
the result payload. A small thread pool is plenty for a single local user.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import threading
|
| 11 |
+
import traceback
|
| 12 |
+
import uuid
|
| 13 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from typing import Any, Callable, Optional
|
| 16 |
+
|
| 17 |
+
from core.utils import log
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class Job:
|
| 22 |
+
id: str
|
| 23 |
+
kind: str
|
| 24 |
+
status: str = "queued" # queued | running | done | error
|
| 25 |
+
progress: list[str] = field(default_factory=list)
|
| 26 |
+
result: Any = None
|
| 27 |
+
error: str = ""
|
| 28 |
+
|
| 29 |
+
def to_dict(self) -> dict:
|
| 30 |
+
return {
|
| 31 |
+
"id": self.id,
|
| 32 |
+
"kind": self.kind,
|
| 33 |
+
"status": self.status,
|
| 34 |
+
"progress": list(self.progress),
|
| 35 |
+
"result": self.result,
|
| 36 |
+
"error": self.error,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class JobManager:
|
| 41 |
+
def __init__(self, workers: int = 2):
|
| 42 |
+
self._pool = ThreadPoolExecutor(max_workers=workers)
|
| 43 |
+
self._jobs: dict[str, Job] = {}
|
| 44 |
+
self._lock = threading.Lock()
|
| 45 |
+
|
| 46 |
+
def submit(self, kind: str, fn: Callable[[Callable[[str], None]], Any]) -> Job:
|
| 47 |
+
"""Run ``fn(progress_callback)`` in the background; return the Job."""
|
| 48 |
+
job = Job(id=uuid.uuid4().hex[:10], kind=kind)
|
| 49 |
+
with self._lock:
|
| 50 |
+
self._jobs[job.id] = job
|
| 51 |
+
|
| 52 |
+
def progress(msg: str) -> None:
|
| 53 |
+
log.info("[job %s] %s", job.id, msg)
|
| 54 |
+
job.progress.append(msg)
|
| 55 |
+
|
| 56 |
+
def run() -> None:
|
| 57 |
+
job.status = "running"
|
| 58 |
+
try:
|
| 59 |
+
job.result = fn(progress)
|
| 60 |
+
job.status = "done"
|
| 61 |
+
except Exception as e:
|
| 62 |
+
log.error("Job %s (%s) failed: %s\n%s", job.id, kind, e, traceback.format_exc())
|
| 63 |
+
job.error = str(e)
|
| 64 |
+
job.status = "error"
|
| 65 |
+
|
| 66 |
+
self._pool.submit(run)
|
| 67 |
+
return job
|
| 68 |
+
|
| 69 |
+
def get(self, job_id: str) -> Optional[Job]:
|
| 70 |
+
with self._lock:
|
| 71 |
+
return self._jobs.get(job_id)
|
tests/__init__.py
ADDED
|
File without changes
|
tests/mocks.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline mock adapters. Registered under the name "mock" per capability.
|
| 2 |
+
|
| 3 |
+
They synthesize media locally with ffmpeg's lavfi sources, so the whole
|
| 4 |
+
pipeline (including the real render stage) runs with zero network access.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from core.adapters.base import (
|
| 13 |
+
LLMAdapter,
|
| 14 |
+
MusicAdapter,
|
| 15 |
+
ReferenceDataAdapter,
|
| 16 |
+
SFXAdapter,
|
| 17 |
+
StockVideoAdapter,
|
| 18 |
+
TranscriptAdapter,
|
| 19 |
+
TTSAdapter,
|
| 20 |
+
register,
|
| 21 |
+
)
|
| 22 |
+
from core.contracts import ClipCandidate, WordTiming
|
| 23 |
+
from core.utils import run_ffmpeg
|
| 24 |
+
|
| 25 |
+
MOCK_SCRIPT = {
|
| 26 |
+
"title": "Three wild facts about deep sea creatures",
|
| 27 |
+
"hook_options": [
|
| 28 |
+
"This fish hunts with a built-in flashlight.",
|
| 29 |
+
"The deep sea is weirder than any alien movie.",
|
| 30 |
+
"Scientists just filmed something impossible down here.",
|
| 31 |
+
],
|
| 32 |
+
"scenes": [
|
| 33 |
+
{"scene": 1, "narration": "Meet the anglerfish, it lures prey with glowing bait.",
|
| 34 |
+
"visual_query": "deep sea fish glowing", "sfx_query": "underwater bubble", "duration_sec": 4},
|
| 35 |
+
{"scene": 2, "narration": "Giant isopods can survive years without a single meal.",
|
| 36 |
+
"visual_query": "ocean floor creature", "sfx_query": None, "duration_sec": 4},
|
| 37 |
+
{"scene": 3, "narration": "And the vampire squid turns itself completely inside out.",
|
| 38 |
+
"visual_query": "squid swimming dark water", "sfx_query": None, "duration_sec": 4},
|
| 39 |
+
],
|
| 40 |
+
"cta": "Follow for more ocean weirdness.",
|
| 41 |
+
"total_duration_sec": 14,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
MOCK_TOPICS = [
|
| 45 |
+
{"title": f"Mock topic {i}: deep sea discovery #{i}",
|
| 46 |
+
"reason": "trending in references",
|
| 47 |
+
"signals": [f"reference Short #{i} >100k views", "r/ocean top post"]}
|
| 48 |
+
for i in range(1, 9)
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
MOCK_CLONE = {
|
| 52 |
+
"topic": "How octopuses edit their own genes",
|
| 53 |
+
"hook_pattern": "impossible claim then immediate proof",
|
| 54 |
+
"structure": "hook -> 3 escalating facts -> twist -> CTA",
|
| 55 |
+
"style_notes": "fast, second person, playful",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
MOCK_CHANNELS = [{"name": "Deep Sea Daily", "handle": "@deepseadaily"}]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class MockLLM(LLMAdapter):
|
| 62 |
+
def __init__(self, config=None):
|
| 63 |
+
self.calls: list[str] = []
|
| 64 |
+
|
| 65 |
+
def complete(self, prompt: str) -> str:
|
| 66 |
+
self.calls.append(prompt)
|
| 67 |
+
if "STYLE CARD" in prompt:
|
| 68 |
+
return "- Hook: shocking claim first\n- Pacing: fast\n- CTA: question"
|
| 69 |
+
return json.dumps(self._route(prompt))
|
| 70 |
+
|
| 71 |
+
def complete_json(self, prompt: str):
|
| 72 |
+
self.calls.append(prompt)
|
| 73 |
+
return self._route(prompt)
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
def _route(prompt: str):
|
| 77 |
+
if "Rewrite ONE scene" in prompt:
|
| 78 |
+
return {"narration": "Rewritten: the blobfish only looks sad at the surface.",
|
| 79 |
+
"visual_query": "strange fish closeup", "sfx_query": None, "duration_sec": 4}
|
| 80 |
+
if "Return JSON only, exactly this shape" in prompt:
|
| 81 |
+
return MOCK_SCRIPT
|
| 82 |
+
if "extract its essence" in prompt:
|
| 83 |
+
return MOCK_CLONE
|
| 84 |
+
if "Propose exactly" in prompt:
|
| 85 |
+
return MOCK_TOPICS
|
| 86 |
+
if "Suggest" in prompt and "channels" in prompt:
|
| 87 |
+
return MOCK_CHANNELS
|
| 88 |
+
return {"ok": True}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class MockTTS(TTSAdapter):
|
| 92 |
+
"""Sine-tone 'speech' with evenly spread word timings."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, config=None):
|
| 95 |
+
pass
|
| 96 |
+
|
| 97 |
+
def synth(self, text: str, out_path: str, voice: str = "") -> Optional[list[WordTiming]]:
|
| 98 |
+
words = text.split()
|
| 99 |
+
duration = max(0.8, len(words) * 0.28)
|
| 100 |
+
run_ffmpeg(
|
| 101 |
+
["-f", "lavfi", "-i", f"sine=frequency=320:duration={duration:.2f}",
|
| 102 |
+
"-c:a", "libmp3lame", "-q:a", "7", out_path],
|
| 103 |
+
label="mock-tts",
|
| 104 |
+
)
|
| 105 |
+
per = duration / len(words) if words else duration
|
| 106 |
+
return [WordTiming(word=w, start=i * per, end=(i + 1) * per) for i, w in enumerate(words)]
|
| 107 |
+
|
| 108 |
+
def voices(self) -> list[str]:
|
| 109 |
+
return ["mock-voice"]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
_COLORS = ["0x335577", "0x775533", "0x557733", "0x553377", "0x337755"]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class MockStock(StockVideoAdapter):
|
| 116 |
+
def __init__(self, config=None):
|
| 117 |
+
self.counter = 0
|
| 118 |
+
|
| 119 |
+
def search(self, query, orientation="portrait", min_duration=3.0,
|
| 120 |
+
max_duration=30.0, per_query=4) -> list[ClipCandidate]:
|
| 121 |
+
out = []
|
| 122 |
+
for i in range(per_query):
|
| 123 |
+
self.counter += 1
|
| 124 |
+
out.append(ClipCandidate(
|
| 125 |
+
id=f"mock{self.counter}", source="mock",
|
| 126 |
+
preview_url="", download_url=f"lavfi://{self.counter}",
|
| 127 |
+
width=608, height=1080, duration_sec=6.0,
|
| 128 |
+
credit="Mock / lavfi",
|
| 129 |
+
))
|
| 130 |
+
return out
|
| 131 |
+
|
| 132 |
+
def download(self, candidate: ClipCandidate, out_path: str) -> str:
|
| 133 |
+
color = _COLORS[int(candidate.id.removeprefix("mock")) % len(_COLORS)]
|
| 134 |
+
run_ffmpeg(
|
| 135 |
+
["-f", "lavfi", "-i", f"color=c={color}:s=608x1080:r=30:d=6",
|
| 136 |
+
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", out_path],
|
| 137 |
+
label="mock-clip",
|
| 138 |
+
)
|
| 139 |
+
candidate.local_path = out_path
|
| 140 |
+
return out_path
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class MockSFX(SFXAdapter):
|
| 144 |
+
def __init__(self, config=None):
|
| 145 |
+
pass
|
| 146 |
+
|
| 147 |
+
def fetch(self, query: str, out_path: str, max_duration: float = 4.0) -> Optional[str]:
|
| 148 |
+
run_ffmpeg(
|
| 149 |
+
["-f", "lavfi", "-i", "sine=frequency=880:duration=0.6",
|
| 150 |
+
"-c:a", "libmp3lame", "-q:a", "7", out_path],
|
| 151 |
+
label="mock-sfx",
|
| 152 |
+
)
|
| 153 |
+
return out_path
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class MockMusic(MusicAdapter):
|
| 157 |
+
"""Generates one quiet tone track into the configured music dir."""
|
| 158 |
+
|
| 159 |
+
def __init__(self, config):
|
| 160 |
+
self.music_dir = config.music_dir
|
| 161 |
+
|
| 162 |
+
def pick(self, mood: str = "") -> tuple[str, str]:
|
| 163 |
+
self.music_dir.mkdir(parents=True, exist_ok=True)
|
| 164 |
+
track = self.music_dir / "mock_track.mp3"
|
| 165 |
+
if not track.exists():
|
| 166 |
+
run_ffmpeg(
|
| 167 |
+
["-f", "lavfi", "-i", "sine=frequency=110:duration=20",
|
| 168 |
+
"-c:a", "libmp3lame", "-q:a", "7", str(track)],
|
| 169 |
+
label="mock-music",
|
| 170 |
+
)
|
| 171 |
+
return str(track), "mock tone"
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class MockRefData(ReferenceDataAdapter):
|
| 175 |
+
def __init__(self, config=None):
|
| 176 |
+
pass
|
| 177 |
+
|
| 178 |
+
def resolve_channel(self, name_or_url: str):
|
| 179 |
+
return {"channel_id": "UCmock123", "title": name_or_url.lstrip("@"),
|
| 180 |
+
"url": "https://www.youtube.com/channel/UCmock123", "subscribers": 100000}
|
| 181 |
+
|
| 182 |
+
def top_shorts(self, channel_id: str, n: int = 5, since_days: int = 90):
|
| 183 |
+
return [
|
| 184 |
+
{"video_id": f"vid{i:03d}", "title": f"Wild ocean fact #{i}",
|
| 185 |
+
"views": 1_000_000 - i * 1000, "likes": 50_000, "published": "2026-06-01T00:00:00Z"}
|
| 186 |
+
for i in range(n)
|
| 187 |
+
]
|
| 188 |
+
|
| 189 |
+
def _get(self, path: str, **params): # used by the clone stage for metadata
|
| 190 |
+
return {"items": [{"snippet": {"title": "Mock source short",
|
| 191 |
+
"channelTitle": "Mock Channel"}}]}
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class MockTranscript(TranscriptAdapter):
|
| 195 |
+
def __init__(self, config=None):
|
| 196 |
+
pass
|
| 197 |
+
|
| 198 |
+
def fetch(self, video_id: str) -> Optional[str]:
|
| 199 |
+
return (
|
| 200 |
+
"Did you know the ocean is basically another planet? First, anglerfish "
|
| 201 |
+
"carry their own lanterns. Second, isopods fast for years. Third, the "
|
| 202 |
+
"vampire squid does the impossible. Follow for more."
|
| 203 |
+
) * 2
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def register_mocks() -> None:
|
| 207 |
+
register("llm", "mock", MockLLM)
|
| 208 |
+
register("tts", "mock", MockTTS)
|
| 209 |
+
register("stock", "mock", MockStock)
|
| 210 |
+
register("sfx", "mock", MockSFX)
|
| 211 |
+
register("music", "mock", MockMusic)
|
| 212 |
+
register("refdata", "mock", MockRefData)
|
| 213 |
+
register("transcript", "mock", MockTranscript)
|
tests/test_server.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Server integration test: the full wizard flow over HTTP with mock adapters.
|
| 2 |
+
|
| 3 |
+
Covers the job lifecycle (submit -> poll -> result), artifact endpoints,
|
| 4 |
+
static file serving of the rendered MP4, and the clone route.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
# Mock adapters must be selected BEFORE server.app builds its Pipeline.
|
| 15 |
+
os.environ.update(
|
| 16 |
+
ADAPTER_LLM="mock", ADAPTER_TTS="mock", ADAPTER_STOCK="mock",
|
| 17 |
+
ADAPTER_SFX="mock", ADAPTER_MUSIC="mock", ADAPTER_REFDATA="mock",
|
| 18 |
+
ADAPTER_TRANSCRIPT="mock",
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
from tests.mocks import register_mocks # noqa: E402
|
| 22 |
+
|
| 23 |
+
register_mocks()
|
| 24 |
+
|
| 25 |
+
from fastapi.testclient import TestClient # noqa: E402
|
| 26 |
+
|
| 27 |
+
from core.stages import topics as topics_stage # noqa: E402
|
| 28 |
+
from server.app import app # noqa: E402
|
| 29 |
+
|
| 30 |
+
client = TestClient(app)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def wait_job(job_id: str, timeout: float = 120.0) -> dict:
|
| 34 |
+
deadline = time.time() + timeout
|
| 35 |
+
while time.time() < deadline:
|
| 36 |
+
job = client.get(f"/api/jobs/{job_id}").json()
|
| 37 |
+
if job["status"] == "done":
|
| 38 |
+
return job["result"]
|
| 39 |
+
if job["status"] == "error":
|
| 40 |
+
raise AssertionError(f"job {job['kind']} failed: {job['error']}")
|
| 41 |
+
time.sleep(0.2)
|
| 42 |
+
raise AssertionError("job timed out")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.fixture(scope="module")
|
| 46 |
+
def project_id(request):
|
| 47 |
+
resp = client.post("/api/projects", json={
|
| 48 |
+
"name": "Server Smoke",
|
| 49 |
+
"niche": {"topic": "deep sea creatures", "keywords": ["ocean"],
|
| 50 |
+
"subreddits": ["ocean"], "audience": "everyone", "tone": "fun"},
|
| 51 |
+
"format": "youtube_short",
|
| 52 |
+
"references": [
|
| 53 |
+
{"kind": "youtube", "name": "@deepseadaily"},
|
| 54 |
+
{"kind": "instagram_style", "name": "oceanreels",
|
| 55 |
+
"url": "https://instagram.com/oceanreels", "notes": "big captions"},
|
| 56 |
+
],
|
| 57 |
+
})
|
| 58 |
+
assert resp.status_code == 200, resp.text
|
| 59 |
+
pid = resp.json()["id"]
|
| 60 |
+
request.addfinalizer(lambda: client.delete(f"/api/projects/{pid}"))
|
| 61 |
+
return pid
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_health():
|
| 65 |
+
body = client.get("/api/health").json()
|
| 66 |
+
assert body["ok"] and "caption_presets" in body
|
| 67 |
+
assert "tts_providers" in body
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_wizard_flow(project_id, monkeypatch):
|
| 71 |
+
monkeypatch.setattr(topics_stage, "_news_signals", lambda p: [])
|
| 72 |
+
monkeypatch.setattr(topics_stage, "_reddit_signals", lambda p: [])
|
| 73 |
+
|
| 74 |
+
# style: prompt prepare (job) then refresh, then edit-and-save the output
|
| 75 |
+
job = client.post(f"/api/projects/{project_id}/style/prompt").json()
|
| 76 |
+
assert "STYLE CARD" in wait_job(job["job_id"])["prompt"]
|
| 77 |
+
job = client.post(f"/api/projects/{project_id}/style/refresh", json={}).json()
|
| 78 |
+
style = wait_job(job["job_id"])
|
| 79 |
+
assert style["style_card"] and style["prompt_used"]
|
| 80 |
+
style["style_card"] = "EDITED style card"
|
| 81 |
+
assert client.put(f"/api/projects/{project_id}/style", json=style).json()["style_card"] == "EDITED style card"
|
| 82 |
+
|
| 83 |
+
# suggest references (job-based)
|
| 84 |
+
job = client.post(f"/api/projects/{project_id}/references/suggest", json={"n": 3}).json()
|
| 85 |
+
assert isinstance(wait_job(job["job_id"]), list)
|
| 86 |
+
|
| 87 |
+
# topics: prompt prepare + generate + inline edit
|
| 88 |
+
job = client.post(f"/api/projects/{project_id}/topics/prompt", json={"n": 5}).json()
|
| 89 |
+
assert wait_job(job["job_id"])["prompt"]
|
| 90 |
+
job = client.post(f"/api/projects/{project_id}/topics/generate", json={"n": 5}).json()
|
| 91 |
+
topics = wait_job(job["job_id"])["topics"]
|
| 92 |
+
assert len(topics) == 5
|
| 93 |
+
topics[0]["title"] = "Edited topic title"
|
| 94 |
+
resp = client.put(f"/api/projects/{project_id}/topics", json={"topics": topics})
|
| 95 |
+
assert resp.json()["topics"][0]["title"] == "Edited topic title"
|
| 96 |
+
|
| 97 |
+
# create a reel for the chosen topic
|
| 98 |
+
reel = client.post(f"/api/projects/{project_id}/reels",
|
| 99 |
+
json={"topic": "Edited topic title"}).json()
|
| 100 |
+
rid = reel["id"]
|
| 101 |
+
base = f"/api/projects/{project_id}/reels/{rid}"
|
| 102 |
+
|
| 103 |
+
# script: prompt prepare + generate + patch + scene regen
|
| 104 |
+
assert "scripts" in client.post(f"{base}/script/prompt", json={"topic": "Edited topic title"}).json()["prompt"].lower()
|
| 105 |
+
job = client.post(f"{base}/script/generate", json={"topic": "Edited topic title"}).json()
|
| 106 |
+
script = wait_job(job["job_id"])
|
| 107 |
+
assert len(script["hook_options"]) == 3 and script["prompt_used"]
|
| 108 |
+
script["selected_hook"] = 1
|
| 109 |
+
assert client.patch(f"{base}/script", json=script).json()["selected_hook"] == 1
|
| 110 |
+
job = client.post(f"{base}/script/regenerate-scene", json={"scene": 1}).json()
|
| 111 |
+
assert "Rewritten" in wait_job(job["job_id"])["scenes"][0]["narration"]
|
| 112 |
+
|
| 113 |
+
# voices listing (with providers) + voice synthesis
|
| 114 |
+
vinfo = client.get("/api/voices").json()
|
| 115 |
+
assert vinfo["voices"] and "providers" in vinfo
|
| 116 |
+
job = client.post(f"{base}/voice", json={"voice": "", "provider": "mock"}).json()
|
| 117 |
+
assert wait_job(job["job_id"])["total_duration_sec"] > 3
|
| 118 |
+
|
| 119 |
+
# media + swap
|
| 120 |
+
job = client.post(f"{base}/media").json()
|
| 121 |
+
media = wait_job(job["job_id"])
|
| 122 |
+
assert media["scenes"] and media["scenes"][0]["candidates"]
|
| 123 |
+
before = media["scenes"][0]["selected"]
|
| 124 |
+
swapped = client.post(f"{base}/media/swap", json={"line_index": 0}).json()
|
| 125 |
+
assert swapped["scenes"][0]["selected"] != before
|
| 126 |
+
|
| 127 |
+
# render one variant, then fetch the file over HTTP
|
| 128 |
+
job = client.post(f"{base}/render", json={
|
| 129 |
+
"variants": [{"name": "Variant A", "caption_preset": "bold-center-karaoke", "clip_offset": 0}],
|
| 130 |
+
}).json()
|
| 131 |
+
renders = wait_job(job["job_id"], timeout=300)
|
| 132 |
+
assert renders["results"]
|
| 133 |
+
url = renders["results"][0]["url"]
|
| 134 |
+
assert url.startswith("/files/")
|
| 135 |
+
video = client.get(url)
|
| 136 |
+
assert video.status_code == 200 and len(video.content) > 50_000
|
| 137 |
+
|
| 138 |
+
# reel payload includes everything
|
| 139 |
+
payload = client.get(base).json()
|
| 140 |
+
assert payload["script"] and payload["voice"] and payload["media"] and payload["renders"]
|
| 141 |
+
# project payload lists the reel
|
| 142 |
+
proj = client.get(f"/api/projects/{project_id}").json()
|
| 143 |
+
assert any(r["id"] == rid for r in proj["reels"])
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_multiple_reels_via_api(project_id):
|
| 147 |
+
r1 = client.post(f"/api/projects/{project_id}/reels", json={"topic": "alpha"}).json()
|
| 148 |
+
r2 = client.post(f"/api/projects/{project_id}/reels", json={"topic": "beta"}).json()
|
| 149 |
+
reels = client.get(f"/api/projects/{project_id}/reels").json()
|
| 150 |
+
ids = {r["id"] for r in reels}
|
| 151 |
+
assert {r1["id"], r2["id"]} <= ids
|
| 152 |
+
assert client.delete(f"/api/projects/{project_id}/reels/{r1['id']}").json()["ok"]
|
| 153 |
+
remaining = {r["id"] for r in client.get(f"/api/projects/{project_id}/reels").json()}
|
| 154 |
+
assert r1["id"] not in remaining and r2["id"] in remaining
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_clone_route(project_id):
|
| 158 |
+
job = client.post(f"/api/projects/{project_id}/clone",
|
| 159 |
+
json={"url": "https://www.youtube.com/shorts/abcdefghijk"}).json()
|
| 160 |
+
result = wait_job(job["job_id"])
|
| 161 |
+
assert result["reel"]["clone_source"].endswith("abcdefghijk")
|
| 162 |
+
assert result["script"]["clone_source"].endswith("abcdefghijk")
|
| 163 |
+
|
| 164 |
+
job = client.post(f"/api/projects/{project_id}/clone",
|
| 165 |
+
json={"url": "https://example.com/nope"}).json()
|
| 166 |
+
status = None
|
| 167 |
+
for _ in range(50):
|
| 168 |
+
status = client.get(f"/api/jobs/{job['job_id']}").json()
|
| 169 |
+
if status["status"] in ("done", "error"):
|
| 170 |
+
break
|
| 171 |
+
time.sleep(0.1)
|
| 172 |
+
assert status["status"] == "error" and "YouTube" in status["error"]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_bad_render_preset(project_id):
|
| 176 |
+
reel = client.post(f"/api/projects/{project_id}/reels", json={"topic": "x"}).json()
|
| 177 |
+
resp = client.post(f"/api/projects/{project_id}/reels/{reel['id']}/render", json={
|
| 178 |
+
"variants": [{"name": "X", "caption_preset": "nope", "clip_offset": 0}],
|
| 179 |
+
})
|
| 180 |
+
assert resp.status_code == 400
|
tests/test_smoke.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline end-to-end smoke test.
|
| 2 |
+
|
| 3 |
+
Runs the REAL pipeline and render stage with mock (lavfi-backed) adapters:
|
| 4 |
+
no network, no API keys. Asserts at least one 9:16 MP4 variant lands on
|
| 5 |
+
disk with sane duration.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import subprocess
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from core.config import Config
|
| 17 |
+
from core.contracts import DEFAULT_VARIANTS, NicheProfile, ReferenceEntry
|
| 18 |
+
from core.pipeline import Pipeline
|
| 19 |
+
from core.stages import topics as topics_stage
|
| 20 |
+
from core.store import ProjectStore
|
| 21 |
+
from tests.mocks import register_mocks
|
| 22 |
+
|
| 23 |
+
register_mocks()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture()
|
| 27 |
+
def pipe(tmp_path: Path) -> Pipeline:
|
| 28 |
+
cfg = Config(
|
| 29 |
+
llm_adapter="mock",
|
| 30 |
+
tts_adapter="mock",
|
| 31 |
+
stock_adapter="mock",
|
| 32 |
+
sfx_adapter="mock",
|
| 33 |
+
music_adapter="mock",
|
| 34 |
+
refdata_adapter="mock",
|
| 35 |
+
transcript_adapter="mock",
|
| 36 |
+
projects_dir=tmp_path / "projects",
|
| 37 |
+
music_dir=tmp_path / "music",
|
| 38 |
+
)
|
| 39 |
+
return Pipeline(config=cfg, store=ProjectStore(cfg.projects_dir))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.fixture()
|
| 43 |
+
def project(pipe: Pipeline):
|
| 44 |
+
niche = NicheProfile(
|
| 45 |
+
topic="deep sea creatures",
|
| 46 |
+
keywords=["ocean", "marine biology"],
|
| 47 |
+
subreddits=["thalassophobia"],
|
| 48 |
+
audience="curious adults",
|
| 49 |
+
tone="playful",
|
| 50 |
+
)
|
| 51 |
+
refs = [
|
| 52 |
+
ReferenceEntry(kind="youtube", name="@deepseadaily"),
|
| 53 |
+
ReferenceEntry(kind="instagram_style", name="oceanreels",
|
| 54 |
+
url="https://instagram.com/oceanreels",
|
| 55 |
+
notes="moody color grade, big captions"),
|
| 56 |
+
]
|
| 57 |
+
return pipe.create_project("Deep Sea", niche, "youtube_short", refs)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _ffprobe(path: str, entries: str) -> str:
|
| 61 |
+
return subprocess.run(
|
| 62 |
+
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
| 63 |
+
"-show_entries", entries, "-of", "csv=p=0", path],
|
| 64 |
+
capture_output=True, text=True, check=True,
|
| 65 |
+
).stdout.strip()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_full_pipeline_offline(pipe: Pipeline, project, monkeypatch):
|
| 69 |
+
# Keep the topics stage offline: external trend sources are stubbed out.
|
| 70 |
+
monkeypatch.setattr(topics_stage, "_news_signals", lambda p: ["[news] mock headline"])
|
| 71 |
+
monkeypatch.setattr(topics_stage, "_reddit_signals", lambda p: ["[r/mock] mock post"])
|
| 72 |
+
|
| 73 |
+
# Stage 2: style (project-level) — prompt captured for QC
|
| 74 |
+
style = pipe.build_style(project)
|
| 75 |
+
assert style.style_card
|
| 76 |
+
assert style.exemplars, "transcripts should become exemplars"
|
| 77 |
+
assert style.instagram_notes, "IG notes must flow into the style profile"
|
| 78 |
+
assert style.prompt_used, "the assembled prompt is stored for QC"
|
| 79 |
+
|
| 80 |
+
# Stage 3: topics (project-level pool)
|
| 81 |
+
batch = pipe.generate_topics(project, n=6)
|
| 82 |
+
assert len(batch.topics) == 6
|
| 83 |
+
assert batch.prompt_used
|
| 84 |
+
|
| 85 |
+
# A reel owns the script -> voice -> media -> render chain
|
| 86 |
+
reel = pipe.create_reel(project, topic=batch.topics[0].title)
|
| 87 |
+
|
| 88 |
+
# Stage 4: script
|
| 89 |
+
script = pipe.generate_script(project, reel.id, batch.topics[0].title)
|
| 90 |
+
assert len(script.hook_options) == 3
|
| 91 |
+
assert 1 <= len(script.scenes) <= 6
|
| 92 |
+
assert script.narration_lines()[0] == script.hook_options[0]
|
| 93 |
+
assert script.prompt_used
|
| 94 |
+
|
| 95 |
+
# Stage 5: voice — true ffprobe durations drive timing
|
| 96 |
+
voice = pipe.synthesize_voice(project, reel.id)
|
| 97 |
+
assert len(voice.lines) == len(script.narration_lines())
|
| 98 |
+
for line in voice.lines:
|
| 99 |
+
assert line.duration_sec > 0.2
|
| 100 |
+
assert line.word_timings, "mock TTS emits timings"
|
| 101 |
+
|
| 102 |
+
# Stage 6: media — alternates kept, dedupe enforced
|
| 103 |
+
manifest = pipe.gather_media(project, reel.id)
|
| 104 |
+
assert len(manifest.scenes) == len(voice.lines)
|
| 105 |
+
used = [s.candidates[s.selected].id for s in manifest.scenes if s.candidates]
|
| 106 |
+
assert len(used) == len(set(used)), "the same clip must never appear twice"
|
| 107 |
+
assert any(len(s.candidates) > 1 for s in manifest.scenes), "alternates kept for the UI"
|
| 108 |
+
assert manifest.music_path, "mock music track picked"
|
| 109 |
+
|
| 110 |
+
# Stage 7: render — at least 2 variants, 9:16, duration matches voice
|
| 111 |
+
batch = pipe.render(project, reel.id, variants=DEFAULT_VARIANTS[:2])
|
| 112 |
+
assert len(batch.results) >= 1
|
| 113 |
+
for r in batch.results:
|
| 114 |
+
assert Path(r.path).exists()
|
| 115 |
+
w, h = _ffprobe(r.path, "stream=width,height").split(",")
|
| 116 |
+
assert (w, h) == ("1080", "1920")
|
| 117 |
+
assert abs(r.duration_sec - voice.total_duration_sec) < 1.5
|
| 118 |
+
|
| 119 |
+
# Artifacts persisted: project-level + reel-level
|
| 120 |
+
pdir = pipe.store.dir(project.id)
|
| 121 |
+
for name in ["project.json", "style.json", "topics.json"]:
|
| 122 |
+
assert (pdir / name).exists(), f"missing project artifact {name}"
|
| 123 |
+
rdir = pipe.store.reel_dir(project.id, reel.id)
|
| 124 |
+
for name in ["reel.json", "script.json", "voice/voice.json",
|
| 125 |
+
"media/media.json", "renders/renders.json"]:
|
| 126 |
+
assert (rdir / name).exists(), f"missing reel artifact {name}"
|
| 127 |
+
assert json.loads((rdir / "script.json").read_text())["title"]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_multiple_reels_independent(pipe: Pipeline, project):
|
| 131 |
+
"""Two reels in one project keep separate scripts."""
|
| 132 |
+
r1 = pipe.create_reel(project, topic="topic one")
|
| 133 |
+
r2 = pipe.create_reel(project, topic="topic two")
|
| 134 |
+
pipe.generate_script(project, r1.id, "topic one")
|
| 135 |
+
pipe.generate_script(project, r2.id, "topic two")
|
| 136 |
+
assert {r.id for r in pipe.list_reels(project)} >= {r1.id, r2.id}
|
| 137 |
+
s1 = pipe.store.load_script(project.id, r1.id)
|
| 138 |
+
s2 = pipe.store.load_script(project.id, r2.id)
|
| 139 |
+
assert s1.topic == "topic one" and s2.topic == "topic two"
|
| 140 |
+
# Deleting one leaves the other intact
|
| 141 |
+
pipe.delete_reel(project, r1.id)
|
| 142 |
+
assert pipe.store.load_script(project.id, r1.id) is None
|
| 143 |
+
assert pipe.store.load_script(project.id, r2.id) is not None
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_prompt_override_used(pipe: Pipeline, project):
|
| 147 |
+
"""An edited prompt is passed through and recorded."""
|
| 148 |
+
reel = pipe.create_reel(project, topic="x")
|
| 149 |
+
custom = "CUSTOM PROMPT — return the mock script JSON. Return JSON only, exactly this shape: {}"
|
| 150 |
+
script = pipe.generate_script(project, reel.id, "x", prompt=custom)
|
| 151 |
+
assert script.prompt_used == custom
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_prepare_prompts(pipe: Pipeline, project, monkeypatch):
|
| 155 |
+
monkeypatch.setattr(topics_stage, "_news_signals", lambda p: [])
|
| 156 |
+
monkeypatch.setattr(topics_stage, "_reddit_signals", lambda p: [])
|
| 157 |
+
assert "Niche" in pipe.prepare_suggest_prompt(project)
|
| 158 |
+
assert "STYLE CARD" in pipe.prepare_style_prompt(project)
|
| 159 |
+
assert "topics" in pipe.prepare_topics_prompt(project, n=5).lower()
|
| 160 |
+
reel = pipe.create_reel(project, topic="t")
|
| 161 |
+
assert "scripts" in pipe.prepare_script_prompt(project, reel.id, "t").lower()
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_clone_flow_offline(pipe: Pipeline, project):
|
| 165 |
+
result = pipe.clone_reel(project, "https://www.youtube.com/shorts/abcdefghijk")
|
| 166 |
+
reel, script = result["reel"], result["script"]
|
| 167 |
+
assert reel.clone_source == "https://www.youtube.com/shorts/abcdefghijk"
|
| 168 |
+
assert script.clone_source == "https://www.youtube.com/shorts/abcdefghijk"
|
| 169 |
+
assert script.scenes
|
| 170 |
+
brief = pipe.store.load_clone(project.id, reel.id)
|
| 171 |
+
assert brief and brief.topic and brief.structure
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def test_clone_rejects_bad_url(pipe: Pipeline, project):
|
| 175 |
+
from core.stages.clone import CloneError
|
| 176 |
+
|
| 177 |
+
with pytest.raises(CloneError):
|
| 178 |
+
pipe.clone_reel(project, "https://example.com/not-youtube")
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_scene_regeneration(pipe: Pipeline, project):
|
| 182 |
+
reel = pipe.create_reel(project, topic="test topic")
|
| 183 |
+
pipe.generate_script(project, reel.id, "test topic")
|
| 184 |
+
script = pipe.regenerate_scene(project, reel.id, scene_number=2)
|
| 185 |
+
assert "Rewritten" in script.scenes[1].narration
|
| 186 |
+
assert "Rewritten" not in script.scenes[0].narration
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_static_captions_when_no_timings(pipe: Pipeline, project, tmp_path):
|
| 190 |
+
"""No word timings -> static captions, render still succeeds."""
|
| 191 |
+
reel = pipe.create_reel(project, topic="test topic")
|
| 192 |
+
pipe.generate_script(project, reel.id, "test topic")
|
| 193 |
+
voice = pipe.synthesize_voice(project, reel.id)
|
| 194 |
+
for line in voice.lines:
|
| 195 |
+
line.word_timings = []
|
| 196 |
+
pipe.store.save_voice(project.id, reel.id, voice)
|
| 197 |
+
pipe.gather_media(project, reel.id)
|
| 198 |
+
batch = pipe.render(project, reel.id, variants=[DEFAULT_VARIANTS[0]])
|
| 199 |
+
assert batch.results and Path(batch.results[0].path).exists()
|
web/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Reel Studio v2</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
web/package-lock.json
ADDED
|
@@ -0,0 +1,1680 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "reel-studio-web",
|
| 3 |
+
"version": "2.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "reel-studio-web",
|
| 9 |
+
"version": "2.0.0",
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"react": "^18.3.1",
|
| 12 |
+
"react-dom": "^18.3.1"
|
| 13 |
+
},
|
| 14 |
+
"devDependencies": {
|
| 15 |
+
"@vitejs/plugin-react": "^4.3.1",
|
| 16 |
+
"vite": "^5.4.8"
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
"node_modules/@babel/code-frame": {
|
| 20 |
+
"version": "7.29.7",
|
| 21 |
+
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
| 22 |
+
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
| 23 |
+
"dev": true,
|
| 24 |
+
"license": "MIT",
|
| 25 |
+
"dependencies": {
|
| 26 |
+
"@babel/helper-validator-identifier": "^7.29.7",
|
| 27 |
+
"js-tokens": "^4.0.0",
|
| 28 |
+
"picocolors": "^1.1.1"
|
| 29 |
+
},
|
| 30 |
+
"engines": {
|
| 31 |
+
"node": ">=6.9.0"
|
| 32 |
+
}
|
| 33 |
+
},
|
| 34 |
+
"node_modules/@babel/compat-data": {
|
| 35 |
+
"version": "7.29.7",
|
| 36 |
+
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
| 37 |
+
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
| 38 |
+
"dev": true,
|
| 39 |
+
"license": "MIT",
|
| 40 |
+
"engines": {
|
| 41 |
+
"node": ">=6.9.0"
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
"node_modules/@babel/core": {
|
| 45 |
+
"version": "7.29.7",
|
| 46 |
+
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
| 47 |
+
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
| 48 |
+
"dev": true,
|
| 49 |
+
"license": "MIT",
|
| 50 |
+
"dependencies": {
|
| 51 |
+
"@babel/code-frame": "^7.29.7",
|
| 52 |
+
"@babel/generator": "^7.29.7",
|
| 53 |
+
"@babel/helper-compilation-targets": "^7.29.7",
|
| 54 |
+
"@babel/helper-module-transforms": "^7.29.7",
|
| 55 |
+
"@babel/helpers": "^7.29.7",
|
| 56 |
+
"@babel/parser": "^7.29.7",
|
| 57 |
+
"@babel/template": "^7.29.7",
|
| 58 |
+
"@babel/traverse": "^7.29.7",
|
| 59 |
+
"@babel/types": "^7.29.7",
|
| 60 |
+
"@jridgewell/remapping": "^2.3.5",
|
| 61 |
+
"convert-source-map": "^2.0.0",
|
| 62 |
+
"debug": "^4.1.0",
|
| 63 |
+
"gensync": "^1.0.0-beta.2",
|
| 64 |
+
"json5": "^2.2.3",
|
| 65 |
+
"semver": "^6.3.1"
|
| 66 |
+
},
|
| 67 |
+
"engines": {
|
| 68 |
+
"node": ">=6.9.0"
|
| 69 |
+
},
|
| 70 |
+
"funding": {
|
| 71 |
+
"type": "opencollective",
|
| 72 |
+
"url": "https://opencollective.com/babel"
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
"node_modules/@babel/generator": {
|
| 76 |
+
"version": "7.29.7",
|
| 77 |
+
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
| 78 |
+
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
| 79 |
+
"dev": true,
|
| 80 |
+
"license": "MIT",
|
| 81 |
+
"dependencies": {
|
| 82 |
+
"@babel/parser": "^7.29.7",
|
| 83 |
+
"@babel/types": "^7.29.7",
|
| 84 |
+
"@jridgewell/gen-mapping": "^0.3.12",
|
| 85 |
+
"@jridgewell/trace-mapping": "^0.3.28",
|
| 86 |
+
"jsesc": "^3.0.2"
|
| 87 |
+
},
|
| 88 |
+
"engines": {
|
| 89 |
+
"node": ">=6.9.0"
|
| 90 |
+
}
|
| 91 |
+
},
|
| 92 |
+
"node_modules/@babel/helper-compilation-targets": {
|
| 93 |
+
"version": "7.29.7",
|
| 94 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
| 95 |
+
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
| 96 |
+
"dev": true,
|
| 97 |
+
"license": "MIT",
|
| 98 |
+
"dependencies": {
|
| 99 |
+
"@babel/compat-data": "^7.29.7",
|
| 100 |
+
"@babel/helper-validator-option": "^7.29.7",
|
| 101 |
+
"browserslist": "^4.24.0",
|
| 102 |
+
"lru-cache": "^5.1.1",
|
| 103 |
+
"semver": "^6.3.1"
|
| 104 |
+
},
|
| 105 |
+
"engines": {
|
| 106 |
+
"node": ">=6.9.0"
|
| 107 |
+
}
|
| 108 |
+
},
|
| 109 |
+
"node_modules/@babel/helper-globals": {
|
| 110 |
+
"version": "7.29.7",
|
| 111 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
| 112 |
+
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
| 113 |
+
"dev": true,
|
| 114 |
+
"license": "MIT",
|
| 115 |
+
"engines": {
|
| 116 |
+
"node": ">=6.9.0"
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
"node_modules/@babel/helper-module-imports": {
|
| 120 |
+
"version": "7.29.7",
|
| 121 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
| 122 |
+
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
| 123 |
+
"dev": true,
|
| 124 |
+
"license": "MIT",
|
| 125 |
+
"dependencies": {
|
| 126 |
+
"@babel/traverse": "^7.29.7",
|
| 127 |
+
"@babel/types": "^7.29.7"
|
| 128 |
+
},
|
| 129 |
+
"engines": {
|
| 130 |
+
"node": ">=6.9.0"
|
| 131 |
+
}
|
| 132 |
+
},
|
| 133 |
+
"node_modules/@babel/helper-module-transforms": {
|
| 134 |
+
"version": "7.29.7",
|
| 135 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
| 136 |
+
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
| 137 |
+
"dev": true,
|
| 138 |
+
"license": "MIT",
|
| 139 |
+
"dependencies": {
|
| 140 |
+
"@babel/helper-module-imports": "^7.29.7",
|
| 141 |
+
"@babel/helper-validator-identifier": "^7.29.7",
|
| 142 |
+
"@babel/traverse": "^7.29.7"
|
| 143 |
+
},
|
| 144 |
+
"engines": {
|
| 145 |
+
"node": ">=6.9.0"
|
| 146 |
+
},
|
| 147 |
+
"peerDependencies": {
|
| 148 |
+
"@babel/core": "^7.0.0"
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"node_modules/@babel/helper-plugin-utils": {
|
| 152 |
+
"version": "7.29.7",
|
| 153 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
|
| 154 |
+
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
|
| 155 |
+
"dev": true,
|
| 156 |
+
"license": "MIT",
|
| 157 |
+
"engines": {
|
| 158 |
+
"node": ">=6.9.0"
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
"node_modules/@babel/helper-string-parser": {
|
| 162 |
+
"version": "7.29.7",
|
| 163 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
| 164 |
+
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
| 165 |
+
"dev": true,
|
| 166 |
+
"license": "MIT",
|
| 167 |
+
"engines": {
|
| 168 |
+
"node": ">=6.9.0"
|
| 169 |
+
}
|
| 170 |
+
},
|
| 171 |
+
"node_modules/@babel/helper-validator-identifier": {
|
| 172 |
+
"version": "7.29.7",
|
| 173 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
| 174 |
+
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
| 175 |
+
"dev": true,
|
| 176 |
+
"license": "MIT",
|
| 177 |
+
"engines": {
|
| 178 |
+
"node": ">=6.9.0"
|
| 179 |
+
}
|
| 180 |
+
},
|
| 181 |
+
"node_modules/@babel/helper-validator-option": {
|
| 182 |
+
"version": "7.29.7",
|
| 183 |
+
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
| 184 |
+
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
| 185 |
+
"dev": true,
|
| 186 |
+
"license": "MIT",
|
| 187 |
+
"engines": {
|
| 188 |
+
"node": ">=6.9.0"
|
| 189 |
+
}
|
| 190 |
+
},
|
| 191 |
+
"node_modules/@babel/helpers": {
|
| 192 |
+
"version": "7.29.7",
|
| 193 |
+
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
| 194 |
+
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
| 195 |
+
"dev": true,
|
| 196 |
+
"license": "MIT",
|
| 197 |
+
"dependencies": {
|
| 198 |
+
"@babel/template": "^7.29.7",
|
| 199 |
+
"@babel/types": "^7.29.7"
|
| 200 |
+
},
|
| 201 |
+
"engines": {
|
| 202 |
+
"node": ">=6.9.0"
|
| 203 |
+
}
|
| 204 |
+
},
|
| 205 |
+
"node_modules/@babel/parser": {
|
| 206 |
+
"version": "7.29.7",
|
| 207 |
+
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
| 208 |
+
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
| 209 |
+
"dev": true,
|
| 210 |
+
"license": "MIT",
|
| 211 |
+
"dependencies": {
|
| 212 |
+
"@babel/types": "^7.29.7"
|
| 213 |
+
},
|
| 214 |
+
"bin": {
|
| 215 |
+
"parser": "bin/babel-parser.js"
|
| 216 |
+
},
|
| 217 |
+
"engines": {
|
| 218 |
+
"node": ">=6.0.0"
|
| 219 |
+
}
|
| 220 |
+
},
|
| 221 |
+
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
| 222 |
+
"version": "7.29.7",
|
| 223 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
|
| 224 |
+
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
|
| 225 |
+
"dev": true,
|
| 226 |
+
"license": "MIT",
|
| 227 |
+
"dependencies": {
|
| 228 |
+
"@babel/helper-plugin-utils": "^7.29.7"
|
| 229 |
+
},
|
| 230 |
+
"engines": {
|
| 231 |
+
"node": ">=6.9.0"
|
| 232 |
+
},
|
| 233 |
+
"peerDependencies": {
|
| 234 |
+
"@babel/core": "^7.0.0-0"
|
| 235 |
+
}
|
| 236 |
+
},
|
| 237 |
+
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
| 238 |
+
"version": "7.29.7",
|
| 239 |
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
|
| 240 |
+
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
|
| 241 |
+
"dev": true,
|
| 242 |
+
"license": "MIT",
|
| 243 |
+
"dependencies": {
|
| 244 |
+
"@babel/helper-plugin-utils": "^7.29.7"
|
| 245 |
+
},
|
| 246 |
+
"engines": {
|
| 247 |
+
"node": ">=6.9.0"
|
| 248 |
+
},
|
| 249 |
+
"peerDependencies": {
|
| 250 |
+
"@babel/core": "^7.0.0-0"
|
| 251 |
+
}
|
| 252 |
+
},
|
| 253 |
+
"node_modules/@babel/template": {
|
| 254 |
+
"version": "7.29.7",
|
| 255 |
+
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
| 256 |
+
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
| 257 |
+
"dev": true,
|
| 258 |
+
"license": "MIT",
|
| 259 |
+
"dependencies": {
|
| 260 |
+
"@babel/code-frame": "^7.29.7",
|
| 261 |
+
"@babel/parser": "^7.29.7",
|
| 262 |
+
"@babel/types": "^7.29.7"
|
| 263 |
+
},
|
| 264 |
+
"engines": {
|
| 265 |
+
"node": ">=6.9.0"
|
| 266 |
+
}
|
| 267 |
+
},
|
| 268 |
+
"node_modules/@babel/traverse": {
|
| 269 |
+
"version": "7.29.7",
|
| 270 |
+
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
| 271 |
+
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
| 272 |
+
"dev": true,
|
| 273 |
+
"license": "MIT",
|
| 274 |
+
"dependencies": {
|
| 275 |
+
"@babel/code-frame": "^7.29.7",
|
| 276 |
+
"@babel/generator": "^7.29.7",
|
| 277 |
+
"@babel/helper-globals": "^7.29.7",
|
| 278 |
+
"@babel/parser": "^7.29.7",
|
| 279 |
+
"@babel/template": "^7.29.7",
|
| 280 |
+
"@babel/types": "^7.29.7",
|
| 281 |
+
"debug": "^4.3.1"
|
| 282 |
+
},
|
| 283 |
+
"engines": {
|
| 284 |
+
"node": ">=6.9.0"
|
| 285 |
+
}
|
| 286 |
+
},
|
| 287 |
+
"node_modules/@babel/types": {
|
| 288 |
+
"version": "7.29.7",
|
| 289 |
+
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
| 290 |
+
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
| 291 |
+
"dev": true,
|
| 292 |
+
"license": "MIT",
|
| 293 |
+
"dependencies": {
|
| 294 |
+
"@babel/helper-string-parser": "^7.29.7",
|
| 295 |
+
"@babel/helper-validator-identifier": "^7.29.7"
|
| 296 |
+
},
|
| 297 |
+
"engines": {
|
| 298 |
+
"node": ">=6.9.0"
|
| 299 |
+
}
|
| 300 |
+
},
|
| 301 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 302 |
+
"version": "0.21.5",
|
| 303 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
| 304 |
+
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
| 305 |
+
"cpu": [
|
| 306 |
+
"ppc64"
|
| 307 |
+
],
|
| 308 |
+
"dev": true,
|
| 309 |
+
"license": "MIT",
|
| 310 |
+
"optional": true,
|
| 311 |
+
"os": [
|
| 312 |
+
"aix"
|
| 313 |
+
],
|
| 314 |
+
"engines": {
|
| 315 |
+
"node": ">=12"
|
| 316 |
+
}
|
| 317 |
+
},
|
| 318 |
+
"node_modules/@esbuild/android-arm": {
|
| 319 |
+
"version": "0.21.5",
|
| 320 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
| 321 |
+
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
| 322 |
+
"cpu": [
|
| 323 |
+
"arm"
|
| 324 |
+
],
|
| 325 |
+
"dev": true,
|
| 326 |
+
"license": "MIT",
|
| 327 |
+
"optional": true,
|
| 328 |
+
"os": [
|
| 329 |
+
"android"
|
| 330 |
+
],
|
| 331 |
+
"engines": {
|
| 332 |
+
"node": ">=12"
|
| 333 |
+
}
|
| 334 |
+
},
|
| 335 |
+
"node_modules/@esbuild/android-arm64": {
|
| 336 |
+
"version": "0.21.5",
|
| 337 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
| 338 |
+
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
| 339 |
+
"cpu": [
|
| 340 |
+
"arm64"
|
| 341 |
+
],
|
| 342 |
+
"dev": true,
|
| 343 |
+
"license": "MIT",
|
| 344 |
+
"optional": true,
|
| 345 |
+
"os": [
|
| 346 |
+
"android"
|
| 347 |
+
],
|
| 348 |
+
"engines": {
|
| 349 |
+
"node": ">=12"
|
| 350 |
+
}
|
| 351 |
+
},
|
| 352 |
+
"node_modules/@esbuild/android-x64": {
|
| 353 |
+
"version": "0.21.5",
|
| 354 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
| 355 |
+
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
| 356 |
+
"cpu": [
|
| 357 |
+
"x64"
|
| 358 |
+
],
|
| 359 |
+
"dev": true,
|
| 360 |
+
"license": "MIT",
|
| 361 |
+
"optional": true,
|
| 362 |
+
"os": [
|
| 363 |
+
"android"
|
| 364 |
+
],
|
| 365 |
+
"engines": {
|
| 366 |
+
"node": ">=12"
|
| 367 |
+
}
|
| 368 |
+
},
|
| 369 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 370 |
+
"version": "0.21.5",
|
| 371 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
| 372 |
+
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
| 373 |
+
"cpu": [
|
| 374 |
+
"arm64"
|
| 375 |
+
],
|
| 376 |
+
"dev": true,
|
| 377 |
+
"license": "MIT",
|
| 378 |
+
"optional": true,
|
| 379 |
+
"os": [
|
| 380 |
+
"darwin"
|
| 381 |
+
],
|
| 382 |
+
"engines": {
|
| 383 |
+
"node": ">=12"
|
| 384 |
+
}
|
| 385 |
+
},
|
| 386 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 387 |
+
"version": "0.21.5",
|
| 388 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
| 389 |
+
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
| 390 |
+
"cpu": [
|
| 391 |
+
"x64"
|
| 392 |
+
],
|
| 393 |
+
"dev": true,
|
| 394 |
+
"license": "MIT",
|
| 395 |
+
"optional": true,
|
| 396 |
+
"os": [
|
| 397 |
+
"darwin"
|
| 398 |
+
],
|
| 399 |
+
"engines": {
|
| 400 |
+
"node": ">=12"
|
| 401 |
+
}
|
| 402 |
+
},
|
| 403 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 404 |
+
"version": "0.21.5",
|
| 405 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
| 406 |
+
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
| 407 |
+
"cpu": [
|
| 408 |
+
"arm64"
|
| 409 |
+
],
|
| 410 |
+
"dev": true,
|
| 411 |
+
"license": "MIT",
|
| 412 |
+
"optional": true,
|
| 413 |
+
"os": [
|
| 414 |
+
"freebsd"
|
| 415 |
+
],
|
| 416 |
+
"engines": {
|
| 417 |
+
"node": ">=12"
|
| 418 |
+
}
|
| 419 |
+
},
|
| 420 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 421 |
+
"version": "0.21.5",
|
| 422 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
| 423 |
+
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
| 424 |
+
"cpu": [
|
| 425 |
+
"x64"
|
| 426 |
+
],
|
| 427 |
+
"dev": true,
|
| 428 |
+
"license": "MIT",
|
| 429 |
+
"optional": true,
|
| 430 |
+
"os": [
|
| 431 |
+
"freebsd"
|
| 432 |
+
],
|
| 433 |
+
"engines": {
|
| 434 |
+
"node": ">=12"
|
| 435 |
+
}
|
| 436 |
+
},
|
| 437 |
+
"node_modules/@esbuild/linux-arm": {
|
| 438 |
+
"version": "0.21.5",
|
| 439 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
| 440 |
+
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
| 441 |
+
"cpu": [
|
| 442 |
+
"arm"
|
| 443 |
+
],
|
| 444 |
+
"dev": true,
|
| 445 |
+
"license": "MIT",
|
| 446 |
+
"optional": true,
|
| 447 |
+
"os": [
|
| 448 |
+
"linux"
|
| 449 |
+
],
|
| 450 |
+
"engines": {
|
| 451 |
+
"node": ">=12"
|
| 452 |
+
}
|
| 453 |
+
},
|
| 454 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 455 |
+
"version": "0.21.5",
|
| 456 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
| 457 |
+
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
| 458 |
+
"cpu": [
|
| 459 |
+
"arm64"
|
| 460 |
+
],
|
| 461 |
+
"dev": true,
|
| 462 |
+
"license": "MIT",
|
| 463 |
+
"optional": true,
|
| 464 |
+
"os": [
|
| 465 |
+
"linux"
|
| 466 |
+
],
|
| 467 |
+
"engines": {
|
| 468 |
+
"node": ">=12"
|
| 469 |
+
}
|
| 470 |
+
},
|
| 471 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 472 |
+
"version": "0.21.5",
|
| 473 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
| 474 |
+
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
| 475 |
+
"cpu": [
|
| 476 |
+
"ia32"
|
| 477 |
+
],
|
| 478 |
+
"dev": true,
|
| 479 |
+
"license": "MIT",
|
| 480 |
+
"optional": true,
|
| 481 |
+
"os": [
|
| 482 |
+
"linux"
|
| 483 |
+
],
|
| 484 |
+
"engines": {
|
| 485 |
+
"node": ">=12"
|
| 486 |
+
}
|
| 487 |
+
},
|
| 488 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 489 |
+
"version": "0.21.5",
|
| 490 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
| 491 |
+
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
| 492 |
+
"cpu": [
|
| 493 |
+
"loong64"
|
| 494 |
+
],
|
| 495 |
+
"dev": true,
|
| 496 |
+
"license": "MIT",
|
| 497 |
+
"optional": true,
|
| 498 |
+
"os": [
|
| 499 |
+
"linux"
|
| 500 |
+
],
|
| 501 |
+
"engines": {
|
| 502 |
+
"node": ">=12"
|
| 503 |
+
}
|
| 504 |
+
},
|
| 505 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 506 |
+
"version": "0.21.5",
|
| 507 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
| 508 |
+
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
| 509 |
+
"cpu": [
|
| 510 |
+
"mips64el"
|
| 511 |
+
],
|
| 512 |
+
"dev": true,
|
| 513 |
+
"license": "MIT",
|
| 514 |
+
"optional": true,
|
| 515 |
+
"os": [
|
| 516 |
+
"linux"
|
| 517 |
+
],
|
| 518 |
+
"engines": {
|
| 519 |
+
"node": ">=12"
|
| 520 |
+
}
|
| 521 |
+
},
|
| 522 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 523 |
+
"version": "0.21.5",
|
| 524 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
| 525 |
+
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
| 526 |
+
"cpu": [
|
| 527 |
+
"ppc64"
|
| 528 |
+
],
|
| 529 |
+
"dev": true,
|
| 530 |
+
"license": "MIT",
|
| 531 |
+
"optional": true,
|
| 532 |
+
"os": [
|
| 533 |
+
"linux"
|
| 534 |
+
],
|
| 535 |
+
"engines": {
|
| 536 |
+
"node": ">=12"
|
| 537 |
+
}
|
| 538 |
+
},
|
| 539 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 540 |
+
"version": "0.21.5",
|
| 541 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
| 542 |
+
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
| 543 |
+
"cpu": [
|
| 544 |
+
"riscv64"
|
| 545 |
+
],
|
| 546 |
+
"dev": true,
|
| 547 |
+
"license": "MIT",
|
| 548 |
+
"optional": true,
|
| 549 |
+
"os": [
|
| 550 |
+
"linux"
|
| 551 |
+
],
|
| 552 |
+
"engines": {
|
| 553 |
+
"node": ">=12"
|
| 554 |
+
}
|
| 555 |
+
},
|
| 556 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 557 |
+
"version": "0.21.5",
|
| 558 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
| 559 |
+
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
| 560 |
+
"cpu": [
|
| 561 |
+
"s390x"
|
| 562 |
+
],
|
| 563 |
+
"dev": true,
|
| 564 |
+
"license": "MIT",
|
| 565 |
+
"optional": true,
|
| 566 |
+
"os": [
|
| 567 |
+
"linux"
|
| 568 |
+
],
|
| 569 |
+
"engines": {
|
| 570 |
+
"node": ">=12"
|
| 571 |
+
}
|
| 572 |
+
},
|
| 573 |
+
"node_modules/@esbuild/linux-x64": {
|
| 574 |
+
"version": "0.21.5",
|
| 575 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
| 576 |
+
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
| 577 |
+
"cpu": [
|
| 578 |
+
"x64"
|
| 579 |
+
],
|
| 580 |
+
"dev": true,
|
| 581 |
+
"license": "MIT",
|
| 582 |
+
"optional": true,
|
| 583 |
+
"os": [
|
| 584 |
+
"linux"
|
| 585 |
+
],
|
| 586 |
+
"engines": {
|
| 587 |
+
"node": ">=12"
|
| 588 |
+
}
|
| 589 |
+
},
|
| 590 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 591 |
+
"version": "0.21.5",
|
| 592 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
| 593 |
+
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
| 594 |
+
"cpu": [
|
| 595 |
+
"x64"
|
| 596 |
+
],
|
| 597 |
+
"dev": true,
|
| 598 |
+
"license": "MIT",
|
| 599 |
+
"optional": true,
|
| 600 |
+
"os": [
|
| 601 |
+
"netbsd"
|
| 602 |
+
],
|
| 603 |
+
"engines": {
|
| 604 |
+
"node": ">=12"
|
| 605 |
+
}
|
| 606 |
+
},
|
| 607 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 608 |
+
"version": "0.21.5",
|
| 609 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
| 610 |
+
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
| 611 |
+
"cpu": [
|
| 612 |
+
"x64"
|
| 613 |
+
],
|
| 614 |
+
"dev": true,
|
| 615 |
+
"license": "MIT",
|
| 616 |
+
"optional": true,
|
| 617 |
+
"os": [
|
| 618 |
+
"openbsd"
|
| 619 |
+
],
|
| 620 |
+
"engines": {
|
| 621 |
+
"node": ">=12"
|
| 622 |
+
}
|
| 623 |
+
},
|
| 624 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 625 |
+
"version": "0.21.5",
|
| 626 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
| 627 |
+
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
| 628 |
+
"cpu": [
|
| 629 |
+
"x64"
|
| 630 |
+
],
|
| 631 |
+
"dev": true,
|
| 632 |
+
"license": "MIT",
|
| 633 |
+
"optional": true,
|
| 634 |
+
"os": [
|
| 635 |
+
"sunos"
|
| 636 |
+
],
|
| 637 |
+
"engines": {
|
| 638 |
+
"node": ">=12"
|
| 639 |
+
}
|
| 640 |
+
},
|
| 641 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 642 |
+
"version": "0.21.5",
|
| 643 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
| 644 |
+
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
| 645 |
+
"cpu": [
|
| 646 |
+
"arm64"
|
| 647 |
+
],
|
| 648 |
+
"dev": true,
|
| 649 |
+
"license": "MIT",
|
| 650 |
+
"optional": true,
|
| 651 |
+
"os": [
|
| 652 |
+
"win32"
|
| 653 |
+
],
|
| 654 |
+
"engines": {
|
| 655 |
+
"node": ">=12"
|
| 656 |
+
}
|
| 657 |
+
},
|
| 658 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 659 |
+
"version": "0.21.5",
|
| 660 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
| 661 |
+
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
| 662 |
+
"cpu": [
|
| 663 |
+
"ia32"
|
| 664 |
+
],
|
| 665 |
+
"dev": true,
|
| 666 |
+
"license": "MIT",
|
| 667 |
+
"optional": true,
|
| 668 |
+
"os": [
|
| 669 |
+
"win32"
|
| 670 |
+
],
|
| 671 |
+
"engines": {
|
| 672 |
+
"node": ">=12"
|
| 673 |
+
}
|
| 674 |
+
},
|
| 675 |
+
"node_modules/@esbuild/win32-x64": {
|
| 676 |
+
"version": "0.21.5",
|
| 677 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
| 678 |
+
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
| 679 |
+
"cpu": [
|
| 680 |
+
"x64"
|
| 681 |
+
],
|
| 682 |
+
"dev": true,
|
| 683 |
+
"license": "MIT",
|
| 684 |
+
"optional": true,
|
| 685 |
+
"os": [
|
| 686 |
+
"win32"
|
| 687 |
+
],
|
| 688 |
+
"engines": {
|
| 689 |
+
"node": ">=12"
|
| 690 |
+
}
|
| 691 |
+
},
|
| 692 |
+
"node_modules/@jridgewell/gen-mapping": {
|
| 693 |
+
"version": "0.3.13",
|
| 694 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
| 695 |
+
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
| 696 |
+
"dev": true,
|
| 697 |
+
"license": "MIT",
|
| 698 |
+
"dependencies": {
|
| 699 |
+
"@jridgewell/sourcemap-codec": "^1.5.0",
|
| 700 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 701 |
+
}
|
| 702 |
+
},
|
| 703 |
+
"node_modules/@jridgewell/remapping": {
|
| 704 |
+
"version": "2.3.5",
|
| 705 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
| 706 |
+
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
| 707 |
+
"dev": true,
|
| 708 |
+
"license": "MIT",
|
| 709 |
+
"dependencies": {
|
| 710 |
+
"@jridgewell/gen-mapping": "^0.3.5",
|
| 711 |
+
"@jridgewell/trace-mapping": "^0.3.24"
|
| 712 |
+
}
|
| 713 |
+
},
|
| 714 |
+
"node_modules/@jridgewell/resolve-uri": {
|
| 715 |
+
"version": "3.1.2",
|
| 716 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
| 717 |
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
| 718 |
+
"dev": true,
|
| 719 |
+
"license": "MIT",
|
| 720 |
+
"engines": {
|
| 721 |
+
"node": ">=6.0.0"
|
| 722 |
+
}
|
| 723 |
+
},
|
| 724 |
+
"node_modules/@jridgewell/sourcemap-codec": {
|
| 725 |
+
"version": "1.5.5",
|
| 726 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
| 727 |
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
| 728 |
+
"dev": true,
|
| 729 |
+
"license": "MIT"
|
| 730 |
+
},
|
| 731 |
+
"node_modules/@jridgewell/trace-mapping": {
|
| 732 |
+
"version": "0.3.31",
|
| 733 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
| 734 |
+
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
| 735 |
+
"dev": true,
|
| 736 |
+
"license": "MIT",
|
| 737 |
+
"dependencies": {
|
| 738 |
+
"@jridgewell/resolve-uri": "^3.1.0",
|
| 739 |
+
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 740 |
+
}
|
| 741 |
+
},
|
| 742 |
+
"node_modules/@rolldown/pluginutils": {
|
| 743 |
+
"version": "1.0.0-beta.27",
|
| 744 |
+
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
| 745 |
+
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
| 746 |
+
"dev": true,
|
| 747 |
+
"license": "MIT"
|
| 748 |
+
},
|
| 749 |
+
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 750 |
+
"version": "4.61.1",
|
| 751 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
|
| 752 |
+
"integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
|
| 753 |
+
"cpu": [
|
| 754 |
+
"arm"
|
| 755 |
+
],
|
| 756 |
+
"dev": true,
|
| 757 |
+
"license": "MIT",
|
| 758 |
+
"optional": true,
|
| 759 |
+
"os": [
|
| 760 |
+
"android"
|
| 761 |
+
]
|
| 762 |
+
},
|
| 763 |
+
"node_modules/@rollup/rollup-android-arm64": {
|
| 764 |
+
"version": "4.61.1",
|
| 765 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
|
| 766 |
+
"integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
|
| 767 |
+
"cpu": [
|
| 768 |
+
"arm64"
|
| 769 |
+
],
|
| 770 |
+
"dev": true,
|
| 771 |
+
"license": "MIT",
|
| 772 |
+
"optional": true,
|
| 773 |
+
"os": [
|
| 774 |
+
"android"
|
| 775 |
+
]
|
| 776 |
+
},
|
| 777 |
+
"node_modules/@rollup/rollup-darwin-arm64": {
|
| 778 |
+
"version": "4.61.1",
|
| 779 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
|
| 780 |
+
"integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
|
| 781 |
+
"cpu": [
|
| 782 |
+
"arm64"
|
| 783 |
+
],
|
| 784 |
+
"dev": true,
|
| 785 |
+
"license": "MIT",
|
| 786 |
+
"optional": true,
|
| 787 |
+
"os": [
|
| 788 |
+
"darwin"
|
| 789 |
+
]
|
| 790 |
+
},
|
| 791 |
+
"node_modules/@rollup/rollup-darwin-x64": {
|
| 792 |
+
"version": "4.61.1",
|
| 793 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
|
| 794 |
+
"integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
|
| 795 |
+
"cpu": [
|
| 796 |
+
"x64"
|
| 797 |
+
],
|
| 798 |
+
"dev": true,
|
| 799 |
+
"license": "MIT",
|
| 800 |
+
"optional": true,
|
| 801 |
+
"os": [
|
| 802 |
+
"darwin"
|
| 803 |
+
]
|
| 804 |
+
},
|
| 805 |
+
"node_modules/@rollup/rollup-freebsd-arm64": {
|
| 806 |
+
"version": "4.61.1",
|
| 807 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
|
| 808 |
+
"integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
|
| 809 |
+
"cpu": [
|
| 810 |
+
"arm64"
|
| 811 |
+
],
|
| 812 |
+
"dev": true,
|
| 813 |
+
"license": "MIT",
|
| 814 |
+
"optional": true,
|
| 815 |
+
"os": [
|
| 816 |
+
"freebsd"
|
| 817 |
+
]
|
| 818 |
+
},
|
| 819 |
+
"node_modules/@rollup/rollup-freebsd-x64": {
|
| 820 |
+
"version": "4.61.1",
|
| 821 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
|
| 822 |
+
"integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
|
| 823 |
+
"cpu": [
|
| 824 |
+
"x64"
|
| 825 |
+
],
|
| 826 |
+
"dev": true,
|
| 827 |
+
"license": "MIT",
|
| 828 |
+
"optional": true,
|
| 829 |
+
"os": [
|
| 830 |
+
"freebsd"
|
| 831 |
+
]
|
| 832 |
+
},
|
| 833 |
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
| 834 |
+
"version": "4.61.1",
|
| 835 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
|
| 836 |
+
"integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
|
| 837 |
+
"cpu": [
|
| 838 |
+
"arm"
|
| 839 |
+
],
|
| 840 |
+
"dev": true,
|
| 841 |
+
"license": "MIT",
|
| 842 |
+
"optional": true,
|
| 843 |
+
"os": [
|
| 844 |
+
"linux"
|
| 845 |
+
]
|
| 846 |
+
},
|
| 847 |
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
| 848 |
+
"version": "4.61.1",
|
| 849 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
|
| 850 |
+
"integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
|
| 851 |
+
"cpu": [
|
| 852 |
+
"arm"
|
| 853 |
+
],
|
| 854 |
+
"dev": true,
|
| 855 |
+
"license": "MIT",
|
| 856 |
+
"optional": true,
|
| 857 |
+
"os": [
|
| 858 |
+
"linux"
|
| 859 |
+
]
|
| 860 |
+
},
|
| 861 |
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
| 862 |
+
"version": "4.61.1",
|
| 863 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
|
| 864 |
+
"integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
|
| 865 |
+
"cpu": [
|
| 866 |
+
"arm64"
|
| 867 |
+
],
|
| 868 |
+
"dev": true,
|
| 869 |
+
"license": "MIT",
|
| 870 |
+
"optional": true,
|
| 871 |
+
"os": [
|
| 872 |
+
"linux"
|
| 873 |
+
]
|
| 874 |
+
},
|
| 875 |
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
| 876 |
+
"version": "4.61.1",
|
| 877 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
|
| 878 |
+
"integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
|
| 879 |
+
"cpu": [
|
| 880 |
+
"arm64"
|
| 881 |
+
],
|
| 882 |
+
"dev": true,
|
| 883 |
+
"license": "MIT",
|
| 884 |
+
"optional": true,
|
| 885 |
+
"os": [
|
| 886 |
+
"linux"
|
| 887 |
+
]
|
| 888 |
+
},
|
| 889 |
+
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
| 890 |
+
"version": "4.61.1",
|
| 891 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
|
| 892 |
+
"integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
|
| 893 |
+
"cpu": [
|
| 894 |
+
"loong64"
|
| 895 |
+
],
|
| 896 |
+
"dev": true,
|
| 897 |
+
"license": "MIT",
|
| 898 |
+
"optional": true,
|
| 899 |
+
"os": [
|
| 900 |
+
"linux"
|
| 901 |
+
]
|
| 902 |
+
},
|
| 903 |
+
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
| 904 |
+
"version": "4.61.1",
|
| 905 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
|
| 906 |
+
"integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
|
| 907 |
+
"cpu": [
|
| 908 |
+
"loong64"
|
| 909 |
+
],
|
| 910 |
+
"dev": true,
|
| 911 |
+
"license": "MIT",
|
| 912 |
+
"optional": true,
|
| 913 |
+
"os": [
|
| 914 |
+
"linux"
|
| 915 |
+
]
|
| 916 |
+
},
|
| 917 |
+
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
| 918 |
+
"version": "4.61.1",
|
| 919 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
|
| 920 |
+
"integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
|
| 921 |
+
"cpu": [
|
| 922 |
+
"ppc64"
|
| 923 |
+
],
|
| 924 |
+
"dev": true,
|
| 925 |
+
"license": "MIT",
|
| 926 |
+
"optional": true,
|
| 927 |
+
"os": [
|
| 928 |
+
"linux"
|
| 929 |
+
]
|
| 930 |
+
},
|
| 931 |
+
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
| 932 |
+
"version": "4.61.1",
|
| 933 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
|
| 934 |
+
"integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
|
| 935 |
+
"cpu": [
|
| 936 |
+
"ppc64"
|
| 937 |
+
],
|
| 938 |
+
"dev": true,
|
| 939 |
+
"license": "MIT",
|
| 940 |
+
"optional": true,
|
| 941 |
+
"os": [
|
| 942 |
+
"linux"
|
| 943 |
+
]
|
| 944 |
+
},
|
| 945 |
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
| 946 |
+
"version": "4.61.1",
|
| 947 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
|
| 948 |
+
"integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
|
| 949 |
+
"cpu": [
|
| 950 |
+
"riscv64"
|
| 951 |
+
],
|
| 952 |
+
"dev": true,
|
| 953 |
+
"license": "MIT",
|
| 954 |
+
"optional": true,
|
| 955 |
+
"os": [
|
| 956 |
+
"linux"
|
| 957 |
+
]
|
| 958 |
+
},
|
| 959 |
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
| 960 |
+
"version": "4.61.1",
|
| 961 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
|
| 962 |
+
"integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
|
| 963 |
+
"cpu": [
|
| 964 |
+
"riscv64"
|
| 965 |
+
],
|
| 966 |
+
"dev": true,
|
| 967 |
+
"license": "MIT",
|
| 968 |
+
"optional": true,
|
| 969 |
+
"os": [
|
| 970 |
+
"linux"
|
| 971 |
+
]
|
| 972 |
+
},
|
| 973 |
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
| 974 |
+
"version": "4.61.1",
|
| 975 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
|
| 976 |
+
"integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
|
| 977 |
+
"cpu": [
|
| 978 |
+
"s390x"
|
| 979 |
+
],
|
| 980 |
+
"dev": true,
|
| 981 |
+
"license": "MIT",
|
| 982 |
+
"optional": true,
|
| 983 |
+
"os": [
|
| 984 |
+
"linux"
|
| 985 |
+
]
|
| 986 |
+
},
|
| 987 |
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
| 988 |
+
"version": "4.61.1",
|
| 989 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
|
| 990 |
+
"integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
|
| 991 |
+
"cpu": [
|
| 992 |
+
"x64"
|
| 993 |
+
],
|
| 994 |
+
"dev": true,
|
| 995 |
+
"license": "MIT",
|
| 996 |
+
"optional": true,
|
| 997 |
+
"os": [
|
| 998 |
+
"linux"
|
| 999 |
+
]
|
| 1000 |
+
},
|
| 1001 |
+
"node_modules/@rollup/rollup-linux-x64-musl": {
|
| 1002 |
+
"version": "4.61.1",
|
| 1003 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
|
| 1004 |
+
"integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
|
| 1005 |
+
"cpu": [
|
| 1006 |
+
"x64"
|
| 1007 |
+
],
|
| 1008 |
+
"dev": true,
|
| 1009 |
+
"license": "MIT",
|
| 1010 |
+
"optional": true,
|
| 1011 |
+
"os": [
|
| 1012 |
+
"linux"
|
| 1013 |
+
]
|
| 1014 |
+
},
|
| 1015 |
+
"node_modules/@rollup/rollup-openbsd-x64": {
|
| 1016 |
+
"version": "4.61.1",
|
| 1017 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
|
| 1018 |
+
"integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
|
| 1019 |
+
"cpu": [
|
| 1020 |
+
"x64"
|
| 1021 |
+
],
|
| 1022 |
+
"dev": true,
|
| 1023 |
+
"license": "MIT",
|
| 1024 |
+
"optional": true,
|
| 1025 |
+
"os": [
|
| 1026 |
+
"openbsd"
|
| 1027 |
+
]
|
| 1028 |
+
},
|
| 1029 |
+
"node_modules/@rollup/rollup-openharmony-arm64": {
|
| 1030 |
+
"version": "4.61.1",
|
| 1031 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
|
| 1032 |
+
"integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
|
| 1033 |
+
"cpu": [
|
| 1034 |
+
"arm64"
|
| 1035 |
+
],
|
| 1036 |
+
"dev": true,
|
| 1037 |
+
"license": "MIT",
|
| 1038 |
+
"optional": true,
|
| 1039 |
+
"os": [
|
| 1040 |
+
"openharmony"
|
| 1041 |
+
]
|
| 1042 |
+
},
|
| 1043 |
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
| 1044 |
+
"version": "4.61.1",
|
| 1045 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
|
| 1046 |
+
"integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
|
| 1047 |
+
"cpu": [
|
| 1048 |
+
"arm64"
|
| 1049 |
+
],
|
| 1050 |
+
"dev": true,
|
| 1051 |
+
"license": "MIT",
|
| 1052 |
+
"optional": true,
|
| 1053 |
+
"os": [
|
| 1054 |
+
"win32"
|
| 1055 |
+
]
|
| 1056 |
+
},
|
| 1057 |
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
| 1058 |
+
"version": "4.61.1",
|
| 1059 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
|
| 1060 |
+
"integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
|
| 1061 |
+
"cpu": [
|
| 1062 |
+
"ia32"
|
| 1063 |
+
],
|
| 1064 |
+
"dev": true,
|
| 1065 |
+
"license": "MIT",
|
| 1066 |
+
"optional": true,
|
| 1067 |
+
"os": [
|
| 1068 |
+
"win32"
|
| 1069 |
+
]
|
| 1070 |
+
},
|
| 1071 |
+
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
| 1072 |
+
"version": "4.61.1",
|
| 1073 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
|
| 1074 |
+
"integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
|
| 1075 |
+
"cpu": [
|
| 1076 |
+
"x64"
|
| 1077 |
+
],
|
| 1078 |
+
"dev": true,
|
| 1079 |
+
"license": "MIT",
|
| 1080 |
+
"optional": true,
|
| 1081 |
+
"os": [
|
| 1082 |
+
"win32"
|
| 1083 |
+
]
|
| 1084 |
+
},
|
| 1085 |
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
| 1086 |
+
"version": "4.61.1",
|
| 1087 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
|
| 1088 |
+
"integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
|
| 1089 |
+
"cpu": [
|
| 1090 |
+
"x64"
|
| 1091 |
+
],
|
| 1092 |
+
"dev": true,
|
| 1093 |
+
"license": "MIT",
|
| 1094 |
+
"optional": true,
|
| 1095 |
+
"os": [
|
| 1096 |
+
"win32"
|
| 1097 |
+
]
|
| 1098 |
+
},
|
| 1099 |
+
"node_modules/@types/babel__core": {
|
| 1100 |
+
"version": "7.20.5",
|
| 1101 |
+
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
| 1102 |
+
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
| 1103 |
+
"dev": true,
|
| 1104 |
+
"license": "MIT",
|
| 1105 |
+
"dependencies": {
|
| 1106 |
+
"@babel/parser": "^7.20.7",
|
| 1107 |
+
"@babel/types": "^7.20.7",
|
| 1108 |
+
"@types/babel__generator": "*",
|
| 1109 |
+
"@types/babel__template": "*",
|
| 1110 |
+
"@types/babel__traverse": "*"
|
| 1111 |
+
}
|
| 1112 |
+
},
|
| 1113 |
+
"node_modules/@types/babel__generator": {
|
| 1114 |
+
"version": "7.27.0",
|
| 1115 |
+
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
| 1116 |
+
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
| 1117 |
+
"dev": true,
|
| 1118 |
+
"license": "MIT",
|
| 1119 |
+
"dependencies": {
|
| 1120 |
+
"@babel/types": "^7.0.0"
|
| 1121 |
+
}
|
| 1122 |
+
},
|
| 1123 |
+
"node_modules/@types/babel__template": {
|
| 1124 |
+
"version": "7.4.4",
|
| 1125 |
+
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
| 1126 |
+
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
| 1127 |
+
"dev": true,
|
| 1128 |
+
"license": "MIT",
|
| 1129 |
+
"dependencies": {
|
| 1130 |
+
"@babel/parser": "^7.1.0",
|
| 1131 |
+
"@babel/types": "^7.0.0"
|
| 1132 |
+
}
|
| 1133 |
+
},
|
| 1134 |
+
"node_modules/@types/babel__traverse": {
|
| 1135 |
+
"version": "7.28.0",
|
| 1136 |
+
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
| 1137 |
+
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
| 1138 |
+
"dev": true,
|
| 1139 |
+
"license": "MIT",
|
| 1140 |
+
"dependencies": {
|
| 1141 |
+
"@babel/types": "^7.28.2"
|
| 1142 |
+
}
|
| 1143 |
+
},
|
| 1144 |
+
"node_modules/@types/estree": {
|
| 1145 |
+
"version": "1.0.9",
|
| 1146 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
| 1147 |
+
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
| 1148 |
+
"dev": true,
|
| 1149 |
+
"license": "MIT"
|
| 1150 |
+
},
|
| 1151 |
+
"node_modules/@vitejs/plugin-react": {
|
| 1152 |
+
"version": "4.7.0",
|
| 1153 |
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
| 1154 |
+
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
| 1155 |
+
"dev": true,
|
| 1156 |
+
"license": "MIT",
|
| 1157 |
+
"dependencies": {
|
| 1158 |
+
"@babel/core": "^7.28.0",
|
| 1159 |
+
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
| 1160 |
+
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
| 1161 |
+
"@rolldown/pluginutils": "1.0.0-beta.27",
|
| 1162 |
+
"@types/babel__core": "^7.20.5",
|
| 1163 |
+
"react-refresh": "^0.17.0"
|
| 1164 |
+
},
|
| 1165 |
+
"engines": {
|
| 1166 |
+
"node": "^14.18.0 || >=16.0.0"
|
| 1167 |
+
},
|
| 1168 |
+
"peerDependencies": {
|
| 1169 |
+
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
| 1170 |
+
}
|
| 1171 |
+
},
|
| 1172 |
+
"node_modules/baseline-browser-mapping": {
|
| 1173 |
+
"version": "2.10.37",
|
| 1174 |
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
| 1175 |
+
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
|
| 1176 |
+
"dev": true,
|
| 1177 |
+
"license": "Apache-2.0",
|
| 1178 |
+
"bin": {
|
| 1179 |
+
"baseline-browser-mapping": "dist/cli.cjs"
|
| 1180 |
+
},
|
| 1181 |
+
"engines": {
|
| 1182 |
+
"node": ">=6.0.0"
|
| 1183 |
+
}
|
| 1184 |
+
},
|
| 1185 |
+
"node_modules/browserslist": {
|
| 1186 |
+
"version": "4.28.2",
|
| 1187 |
+
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
| 1188 |
+
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
| 1189 |
+
"dev": true,
|
| 1190 |
+
"funding": [
|
| 1191 |
+
{
|
| 1192 |
+
"type": "opencollective",
|
| 1193 |
+
"url": "https://opencollective.com/browserslist"
|
| 1194 |
+
},
|
| 1195 |
+
{
|
| 1196 |
+
"type": "tidelift",
|
| 1197 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1198 |
+
},
|
| 1199 |
+
{
|
| 1200 |
+
"type": "github",
|
| 1201 |
+
"url": "https://github.com/sponsors/ai"
|
| 1202 |
+
}
|
| 1203 |
+
],
|
| 1204 |
+
"license": "MIT",
|
| 1205 |
+
"dependencies": {
|
| 1206 |
+
"baseline-browser-mapping": "^2.10.12",
|
| 1207 |
+
"caniuse-lite": "^1.0.30001782",
|
| 1208 |
+
"electron-to-chromium": "^1.5.328",
|
| 1209 |
+
"node-releases": "^2.0.36",
|
| 1210 |
+
"update-browserslist-db": "^1.2.3"
|
| 1211 |
+
},
|
| 1212 |
+
"bin": {
|
| 1213 |
+
"browserslist": "cli.js"
|
| 1214 |
+
},
|
| 1215 |
+
"engines": {
|
| 1216 |
+
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
| 1217 |
+
}
|
| 1218 |
+
},
|
| 1219 |
+
"node_modules/caniuse-lite": {
|
| 1220 |
+
"version": "1.0.30001799",
|
| 1221 |
+
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
| 1222 |
+
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
| 1223 |
+
"dev": true,
|
| 1224 |
+
"funding": [
|
| 1225 |
+
{
|
| 1226 |
+
"type": "opencollective",
|
| 1227 |
+
"url": "https://opencollective.com/browserslist"
|
| 1228 |
+
},
|
| 1229 |
+
{
|
| 1230 |
+
"type": "tidelift",
|
| 1231 |
+
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
| 1232 |
+
},
|
| 1233 |
+
{
|
| 1234 |
+
"type": "github",
|
| 1235 |
+
"url": "https://github.com/sponsors/ai"
|
| 1236 |
+
}
|
| 1237 |
+
],
|
| 1238 |
+
"license": "CC-BY-4.0"
|
| 1239 |
+
},
|
| 1240 |
+
"node_modules/convert-source-map": {
|
| 1241 |
+
"version": "2.0.0",
|
| 1242 |
+
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
| 1243 |
+
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
| 1244 |
+
"dev": true,
|
| 1245 |
+
"license": "MIT"
|
| 1246 |
+
},
|
| 1247 |
+
"node_modules/debug": {
|
| 1248 |
+
"version": "4.4.3",
|
| 1249 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
| 1250 |
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
| 1251 |
+
"dev": true,
|
| 1252 |
+
"license": "MIT",
|
| 1253 |
+
"dependencies": {
|
| 1254 |
+
"ms": "^2.1.3"
|
| 1255 |
+
},
|
| 1256 |
+
"engines": {
|
| 1257 |
+
"node": ">=6.0"
|
| 1258 |
+
},
|
| 1259 |
+
"peerDependenciesMeta": {
|
| 1260 |
+
"supports-color": {
|
| 1261 |
+
"optional": true
|
| 1262 |
+
}
|
| 1263 |
+
}
|
| 1264 |
+
},
|
| 1265 |
+
"node_modules/electron-to-chromium": {
|
| 1266 |
+
"version": "1.5.372",
|
| 1267 |
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz",
|
| 1268 |
+
"integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==",
|
| 1269 |
+
"dev": true,
|
| 1270 |
+
"license": "ISC"
|
| 1271 |
+
},
|
| 1272 |
+
"node_modules/esbuild": {
|
| 1273 |
+
"version": "0.21.5",
|
| 1274 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
| 1275 |
+
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
| 1276 |
+
"dev": true,
|
| 1277 |
+
"hasInstallScript": true,
|
| 1278 |
+
"license": "MIT",
|
| 1279 |
+
"bin": {
|
| 1280 |
+
"esbuild": "bin/esbuild"
|
| 1281 |
+
},
|
| 1282 |
+
"engines": {
|
| 1283 |
+
"node": ">=12"
|
| 1284 |
+
},
|
| 1285 |
+
"optionalDependencies": {
|
| 1286 |
+
"@esbuild/aix-ppc64": "0.21.5",
|
| 1287 |
+
"@esbuild/android-arm": "0.21.5",
|
| 1288 |
+
"@esbuild/android-arm64": "0.21.5",
|
| 1289 |
+
"@esbuild/android-x64": "0.21.5",
|
| 1290 |
+
"@esbuild/darwin-arm64": "0.21.5",
|
| 1291 |
+
"@esbuild/darwin-x64": "0.21.5",
|
| 1292 |
+
"@esbuild/freebsd-arm64": "0.21.5",
|
| 1293 |
+
"@esbuild/freebsd-x64": "0.21.5",
|
| 1294 |
+
"@esbuild/linux-arm": "0.21.5",
|
| 1295 |
+
"@esbuild/linux-arm64": "0.21.5",
|
| 1296 |
+
"@esbuild/linux-ia32": "0.21.5",
|
| 1297 |
+
"@esbuild/linux-loong64": "0.21.5",
|
| 1298 |
+
"@esbuild/linux-mips64el": "0.21.5",
|
| 1299 |
+
"@esbuild/linux-ppc64": "0.21.5",
|
| 1300 |
+
"@esbuild/linux-riscv64": "0.21.5",
|
| 1301 |
+
"@esbuild/linux-s390x": "0.21.5",
|
| 1302 |
+
"@esbuild/linux-x64": "0.21.5",
|
| 1303 |
+
"@esbuild/netbsd-x64": "0.21.5",
|
| 1304 |
+
"@esbuild/openbsd-x64": "0.21.5",
|
| 1305 |
+
"@esbuild/sunos-x64": "0.21.5",
|
| 1306 |
+
"@esbuild/win32-arm64": "0.21.5",
|
| 1307 |
+
"@esbuild/win32-ia32": "0.21.5",
|
| 1308 |
+
"@esbuild/win32-x64": "0.21.5"
|
| 1309 |
+
}
|
| 1310 |
+
},
|
| 1311 |
+
"node_modules/escalade": {
|
| 1312 |
+
"version": "3.2.0",
|
| 1313 |
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
| 1314 |
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
| 1315 |
+
"dev": true,
|
| 1316 |
+
"license": "MIT",
|
| 1317 |
+
"engines": {
|
| 1318 |
+
"node": ">=6"
|
| 1319 |
+
}
|
| 1320 |
+
},
|
| 1321 |
+
"node_modules/fsevents": {
|
| 1322 |
+
"version": "2.3.3",
|
| 1323 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 1324 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 1325 |
+
"dev": true,
|
| 1326 |
+
"hasInstallScript": true,
|
| 1327 |
+
"license": "MIT",
|
| 1328 |
+
"optional": true,
|
| 1329 |
+
"os": [
|
| 1330 |
+
"darwin"
|
| 1331 |
+
],
|
| 1332 |
+
"engines": {
|
| 1333 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1334 |
+
}
|
| 1335 |
+
},
|
| 1336 |
+
"node_modules/gensync": {
|
| 1337 |
+
"version": "1.0.0-beta.2",
|
| 1338 |
+
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
| 1339 |
+
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
| 1340 |
+
"dev": true,
|
| 1341 |
+
"license": "MIT",
|
| 1342 |
+
"engines": {
|
| 1343 |
+
"node": ">=6.9.0"
|
| 1344 |
+
}
|
| 1345 |
+
},
|
| 1346 |
+
"node_modules/js-tokens": {
|
| 1347 |
+
"version": "4.0.0",
|
| 1348 |
+
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
| 1349 |
+
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
| 1350 |
+
"license": "MIT"
|
| 1351 |
+
},
|
| 1352 |
+
"node_modules/jsesc": {
|
| 1353 |
+
"version": "3.1.0",
|
| 1354 |
+
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
| 1355 |
+
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
| 1356 |
+
"dev": true,
|
| 1357 |
+
"license": "MIT",
|
| 1358 |
+
"bin": {
|
| 1359 |
+
"jsesc": "bin/jsesc"
|
| 1360 |
+
},
|
| 1361 |
+
"engines": {
|
| 1362 |
+
"node": ">=6"
|
| 1363 |
+
}
|
| 1364 |
+
},
|
| 1365 |
+
"node_modules/json5": {
|
| 1366 |
+
"version": "2.2.3",
|
| 1367 |
+
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
| 1368 |
+
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
| 1369 |
+
"dev": true,
|
| 1370 |
+
"license": "MIT",
|
| 1371 |
+
"bin": {
|
| 1372 |
+
"json5": "lib/cli.js"
|
| 1373 |
+
},
|
| 1374 |
+
"engines": {
|
| 1375 |
+
"node": ">=6"
|
| 1376 |
+
}
|
| 1377 |
+
},
|
| 1378 |
+
"node_modules/loose-envify": {
|
| 1379 |
+
"version": "1.4.0",
|
| 1380 |
+
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
| 1381 |
+
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
| 1382 |
+
"license": "MIT",
|
| 1383 |
+
"dependencies": {
|
| 1384 |
+
"js-tokens": "^3.0.0 || ^4.0.0"
|
| 1385 |
+
},
|
| 1386 |
+
"bin": {
|
| 1387 |
+
"loose-envify": "cli.js"
|
| 1388 |
+
}
|
| 1389 |
+
},
|
| 1390 |
+
"node_modules/lru-cache": {
|
| 1391 |
+
"version": "5.1.1",
|
| 1392 |
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
| 1393 |
+
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
| 1394 |
+
"dev": true,
|
| 1395 |
+
"license": "ISC",
|
| 1396 |
+
"dependencies": {
|
| 1397 |
+
"yallist": "^3.0.2"
|
| 1398 |
+
}
|
| 1399 |
+
},
|
| 1400 |
+
"node_modules/ms": {
|
| 1401 |
+
"version": "2.1.3",
|
| 1402 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 1403 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 1404 |
+
"dev": true,
|
| 1405 |
+
"license": "MIT"
|
| 1406 |
+
},
|
| 1407 |
+
"node_modules/nanoid": {
|
| 1408 |
+
"version": "3.3.12",
|
| 1409 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
| 1410 |
+
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
| 1411 |
+
"dev": true,
|
| 1412 |
+
"funding": [
|
| 1413 |
+
{
|
| 1414 |
+
"type": "github",
|
| 1415 |
+
"url": "https://github.com/sponsors/ai"
|
| 1416 |
+
}
|
| 1417 |
+
],
|
| 1418 |
+
"license": "MIT",
|
| 1419 |
+
"bin": {
|
| 1420 |
+
"nanoid": "bin/nanoid.cjs"
|
| 1421 |
+
},
|
| 1422 |
+
"engines": {
|
| 1423 |
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
| 1424 |
+
}
|
| 1425 |
+
},
|
| 1426 |
+
"node_modules/node-releases": {
|
| 1427 |
+
"version": "2.0.47",
|
| 1428 |
+
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
|
| 1429 |
+
"integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
|
| 1430 |
+
"dev": true,
|
| 1431 |
+
"license": "MIT",
|
| 1432 |
+
"engines": {
|
| 1433 |
+
"node": ">=18"
|
| 1434 |
+
}
|
| 1435 |
+
},
|
| 1436 |
+
"node_modules/picocolors": {
|
| 1437 |
+
"version": "1.1.1",
|
| 1438 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 1439 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
| 1440 |
+
"dev": true,
|
| 1441 |
+
"license": "ISC"
|
| 1442 |
+
},
|
| 1443 |
+
"node_modules/postcss": {
|
| 1444 |
+
"version": "8.5.15",
|
| 1445 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
| 1446 |
+
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
| 1447 |
+
"dev": true,
|
| 1448 |
+
"funding": [
|
| 1449 |
+
{
|
| 1450 |
+
"type": "opencollective",
|
| 1451 |
+
"url": "https://opencollective.com/postcss/"
|
| 1452 |
+
},
|
| 1453 |
+
{
|
| 1454 |
+
"type": "tidelift",
|
| 1455 |
+
"url": "https://tidelift.com/funding/github/npm/postcss"
|
| 1456 |
+
},
|
| 1457 |
+
{
|
| 1458 |
+
"type": "github",
|
| 1459 |
+
"url": "https://github.com/sponsors/ai"
|
| 1460 |
+
}
|
| 1461 |
+
],
|
| 1462 |
+
"license": "MIT",
|
| 1463 |
+
"dependencies": {
|
| 1464 |
+
"nanoid": "^3.3.12",
|
| 1465 |
+
"picocolors": "^1.1.1",
|
| 1466 |
+
"source-map-js": "^1.2.1"
|
| 1467 |
+
},
|
| 1468 |
+
"engines": {
|
| 1469 |
+
"node": "^10 || ^12 || >=14"
|
| 1470 |
+
}
|
| 1471 |
+
},
|
| 1472 |
+
"node_modules/react": {
|
| 1473 |
+
"version": "18.3.1",
|
| 1474 |
+
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 1475 |
+
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 1476 |
+
"license": "MIT",
|
| 1477 |
+
"dependencies": {
|
| 1478 |
+
"loose-envify": "^1.1.0"
|
| 1479 |
+
},
|
| 1480 |
+
"engines": {
|
| 1481 |
+
"node": ">=0.10.0"
|
| 1482 |
+
}
|
| 1483 |
+
},
|
| 1484 |
+
"node_modules/react-dom": {
|
| 1485 |
+
"version": "18.3.1",
|
| 1486 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
| 1487 |
+
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
| 1488 |
+
"license": "MIT",
|
| 1489 |
+
"dependencies": {
|
| 1490 |
+
"loose-envify": "^1.1.0",
|
| 1491 |
+
"scheduler": "^0.23.2"
|
| 1492 |
+
},
|
| 1493 |
+
"peerDependencies": {
|
| 1494 |
+
"react": "^18.3.1"
|
| 1495 |
+
}
|
| 1496 |
+
},
|
| 1497 |
+
"node_modules/react-refresh": {
|
| 1498 |
+
"version": "0.17.0",
|
| 1499 |
+
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
| 1500 |
+
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
| 1501 |
+
"dev": true,
|
| 1502 |
+
"license": "MIT",
|
| 1503 |
+
"engines": {
|
| 1504 |
+
"node": ">=0.10.0"
|
| 1505 |
+
}
|
| 1506 |
+
},
|
| 1507 |
+
"node_modules/rollup": {
|
| 1508 |
+
"version": "4.61.1",
|
| 1509 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
| 1510 |
+
"integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
|
| 1511 |
+
"dev": true,
|
| 1512 |
+
"license": "MIT",
|
| 1513 |
+
"dependencies": {
|
| 1514 |
+
"@types/estree": "1.0.9"
|
| 1515 |
+
},
|
| 1516 |
+
"bin": {
|
| 1517 |
+
"rollup": "dist/bin/rollup"
|
| 1518 |
+
},
|
| 1519 |
+
"engines": {
|
| 1520 |
+
"node": ">=18.0.0",
|
| 1521 |
+
"npm": ">=8.0.0"
|
| 1522 |
+
},
|
| 1523 |
+
"optionalDependencies": {
|
| 1524 |
+
"@rollup/rollup-android-arm-eabi": "4.61.1",
|
| 1525 |
+
"@rollup/rollup-android-arm64": "4.61.1",
|
| 1526 |
+
"@rollup/rollup-darwin-arm64": "4.61.1",
|
| 1527 |
+
"@rollup/rollup-darwin-x64": "4.61.1",
|
| 1528 |
+
"@rollup/rollup-freebsd-arm64": "4.61.1",
|
| 1529 |
+
"@rollup/rollup-freebsd-x64": "4.61.1",
|
| 1530 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
|
| 1531 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.61.1",
|
| 1532 |
+
"@rollup/rollup-linux-arm64-gnu": "4.61.1",
|
| 1533 |
+
"@rollup/rollup-linux-arm64-musl": "4.61.1",
|
| 1534 |
+
"@rollup/rollup-linux-loong64-gnu": "4.61.1",
|
| 1535 |
+
"@rollup/rollup-linux-loong64-musl": "4.61.1",
|
| 1536 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.61.1",
|
| 1537 |
+
"@rollup/rollup-linux-ppc64-musl": "4.61.1",
|
| 1538 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.61.1",
|
| 1539 |
+
"@rollup/rollup-linux-riscv64-musl": "4.61.1",
|
| 1540 |
+
"@rollup/rollup-linux-s390x-gnu": "4.61.1",
|
| 1541 |
+
"@rollup/rollup-linux-x64-gnu": "4.61.1",
|
| 1542 |
+
"@rollup/rollup-linux-x64-musl": "4.61.1",
|
| 1543 |
+
"@rollup/rollup-openbsd-x64": "4.61.1",
|
| 1544 |
+
"@rollup/rollup-openharmony-arm64": "4.61.1",
|
| 1545 |
+
"@rollup/rollup-win32-arm64-msvc": "4.61.1",
|
| 1546 |
+
"@rollup/rollup-win32-ia32-msvc": "4.61.1",
|
| 1547 |
+
"@rollup/rollup-win32-x64-gnu": "4.61.1",
|
| 1548 |
+
"@rollup/rollup-win32-x64-msvc": "4.61.1",
|
| 1549 |
+
"fsevents": "~2.3.2"
|
| 1550 |
+
}
|
| 1551 |
+
},
|
| 1552 |
+
"node_modules/scheduler": {
|
| 1553 |
+
"version": "0.23.2",
|
| 1554 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
| 1555 |
+
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
| 1556 |
+
"license": "MIT",
|
| 1557 |
+
"dependencies": {
|
| 1558 |
+
"loose-envify": "^1.1.0"
|
| 1559 |
+
}
|
| 1560 |
+
},
|
| 1561 |
+
"node_modules/semver": {
|
| 1562 |
+
"version": "6.3.1",
|
| 1563 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
| 1564 |
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
| 1565 |
+
"dev": true,
|
| 1566 |
+
"license": "ISC",
|
| 1567 |
+
"bin": {
|
| 1568 |
+
"semver": "bin/semver.js"
|
| 1569 |
+
}
|
| 1570 |
+
},
|
| 1571 |
+
"node_modules/source-map-js": {
|
| 1572 |
+
"version": "1.2.1",
|
| 1573 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 1574 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
| 1575 |
+
"dev": true,
|
| 1576 |
+
"license": "BSD-3-Clause",
|
| 1577 |
+
"engines": {
|
| 1578 |
+
"node": ">=0.10.0"
|
| 1579 |
+
}
|
| 1580 |
+
},
|
| 1581 |
+
"node_modules/update-browserslist-db": {
|
| 1582 |
+
"version": "1.2.3",
|
| 1583 |
+
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
| 1584 |
+
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
| 1585 |
+
"dev": true,
|
| 1586 |
+
"funding": [
|
| 1587 |
+
{
|
| 1588 |
+
"type": "opencollective",
|
| 1589 |
+
"url": "https://opencollective.com/browserslist"
|
| 1590 |
+
},
|
| 1591 |
+
{
|
| 1592 |
+
"type": "tidelift",
|
| 1593 |
+
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
| 1594 |
+
},
|
| 1595 |
+
{
|
| 1596 |
+
"type": "github",
|
| 1597 |
+
"url": "https://github.com/sponsors/ai"
|
| 1598 |
+
}
|
| 1599 |
+
],
|
| 1600 |
+
"license": "MIT",
|
| 1601 |
+
"dependencies": {
|
| 1602 |
+
"escalade": "^3.2.0",
|
| 1603 |
+
"picocolors": "^1.1.1"
|
| 1604 |
+
},
|
| 1605 |
+
"bin": {
|
| 1606 |
+
"update-browserslist-db": "cli.js"
|
| 1607 |
+
},
|
| 1608 |
+
"peerDependencies": {
|
| 1609 |
+
"browserslist": ">= 4.21.0"
|
| 1610 |
+
}
|
| 1611 |
+
},
|
| 1612 |
+
"node_modules/vite": {
|
| 1613 |
+
"version": "5.4.21",
|
| 1614 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
| 1615 |
+
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 1616 |
+
"dev": true,
|
| 1617 |
+
"license": "MIT",
|
| 1618 |
+
"dependencies": {
|
| 1619 |
+
"esbuild": "^0.21.3",
|
| 1620 |
+
"postcss": "^8.4.43",
|
| 1621 |
+
"rollup": "^4.20.0"
|
| 1622 |
+
},
|
| 1623 |
+
"bin": {
|
| 1624 |
+
"vite": "bin/vite.js"
|
| 1625 |
+
},
|
| 1626 |
+
"engines": {
|
| 1627 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1628 |
+
},
|
| 1629 |
+
"funding": {
|
| 1630 |
+
"url": "https://github.com/vitejs/vite?sponsor=1"
|
| 1631 |
+
},
|
| 1632 |
+
"optionalDependencies": {
|
| 1633 |
+
"fsevents": "~2.3.3"
|
| 1634 |
+
},
|
| 1635 |
+
"peerDependencies": {
|
| 1636 |
+
"@types/node": "^18.0.0 || >=20.0.0",
|
| 1637 |
+
"less": "*",
|
| 1638 |
+
"lightningcss": "^1.21.0",
|
| 1639 |
+
"sass": "*",
|
| 1640 |
+
"sass-embedded": "*",
|
| 1641 |
+
"stylus": "*",
|
| 1642 |
+
"sugarss": "*",
|
| 1643 |
+
"terser": "^5.4.0"
|
| 1644 |
+
},
|
| 1645 |
+
"peerDependenciesMeta": {
|
| 1646 |
+
"@types/node": {
|
| 1647 |
+
"optional": true
|
| 1648 |
+
},
|
| 1649 |
+
"less": {
|
| 1650 |
+
"optional": true
|
| 1651 |
+
},
|
| 1652 |
+
"lightningcss": {
|
| 1653 |
+
"optional": true
|
| 1654 |
+
},
|
| 1655 |
+
"sass": {
|
| 1656 |
+
"optional": true
|
| 1657 |
+
},
|
| 1658 |
+
"sass-embedded": {
|
| 1659 |
+
"optional": true
|
| 1660 |
+
},
|
| 1661 |
+
"stylus": {
|
| 1662 |
+
"optional": true
|
| 1663 |
+
},
|
| 1664 |
+
"sugarss": {
|
| 1665 |
+
"optional": true
|
| 1666 |
+
},
|
| 1667 |
+
"terser": {
|
| 1668 |
+
"optional": true
|
| 1669 |
+
}
|
| 1670 |
+
}
|
| 1671 |
+
},
|
| 1672 |
+
"node_modules/yallist": {
|
| 1673 |
+
"version": "3.1.1",
|
| 1674 |
+
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
| 1675 |
+
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
| 1676 |
+
"dev": true,
|
| 1677 |
+
"license": "ISC"
|
| 1678 |
+
}
|
| 1679 |
+
}
|
| 1680 |
+
}
|
web/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "reel-studio-web",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "2.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^18.3.1",
|
| 13 |
+
"react-dom": "^18.3.1"
|
| 14 |
+
},
|
| 15 |
+
"devDependencies": {
|
| 16 |
+
"@vitejs/plugin-react": "^4.3.1",
|
| 17 |
+
"vite": "^5.4.8"
|
| 18 |
+
}
|
| 19 |
+
}
|
web/src/App.jsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useState } from 'react'
|
| 2 |
+
import { api } from './api'
|
| 3 |
+
import { ErrorBox } from './hooks'
|
| 4 |
+
import Stepper from './components/Stepper'
|
| 5 |
+
import ReelBar from './components/ReelBar'
|
| 6 |
+
import CloneBar from './components/CloneBar'
|
| 7 |
+
import SetupStep from './steps/SetupStep'
|
| 8 |
+
import ReferencesStep from './steps/ReferencesStep'
|
| 9 |
+
import TopicsStep from './steps/TopicsStep'
|
| 10 |
+
import ScriptStep from './steps/ScriptStep'
|
| 11 |
+
import MediaStep from './steps/MediaStep'
|
| 12 |
+
import RenderStep from './steps/RenderStep'
|
| 13 |
+
import ReviewStep from './steps/ReviewStep'
|
| 14 |
+
|
| 15 |
+
export const STEPS = ['Setup', 'References', 'Topics', 'Script', 'Media', 'Render', 'Review']
|
| 16 |
+
const REEL_STEP_START = 3 // steps >= this operate on the selected reel
|
| 17 |
+
|
| 18 |
+
export default function App() {
|
| 19 |
+
const [projects, setProjects] = useState(null)
|
| 20 |
+
const [projectId, setProjectId] = useState(null)
|
| 21 |
+
const [data, setData] = useState(null) // project-level: {project, style, topics, reels}
|
| 22 |
+
const [reelId, setReelId] = useState(null)
|
| 23 |
+
const [reelData, setReelData] = useState(null) // {reel, script, voice, media, renders, clone}
|
| 24 |
+
const [step, setStep] = useState(0)
|
| 25 |
+
const [error, setError] = useState('')
|
| 26 |
+
|
| 27 |
+
const loadProjects = useCallback(() => {
|
| 28 |
+
api.get('/api/projects').then(setProjects).catch((e) => setError(e.message))
|
| 29 |
+
}, [])
|
| 30 |
+
|
| 31 |
+
const refreshProject = useCallback(async () => {
|
| 32 |
+
if (!projectId) return null
|
| 33 |
+
try {
|
| 34 |
+
const d = await api.get(`/api/projects/${projectId}`)
|
| 35 |
+
setData(d)
|
| 36 |
+
return d
|
| 37 |
+
} catch (e) {
|
| 38 |
+
setError(e.message)
|
| 39 |
+
return null
|
| 40 |
+
}
|
| 41 |
+
}, [projectId])
|
| 42 |
+
|
| 43 |
+
const refreshReel = useCallback(async () => {
|
| 44 |
+
if (!projectId || !reelId) { setReelData(null); return null }
|
| 45 |
+
try {
|
| 46 |
+
const d = await api.get(`/api/projects/${projectId}/reels/${reelId}`)
|
| 47 |
+
setReelData(d)
|
| 48 |
+
return d
|
| 49 |
+
} catch (e) {
|
| 50 |
+
setError(e.message)
|
| 51 |
+
return null
|
| 52 |
+
}
|
| 53 |
+
}, [projectId, reelId])
|
| 54 |
+
|
| 55 |
+
useEffect(loadProjects, [loadProjects])
|
| 56 |
+
useEffect(() => { setData(null); setReelId(null); setReelData(null); refreshProject() }, [projectId, refreshProject])
|
| 57 |
+
useEffect(() => { refreshReel() }, [reelId, refreshReel])
|
| 58 |
+
|
| 59 |
+
const selectReel = (id) => { setReelId(id); setStep(REEL_STEP_START) }
|
| 60 |
+
|
| 61 |
+
const newReel = async () => {
|
| 62 |
+
const reel = await api.post(`/api/projects/${projectId}/reels`, {}).catch((e) => { setError(e.message) })
|
| 63 |
+
if (reel) { await refreshProject(); selectReel(reel.id) }
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const deleteReel = async (id) => {
|
| 67 |
+
if (!window.confirm('Delete this reel and all its media?')) return
|
| 68 |
+
await api.del(`/api/projects/${projectId}/reels/${id}`).catch((e) => setError(e.message))
|
| 69 |
+
if (id === reelId) { setReelId(null); setStep(2) }
|
| 70 |
+
await refreshProject()
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const canEnter = (i) => {
|
| 74 |
+
if (!data) return i === 0
|
| 75 |
+
if (i < REEL_STEP_START) return true
|
| 76 |
+
if (!reelId) return false
|
| 77 |
+
if (i === 3) return true // Script (offers generate)
|
| 78 |
+
if (i === 4) return !!reelData?.script // Media
|
| 79 |
+
if (i === 5) return !!reelData?.voice && !!reelData?.media // Render
|
| 80 |
+
if (i === 6) return !!(reelData?.renders?.results?.length) // Review
|
| 81 |
+
return false
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if (!projectId) {
|
| 85 |
+
return (
|
| 86 |
+
<Home
|
| 87 |
+
projects={projects}
|
| 88 |
+
error={error}
|
| 89 |
+
onOpen={(id) => { setProjectId(id); setStep(1) }}
|
| 90 |
+
onCreate={(project) => { setProjectId(project.id); setStep(1); loadProjects() }}
|
| 91 |
+
onDelete={async (id) => {
|
| 92 |
+
if (!window.confirm('Delete this project and all its reels?')) return
|
| 93 |
+
await api.del(`/api/projects/${id}`).catch((e) => setError(e.message))
|
| 94 |
+
loadProjects()
|
| 95 |
+
}}
|
| 96 |
+
/>
|
| 97 |
+
)
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Props shared with every step.
|
| 101 |
+
const shared = {
|
| 102 |
+
projectId, data, refreshProject,
|
| 103 |
+
reelId, reelData, refreshReel, selectReel, setReelId,
|
| 104 |
+
setStep,
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<div className="layout">
|
| 109 |
+
<div className="sidebar">
|
| 110 |
+
<h1>Reel <span>Studio</span> v2</h1>
|
| 111 |
+
<button className="home-link" onClick={() => { setProjectId(null); loadProjects() }}>
|
| 112 |
+
← All projects
|
| 113 |
+
</button>
|
| 114 |
+
{data && <div className="project-name" title={data.project.name}>{data.project.name}</div>}
|
| 115 |
+
<Stepper steps={STEPS} current={step} canEnter={canEnter} onSelect={setStep} />
|
| 116 |
+
{data && (
|
| 117 |
+
<ReelBar
|
| 118 |
+
reels={data.reels || []}
|
| 119 |
+
reelId={reelId}
|
| 120 |
+
onSelect={selectReel}
|
| 121 |
+
onNew={newReel}
|
| 122 |
+
onDelete={deleteReel}
|
| 123 |
+
/>
|
| 124 |
+
)}
|
| 125 |
+
</div>
|
| 126 |
+
<div className="main">
|
| 127 |
+
<ErrorBox error={error} onRetry={() => { setError(''); refreshProject() }} />
|
| 128 |
+
{!data && !error && <p className="muted"><span className="spinner" />Loading project…</p>}
|
| 129 |
+
{data && (
|
| 130 |
+
<>
|
| 131 |
+
<CloneBar
|
| 132 |
+
projectId={projectId}
|
| 133 |
+
onCloned={async (reel) => { await refreshProject(); selectReel(reel.id) }}
|
| 134 |
+
/>
|
| 135 |
+
{step === 0 && <SetupStep {...shared} mode="edit" />}
|
| 136 |
+
{step === 1 && <ReferencesStep {...shared} />}
|
| 137 |
+
{step === 2 && <TopicsStep {...shared} />}
|
| 138 |
+
{step === 3 && <ScriptStep {...shared} />}
|
| 139 |
+
{step === 4 && <MediaStep {...shared} />}
|
| 140 |
+
{step === 5 && <RenderStep {...shared} />}
|
| 141 |
+
{step === 6 && <ReviewStep {...shared} />}
|
| 142 |
+
</>
|
| 143 |
+
)}
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
)
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function Home({ projects, error, onOpen, onCreate, onDelete }) {
|
| 150 |
+
const [creating, setCreating] = useState(false)
|
| 151 |
+
return (
|
| 152 |
+
<div className="layout">
|
| 153 |
+
<div className="main" style={{ margin: '0 auto' }}>
|
| 154 |
+
<h2>Reel Studio v2</h2>
|
| 155 |
+
<p className="subtitle">AI short-form video pipeline — pick a project or start a new one.</p>
|
| 156 |
+
<ErrorBox error={error} />
|
| 157 |
+
{creating ? (
|
| 158 |
+
<SetupStep mode="create" onCreated={onCreate} onCancel={() => setCreating(false)} />
|
| 159 |
+
) : (
|
| 160 |
+
<>
|
| 161 |
+
<button className="btn" onClick={() => setCreating(true)}>+ New project</button>
|
| 162 |
+
<div className="spacer" />
|
| 163 |
+
{projects === null && <p className="muted"><span className="spinner" />Loading…</p>}
|
| 164 |
+
{projects?.length === 0 && <p className="muted">No projects yet.</p>}
|
| 165 |
+
{projects?.map((p) => (
|
| 166 |
+
<div key={p.id} className="project-tile" onClick={() => onOpen(p.id)}>
|
| 167 |
+
<div>
|
| 168 |
+
<strong>{p.name}</strong>
|
| 169 |
+
<div className="muted">{p.niche.topic} · {p.format.replace('_', ' ')}</div>
|
| 170 |
+
</div>
|
| 171 |
+
<button
|
| 172 |
+
className="btn small danger"
|
| 173 |
+
onClick={(e) => { e.stopPropagation(); onDelete(p.id) }}
|
| 174 |
+
>
|
| 175 |
+
Delete
|
| 176 |
+
</button>
|
| 177 |
+
</div>
|
| 178 |
+
))}
|
| 179 |
+
</>
|
| 180 |
+
)}
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
)
|
| 184 |
+
}
|
web/src/api.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Thin fetch wrapper + job polling for the FastAPI backend.
|
| 2 |
+
|
| 3 |
+
async function request(path, options = {}) {
|
| 4 |
+
const res = await fetch(path, {
|
| 5 |
+
headers: { 'Content-Type': 'application/json' },
|
| 6 |
+
...options,
|
| 7 |
+
})
|
| 8 |
+
if (!res.ok) {
|
| 9 |
+
let detail = res.statusText
|
| 10 |
+
try {
|
| 11 |
+
const body = await res.json()
|
| 12 |
+
detail = body.detail || JSON.stringify(body)
|
| 13 |
+
} catch { /* keep statusText */ }
|
| 14 |
+
throw new Error(detail)
|
| 15 |
+
}
|
| 16 |
+
return res.json()
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const api = {
|
| 20 |
+
get: (path) => request(path),
|
| 21 |
+
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
|
| 22 |
+
put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body) }),
|
| 23 |
+
patch: (path, body) => request(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
| 24 |
+
del: (path) => request(path, { method: 'DELETE' }),
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Poll a job until done/error. onProgress receives the list of messages.
|
| 28 |
+
export async function pollJob(jobId, onProgress, intervalMs = 1200) {
|
| 29 |
+
for (;;) {
|
| 30 |
+
const job = await api.get(`/api/jobs/${jobId}`)
|
| 31 |
+
if (onProgress) onProgress(job.progress || [])
|
| 32 |
+
if (job.status === 'done') return job.result
|
| 33 |
+
if (job.status === 'error') throw new Error(job.error || 'Job failed')
|
| 34 |
+
await new Promise((r) => setTimeout(r, intervalMs))
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Convenience: POST that returns {job_id}, then poll it.
|
| 39 |
+
export async function runJob(path, body, onProgress) {
|
| 40 |
+
const { job_id } = await api.post(path, body)
|
| 41 |
+
return pollJob(job_id, onProgress)
|
| 42 |
+
}
|
web/src/components/CloneBar.jsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import { useJob, ProgressBox, ErrorBox } from '../hooks'
|
| 3 |
+
|
| 4 |
+
// Paste a YouTube Shorts URL -> backend builds a clone brief and writes a
|
| 5 |
+
// FRESH script -> the wizard jumps to the Script step.
|
| 6 |
+
export default function CloneBar({ projectId, onCloned }) {
|
| 7 |
+
const [url, setUrl] = useState('')
|
| 8 |
+
const { running, progress, error, setError, start } = useJob()
|
| 9 |
+
|
| 10 |
+
const submit = async () => {
|
| 11 |
+
if (!url.trim()) return
|
| 12 |
+
try {
|
| 13 |
+
const result = await start(`/api/projects/${projectId}/clone`, { url: url.trim() })
|
| 14 |
+
setUrl('')
|
| 15 |
+
onCloned(result.reel) // {reel, script} — select the new reel
|
| 16 |
+
} catch { /* error already shown */ }
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div className="card">
|
| 21 |
+
<div className="row">
|
| 22 |
+
<input
|
| 23 |
+
type="text"
|
| 24 |
+
className="grow"
|
| 25 |
+
placeholder="Clone a reel: paste a YouTube Shorts URL — a fresh script on the same topic, never the same footage or wording"
|
| 26 |
+
value={url}
|
| 27 |
+
disabled={running}
|
| 28 |
+
onChange={(e) => setUrl(e.target.value)}
|
| 29 |
+
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
| 30 |
+
/>
|
| 31 |
+
<button className="btn secondary" onClick={submit} disabled={running || !url.trim()}>
|
| 32 |
+
{running ? 'Cloning…' : 'Clone'}
|
| 33 |
+
</button>
|
| 34 |
+
</div>
|
| 35 |
+
{running && <div style={{ marginTop: 10 }}><ProgressBox progress={progress.length ? progress : ['Starting…']} /></div>}
|
| 36 |
+
<div style={{ marginTop: error ? 10 : 0 }}>
|
| 37 |
+
<ErrorBox error={error} onRetry={() => { setError(''); submit() }} />
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
)
|
| 41 |
+
}
|
web/src/components/PromptPanel.jsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
|
| 3 |
+
// Collapsible "view / edit the exact prompt before it runs" panel.
|
| 4 |
+
//
|
| 5 |
+
// `value` is the current prompt text (null = use the backend default).
|
| 6 |
+
// `fetchPrompt` is an async () => string that assembles the default prompt
|
| 7 |
+
// (some steps fetch live data to build it, so it may take a moment).
|
| 8 |
+
// When the user edits the box, value diverges from the default — the run
|
| 9 |
+
// button sends `value`; if the panel was never opened, value stays null and
|
| 10 |
+
// the backend assembles the default itself.
|
| 11 |
+
export default function PromptPanel({ fetchPrompt, value, setValue, disabled, label = 'prompt' }) {
|
| 12 |
+
const [open, setOpen] = useState(false)
|
| 13 |
+
const [loading, setLoading] = useState(false)
|
| 14 |
+
const [error, setError] = useState('')
|
| 15 |
+
const [original, setOriginal] = useState(null)
|
| 16 |
+
|
| 17 |
+
const load = async () => {
|
| 18 |
+
setLoading(true)
|
| 19 |
+
setError('')
|
| 20 |
+
try {
|
| 21 |
+
const p = await fetchPrompt()
|
| 22 |
+
setValue(p)
|
| 23 |
+
setOriginal(p)
|
| 24 |
+
} catch (e) {
|
| 25 |
+
setError(e.message)
|
| 26 |
+
} finally {
|
| 27 |
+
setLoading(false)
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const toggle = async () => {
|
| 32 |
+
if (!open && value == null) await load()
|
| 33 |
+
setOpen(!open)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const edited = value != null && original != null && value !== original
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="prompt-panel">
|
| 40 |
+
<button className="prompt-toggle" onClick={toggle} disabled={disabled}>
|
| 41 |
+
{open ? '▾ Hide' : '▸ Show / edit'} the {label} prompt (advanced)
|
| 42 |
+
{edited && <span className="edited-badge">● edited</span>}
|
| 43 |
+
</button>
|
| 44 |
+
{open && (
|
| 45 |
+
<div>
|
| 46 |
+
<p className="prompt-hint">
|
| 47 |
+
This is the exact text sent to the model. Edit it to QC the output, then run. Your edits are used as-is.
|
| 48 |
+
</p>
|
| 49 |
+
{loading ? (
|
| 50 |
+
<p className="muted"><span className="spinner" />Assembling prompt…</p>
|
| 51 |
+
) : error ? (
|
| 52 |
+
<div className="error-box">{error}</div>
|
| 53 |
+
) : (
|
| 54 |
+
<textarea className="prompt-area" value={value || ''} onChange={(e) => setValue(e.target.value)} />
|
| 55 |
+
)}
|
| 56 |
+
<button className="btn small secondary" onClick={load} disabled={loading}>
|
| 57 |
+
↺ Reset to default
|
| 58 |
+
</button>
|
| 59 |
+
</div>
|
| 60 |
+
)}
|
| 61 |
+
</div>
|
| 62 |
+
)
|
| 63 |
+
}
|