feat: implement vertical crop, local LLM deepseek-v4-flash, multi-threading, word-level pink box highlight subtitles, and Nextcloud scan sync

This commit is contained in:
Renato
2026-07-02 16:12:23 +02:00
parent 50bdaedebb
commit c7c3c4b74a
154 changed files with 36256 additions and 3 deletions
Submodule ai-youtube-shorts-generator deleted from 063f9e950f
@@ -0,0 +1,156 @@
---
name: youtube-shorts-generator
description: Generate viral 9:16 YouTube Shorts (or TikTok/Reels clips) from a long-form YouTube URL or local video. Triggers on requests like "make shorts from this video", "extract viral clips from this YouTube link", "auto-clip this podcast", "find the best moments and crop vertical". Pipeline downloads the source, transcribes via MuAPI /openai-whisper, ranks highlights through a virality framework (hook / emotional peak / opinion bomb / revelation / conflict / quotable / story peak / practical value), dedupes overlapping candidates, and vertically auto-crops the top N as mp4s.
---
# YouTube Shorts Generator
End-to-end pipeline that turns one long video into N viral-ready vertical clips. Each clip ships with a viral score (0100), an opening hook line, and a one-sentence reason it should perform.
Reference implementation: https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator
## When to use this skill
- "Generate shorts from this YouTube video"
- "Find the most viral 60-second clips in this podcast"
- "Auto-crop this interview to 9:16"
- "Give me TikTok clips from this lecture"
If the user only wants transcription, summarization, or thumbnails — this is the wrong skill.
## Inputs to collect before running
Ask once, then proceed:
1. **Source** — YouTube URL (preferred) or path/URL to an mp4
2. **`num_clips`** — default 3
3. **`aspect_ratio`** — default `9:16` (also: `1:1`, `4:5`)
4. **`language`** — default auto-detect (forwarded to MuAPI Whisper as ISO-639-1)
5. **Output JSON path** — optional; if set, dump full result there
If the user gave a URL and nothing else, use defaults and don't block on questions.
## Prerequisites (verify before first run)
- Python 3.10+
- A MuAPI key — set `MUAPI_API_KEY` in `.env`. Powers download, transcription, highlight ranking, and clipping. If missing, stop and ask the user for it; do not invent one.
- `pip install -r requirements.txt` inside a venv
If the repo isn't cloned yet, clone `https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator.git` into the working directory.
## Pipeline (what to execute)
Run the eight stages in order. Each maps to a module in `shorts_generator/`.
1. **Download** (`downloader.py`) — pull the source video at the requested resolution (`360`/`480`/`720`/`1080`, default `720`).
2. **Transcribe** (`transcriber.py`) — MuAPI `/openai-whisper` runs Whisper server-side and returns timestamped `verbose_json` segments. Billed per minute of audio.
3. **Classify content type** — LLM tags the video (podcast / interview / tutorial / vlog / lecture / monologue) and density. Tune the highlight prompt per type.
4. **Chunk if long** (`highlights.py`) — videos > `LONG_VIDEO_THRESHOLD` (1800s default) are split into `CHUNK_SIZE_SECONDS` (1200s default) windows with `CHUNK_OVERLAP_SECONDS` (60s default) overlap so cross-boundary highlights aren't missed.
5. **Rank highlights** — LLM scans each chunk through `VIRALITY_CRITERIA`:
- **Hook moments** — strong opening line that stops the scroll
- **Emotional peaks** — laughter, anger, vulnerability, awe
- **Opinion bombs** — spicy, contrarian, debate-bait takes
- **Revelation moments** — "wait, what?" reframes
- **Conflict** — disagreement, tension, callouts
- **Quotable lines** — tight, screenshot-worthy phrasing
- **Story peaks** — climax of a narrative arc
- **Practical value** — actionable insight a viewer will save
Each candidate gets `start_time`, `end_time`, `score` 0100, `title`, `hook_sentence`, `virality_reason`. Aim for 3075s clips unless content dictates otherwise.
6. **Dedupe** — collapse overlaps. Rule: if two candidates overlap > 50%, keep the higher score, drop the other.
7. **Top-N selection** — sort surviving candidates by score, take `num_clips`.
8. **Vertical auto-crop** (`clipper.py`) — render each highlight at `aspect_ratio`. Auto-handles face tracking and screen recordings; no Haar cascades.
## Invocation
CLI (the standard path):
```bash
python main.py "<YOUTUBE_URL>" \
--num-clips 5 \
--aspect-ratio 9:16 \
--output-json result.json
```
Python API (when embedding in another pipeline):
```python
from shorts_generator import generate_shorts
result = generate_shorts(
"<URL>",
num_clips=5,
aspect_ratio="9:16",
)
for short in result["shorts"]:
print(short["score"], short["title"], short["clip_url"])
```
Batch mode — `urls.txt` with one URL per line:
```bash
xargs -a urls.txt -I{} python main.py "{}"
```
## CLI flags reference
| Flag | Default | Notes |
|------|---------|-------|
| `--num-clips` | `3` | How many shorts to render |
| `--aspect-ratio` | `9:16` | `9:16` for TikTok/Reels, `1:1` square, anything else by flag |
| `--format` | `720` | Source download resolution |
| `--language` | auto | Whisper language code (e.g. `en`) |
| `--output-json` | — | Dump full result (transcript + all candidates + clip URLs) |
## Output schema
```json
{
"source_video_url": "...",
"transcript": { "duration": 1873.4, "segments": [...] },
"highlights": [ /* every candidate, before top-N cut */ ],
"shorts": [
{
"title": "The one mistake that cost me $50K",
"start_time": 124.3,
"end_time": 187.6,
"score": 92,
"hook_sentence": "Nobody talks about this, but it killed my first startup...",
"virality_reason": "Opens with a number + regret, peaks on a contrarian lesson",
"clip_url": "https://.../short_1.mp4"
}
]
}
```
When reporting back to the user, surface for each clip: rank, score, time range, title, hook, and clip URL. Skip the raw transcript unless asked.
## Tunable knobs
- `shorts_generator/highlights.py`
- `VIRALITY_CRITERIA` — reorder or extend signals
- `HIGHLIGHT_SYSTEM_PROMPT` — duration sweet spot, hook rules, JSON schema
- `CHUNK_SIZE_SECONDS` — 1200s default
- `LONG_VIDEO_THRESHOLD` — 1800s default
- `CHUNK_OVERLAP_SECONDS` — 60s default
- `shorts_generator/config.py` (or env vars)
- `MUAPI_POLL_INTERVAL` — 5s
- `MUAPI_POLL_TIMEOUT` — 1800s
## Whisper transcription
Audio is transcribed by MuAPI's `/openai-whisper` endpoint (server-side `whisper-1`, billed per minute). The CLI passes `--language` straight through; leave it empty for auto-detection, or pass an ISO-639-1 code (e.g. `en`) to lock it.
## Failure modes — handle, don't paper over
- **Whisper produced no segments** — likely no detectable speech or a hard language. Retry with `--language <code>` (correct ISO-639-1) before declaring failure.
- **API key missing or rejected** — surface the exact error; never fabricate a key.
- **Job timed out** — bump `MUAPI_POLL_TIMEOUT` and retry; don't silently truncate.
- **Highlight ranker returned <`num_clips`** — return what survived dedupe with a note; don't pad with low-score filler.
## Done criteria
The skill is done when:
1. `result["shorts"]` has up to `num_clips` entries, each with a working `clip_url`.
2. The user has been shown the ranked list (score, time range, title, hook, URL).
3. If `--output-json` was set, the file exists and parses.
If any clip URL 404s on a HEAD check, re-run just the crop stage for that highlight rather than re-running the whole pipeline.
+12
View File
@@ -0,0 +1,12 @@
# API mode (default)
MUAPI_API_KEY=your_muapi_key_here
# Local mode (--mode local)
LLM_PROVIDER=openai # openai or gemini
OPENAI_API_KEY=your_openai_key_here
OPENAI_MODEL=gpt-4o-mini
GEMINI_API_KEY=your_gemini_key_here
GEMINI_MODEL=gemini-2.5-flash
LOCAL_WHISPER_MODEL=base
LOCAL_WHISPER_DEVICE=auto
LOCAL_OUTPUT_DIR=output
+8
View File
@@ -0,0 +1,8 @@
.env
__pycache__/
*.pyc
.venv/
venv/
output/
*.mp4
.DS_Store
+307
View File
@@ -0,0 +1,307 @@
# AI YouTube Shorts Generator
[![Powered by MuAPI](https://img.shields.io/badge/Powered%20by-MuAPI-6366f1?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMSAxNHYtNGgtMnYtMmg0djZoLTJ6bTAtOFY2aDJ2MmgtMnoiLz48L3N2Zz4=)](https://muapi.ai?utm_source=github&utm_medium=badge&utm_campaign=ai-youtube-shorts-generator)
**The open-source alternative to Opus Clip, Vidyo.ai, Klap, SubMagic, 2short.ai, and other AI clipping tools.** Drop in any long-form YouTube video and get back ranked, viral-ready 9:16 shorts — for free, with no per-clip credits, no watermarks, and full control over the highlight algorithm.
Built for creators, agencies, and developers who don't want to pay $20$300/month or be capped on minutes processed. Uses GPT-class LLM highlight detection and Whisper transcription to extract the most viral-worthy moments and auto-crop them vertically for TikTok, Reels, and Shorts.
> **Building your own Opus Clipstyle SaaS?** Skip the infra and ship on the same APIs that power this repo:
> - [AI Clipping API](https://muapi.ai/playground/ai-clipping?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) — end-to-end clip selection + render
> - [Auto-Crop API](https://muapi.ai/playground/autocrop?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) — vertical reframing only
![longshorts](https://github.com/user-attachments/assets/3f5d1abf-bf3b-475f-8abf-5e253003453a)
<p align="center">
<a href="https://github.com/Anil-matcha/awesome-generative-ai-apps">
<img src="https://img.shields.io/badge/Part%20of-Awesome%20Generative%20AI%20Apps-FFD700?style=for-the-badge&logo=github&logoColor=black" alt="Awesome Generative AI Apps">
</a>
</p>
> 🎨 **[Explore 50+ more open-source AI apps →](https://github.com/Anil-matcha/awesome-generative-ai-apps)**
## Why Use This Instead of Opus Clip / Vidyo.ai / Klap?
| | This repo | Opus Clip / Vidyo.ai / Klap / SubMagic |
|---|---|---|
| **Price** | Free + open source (pay only for API usage) | $20$300/month subscriptions |
| **Per-clip credits** | None — process unlimited videos | Monthly minute caps, overage fees |
| **Watermarks** | Never | On free tiers |
| **Highlight algorithm** | Fully editable virality framework | Black box |
| **Output format** | Any aspect ratio, any resolution | Locked presets |
| **Batch processing** | `xargs` an entire URL list | Manual upload one-by-one |
| **JSON / API output** | Built-in (`--output-json`) | Limited or paid tier only |
| **Self-hostable** | Yes — runs on your machine or server | SaaS only, your videos sit on their servers |
| **White-label / embeddable** | Yes — MIT licensed, import as Python lib | No |
## Features
- **🎬 YouTube In, Vertical Out**: Hand it any YouTube URL — get back N viral-ready 9:16 mp4s
- **🔀 Two Modes — API (fast) or Local (offline)**: Default `--mode api` uses MuAPI for download/transcription/cropping; `--mode local` runs entirely on your machine with `yt-dlp`, `faster-whisper`, and `ffmpeg`/`opencv`, and lets you pick OpenAI or Gemini for highlight ranking
- **🤖 Virality-Aware Highlight Selection**: Clips ranked on hooks, emotional peaks, opinion bombs, revelation moments, conflict, quotable lines, story peaks, and practical value — not just generic "interesting"
- **📈 Score + Hook + Reason for Every Clip**: Each highlight comes with a viral score, an opening hook line, and a one-sentence explanation of why it works
- **🎤 Whisper Transcription, Your Choice**: Cloud (`/openai-whisper` via MuAPI) or local (`faster-whisper`, CPU or CUDA) — same downstream output shape
- **🧩 Long-Video Aware**: Videos over 30 minutes are auto-chunked with overlap so nothing gets missed
- **♻️ Smart Dedupe**: Overlapping highlights are collapsed by score so you never get two near-duplicate clips
- **🎯 Smart Vertical Crop**: API mode uses MuAPI's auto-crop; local mode runs OpenCV face tracking with motion smoothing
- **📱 Any Aspect Ratio**: 9:16 for TikTok/Reels/Shorts, 1:1 for square, anything else by flag
- **🧰 CLI + Python Library**: Use it from the shell or import `generate_shorts(...)` into your own pipeline
- **📦 JSON Output**: `--output-json` dumps the full result (transcript + every candidate highlight + final clip URLs/paths) for downstream automation
## Quick Start (No Setup)
Don't want to self-host? The [AI Clipping API](https://muapi.ai/playground/ai-clipping?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) gives you the same Opus Clipstyle pipeline as a single HTTP call — no Python, no dependencies, pay-per-clip instead of monthly subscriptions.
---
## Installation (Self-Hosted)
### Prerequisites
- Python 3.10+
- For **API mode (default)**: a MuAPI key — powers download, transcription, highlight ranking, and clipping in a single dependency
- For **Local mode** (`--mode local`): `ffmpeg` on your PATH and an LLM API key (`OPENAI_API_KEY` or `GEMINI_API_KEY`; only the LLM step is remote)
### Steps
1. **Clone the repository:**
```bash
git clone https://github.com/SamurAIGPT/AI-Youtube-Shorts-Generator.git
cd AI-Youtube-Shorts-Generator
```
2. **Create and activate a virtual environment:**
```bash
python3.10 -m venv venv
source venv/bin/activate
```
3. **Install Python dependencies:**
```bash
pip install -r requirements.txt
# Only if you plan to use --mode local:
pip install -r requirements-local.txt
```
4. **Set up environment variables:**
Create a `.env` file in the project root:
```bash
# API mode (default)
MUAPI_API_KEY=your_muapi_key_here
# Local mode (--mode local)
LLM_PROVIDER=openai # openai or gemini
OPENAI_API_KEY=your_openai_key_here
OPENAI_MODEL=gpt-4o-mini # optional, default gpt-4o-mini
GEMINI_API_KEY=your_gemini_key_here
GEMINI_MODEL=gemini-2.5-flash # optional, default gemini-2.5-flash
LOCAL_WHISPER_MODEL=base # tiny / base / small / medium / large-v3
LOCAL_WHISPER_DEVICE=auto # auto / cpu / cuda
LOCAL_OUTPUT_DIR=output # where local mp4s land
```
## Usage
### Single video (API mode — default)
```bash
python main.py "https://www.youtube.com/watch?v=VIDEO_ID"
```
### Single video (Local mode — runs offline except for the LLM call)
```bash
python main.py "https://www.youtube.com/watch?v=VIDEO_ID" --mode local
```
Local mode writes the rendered shorts to `./output/short_01.mp4`, `short_02.mp4`, … (override with `LOCAL_OUTPUT_DIR`).
### With options
```bash
python main.py "https://www.youtube.com/watch?v=VIDEO_ID" \
--mode api \
--num-clips 5 \
--aspect-ratio 9:16 \
--output-json result.json
```
### Local file or path
In `--mode local`, you can pass a `file://` URL or a direct filesystem path and skip YouTube entirely:
```bash
python main.py "/Users/you/Videos/input.mp4" --mode local
python main.py "file:///Users/you/Videos/input.mp4" --mode local
```
The Python API works the same way:
```python
from shorts_generator import generate_shorts
result = generate_shorts(
"/Users/you/Videos/input.mp4",
num_clips=5,
aspect_ratio="9:16",
mode="local",
)
for short in result["shorts"]:
print(short["score"], short["title"], short["clip_url"])
```
Local transcription is cached as an `.srt` file in `LOCAL_OUTPUT_DIR` using the
video's base name. If the cache already exists and is newer than the source
file, the app reuses it instead of running Whisper again.
Local downloads are also cached in `LOCAL_OUTPUT_DIR` as
`source_<youtube_id>.mp4` when the input is a YouTube URL. If that file already
exists, the app skips `yt-dlp` and reuses the cached video.
### Batch processing
Create a `urls.txt` file with one URL per line, then:
```bash
xargs -a urls.txt -I{} python main.py "{}"
```
### CLI flags
| Flag | Default | Notes |
|------|---------|-------|
| `--mode` | `api` | `api` (MuAPI, fast, no setup) or `local` (remote URL, `file://`, or local path + faster-whisper + LLM provider + ffmpeg) |
| `--num-clips` | `3` | How many shorts to render |
| `--aspect-ratio` | `9:16` | Any ratio; `9:16` for TikTok/Reels, `1:1` for square |
| `--format` | `720` | Source download resolution: `360` / `480` / `720` / `1080` |
| `--language` | auto | Force Whisper language code (e.g. `en`) |
| `--output-json` | — | Dump the full result (transcript + all candidates) to a file |
### API mode vs Local mode
| Step | API mode (`--mode api`) | Local mode (`--mode local`) |
|---|---|---|
| Download | MuAPI `/youtube-download` | `yt-dlp` for remote URLs, direct file path for local inputs |
| Transcription | MuAPI `/openai-whisper` | `faster-whisper` (CPU or CUDA) |
| Highlight LLM | MuAPI `gpt-5-mini` | `LLM_PROVIDER=openai` uses OpenAI (`gpt-4o-mini` by default), `LLM_PROVIDER=gemini` uses Gemini (`gemini-2.5-flash` by default) |
| Vertical crop | MuAPI `/autocrop` | `ffmpeg` + OpenCV face tracking |
| Output | hosted URLs | local mp4 paths |
| Required keys | `MUAPI_API_KEY` | `OPENAI_API_KEY` or `GEMINI_API_KEY` (+ `ffmpeg` on PATH) |
## How It Works
1. **Download**: Fetches the source video from YouTube
2. **Transcribe**: MuAPI `/openai-whisper` produces a timestamped transcript (verbose_json segments)
3. **Detect content type**: An LLM classifies the video (podcast, interview, tutorial, vlog, etc.) and density, so the prompt can be tuned per content style
4. **Long-video chunking**: Videos > 30 min are split into 20-min overlapping chunks
5. **Highlight ranking**: An LLM scans the transcript through a virality framework — hook moments, emotional peaks, opinion bombs, revelations, conflict, quotables, story peaks, practical value — and emits ranked candidates with scores 0100
6. **Dedupe**: Overlapping candidates are collapsed by score (>50% overlap → keep the higher score)
7. **Top-N selection**: The top `--num-clips` candidates are selected
8. **Auto-crop**: Each highlight is rendered as a vertical short at the requested aspect ratio
**Output**: a list of mp4 URLs plus, for each clip, its title, viral score, hook sentence, and a one-line reason explaining why it should perform.
## Output
Console output looks like:
```
========================================================================
Highlights: 7 candidates → kept top 3
========================================================================
#1 score=92 124.3s → 187.6s
title: The one mistake that cost me $50K
hook: "Nobody talks about this, but it killed my first startup..."
clip: https://.../short_1.mp4
#2 score=88 ...
```
`--output-json result.json` produces:
```json
{
"source_video_url": "...",
"transcript": { "duration": 1873.4, "segments": [...] },
"highlights": [ {...}, {...}, ... ],
"shorts": [
{
"title": "...",
"start_time": 124.3,
"end_time": 187.6,
"score": 92,
"hook_sentence": "...",
"virality_reason": "...",
"clip_url": "https://.../short_1.mp4"
}
]
}
```
## Configuration
### Highlight selection criteria
Edit `shorts_generator/highlights.py`:
- **Virality framework**: `VIRALITY_CRITERIA` — the ranked list of signals the LLM optimizes for
- **System prompt**: `HIGHLIGHT_SYSTEM_PROMPT` — duration sweet spot, hook rules, JSON schema
- **Chunk size**: `CHUNK_SIZE_SECONDS` (default 1200) — chunk length for long videos
- **Long-video threshold**: `LONG_VIDEO_THRESHOLD` (default 1800) — videos longer than this are chunked
- **Chunk overlap**: `CHUNK_OVERLAP_SECONDS` (default 60) — overlap between chunks so cross-boundary clips aren't missed
### Polling / timeout
Edit `shorts_generator/config.py` (or set env vars):
- `MUAPI_POLL_INTERVAL` (default 5s) — seconds between job-status polls
- `MUAPI_POLL_TIMEOUT` (default 1800s) — give up after this long
### Whisper transcription
Audio is transcribed by MuAPI's `/openai-whisper` endpoint (server-side `whisper-1`). Pass `--language <code>` to lock the recognition to a specific language; otherwise it auto-detects.
## Project Structure
```
AI-Youtube-Shorts-Generator/
├── main.py CLI entry point
├── requirements.txt core deps (api mode)
├── requirements-local.txt optional deps for --mode local
├── .env.example
└── shorts_generator/
├── config.py env / settings (MuAPI + local LLM + Whisper)
├── muapi.py generic submit + poll wrapper
├── downloader.py API mode: YouTube download via MuAPI
├── transcriber.py API mode: MuAPI /openai-whisper client
├── highlights.py shared LLM virality ranking (pluggable backend)
├── clipper.py API mode: MuAPI /autocrop
├── pipeline.py mode dispatcher (api ↔ local)
└── local/ --mode local backends (offline)
├── downloader.py yt-dlp download
├── transcriber.py faster-whisper transcription
├── llm.py OpenAI or Gemini client selector
└── clipper.py ffmpeg cut + OpenCV vertical crop
```
## Troubleshooting
### Whisper produced no segments
The video may have no detectable speech, or it may be in a language Whisper struggles with. Try passing `--language en` (or the correct ISO-639-1 code) to skip auto-detection.
### Looking for better results?
The [AI Clipping API](https://muapi.ai/playground/ai-clipping?utm_source=github&utm_medium=readme&utm_campaign=ai-youtube-shorts-generator) uses an improved algorithm that produces higher-quality clips with better highlight detection.
## Contributing
Contributions are welcome! Please fork the repository and submit a pull request.
## License
This project is licensed under the MIT License.
## Related Projects
- [AI Influencer Generator](https://github.com/SamurAIGPT/AI-Influencer-Generator)
- [Text to Video AI](https://github.com/SamurAIGPT/Text-To-Video-AI)
- [Faceless Video Generator](https://github.com/SamurAIGPT/Faceless-Video-Generator)
- [AI B-roll Generator](https://github.com/Anil-matcha/AI-B-roll)
- [No-code YouTube Shorts Generator](https://www.vadoo.tv/clip-youtube-video)
+73
View File
@@ -0,0 +1,73 @@
"""CLI entry point.
Usage:
python main.py "https://www.youtube.com/watch?v=..." \
--num-clips 3 --aspect-ratio 9:16
"""
import argparse
import json
import sys
# Windows uses 'charmap' by default, which can't encode Unicode characters
# like →. Reconfigure stdout/stderr to UTF-8 so output works on all platforms.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
from shorts_generator import generate_shorts
def main() -> int:
parser = argparse.ArgumentParser(description="AI YouTube Shorts Generator")
parser.add_argument("url", help="YouTube URL, file:// URL, or local file path")
parser.add_argument(
"--mode",
choices=["api", "local"],
default="api",
help="api (default, MuAPI) or local (remote URL, file://, or local path + faster-whisper + LLM provider + ffmpeg).",
)
parser.add_argument("--num-clips", type=int, default=3, help="How many shorts to render (default: 3)")
parser.add_argument("--aspect-ratio", default="9:16", help="Output aspect ratio (default: 9:16)")
parser.add_argument("--format", default="720", help="Source download resolution: 360 / 480 / 720 / 1080 (default: 720)")
parser.add_argument("--language", default=None, help="Force Whisper language code, e.g. 'en' (default: auto-detect)")
parser.add_argument("--output-json", default=None, help="Write the full result JSON to this path")
args = parser.parse_args()
try:
result = generate_shorts(
youtube_url=args.url,
num_clips=args.num_clips,
aspect_ratio=args.aspect_ratio,
download_format=args.format,
language=args.language,
mode=args.mode,
)
except Exception as e:
print(f"\nFAILED: {e}", file=sys.stderr)
return 1
print("\n" + "=" * 72)
print(f"Mode: {result.get('mode', args.mode)}")
print(f"Source video: {result['source_video_url']}")
print(f"Highlights: {len(result['highlights'])} candidates → kept top {len(result['shorts'])}")
print("=" * 72)
for i, s in enumerate(result["shorts"], 1):
print(f"\n#{i} score={s.get('score')} {s.get('start_time'):.1f}s → {s.get('end_time'):.1f}s")
print(f" title: {s.get('title')}")
print(f" hook: {s.get('hook_sentence')}")
if s.get("clip_url"):
print(f" clip: {s['clip_url']}")
else:
print(f" clip: FAILED ({s.get('error')})")
if args.output_json:
with open(args.output_json, "w") as f:
json.dump(result, f, indent=2)
print(f"\nFull JSON written to {args.output_json}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,10 @@
-r requirements.txt
# Optional dependencies for --mode local.
yt-dlp>=2024.1.1
faster-whisper>=1.0.0
openai>=1.0.0
google-genai>=1.0.0
opencv-python>=4.8.0
# torch is only needed if you want CUDA Whisper. CPU works without it.
# torch>=2.0
@@ -0,0 +1,2 @@
requests>=2.31
python-dotenv>=1.0
@@ -0,0 +1,3 @@
from .pipeline import generate_shorts
__all__ = ["generate_shorts"]
@@ -0,0 +1,41 @@
"""Per-clip cropping via MuAPI /autocrop.
Given the source video URL plus a highlight's start/end and a target aspect
ratio, MuAPI returns a vertically-cropped short ready for posting.
"""
from typing import Dict
from . import muapi
from .downloader import _extract_video_url
def crop_clip(source_video_url: str, start_time: float, end_time: float, aspect_ratio: str = "9:16") -> str:
"""Submit one autocrop job and return the URL of the rendered short."""
payload = {
"video_url": source_video_url,
"start_time": float(start_time),
"end_time": float(end_time),
"aspect_ratio": aspect_ratio,
}
print(f"[clip] {start_time:.1f}s → {end_time:.1f}s @ {aspect_ratio}", flush=True)
result = muapi.run("autocrop", payload, label=f"autocrop({start_time:.0f}-{end_time:.0f})")
return _extract_video_url(result)
def crop_highlights(source_video_url: str, highlights: list, aspect_ratio: str = "9:16") -> list:
"""Crop every highlight, attaching the resulting URL back onto the dict."""
out = []
for i, h in enumerate(highlights, 1):
print(f"[clip] {i}/{len(highlights)}: {h.get('title', '(untitled)')}", flush=True)
try:
url = crop_clip(
source_video_url,
h["start_time"],
h["end_time"],
aspect_ratio=aspect_ratio,
)
out.append({**h, "clip_url": url})
except Exception as e:
print(f"[clip] {i} failed: {e}", flush=True)
out.append({**h, "clip_url": None, "error": str(e)})
return out
@@ -0,0 +1,67 @@
import os
from dotenv import load_dotenv
load_dotenv()
MUAPI_API_KEY = os.getenv("MUAPI_API_KEY", "").strip()
MUAPI_BASE_URL = os.getenv("MUAPI_BASE_URL", "https://api.muapi.ai/api/v1").rstrip("/")
POLL_INTERVAL_SECONDS = float(os.getenv("MUAPI_POLL_INTERVAL", "5"))
POLL_TIMEOUT_SECONDS = float(os.getenv("MUAPI_POLL_TIMEOUT", "600"))
# Local-mode (--mode local) settings — only consulted when running offline.
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai").strip().lower()
LOCAL_WHISPER_MODEL = os.getenv("LOCAL_WHISPER_MODEL", "base")
LOCAL_WHISPER_DEVICE = os.getenv("LOCAL_WHISPER_DEVICE", "auto") # auto / cpu / cuda
LOCAL_OUTPUT_DIR = os.getenv("LOCAL_OUTPUT_DIR", "output")
# VAD (Voice Activity Detection) settings for faster-whisper
# Default threshold is 0.5; lower = more sensitive, higher = less sensitive
# Default min_speech_duration_ms is 250ms; increase to avoid tiny false positives
# Default min_silence_duration_ms is 2000ms; increase to avoid splitting mid-sentence
# DISABLED by default because VAD is too aggressive on mixed speech/music content
LOCAL_WHISPER_VAD_FILTER = os.getenv("LOCAL_WHISPER_VAD_FILTER", "false").strip().lower() == "true"
_vad_params_env = os.getenv("LOCAL_WHISPER_VAD_PARAMETERS", "")
if _vad_params_env:
import json
LOCAL_WHISPER_VAD_PARAMETERS = json.loads(_vad_params_env)
else:
# Match faster-whisper defaults when VAD is enabled
LOCAL_WHISPER_VAD_PARAMETERS = {
"threshold": 0.5,
"min_speech_duration_ms": 250,
"max_speech_duration_s": float("inf"),
"min_silence_duration_ms": 2000,
"speech_pad_ms": 400,
}
def require_api_key() -> str:
if not MUAPI_API_KEY:
raise RuntimeError(
"MUAPI_API_KEY is not set. Add it to your .env file or export it as an env var."
)
return MUAPI_API_KEY
def require_openai_key() -> str:
if not OPENAI_API_KEY:
raise RuntimeError(
"OPENAI_API_KEY is not set. Local mode needs an OpenAI key for highlight ranking. "
"Add it to your .env or export it, or switch back to --mode api."
)
return OPENAI_API_KEY
def require_gemini_key() -> str:
if not GEMINI_API_KEY:
raise RuntimeError(
"GEMINI_API_KEY is not set. Local mode needs a Gemini key when LLM_PROVIDER=gemini. "
"Add it to your .env or export it, or switch LLM_PROVIDER back to openai."
)
return GEMINI_API_KEY
@@ -0,0 +1,36 @@
"""YouTube source video download via MuAPI /youtube-download."""
from typing import Dict
from . import muapi
def _extract_video_url(result: Dict) -> str:
"""MuAPI result shapes vary by endpoint — try common keys."""
for key in ("video_url", "url", "output_url", "result_url"):
v = result.get(key)
if isinstance(v, str) and v.startswith("http"):
return v
output = result.get("outputs") or result.get("output") or result.get("result") or {}
if isinstance(output, dict):
for key in ("video_url", "url", "output_url"):
v = output.get(key)
if isinstance(v, str) and v.startswith("http"):
return v
if isinstance(output, list) and output and isinstance(output[0], str) and output[0].startswith("http"):
return output[0]
raise RuntimeError(f"Could not find downloaded video URL in MuAPI response: {result}")
def download_youtube(video_url: str, fmt: str = "720") -> str:
"""Hand a YouTube URL to MuAPI; return a hosted mp4 URL we can read from."""
print(f"[download] requesting {video_url} @ {fmt}p", flush=True)
result = muapi.run(
"youtube-download",
{"video_url": video_url, "format": fmt},
label="youtube-download",
)
out = _extract_video_url(result)
print(f"[download] ready: {out}", flush=True)
return out
@@ -0,0 +1,306 @@
"""Find the most viral-worthy highlights in a transcript.
Logic ported from ViralVadoo's transcript_analysis/highlight_generator.py:
- content-type / density detection
- chunking for long videos with overlap
- virality-criteria prompt
- score-based dedupe with overlap suppression
The LLM call is pluggable via the `llm_fn` argument so the same prompts can
drive either MuAPI (default, --mode api) or a direct local LLM client
(--mode local).
"""
import json
import re
from typing import Callable, Dict, List, Optional
from . import muapi
LLMFn = Callable[[str], str]
CONTENT_TYPE_PROMPT = """Analyze this video transcript sample and classify the content type.
Choose one: podcast, interview, tutorial, lecture, commentary, debate, vlog, other.
Also estimate content density: low (mostly filler/chit-chat), medium, or high (dense info/stories).
Respond with JSON only: {"content_type": "...", "density": "..."}"""
VIRALITY_CRITERIA = """
Virality signals to prioritize (ranked by impact):
1. HOOK MOMENTS — statements that create immediate curiosity ("The secret is...", "Nobody talks about...", "I was completely wrong about...")
2. EMOTIONAL PEAKS — genuine surprise, laughter, anger, vulnerability, excitement; raw unscripted reactions
3. OPINION BOMBS — strong, polarizing or counter-intuitive statements that trigger agree/disagree
4. REVELATION MOMENTS — surprising facts, stats, or confessions that reframe how the viewer thinks
5. CONFLICT/TENSION — disagreement, pushback, or a problem being confronted head-on
6. QUOTABLE ONE-LINERS — a sentence that works as a standalone quote card
7. STORY PEAKS — the climax or twist of an anecdote; the payoff moment
8. PRACTICAL VALUE — a concrete tip, hack, or insight the viewer can immediately apply
"""
HIGHLIGHT_SYSTEM_PROMPT = """You are an elite short-form video editor who has studied thousands of viral clips on TikTok, Instagram Reels, and YouTube Shorts. You know exactly what makes viewers stop scrolling, watch to the end, and share.
{virality_criteria}
Content type: {content_type} | Density: {density}
Your task: identify the most viral-worthy highlights from the transcript.
Rules:
- Every highlight must open with a strong HOOK — a line that grabs attention within the first 3 seconds
- Duration sweet spot: 45-90 seconds. Go shorter (20-44s) only for a perfect standalone one-liner. Go longer (91-180s) only when a story arc needs full context to land
- Never cut mid-sentence or mid-thought — each clip must feel complete and self-contained
- Clips must not overlap significantly with each other
- Score 0-100 on viral potential (not general quality)
- {num_clips_instruction}
- For each highlight, identify the single best "hook_sentence" — the opening line that would make someone stop scrolling
- Explain in one sentence why this clip is viral ("virality_reason")
Respond ONLY with valid JSON (no markdown, no explanation):
{{"highlights":[{{"title":"string","start_time":float,"end_time":float,"score":int,"hook_sentence":"string","virality_reason":"string"}}]}}"""
CHUNK_SIZE_SECONDS = 1200 # 20-min chunks for long videos
LONG_VIDEO_THRESHOLD = 1800 # chunk videos longer than 30 min
CHUNK_OVERLAP_SECONDS = 60
GPT_CALL_TIMEOUT_SECONDS = 300 # cap LLM polls at 5 min — a wedged call should fail fast
MAX_HIGHLIGHT_API_ATTEMPTS = 3
def call_muapi_llm(prompt: str) -> str:
"""Default LLM backend: MuAPI gpt-5-mini."""
result = muapi.run(
"gpt-5-mini",
{"prompt": prompt},
label="gpt-5-mini",
timeout=GPT_CALL_TIMEOUT_SECONDS,
)
outputs = result.get("outputs")
if isinstance(outputs, list) and outputs and isinstance(outputs[0], str) and outputs[0].strip():
return outputs[0]
for key in ("output", "text", "response", "result", "content"):
v = result.get(key)
if isinstance(v, str) and v.strip():
return v
if isinstance(v, dict):
inner = v.get("text") or v.get("content")
if isinstance(inner, str) and inner.strip():
return inner
if isinstance(v, list) and v and isinstance(v[0], str):
return v[0]
raise RuntimeError(f"Could not extract gpt-5-mini text from response: {result}")
def _parse_json_loose(raw: str) -> Dict:
"""gpt-5-4 sometimes wraps JSON in markdown fences — strip and parse."""
text = raw.strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1:
return json.loads(text[start:end + 1])
raise
def _coerce_float(value: object, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _coerce_int(value: object, default: int = 0) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return default
def _sanitize_highlights(raw_highlights: object, duration: float) -> List[Dict]:
"""Normalize model output into the expected shape; skip invalid entries."""
if not isinstance(raw_highlights, list):
return []
max_end = duration if duration > 0 else float("inf")
cleaned: List[Dict] = []
for item in raw_highlights:
if not isinstance(item, dict):
continue
start = _coerce_float(item.get("start_time"), default=-1.0)
end = _coerce_float(item.get("end_time"), default=-1.0)
if start < 0 or end <= start:
continue
if max_end != float("inf"):
start = min(start, max_end)
end = min(end, max_end)
if end <= start:
continue
cleaned.append(
{
"title": str(item.get("title") or "Untitled Highlight").strip(),
"start_time": start,
"end_time": end,
"score": max(0, min(100, _coerce_int(item.get("score"), default=0))),
"hook_sentence": str(item.get("hook_sentence") or "").strip(),
"virality_reason": str(item.get("virality_reason") or "").strip(),
}
)
return cleaned
def detect_content_type(transcript: Dict, llm_fn: LLMFn = call_muapi_llm) -> Dict[str, str]:
segments = transcript.get("segments", [])
sample = " ".join(s["text"] for s in segments[:25])[:3000]
prompt = f"{CONTENT_TYPE_PROMPT}\n\nTranscript sample:\n{sample}"
try:
raw = llm_fn(prompt)
return _parse_json_loose(raw)
except Exception:
return {"content_type": "other", "density": "medium"}
def build_transcript_text(transcript: Dict) -> str:
segments = transcript.get("segments", [])
return "\n".join(f"[{s['start']:.1f}s] {s['text'].strip()}" for s in segments)
def chunk_transcript(transcript: Dict) -> List[Dict]:
segments = transcript.get("segments", [])
duration = transcript.get("duration", segments[-1]["end"] if segments else 0)
chunks = []
start = 0
while start < duration:
end = min(start + CHUNK_SIZE_SECONDS, duration)
chunk_segs = [
s for s in segments
if s["start"] >= start and s["end"] <= end + CHUNK_OVERLAP_SECONDS
]
if chunk_segs:
chunk = dict(transcript)
chunk["segments"] = chunk_segs
chunk["duration"] = end - start
chunk["_offset"] = start
chunks.append(chunk)
start += CHUNK_SIZE_SECONDS - CHUNK_OVERLAP_SECONDS
return chunks
def call_highlight_api(
transcript_text: str,
content_info: Dict,
duration: float,
num_clips: int,
is_chunk: bool = False,
llm_fn: LLMFn = call_muapi_llm,
) -> Dict:
# Ask for ~2× the user's target so dedupe has headroom, but cap so the model
# doesn't have to generate a huge JSON payload (which times out gpt-5-mini).
target = max(num_clips * 2, 5)
natural_max = max(2 if is_chunk else 3, int(duration / 90))
min_clips = min(target, natural_max, 8)
system = HIGHLIGHT_SYSTEM_PROMPT.format(
virality_criteria=VIRALITY_CRITERIA,
content_type=content_info.get("content_type", "other"),
density=content_info.get("density", "medium"),
num_clips_instruction=f"Generate at least {min_clips} highlights",
)
base_prompt = f"{system}\n\nTranscript:\n{transcript_text}"
prompt = base_prompt
last_error = "unknown"
for attempt in range(1, MAX_HIGHLIGHT_API_ATTEMPTS + 1):
raw = llm_fn(prompt)
try:
parsed = _parse_json_loose(raw)
highlights = _sanitize_highlights(parsed.get("highlights"), duration=duration)
if highlights:
return {"highlights": highlights}
last_error = "no valid highlights in response"
except Exception as e:
last_error = str(e)
if attempt < MAX_HIGHLIGHT_API_ATTEMPTS:
print(
f"[highlights] invalid model output on attempt {attempt}/{MAX_HIGHLIGHT_API_ATTEMPTS}; retrying",
flush=True,
)
prompt = (
base_prompt
+ "\n\nIMPORTANT: Return ONLY valid JSON with a top-level 'highlights' array."
+ " Each item must include: title, start_time, end_time, score, hook_sentence, virality_reason."
+ " No markdown fences, no commentary."
)
raise RuntimeError(
f"Highlight generator produced invalid output after {MAX_HIGHLIGHT_API_ATTEMPTS} attempts: {last_error}"
)
def dedupe_highlights(highlights: List[Dict]) -> List[Dict]:
"""Drop a highlight if it overlaps >50% with a higher-scoring one already kept."""
highlights = sorted(highlights, key=lambda x: int(x.get("score", 0)), reverse=True)
kept: List[Dict] = []
for h in highlights:
h_start = float(h["start_time"])
h_end = float(h["end_time"])
h_dur = h_end - h_start
overlapping = False
for k in kept:
latest_start = max(h_start, float(k["start_time"]))
earliest_end = min(h_end, float(k["end_time"]))
overlap = earliest_end - latest_start
if overlap > 0 and overlap > 0.5 * h_dur:
overlapping = True
break
if not overlapping:
kept.append(h)
return kept
def get_highlights(
transcript: Dict,
num_clips: int = 3,
llm_fn: Optional[LLMFn] = None,
) -> Dict:
"""Main entry point — returns {highlights: [...]} sorted by score.
`llm_fn` swaps the underlying LLM. Defaults to MuAPI gpt-5-mini; local
mode passes in a local LLM-backed callable.
"""
llm_fn = llm_fn or call_muapi_llm
duration = transcript.get("duration", 0)
content_info = detect_content_type(transcript, llm_fn=llm_fn)
print(f"[highlights] content={content_info.get('content_type')} density={content_info.get('density')} duration={duration:.0f}s", flush=True)
if duration >= LONG_VIDEO_THRESHOLD:
chunks = chunk_transcript(transcript)
print(f"[highlights] long video — splitting into {len(chunks)} chunks", flush=True)
all_highlights: List[Dict] = []
for i, chunk in enumerate(chunks):
offset = chunk.get("_offset", 0)
text = build_transcript_text(chunk)
print(f"[highlights] chunk {i + 1}/{len(chunks)} (offset {offset:.0f}s)", flush=True)
result = call_highlight_api(text, content_info, chunk["duration"], num_clips=num_clips, is_chunk=True, llm_fn=llm_fn)
for h in result.get("highlights", []):
h["start_time"] = float(h["start_time"]) + offset
h["end_time"] = float(h["end_time"]) + offset
all_highlights.append(h)
highlights = dedupe_highlights(all_highlights)
else:
text = build_transcript_text(transcript)
result = call_highlight_api(text, content_info, duration, num_clips=num_clips, llm_fn=llm_fn)
highlights = dedupe_highlights(result.get("highlights", []))
return {"highlights": highlights}
@@ -0,0 +1,6 @@
"""Local-mode backends — no MuAPI calls, runs on your machine.
Used when the pipeline is invoked with mode="local". Requires the optional
deps in requirements-local.txt (yt-dlp, faster-whisper, openai, google-genai,
opencv, moviepy) plus an LLM API key for highlight ranking.
"""
@@ -0,0 +1,167 @@
"""Local clipping: ffmpeg subclip + OpenCV face-aware vertical crop.
Two stages per highlight:
1. Cut the source video to [start, end] with ffmpeg (re-encoded, audio kept).
2. Reframe the cut to the target aspect ratio. For 9:16 we slide a vertical
window horizontally across the frame to keep faces centred (Haar
cascade — same approach as the original repo, no external models).
"""
import os
import subprocess
from typing import Dict, List, Optional, Tuple
from ..config import LOCAL_OUTPUT_DIR
def _ratio(aspect_ratio: str) -> float:
"""Parse '9:16' → 9/16, '1:1' → 1.0."""
try:
w, h = aspect_ratio.split(":")
return float(w) / float(h)
except (ValueError, ZeroDivisionError):
return 9.0 / 16.0
def _cut_subclip(source_path: str, start: float, end: float, out_path: str) -> str:
"""ffmpeg -ss start -to end → re-encoded mp4 with audio."""
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-i", source_path,
"-ss", f"{start:.3f}",
"-to", f"{end:.3f}",
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
"-c:a", "aac", "-b:a", "128k",
out_path,
]
subprocess.run(cmd, check=True)
return out_path
def _reframe_vertical(in_path: str, out_path: str, aspect_ratio: str) -> str:
"""Crop the cut clip to the target aspect ratio, tracking faces if possible."""
try:
import cv2 # type: ignore
except ImportError as e:
raise RuntimeError(
"opencv-python is required for --mode local. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
target_ratio = _ratio(aspect_ratio)
cap = cv2.VideoCapture(in_path)
if not cap.isOpened():
raise RuntimeError(f"could not open {in_path}")
src_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
src_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
# Compute the largest crop that fits inside the frame at the target ratio.
if target_ratio < src_w / src_h:
crop_h = src_h
crop_w = int(crop_h * target_ratio)
else:
crop_w = src_w
crop_h = int(crop_w / target_ratio)
crop_w = max(2, crop_w - (crop_w % 2))
crop_h = max(2, crop_h - (crop_h % 2))
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
silent_path = out_path + ".silent.mp4"
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(silent_path, fourcc, fps, (crop_w, crop_h))
last_center: Optional[Tuple[int, int]] = None
smoothing = 0.15 # how aggressively to chase a new face position
while True:
ret, frame = cap.read()
if not ret:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40))
if len(faces) > 0:
# Pick the largest face — usually the speaker.
x, y, w, h = max(faces, key=lambda f: f[2] * f[3])
cx = x + w // 2
cy = y + h // 2
if last_center is None:
last_center = (cx, cy)
else:
lx, ly = last_center
last_center = (
int(lx + (cx - lx) * smoothing),
int(ly + (cy - ly) * smoothing),
)
if last_center is None:
last_center = (src_w // 2, src_h // 2)
cx, cy = last_center
x0 = max(0, min(src_w - crop_w, cx - crop_w // 2))
y0 = max(0, min(src_h - crop_h, cy - crop_h // 2))
cropped = frame[y0:y0 + crop_h, x0:x0 + crop_w]
writer.write(cropped)
cap.release()
writer.release()
# Mux audio from the cut clip back onto the silent reframed video.
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-i", silent_path,
"-i", in_path,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "128k",
"-map", "0:v:0", "-map", "1:a:0?",
"-shortest",
out_path,
]
subprocess.run(cmd, check=True)
os.remove(silent_path)
return out_path
def crop_clip_local(
source_path: str,
start_time: float,
end_time: float,
aspect_ratio: str,
out_path: str,
) -> str:
"""Cut + reframe one highlight, returning the local mp4 path."""
cut_path = out_path + ".cut.mp4"
try:
_cut_subclip(source_path, start_time, end_time, cut_path)
_reframe_vertical(cut_path, out_path, aspect_ratio)
finally:
if os.path.exists(cut_path):
os.remove(cut_path)
return out_path
def crop_highlights_local(
source_path: str,
highlights: List[Dict],
aspect_ratio: str = "9:16",
out_dir: Optional[str] = None,
) -> List[Dict]:
out_dir = out_dir or LOCAL_OUTPUT_DIR
os.makedirs(out_dir, exist_ok=True)
results: List[Dict] = []
for i, h in enumerate(highlights, 1):
out_path = os.path.join(out_dir, f"short_{i:02d}.mp4")
print(f"[clip/local] {i}/{len(highlights)}: {h.get('title', '(untitled)')}", flush=True)
try:
crop_clip_local(
source_path,
float(h["start_time"]),
float(h["end_time"]),
aspect_ratio,
out_path,
)
results.append({**h, "clip_url": out_path})
except Exception as e:
print(f"[clip/local] {i} failed: {e}", flush=True)
results.append({**h, "clip_url": None, "error": str(e)})
return results
@@ -0,0 +1,135 @@
"""Local YouTube download via yt-dlp.
Returns a local mp4 path so the rest of the local pipeline can read it
directly off disk.
"""
import os
import re
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
from typing import Optional
from ..config import LOCAL_OUTPUT_DIR
def _import_ytdlp():
try:
import yt_dlp # type: ignore
except ImportError as e:
raise RuntimeError(
"yt-dlp is required for --mode local. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
return yt_dlp
def _format_for(fmt: str) -> str:
"""Map our '720' / '1080' shorthand to a yt-dlp format selector."""
try:
height = int(fmt)
except ValueError:
height = 720
return (
f"bestvideo[height<={height}][ext=mp4]+bestaudio[ext=m4a]/"
f"best[height<={height}][ext=mp4]/best"
)
def _extract_youtube_video_id(source: str) -> Optional[str]:
"""Best-effort extraction of a YouTube video id from a URL."""
parsed = urlparse(source)
host = (parsed.netloc or "").lower()
if host.startswith("www."):
host = host[4:]
if host in ("youtu.be", "www.youtu.be"):
video_id = parsed.path.lstrip("/").split("/", 1)[0]
return video_id or None
if "youtube.com" in host:
if parsed.path.startswith("/watch"):
qs = parse_qs(parsed.query)
video_id = qs.get("v", [""])[0]
return video_id or None
match = re.search(r"/(?:shorts|embed|live)/([^/?#&]+)", parsed.path)
if match:
return match.group(1)
return None
def _resolve_local_path(source: str) -> Optional[str]:
"""Return a local filesystem path if the input already points at one."""
parsed = urlparse(source)
if parsed.scheme == "file":
raw_path = unquote(parsed.path)
if parsed.netloc and parsed.netloc not in ("", "localhost"):
raw_path = f"//{parsed.netloc}{raw_path}"
candidate = Path(raw_path).expanduser()
if candidate.exists() and candidate.is_file():
return str(candidate.resolve())
raise RuntimeError(f"Local file URL does not exist: {source}")
if parsed.scheme in ("http", "https"):
return None
candidate = Path(source).expanduser()
if candidate.exists() and candidate.is_file():
return str(candidate.resolve())
if any(sep in source for sep in (os.sep, "/")) or source.startswith("~") or source.startswith("."):
raise RuntimeError(f"Local file path does not exist: {source}")
return None
def _existing_download(out_dir: str, video_id: str) -> Optional[str]:
"""Return a cached download path if we already have this YouTube id."""
for ext in (".mp4", ".mkv", ".webm"):
candidate = os.path.join(out_dir, f"source_{video_id}{ext}")
if os.path.exists(candidate):
return candidate
return None
def download_youtube_local(video_url: str, fmt: str = "720", out_dir: Optional[str] = None) -> str:
"""Download a remote URL or return a local file path unchanged."""
local_path = _resolve_local_path(video_url)
if local_path:
print(f"[download/local] using local file: {local_path}", flush=True)
return local_path
yt_dlp = _import_ytdlp()
out_dir = out_dir or LOCAL_OUTPUT_DIR
os.makedirs(out_dir, exist_ok=True)
video_id = _extract_youtube_video_id(video_url)
if video_id:
cached = _existing_download(out_dir, video_id)
if cached:
print(f"[download/local] reusing cached download: {cached}", flush=True)
return cached
print(f"[download/local] {video_url} @ {fmt}p → {out_dir}/", flush=True)
ydl_opts = {
"format": _format_for(fmt),
"outtmpl": os.path.join(out_dir, "source_%(id)s.%(ext)s"),
"merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
"noprogress": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=True)
path = ydl.prepare_filename(info)
# merge_output_format may rename the extension after merge
if not os.path.exists(path):
stem, _ = os.path.splitext(path)
for ext in (".mp4", ".mkv", ".webm"):
if os.path.exists(stem + ext):
path = stem + ext
break
print(f"[download/local] ready: {path}", flush=True)
return path
@@ -0,0 +1,62 @@
"""Local LLM backend — OpenAI or Gemini, selected by LLM_PROVIDER."""
from ..config import (
GEMINI_MODEL,
LLM_PROVIDER,
OPENAI_MODEL,
require_gemini_key,
require_openai_key,
)
def call_openai_llm(prompt: str) -> str:
"""OpenAI Chat Completions backend used by --mode local."""
try:
from openai import OpenAI # type: ignore
except ImportError as e:
raise RuntimeError(
"openai is required for --mode local. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
client = OpenAI(api_key=require_openai_key())
response = client.chat.completions.create(
model=OPENAI_MODEL,
temperature=0.7,
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content or ""
def call_gemini_llm(prompt: str) -> str:
"""Gemini backend used by --mode local when LLM_PROVIDER=gemini."""
try:
from google import genai # type: ignore
except ImportError as e:
raise RuntimeError(
"google-genai is required for LLM_PROVIDER=gemini. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
client = genai.Client(api_key=require_gemini_key())
response = client.models.generate_content(
model=GEMINI_MODEL,
contents=prompt,
config={
"temperature": 0.2,
"response_mime_type": "application/json",
"max_output_tokens": 8192,
},
)
return response.text or ""
def call_local_llm(prompt: str) -> str:
"""Dispatch to the configured local LLM provider."""
provider = (LLM_PROVIDER or "openai").strip().lower()
if provider == "openai":
return call_openai_llm(prompt)
if provider == "gemini":
return call_gemini_llm(prompt)
raise RuntimeError(
f"Unknown LLM_PROVIDER={provider!r}. Use 'openai' or 'gemini'."
)
@@ -0,0 +1,162 @@
"""Local transcription via faster-whisper.
Reads a local media file and returns the same shape the highlight generator
expects: {duration, segments[start, end, text]}.
"""
import os
import re
from pathlib import Path
from typing import Dict, Optional
from ..config import LOCAL_OUTPUT_DIR, LOCAL_WHISPER_DEVICE, LOCAL_WHISPER_MODEL
def _transcript_cache_path(media_path: str) -> Path:
"""Return the .srt cache path for a media file."""
cache_dir = Path(LOCAL_OUTPUT_DIR)
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / (Path(media_path).stem + ".srt")
def _format_srt_timestamp(seconds: float) -> str:
total_ms = max(0, int(round(seconds * 1000)))
ms = total_ms % 1000
total_s = total_ms // 1000
s = total_s % 60
total_m = total_s // 60
m = total_m % 60
h = total_m // 60
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _parse_srt_timestamp(value: str) -> float:
match = re.fullmatch(r"(\d{2}):(\d{2}):(\d{2}),(\d{3})", value.strip())
if not match:
raise ValueError(f"Invalid SRT timestamp: {value!r}")
hours, minutes, seconds, millis = map(int, match.groups())
return hours * 3600 + minutes * 60 + seconds + (millis / 1000.0)
def _write_srt_cache(media_path: str, transcript: Dict) -> Path:
cache_path = _transcript_cache_path(media_path)
lines = []
for idx, segment in enumerate(transcript.get("segments", []), start=1):
start = _format_srt_timestamp(float(segment["start"]))
end = _format_srt_timestamp(float(segment["end"]))
text = str(segment.get("text", "")).strip().replace("\r", "").replace("\n", " ")
lines.append(str(idx))
lines.append(f"{start} --> {end}")
lines.append(text)
lines.append("")
cache_path.write_text("\n".join(lines), encoding="utf-8")
return cache_path
def _load_srt_cache(cache_path: Path) -> Dict:
content = cache_path.read_text(encoding="utf-8-sig").strip()
if not content:
return {"duration": 0.0, "segments": []}
segments = []
for block in re.split(r"\n\s*\n", content):
lines = [line.strip("\ufeff") for line in block.splitlines() if line.strip()]
if not lines:
continue
if "-->" not in lines[0] and len(lines) > 1 and "-->" in lines[1]:
lines = lines[1:]
if not lines or "-->" not in lines[0]:
continue
start_raw, end_raw = [part.strip() for part in lines[0].split("-->", 1)]
text = "\n".join(lines[1:]).strip()
segments.append(
{
"start": _parse_srt_timestamp(start_raw),
"end": _parse_srt_timestamp(end_raw),
"text": text,
}
)
duration = segments[-1]["end"] if segments else 0.0
return {"duration": duration, "segments": segments}
def _resolve_device() -> str:
if LOCAL_WHISPER_DEVICE != "auto":
return LOCAL_WHISPER_DEVICE
try:
import torch # type: ignore
if torch.cuda.is_available():
# Test that CUDA actually works (catches missing cuBLAS/cuDNN libs)
torch.zeros(1, device="cuda")
return "cuda"
except (ImportError, OSError, RuntimeError):
pass
return "cpu"
def transcribe_local(media_path: str, language: Optional[str] = None) -> Dict:
"""Run faster-whisper on a local file path, caching the result as .srt."""
cache_path = _transcript_cache_path(media_path)
if cache_path.exists():
source_mtime = os.path.getmtime(media_path)
cache_mtime = cache_path.stat().st_mtime
if cache_mtime >= source_mtime:
print(f"[transcribe/local] reusing cached transcript: {cache_path}", flush=True)
cached = _load_srt_cache(cache_path)
# Treat empty cache as invalid (likely from a failed/partial run) — delete and re-transcribe
if not cached["segments"] or cached["duration"] <= 0.0:
print(f"[transcribe/local] cache is empty/invalid, deleting: {cache_path}", flush=True)
cache_path.unlink(missing_ok=True)
else:
print(
f"[transcribe/local] {len(cached['segments'])} cached segments, "
f"{cached['duration']:.0f}s of audio",
flush=True,
)
return cached
try:
from faster_whisper import WhisperModel # type: ignore
except ImportError as e:
raise RuntimeError(
"faster-whisper is required for --mode local. Install it with:\n"
" pip install -r requirements-local.txt"
) from e
device = _resolve_device()
compute_type = "float16" if device == "cuda" else "int8"
print(f"[transcribe/local] faster-whisper model={LOCAL_WHISPER_MODEL} device={device}", flush=True)
from ..config import LOCAL_WHISPER_VAD_FILTER, LOCAL_WHISPER_VAD_PARAMETERS
model = WhisperModel(LOCAL_WHISPER_MODEL, device=device, compute_type=compute_type)
transcribe_kwargs = {
"audio": media_path,
"language": language,
"beam_size": 5,
"condition_on_previous_text": False,
}
if LOCAL_WHISPER_VAD_FILTER:
transcribe_kwargs["vad_filter"] = True
transcribe_kwargs["vad_parameters"] = LOCAL_WHISPER_VAD_PARAMETERS
else:
transcribe_kwargs["vad_filter"] = False
segments_iter, info = model.transcribe(**transcribe_kwargs)
segments = []
for s in segments_iter:
segments.append({
"start": float(s.start),
"end": float(s.end),
"text": (s.text or "").strip(),
})
duration = float(getattr(info, "duration", 0.0)) or (segments[-1]["end"] if segments else 0.0)
print(f"[transcribe/local] {len(segments)} segments, {duration:.0f}s of audio", flush=True)
transcript = {"duration": duration, "segments": segments}
cache_path = _write_srt_cache(media_path, transcript)
print(f"[transcribe/local] wrote cache: {cache_path}", flush=True)
return transcript
@@ -0,0 +1,97 @@
"""Thin MuAPI client: submit a job, poll until it finishes, return the result."""
import time
from typing import Any, Dict, Optional
import requests
from .config import (
MUAPI_BASE_URL,
POLL_INTERVAL_SECONDS,
POLL_TIMEOUT_SECONDS,
require_api_key,
)
class MuAPIError(RuntimeError):
pass
def _headers() -> Dict[str, str]:
return {
"Content-Type": "application/json",
"x-api-key": require_api_key(),
}
def submit(endpoint: str, payload: Dict[str, Any], retries: int = 3) -> str:
"""POST to /api/v1/{endpoint} and return the request_id; retry transient errors."""
url = f"{MUAPI_BASE_URL}/{endpoint.lstrip('/')}"
last_err: Optional[Exception] = None
for _ in range(retries):
try:
resp = requests.post(url, json=payload, headers=_headers(), timeout=120)
if resp.status_code >= 400:
raise MuAPIError(f"{endpoint} submit failed [{resp.status_code}]: {resp.text}")
data = resp.json()
request_id = data.get("request_id") or data.get("id")
if not request_id:
raise MuAPIError(f"{endpoint} response had no request_id: {data}")
return str(request_id)
except (requests.Timeout, requests.ConnectionError) as e:
last_err = e
time.sleep(2)
raise MuAPIError(f"{endpoint} submit failed after {retries} retries: {last_err}")
def fetch_result(request_id: str, retries: int = 3) -> Dict[str, Any]:
"""GET the latest result for a request_id; retry on transient timeouts."""
url = f"{MUAPI_BASE_URL}/predictions/{request_id}/result"
last_err: Optional[Exception] = None
for _ in range(retries):
try:
resp = requests.get(url, headers=_headers(), timeout=90)
if resp.status_code >= 400:
raise MuAPIError(f"poll failed [{resp.status_code}]: {resp.text}")
return resp.json()
except (requests.Timeout, requests.ConnectionError) as e:
last_err = e
time.sleep(2)
raise MuAPIError(f"poll failed after {retries} retries: {last_err}")
def poll(
request_id: str,
interval: float = POLL_INTERVAL_SECONDS,
timeout: float = POLL_TIMEOUT_SECONDS,
label: Optional[str] = None,
) -> Dict[str, Any]:
"""Block until the prediction is done; return the final payload."""
deadline = time.time() + timeout
last_status = None
while time.time() < deadline:
data = fetch_result(request_id)
status = (data.get("status") or "").lower()
if status and status != last_status:
print(f"[muapi] {label or request_id}: {status}", flush=True)
last_status = status
if status in ("completed", "succeeded", "success"):
return data
if status in ("failed", "error"):
raise MuAPIError(f"{label or request_id} failed: {data}")
time.sleep(interval)
raise MuAPIError(f"{label or request_id} timed out after {timeout}s")
def run(
endpoint: str,
payload: Dict[str, Any],
label: Optional[str] = None,
interval: float = POLL_INTERVAL_SECONDS,
timeout: float = POLL_TIMEOUT_SECONDS,
) -> Dict[str, Any]:
"""Submit then poll. Returns the final result payload."""
request_id = submit(endpoint, payload)
return poll(request_id, interval=interval, timeout=timeout, label=label or endpoint)
@@ -0,0 +1,123 @@
"""End-to-end orchestrator.
Two modes:
* mode="api" (default) MuAPI does download / transcribe / LLM / autocrop.
Fast, no local deps, pay-per-call.
* mode="local" yt-dlp + faster-whisper + OpenAI or Gemini + ffmpeg/opencv.
Self-hosted, LLM_PROVIDER selects OpenAI or Gemini.
"""
from typing import Dict, List, Optional
from .clipper import crop_highlights
from .downloader import download_youtube
from .highlights import call_muapi_llm, get_highlights
from .transcriber import transcribe
def _run_local(
youtube_url: str,
num_clips: int,
aspect_ratio: str,
download_format: str,
language: Optional[str],
) -> Dict:
from .local.clipper import crop_highlights_local
from .local.downloader import download_youtube_local
from .local.llm import call_local_llm
from .local.transcriber import transcribe_local
source_path = download_youtube_local(youtube_url, fmt=download_format)
transcript = transcribe_local(source_path, language=language)
if not transcript["segments"]:
raise RuntimeError(
"Whisper produced no segments. The video may have no detectable speech."
)
highlights_result = get_highlights(transcript, num_clips=num_clips, llm_fn=call_local_llm)
all_highlights: List[Dict] = highlights_result.get("highlights", [])
if not all_highlights:
raise RuntimeError("Highlight generator returned zero clips.")
top = sorted(all_highlights, key=lambda h: int(h.get("score", 0)), reverse=True)[:num_clips]
print(f"[pipeline/local] cropping {len(top)} of {len(all_highlights)} candidates", flush=True)
shorts = crop_highlights_local(source_path, top, aspect_ratio=aspect_ratio)
return {
"mode": "local",
"source_video_url": source_path,
"transcript": transcript,
"highlights": all_highlights,
"shorts": shorts,
}
def _run_api(
youtube_url: str,
num_clips: int,
aspect_ratio: str,
download_format: str,
language: Optional[str],
) -> Dict:
source_url = download_youtube(youtube_url, fmt=download_format)
transcript = transcribe(source_url, language=language)
if not transcript["segments"]:
raise RuntimeError(
"Whisper produced no segments. The video may have no detectable speech."
)
highlights_result = get_highlights(transcript, num_clips=num_clips, llm_fn=call_muapi_llm)
all_highlights: List[Dict] = highlights_result.get("highlights", [])
if not all_highlights:
raise RuntimeError("Highlight generator returned zero clips.")
top = sorted(all_highlights, key=lambda h: int(h.get("score", 0)), reverse=True)[:num_clips]
print(f"[pipeline] cropping {len(top)} of {len(all_highlights)} candidates", flush=True)
shorts = crop_highlights(source_url, top, aspect_ratio=aspect_ratio)
return {
"mode": "api",
"source_video_url": source_url,
"transcript": transcript,
"highlights": all_highlights,
"shorts": shorts,
}
def generate_shorts(
youtube_url: str,
num_clips: int = 3,
aspect_ratio: str = "9:16",
download_format: str = "720",
language: Optional[str] = None,
mode: str = "api",
) -> Dict:
"""Run the full pipeline and return a structured result.
Args:
youtube_url: source URL.
num_clips: how many shorts to render.
aspect_ratio: e.g. "9:16", "1:1".
download_format: source resolution ("360" / "480" / "720" / "1080").
language: ISO-639-1 to force Whisper language detection.
mode: "api" (default, MuAPI) or "local" (yt-dlp + faster-whisper +
OpenAI or Gemini + ffmpeg).
Returns:
{
"mode": "api" | "local",
"source_video_url": str, # hosted URL (api) or local path (local)
"transcript": {...},
"highlights": [...], # all candidates ranked
"shorts": [...], # top `num_clips` with clip_url / local path
}
"""
mode = (mode or "api").lower()
if mode == "local":
return _run_local(youtube_url, num_clips, aspect_ratio, download_format, language)
if mode == "api":
return _run_api(youtube_url, num_clips, aspect_ratio, download_format, language)
raise ValueError(f"Unknown mode: {mode!r}. Use 'api' or 'local'.")
@@ -0,0 +1,76 @@
"""Transcription via MuAPI /openai-whisper.
Sends a hosted media URL to MuAPI's Whisper endpoint and returns the segment
shape expected by the highlight generator: {duration, segments[start,end,text]}.
The API runs verbose_json server-side, so we get per-segment timestamps for free.
"""
import json
from typing import Dict, Optional
from . import muapi
def _coerce_verbose(raw) -> Dict:
"""The /openai-whisper result can land as a dict or a JSON string depending on
how the worker stored it. Normalise to a dict with `duration` and `segments`."""
if isinstance(raw, str):
try:
return json.loads(raw)
except (TypeError, ValueError):
return {}
if isinstance(raw, dict):
return raw
return {}
def _extract_verbose_payload(result: Dict) -> Dict:
"""MuAPI wraps results inconsistently across endpoints. Hunt for the
verbose_json blob (which has `segments` + `duration`)."""
for key in ("output", "result", "outputs"):
v = result.get(key)
if isinstance(v, dict) and "segments" in v:
return v
if isinstance(v, list) and v:
first = v[0]
decoded = _coerce_verbose(first)
if "segments" in decoded:
return decoded
if isinstance(v, str):
decoded = _coerce_verbose(v)
if "segments" in decoded:
return decoded
if "segments" in result:
return result
raise RuntimeError(f"Could not find Whisper segments in MuAPI response: {result}")
def transcribe(media_url: str, language: Optional[str] = None) -> Dict:
"""Run MuAPI /openai-whisper on a hosted media URL.
Returns {duration: float, segments: [{start, end, text}, ...]} so it slots
straight into the highlight generator.
"""
print(f"[transcribe] muapi /openai-whisper on {media_url}", flush=True)
payload = {
"audio_url": media_url,
"response_format": "verbose_json",
}
if language:
payload["language"] = language
result = muapi.run("openai-whisper", payload, label="openai-whisper")
verbose = _extract_verbose_payload(result)
segments = []
for s in verbose.get("segments") or []:
segments.append({
"start": float(s.get("start", 0.0)),
"end": float(s.get("end", 0.0)),
"text": (s.get("text") or "").strip(),
})
duration = float(verbose.get("duration") or (segments[-1]["end"] if segments else 0.0))
print(f"[transcribe] {len(segments)} segments, {duration:.0f}s of audio", flush=True)
return {"duration": duration, "segments": segments}