+ ${video.channel} · ${video.duration_minutes} min · score ${video.score} +
${video.description || ''}
+ +From 7a96be3febd7e3b835184f53120b37c4e5d24fb1 Mon Sep 17 00:00:00 2001
From: Misaela22 <96578646+Misaela22@users.noreply.github.com>
Date: Thu, 12 Feb 2026 15:14:28 +0300
Subject: [PATCH 1/2] Create YouTube Long2Short webapp with short generation
pipeline
---
README.md | 34 +++++++++
app/main.py | 77 +++++++++++++++++++
app/services/short_generator.py | 126 ++++++++++++++++++++++++++++++++
app/services/summarizer.py | 48 ++++++++++++
app/services/youtube_service.py | 90 +++++++++++++++++++++++
app/static/app.js | 69 +++++++++++++++++
app/static/index.html | 43 +++++++++++
app/static/styles.css | 51 +++++++++++++
requirements.txt | 5 ++
9 files changed, 543 insertions(+)
create mode 100644 README.md
create mode 100644 app/main.py
create mode 100644 app/services/short_generator.py
create mode 100644 app/services/summarizer.py
create mode 100644 app/services/youtube_service.py
create mode 100644 app/static/app.js
create mode 100644 app/static/index.html
create mode 100644 app/static/styles.css
create mode 100644 requirements.txt
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c34e8a4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# YouTube Long2Short Studio
+
+Webapp complète (FastAPI + frontend vanilla) 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.
+
+## Lancer
+
+```bash
+python -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+uvicorn app.main:app --reload
+```
+
+Ouvre `http://127.0.0.1:8000`.
+
+## Dépendances système
+
+- `ffmpeg` (pour créer le short)
+- accès internet (YouTube + TTS)
+
+## API
+
+- `POST /api/analyze`
+- `POST /api/create-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..709be8a
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,77 @@
+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.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
+
+
+@app.get("/", response_class=HTMLResponse)
+def home() -> HTMLResponse:
+ index = STATIC_DIR / "index.html"
+ return HTMLResponse(index.read_text(encoding="utf-8"))
+
+
+@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.get("/downloads/{filename}")
+def download(filename: str):
+ file_path = OUTPUT_DIR / filename
+ if not file_path.exists():
+ raise HTTPException(status_code=404, detail="File not found")
+ return FileResponse(path=file_path)
diff --git a/app/services/short_generator.py b/app/services/short_generator.py
new file mode 100644
index 0000000..d8aa19b
--- /dev/null
+++ b/app/services/short_generator.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+import re
+import subprocess
+import uuid
+from pathlib import Path
+
+import yt_dlp
+from gtts import gTTS
+
+from app.services.summarizer import SummaryService
+from app.services.youtube_service import YouTubeService
+
+
+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 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(summary, lang="fr").save(str(narration_file))
+ self._replace_audio(short_file, narration_file)
+ summary_file.write_text(summary, encoding="utf-8")
+ 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:
+ 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),
+ ]
+ subprocess.run(cmd, check=True, capture_output=True)
+
+ 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),
+ ]
+ subprocess.run(cmd, check=True, capture_output=True)
+ 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..4758f74
--- /dev/null
+++ b/app/services/youtube_service.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+import math
+from datetime import datetime, timezone
+from typing import Any
+
+import yt_dlp
+
+
+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 _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]]:
+ 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]:
+ 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..39d770f
--- /dev/null
+++ b/app/static/app.js
@@ -0,0 +1,69 @@
+const analyzeForm = document.querySelector('#analyze-form');
+const videosEl = document.querySelector('#videos');
+const shortForm = document.querySelector('#short-form');
+const outputEl = document.querySelector('#output');
+
+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),
+ };
+
+ 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.description || ''}
+ ${video.channel} · ${video.duration_minutes} min · score ${video.score}
+
${data.summary}
+ + + ${data.narration_download ? `` : ''} + `; +}); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..3e2d2b9 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,43 @@ + + + + + +Détecte des vidéos longues/tendances, puis fabrique un YouTube Short avec narration ou résumé téléchargeable.
+ +${video.description || ''}
${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...'; - outputEl.innerHTML = ` -${data.summary}
- - - ${data.narration_download ? `` : ''} - `; + 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 index 3e2d2b9..57e21bc 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -12,7 +12,12 @@Détecte des vidéos longues/tendances, puis fabrique un YouTube Short avec narration ou résumé téléchargeable.
Vérification...
+