diff --git a/README.md b/README.md new file mode 100644 index 0000000..6955026 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..86386ed --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/services/dependencies.py b/app/services/dependencies.py new file mode 100644 index 0000000..63618f5 --- /dev/null +++ b/app/services/dependencies.py @@ -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"), + ) diff --git a/app/services/short_generator.py b/app/services/short_generator.py new file mode 100644 index 0000000..3725284 --- /dev/null +++ b/app/services/short_generator.py @@ -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) diff --git a/app/services/summarizer.py b/app/services/summarizer.py new file mode 100644 index 0000000..37e82eb --- /dev/null +++ b/app/services/summarizer.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import re +from collections import Counter + + +class SummaryService: + STOPWORDS = { + "the", + "and", + "for", + "that", + "with", + "this", + "from", + "vous", + "dans", + "pour", + "avec", + "une", + "des", + "les", + "est", + "sur", + } + + def summarize(self, text: str, max_sentences: int = 5) -> str: + cleaned = re.sub(r"\s+", " ", text or "").strip() + if not cleaned: + return "Résumé indisponible : ajoutez un résumé personnalisé." + + sentences = re.split(r"(?<=[.!?])\s+", cleaned) + if len(sentences) <= max_sentences: + return " ".join(sentences) + + words = re.findall(r"[\w']+", cleaned.lower()) + freqs = Counter(w for w in words if len(w) > 3 and w not in self.STOPWORDS) + + scored: list[tuple[int, float]] = [] + for idx, sentence in enumerate(sentences): + s_words = re.findall(r"[\w']+", sentence.lower()) + score = sum(freqs.get(w, 0) for w in s_words) + scored.append((idx, score)) + + top_indexes = sorted( + [idx for idx, _ in sorted(scored, key=lambda row: row[1], reverse=True)[:max_sentences]] + ) + return " ".join(sentences[idx] for idx in top_indexes) diff --git a/app/services/youtube_service.py b/app/services/youtube_service.py new file mode 100644 index 0000000..f5f7067 --- /dev/null +++ b/app/services/youtube_service.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import math +from datetime import datetime, timezone +from typing import Any + + +class YouTubeServiceError(Exception): + pass + + +class YouTubeService: + def __init__(self) -> None: + self._base_opts = { + "quiet": True, + "no_warnings": True, + "extract_flat": False, + "skip_download": True, + } + + def _yt_dlp(self): + try: + import yt_dlp # type: ignore + except Exception as exc: + raise YouTubeServiceError( + "Le module yt-dlp est manquant. Installe les dépendances: pip install -r requirements.txt" + ) from exc + return yt_dlp + + def _score(self, views: int, age_days: float, duration: int) -> float: + freshness = 1 / max(age_days, 1) + duration_bonus = min(duration / 3600, 3) + return math.log10(max(views, 1)) * freshness * (1 + duration_bonus) + + def search_long_trending_videos( + self, + topic: str, + min_duration_minutes: int = 10, + max_results: int = 8, + ) -> list[dict[str, Any]]: + yt_dlp = self._yt_dlp() + search_url = f"ytsearch{max_results * 4}:{topic}" + with yt_dlp.YoutubeDL(self._base_opts) as ydl: + try: + data = ydl.extract_info(search_url, download=False) + except Exception as exc: + raise YouTubeServiceError(f"Erreur YouTube: {exc}") from exc + + now = datetime.now(timezone.utc) + candidates: list[dict[str, Any]] = [] + for entry in data.get("entries", []): + duration = entry.get("duration") or 0 + if duration < min_duration_minutes * 60: + continue + + timestamp = entry.get("timestamp") or now.timestamp() + published_at = datetime.fromtimestamp(timestamp, tz=timezone.utc) + age_days = max((now - published_at).days, 1) + + views = entry.get("view_count") or 0 + score = self._score(views=views, age_days=age_days, duration=duration) + candidates.append( + { + "title": entry.get("title"), + "url": entry.get("webpage_url"), + "channel": entry.get("uploader"), + "duration_minutes": round(duration / 60, 1), + "views": views, + "published": published_at.date().isoformat(), + "score": round(score, 4), + "thumbnail": entry.get("thumbnail"), + "description": (entry.get("description") or "")[:1200], + } + ) + + if not candidates: + raise YouTubeServiceError("Aucune vidéo longue trouvée avec ces critères.") + + candidates.sort(key=lambda item: item["score"], reverse=True) + return candidates[:max_results] + + def get_video_metadata(self, video_url: str) -> dict[str, Any]: + yt_dlp = self._yt_dlp() + with yt_dlp.YoutubeDL(self._base_opts) as ydl: + try: + info = ydl.extract_info(video_url, download=False) + except Exception as exc: + raise YouTubeServiceError(f"Impossible de charger la vidéo: {exc}") from exc + + duration = info.get("duration") or 0 + if duration < 60: + raise YouTubeServiceError("La vidéo est trop courte pour générer un Short utile.") + + return { + "title": info.get("title", "video"), + "duration": duration, + "description": info.get("description") or "", + "url": info.get("webpage_url") or video_url, + } diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..0854e0b --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,132 @@ +const healthEl = document.querySelector('#health'); +const analyzeForm = document.querySelector('#analyze-form'); +const videosEl = document.querySelector('#videos'); +const shortForm = document.querySelector('#short-form'); +const demoForm = document.querySelector('#demo-form'); +const outputEl = document.querySelector('#output'); + +async function checkHealth() { + try { + const res = await fetch('/api/health'); + const data = await res.json(); + const deps = data.dependencies; + healthEl.innerHTML = ` + FastAPI: ${deps.fastapi ? '✅' : '❌'} · + yt-dlp: ${deps.yt_dlp ? '✅' : '❌'} · + gTTS: ${deps.gtts ? '✅' : '❌'}
+ Pipeline complet: ${deps.ready_for_full_pipeline ? 'OK' : 'NON'} + `; + } catch (error) { + healthEl.textContent = `Impossible de vérifier l'état: ${error}`; + } +} + +analyzeForm.addEventListener('submit', async (e) => { + e.preventDefault(); + videosEl.innerHTML = 'Analyse en cours...'; + + const payload = { + topic: document.querySelector('#topic').value, + min_duration_minutes: Number(document.querySelector('#minDuration').value), + max_results: Number(document.querySelector('#maxResults').value), + }; + + try { + const res = await fetch('/api/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await res.json(); + if (!res.ok) { + videosEl.textContent = data.detail || 'Erreur'; + return; + } + + videosEl.innerHTML = data.videos + .map( + (video) => ` +
+ ${video.title}
+ ${video.channel} · ${video.duration_minutes} min · score ${video.score} +

${video.description || ''}

+ +
+ `, + ) + .join(''); + } catch (error) { + videosEl.textContent = `Erreur réseau: ${error}`; + } +}); + +shortForm.addEventListener('submit', async (e) => { + e.preventDefault(); + outputEl.textContent = 'Création du short...'; + + const payload = { + video_url: document.querySelector('#videoUrl').value, + include_narration: document.querySelector('#narration').checked, + custom_summary: document.querySelector('#customSummary').value || null, + }; + + try { + const res = await fetch('/api/create-short', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + + if (!res.ok) { + outputEl.textContent = data.detail || 'Erreur'; + return; + } + + outputEl.innerHTML = ` +

${data.title}

+

${data.summary}

+

Télécharger le short

+

Télécharger le résumé

+ ${data.narration_download ? `

Télécharger la narration

` : ''} + `; + } catch (error) { + outputEl.textContent = `Erreur réseau: ${error}`; + } +}); + +demoForm.addEventListener('submit', async (e) => { + e.preventDefault(); + outputEl.textContent = 'Génération du résumé démo...'; + + const payload = { + title: document.querySelector('#demoTitle').value, + long_description: document.querySelector('#demoDescription').value, + }; + + try { + const res = await fetch('/api/demo-short', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + + if (!res.ok) { + outputEl.textContent = data.detail || 'Erreur'; + return; + } + + outputEl.innerHTML = ` +

${data.title}

+

${data.message}

+

${data.summary}

+

Télécharger le résumé démo

+ `; + } catch (error) { + outputEl.textContent = `Erreur réseau: ${error}`; + } +}); + +checkHealth(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..57e21bc --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,61 @@ + + + + + + YouTube Long2Short Studio + + + +
+

YouTube Long2Short Studio

+

Détecte des vidéos longues/tendances, puis fabrique un YouTube Short avec narration ou résumé téléchargeable.

+ +
+

État système

+

Vérification...

+
+ +
+

1) Détection YouTube

+
+ + + + +
+
+ +
+

2) Résultats

+
+
+ +
+

3) Génération du short réel

+
+ + + + +
+
+ +
+

4) Mode démo (si YouTube indisponible)

+
+ + + +
+
+ +
+

Sortie

+
+
+
+ + + + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..0a020dc --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,59 @@ +:root { + color-scheme: dark; + font-family: Inter, system-ui, sans-serif; +} +body { + margin: 0; + background: #0f172a; + color: #e2e8f0; +} +.container { + max-width: 1000px; + margin: 0 auto; + padding: 2rem; +} +.panel { + background: #1e293b; + border-radius: 14px; + padding: 1rem; + margin-bottom: 1rem; +} +form { + display: grid; + gap: 0.6rem; +} +input, +textarea, +button { + border-radius: 8px; + border: 1px solid #475569; + padding: 0.75rem; + background: #0f172a; + color: #f8fafc; +} +textarea { + min-height: 100px; +} +button { + cursor: pointer; + background: #2563eb; + border: none; +} +.cards { + display: grid; + gap: 0.75rem; +} +.card { + border: 1px solid #334155; + border-radius: 10px; + padding: 0.75rem; +} +small { + color: #93c5fd; +} +#output a { + color: #60a5fa; +} +#health { + color: #cbd5e1; +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eeebea1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +uvicorn==0.32.1 +yt-dlp==2025.1.15 +gTTS==2.5.4 +python-multipart==0.0.20