Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# YouTube Long2Short Studio

Webapp simple (FastAPI + frontend) pour:
- détecter des vidéos YouTube longues et potentiellement tendances,
- générer un short vertical (9:16),
- ajouter une narration automatique (optionnel),
- télécharger la vidéo short et le résumé texte.

## Important (pour débutant)

Si YouTube/ffmpeg ne fonctionne pas sur ta machine, l'app propose aussi un **mode démo**:
- tu colles un texte long,
- l'app génère un résumé téléchargeable.

Ça te permet de vérifier que l'app marche déjà, même sans pipeline vidéo complet.

## Installation pas à pas

### 1) Créer l'environnement Python

```bash
python -m venv .venv
source .venv/bin/activate
```

### 2) Installer les dépendances Python

```bash
pip install -r requirements.txt
```

### 3) Vérifier les dépendances système

- `ffmpeg` (obligatoire pour créer le short vidéo)
- accès internet (YouTube + TTS)

### 4) Lancer l'application

```bash
uvicorn app.main:app --reload
```

Ouvre `http://127.0.0.1:8000`.

## Utilisation

1. **État système**: regarde les indicateurs FastAPI / yt-dlp / gTTS.
2. **Détection YouTube**: cherche un sujet.
3. **Génération du short réel**: colle une URL YouTube.
4. **Mode démo**: si le réel échoue, génère au moins le résumé téléchargeable.

## API

- `GET /api/health` : statut dépendances
- `POST /api/analyze`
- `POST /api/create-short`
- `POST /api/demo-short`
- `GET /downloads/{filename}`

## Limites

- Le scoring "tendance" est heuristique (vues, fraîcheur, durée), sans API YouTube officielle.
- La narration utilise gTTS en français.
113 changes: 113 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field

from app.services.dependencies import get_dependency_status
from app.services.short_generator import ShortGenerator, ShortGenerationError
from app.services.summarizer import SummaryService
from app.services.youtube_service import YouTubeService, YouTubeServiceError

BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
OUTPUT_DIR = BASE_DIR / "output"
OUTPUT_DIR.mkdir(exist_ok=True)

app = FastAPI(title="YouTube Long2Short Studio")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

youtube_service = YouTubeService()
summary_service = SummaryService()
short_generator = ShortGenerator(output_dir=OUTPUT_DIR, summary_service=summary_service)


class AnalyzeRequest(BaseModel):
topic: str = Field(..., min_length=2, max_length=120)
min_duration_minutes: int = Field(default=10, ge=1, le=300)
max_results: int = Field(default=8, ge=1, le=20)


class CreateShortRequest(BaseModel):
video_url: str
include_narration: bool = True
custom_summary: Optional[str] = None


class DemoShortRequest(BaseModel):
title: str = Field(default="Vidéo exemple")
long_description: str = Field(..., min_length=40, max_length=5000)


@app.get("/", response_class=HTMLResponse)
def home() -> HTMLResponse:
index = STATIC_DIR / "index.html"
return HTMLResponse(index.read_text(encoding="utf-8"))


@app.get("/api/health")
def health():
deps = get_dependency_status()
return {
"status": "ok",
"dependencies": {
"fastapi": deps.fastapi,
"yt_dlp": deps.yt_dlp,
"gtts": deps.gtts,
"ready_for_full_pipeline": deps.ready_for_full_pipeline,
},
}


@app.post("/api/analyze")
def analyze(req: AnalyzeRequest):
try:
videos = youtube_service.search_long_trending_videos(
topic=req.topic,
min_duration_minutes=req.min_duration_minutes,
max_results=req.max_results,
)
except YouTubeServiceError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

return {"videos": videos}


@app.post("/api/create-short")
def create_short(req: CreateShortRequest):
try:
package = short_generator.create_short_package(
video_url=req.video_url,
include_narration=req.include_narration,
custom_summary=req.custom_summary,
)
except (YouTubeServiceError, ShortGenerationError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

return package


@app.post("/api/demo-short")
def create_demo_short(req: DemoShortRequest):
summary = summary_service.summarize(req.long_description)
demo_name = "demo-resume.txt"
demo_file = OUTPUT_DIR / demo_name
demo_file.write_text(f"Titre: {req.title}\n\n{summary}", encoding="utf-8")
return {
"title": req.title,
"summary": summary,
"summary_download": f"/downloads/{demo_name}",
"message": "Mode démo: résumé généré sans YouTube/ffmpeg.",
}


@app.get("/downloads/{filename}")
def download(filename: str):
file_path = (OUTPUT_DIR / filename).resolve()
if OUTPUT_DIR.resolve() not in file_path.parents:
raise HTTPException(status_code=400, detail="Nom de fichier invalide")
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path=file_path)
27 changes: 27 additions & 0 deletions app/services/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

import importlib
from dataclasses import dataclass


@dataclass(frozen=True)
class DependencyStatus:
fastapi: bool
yt_dlp: bool
gtts: bool

@property
def ready_for_full_pipeline(self) -> bool:
return self.fastapi and self.yt_dlp and self.gtts


def has_module(module_name: str) -> bool:
return importlib.util.find_spec(module_name) is not None


def get_dependency_status() -> DependencyStatus:
return DependencyStatus(
fastapi=has_module("fastapi"),
yt_dlp=has_module("yt_dlp"),
gtts=has_module("gtts"),
)
151 changes: 151 additions & 0 deletions app/services/short_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
from __future__ import annotations

import re
import subprocess
import uuid
from pathlib import Path

from app.services.summarizer import SummaryService
from app.services.youtube_service import YouTubeService, YouTubeServiceError


class ShortGenerationError(Exception):
pass


class ShortGenerator:
def __init__(self, output_dir: Path, summary_service: SummaryService):
self.output_dir = output_dir
self.summary_service = summary_service
self.youtube_service = YouTubeService()

def _slug(self, value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")[:80] or "video"

def _yt_dlp(self):
try:
import yt_dlp # type: ignore
except Exception as exc:
raise ShortGenerationError(
"Le module yt-dlp est manquant. Installe les dépendances: pip install -r requirements.txt"
) from exc
return yt_dlp

def _gtts(self):
try:
from gtts import gTTS # type: ignore
except Exception as exc:
raise ShortGenerationError(
"Le module gTTS est manquant. Installe les dépendances: pip install -r requirements.txt"
) from exc
return gTTS

def create_short_package(
self,
video_url: str,
include_narration: bool = True,
custom_summary: str | None = None,
) -> dict:
meta = self.youtube_service.get_video_metadata(video_url)
duration = meta["duration"]

summary = custom_summary.strip() if custom_summary else self.summary_service.summarize(meta["description"])
if len(summary) < 40:
summary = f"Résumé: {summary}"

base = f"{self._slug(meta['title'])}-{uuid.uuid4().hex[:8]}"
source_file = self.output_dir / f"{base}-source.mp4"
short_file = self.output_dir / f"{base}-short.mp4"
summary_file = self.output_dir / f"{base}-resume.txt"
narration_file = self.output_dir / f"{base}-narration.mp3"

self._download_video(video_url, source_file)

target_len = min(58, max(35, duration // 5))
start = max(0, (duration // 2) - (target_len // 2))

try:
self._render_vertical_short(source_file, short_file, start, target_len)
if include_narration:
gTTS = self._gtts()
gTTS(summary, lang="fr").save(str(narration_file))
self._replace_audio(short_file, narration_file)
summary_file.write_text(summary, encoding="utf-8")
except (YouTubeServiceError, ShortGenerationError):
raise
except Exception as exc:
raise ShortGenerationError(f"Erreur lors de la création du Short: {exc}") from exc
finally:
if source_file.exists():
source_file.unlink(missing_ok=True)

return {
"title": meta["title"],
"summary": summary,
"short_download": f"/downloads/{short_file.name}",
"summary_download": f"/downloads/{summary_file.name}",
"narration_download": f"/downloads/{narration_file.name}" if include_narration else None,
}

def _download_video(self, video_url: str, output_file: Path) -> None:
yt_dlp = self._yt_dlp()
options = {
"format": "best[ext=mp4]/best",
"outtmpl": str(output_file),
"quiet": True,
"no_warnings": True,
}
with yt_dlp.YoutubeDL(options) as ydl:
ydl.download([video_url])

if not output_file.exists():
raise ShortGenerationError("Téléchargement vidéo échoué.")

def _render_vertical_short(self, source: Path, target: Path, start: int, duration: int) -> None:
cmd = [
"ffmpeg",
"-y",
"-ss",
str(start),
"-i",
str(source),
"-t",
str(duration),
"-vf",
"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-c:a",
"aac",
str(target),
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise ShortGenerationError("ffmpeg est introuvable sur le système.") from exc

def _replace_audio(self, target_video: Path, narration_audio: Path) -> None:
out = target_video.with_name(target_video.stem + "-narrated.mp4")
cmd = [
"ffmpeg",
"-y",
"-i",
str(target_video),
"-i",
str(narration_audio),
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-shortest",
str(out),
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise ShortGenerationError("ffmpeg est introuvable sur le système.") from exc
out.replace(target_video)
Loading