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:
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 (0–100), 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` 0–100, `title`, `hook_sentence`, `virality_reason`. Aim for 30–75s 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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
output/
|
||||
*.mp4
|
||||
.DS_Store
|
||||
@@ -0,0 +1,307 @@
|
||||
# AI YouTube Shorts Generator
|
||||
|
||||
[](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 Clip–style 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
|
||||
|
||||

|
||||
|
||||
<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 Clip–style 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 0–100
|
||||
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)
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user