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.title}
+ ${video.channel} · ${video.duration_minutes} min · score ${video.score} +

${video.description || ''}

+ +
+ `) + .join(''); +}); + +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, + }; + + 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

` : ''} + `; +}); 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 @@ + + + + + + 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.

+ +
+

1) Détection

+
+ + + + +
+
+ +
+

2) Résultats

+
+
+ +
+

3) Génération du short

+
+ + + + +
+
+
+
+ + + + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..6de6e49 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,51 @@ +: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; +} +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; +} 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 From 516dcde7d28eb365de683da983fcdb7423d8da4d Mon Sep 17 00:00:00 2001 From: Misaela22 <96578646+Misaela22@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:29:58 +0300 Subject: [PATCH 2/2] Improve novice usability with health checks and demo mode --- README.md | 39 ++++++++-- app/main.py | 40 ++++++++++- app/services/dependencies.py | 27 +++++++ app/services/short_generator.py | 37 ++++++++-- app/services/youtube_service.py | 13 +++- app/static/app.js | 123 ++++++++++++++++++++++++-------- app/static/index.html | 22 +++++- app/static/styles.css | 10 ++- 8 files changed, 263 insertions(+), 48 deletions(-) create mode 100644 app/services/dependencies.py diff --git a/README.md b/README.md index c34e8a4..6955026 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,60 @@ # YouTube Long2Short Studio -Webapp complète (FastAPI + frontend vanilla) pour: +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. -## Lancer +## 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`. -## Dépendances système +## Utilisation -- `ffmpeg` (pour créer le short) -- accès internet (YouTube + TTS) +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 diff --git a/app/main.py b/app/main.py index 709be8a..86386ed 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ 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 @@ -35,12 +36,31 @@ class CreateShortRequest(BaseModel): 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: @@ -69,9 +89,25 @@ def create_short(req: CreateShortRequest): 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 - if not file_path.exists(): + 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 index d8aa19b..3725284 100644 --- a/app/services/short_generator.py +++ b/app/services/short_generator.py @@ -5,11 +5,8 @@ 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 +from app.services.youtube_service import YouTubeService, YouTubeServiceError class ShortGenerationError(Exception): @@ -25,6 +22,24 @@ def __init__(self, output_dir: Path, summary_service: SummaryService): 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, @@ -52,9 +67,12 @@ def create_short_package( 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: @@ -70,6 +88,7 @@ def create_short_package( } 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), @@ -102,7 +121,10 @@ def _render_vertical_short(self, source: Path, target: Path, start: int, duratio "aac", str(target), ] - subprocess.run(cmd, check=True, capture_output=True) + 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") @@ -122,5 +144,8 @@ def _replace_audio(self, target_video: Path, narration_audio: Path) -> None: "-shortest", str(out), ] - subprocess.run(cmd, check=True, capture_output=True) + 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/youtube_service.py b/app/services/youtube_service.py index 4758f74..f5f7067 100644 --- a/app/services/youtube_service.py +++ b/app/services/youtube_service.py @@ -4,8 +4,6 @@ from datetime import datetime, timezone from typing import Any -import yt_dlp - class YouTubeServiceError(Exception): pass @@ -20,6 +18,15 @@ def __init__(self) -> None: "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) @@ -31,6 +38,7 @@ def search_long_trending_videos( 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: @@ -72,6 +80,7 @@ def search_long_trending_videos( 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) diff --git a/app/static/app.js b/app/static/app.js index 39d770f..0854e0b 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1,8 +1,26 @@ +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...'; @@ -13,28 +31,34 @@ analyzeForm.addEventListener('submit', async (e) => { max_results: Number(document.querySelector('#maxResults').value), }; - const res = await fetch('/api/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); + 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; - } + const data = await res.json(); + if (!res.ok) { + videosEl.textContent = data.detail || 'Erreur'; + return; + } - videosEl.innerHTML = data.videos - .map((video) => ` + videosEl.innerHTML = data.videos + .map( + (video) => `
${video.title}
${video.channel} · ${video.duration_minutes} min · score ${video.score}

${video.description || ''}

- `) - .join(''); + `, + ) + .join(''); + } catch (error) { + videosEl.textContent = `Erreur réseau: ${error}`; + } }); shortForm.addEventListener('submit', async (e) => { @@ -47,23 +71,62 @@ shortForm.addEventListener('submit', async (e) => { custom_summary: document.querySelector('#customSummary').value || null, }; - const res = await fetch('/api/create-short', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const data = await res.json(); + 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; + 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...'; - 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

` : ''} - `; + 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 index 3e2d2b9..57e21bc 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -12,7 +12,12 @@

YouTube Long2Short Studio

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

-

1) Détection

+

État système

+

Vérification...

+
+ +
+

1) Détection YouTube

@@ -27,13 +32,26 @@

2) Résultats

-

3) Génération du short

+

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 index 6de6e49..0a020dc 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -22,13 +22,18 @@ form { display: grid; gap: 0.6rem; } -input, textarea, button { +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; @@ -49,3 +54,6 @@ small { #output a { color: #60a5fa; } +#health { + color: #cbd5e1; +}