+ ${video.channel} · ${video.duration_minutes} min · score ${video.score} +
${video.description || ''}
+ +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 ? '✅' : '❌'} ${video.description || ''}
+ 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.channel} · ${video.duration_minutes} min · score ${video.score}
+
${data.summary}
+ + + ${data.narration_download ? `` : ''} + `; + } 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.message}
+${data.summary}
+ + `; + } 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 @@ + + + + + +Détecte des vidéos longues/tendances, puis fabrique un YouTube Short avec narration ou résumé téléchargeable.
+ +Vérification...
+