xvepkj commited on
Commit
3d2098f
·
0 Parent(s):

initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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
+ }