From 5f32b01ad7e60918d11d72aff19b56bdc826a3f8 Mon Sep 17 00:00:00 2001 From: Kiendas25 Date: Mon, 25 May 2026 06:00:41 +0000 Subject: [PATCH] Add end-to-end autonomous YouTube Shorts pipeline scaffold --- shorts_pipeline/config.yaml | 59 ++++++++++++++ shorts_pipeline/database/db.py | 31 +++++++ shorts_pipeline/database/models.py | 59 ++++++++++++++ shorts_pipeline/main.py | 80 +++++++++++++++++++ shorts_pipeline/modules/analytics.py | 44 ++++++++++ shorts_pipeline/modules/idea_generator.py | 55 +++++++++++++ .../modules/thumbnail_generator.py | 15 ++++ shorts_pipeline/modules/uploader.py | 72 +++++++++++++++++ shorts_pipeline/modules/video_assembler.py | 56 +++++++++++++ shorts_pipeline/modules/voice_synthesizer.py | 56 +++++++++++++ shorts_pipeline/requirements.txt | 18 +++++ 11 files changed, 545 insertions(+) create mode 100644 shorts_pipeline/config.yaml create mode 100644 shorts_pipeline/database/db.py create mode 100644 shorts_pipeline/database/models.py create mode 100644 shorts_pipeline/main.py create mode 100644 shorts_pipeline/modules/analytics.py create mode 100644 shorts_pipeline/modules/idea_generator.py create mode 100644 shorts_pipeline/modules/thumbnail_generator.py create mode 100644 shorts_pipeline/modules/uploader.py create mode 100644 shorts_pipeline/modules/video_assembler.py create mode 100644 shorts_pipeline/modules/voice_synthesizer.py create mode 100644 shorts_pipeline/requirements.txt diff --git a/shorts_pipeline/config.yaml b/shorts_pipeline/config.yaml new file mode 100644 index 0000000000..410782b988 --- /dev/null +++ b/shorts_pipeline/config.yaml @@ -0,0 +1,59 @@ +app: + timezone: "UTC" + log_file: "pipeline.log" + db_path: "shorts_pipeline.db" + gpu_device_id: 0 + +ai: + provider: "openai" # openai or anthropic + openai_api_key: "" + anthropic_api_key: "" + model: "gpt-4o" + ideas_per_batch: 20 + +niches: + active: ["curiosidades", "psicologia", "did_you_know", "ciencia_simples", "fatos_chocantes"] + prompt_templates: + curiosidades: "Generate viral curiosities facts with surprising twists for Brazilian audience." + psicologia: "Generate practical psychology micro-insights with emotional hooks." + did_you_know: "Generate did-you-know style surprising facts with high curiosity loops." + ciencia_simples: "Generate simple science explanations with clear metaphors and wow moments." + fatos_chocantes: "Generate shocking but safe facts that are true and verifiable." + +voice: + elevenlabs_api_key: "" + elevenlabs_voice_id: "EXAVITQu4vr4xnSDxMaL" + elevenlabs_model_id: "eleven_multilingual_v2" + concurrency: 10 + fallback_engine: "coqui" # coqui or piper + piper_model_path: "" + +video: + pexels_api_key: "" + pixabay_api_key: "" + width: 1080 + height: 1920 + min_cut_seconds: 2 + max_cut_seconds: 4 + fps: 30 + subtitle_font: "assets/fonts/Inter-Bold.ttf" + subtitle_size: 68 + subtitle_color: "#FFFFFF" + branding_intro: "assets/branding/intro.mp4" + branding_outro: "assets/branding/outro.mp4" + logo_path: "assets/branding/logo.png" + primary_color: "#4A83DD" + nvenc_preset: "p4" + +upload: + youtube_client_secrets_file: "client_secret.json" + youtube_token_file: "youtube_token.json" + channel_default_privacy: "private" + publish_hours_utc: [10, 14, 18, 21] + max_videos_per_day: 4 + default_language: "pt" + +analytics: + min_age_hours: 48 + report_output_json: "output/weekly_report.json" + report_output_txt: "output/weekly_report.txt" diff --git a/shorts_pipeline/database/db.py b/shorts_pipeline/database/db.py new file mode 100644 index 0000000000..b758aa022a --- /dev/null +++ b/shorts_pipeline/database/db.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +BASE_DIR = Path(__file__).resolve().parents[1] + + +def build_engine(db_path: str): + uri = f"sqlite:///{BASE_DIR / db_path}" + return create_engine(uri, connect_args={"check_same_thread": False}, future=True) + + +def build_session_factory(engine): + return sessionmaker(bind=engine, expire_on_commit=False) + + +@contextmanager +def session_scope(session_factory): + session = session_factory() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/shorts_pipeline/database/models.py b/shorts_pipeline/database/models.py new file mode 100644 index 0000000000..f348658cc9 --- /dev/null +++ b/shorts_pipeline/database/models.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class Script(Base): + __tablename__ = "scripts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + niche: Mapped[str] = mapped_column(String(128), index=True) + idea: Mapped[str] = mapped_column(Text) + hook: Mapped[str] = mapped_column(Text) + content: Mapped[str] = mapped_column(Text) + retention_hook: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(32), default="pending", index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + video: Mapped["Video"] = relationship(back_populates="script", uselist=False) + + +class Video(Base): + __tablename__ = "videos" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + script_id: Mapped[int] = mapped_column(ForeignKey("scripts.id"), unique=True) + audio_path: Mapped[str | None] = mapped_column(Text, nullable=True) + video_path: Mapped[str | None] = mapped_column(Text, nullable=True) + thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True) + youtube_video_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + title: Mapped[str | None] = mapped_column(Text, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + tags: Mapped[str | None] = mapped_column(Text, nullable=True) + scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(32), default="pending", index=True) + + script: Mapped[Script] = relationship(back_populates="video") + analytics: Mapped[list["Analytics"]] = relationship(back_populates="video") + + +class Analytics(Base): + __tablename__ = "analytics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + video_id: Mapped[int] = mapped_column(ForeignKey("videos.id"), index=True) + captured_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + views: Mapped[int] = mapped_column(Integer, default=0) + ctr: Mapped[float] = mapped_column(Float, default=0.0) + avg_view_duration: Mapped[float] = mapped_column(Float, default=0.0) + retention_rate: Mapped[float] = mapped_column(Float, default=0.0) + + video: Mapped[Video] = relationship(back_populates="analytics") diff --git a/shorts_pipeline/main.py b/shorts_pipeline/main.py new file mode 100644 index 0000000000..917d1a1e3c --- /dev/null +++ b/shorts_pipeline/main.py @@ -0,0 +1,80 @@ +""" +README / Setup +1) Install Python 3.11+, FFmpeg with NVENC support, CUDA drivers (NVIDIA RTX), and Tesseract optional. +2) pip install -r requirements.txt +3) Fill config.yaml keys: OpenAI/Anthropic, ElevenLabs, Pexels/Pixabay, YouTube OAuth files. +4) Place branding assets under assets/branding and font under assets/fonts. +5) Run once: python main.py (it bootstraps DB and schedules jobs). +6) Keep this process alive (systemd/supervisor/docker). It runs autonomous loops. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +import yaml +from apscheduler.schedulers.blocking import BlockingScheduler + +from database.db import build_engine, build_session_factory, session_scope +from database.models import Base +from modules.idea_generator import IdeaGenerator +from modules.uploader import Uploader +from modules.video_assembler import VideoAssembler +from modules.voice_synthesizer import VoiceSynthesizer + + +def configure_logging(log_file: str): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + handlers=[logging.FileHandler(log_file), logging.StreamHandler()], + ) + + +def load_config(): + with open("config.yaml", "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def run_pipeline(cfg: dict, sf): + logger = logging.getLogger("pipeline") + with session_scope(sf) as session: + try: + IdeaGenerator(cfg).generate_and_store(session) + except Exception: + logger.exception("Idea generation step failed") + with session_scope(sf) as session: + try: + asyncio.run(VoiceSynthesizer(cfg).synthesize_pending(session)) + except Exception: + logger.exception("Voice step failed") + with session_scope(sf) as session: + try: + VideoAssembler(cfg).assemble_ready(session) + except Exception: + logger.exception("Video assembly step failed") + with session_scope(sf) as session: + try: + Uploader(cfg).upload_due(session) + except Exception: + logger.exception("Upload step failed") + + +def main(): + Path("output").mkdir(exist_ok=True) + cfg = load_config() + configure_logging(cfg["app"]["log_file"]) + engine = build_engine(cfg["app"]["db_path"]) + Base.metadata.create_all(engine) + sf = build_session_factory(engine) + + scheduler = BlockingScheduler(timezone=cfg["app"]["timezone"]) + scheduler.add_job(lambda: run_pipeline(cfg, sf), "interval", hours=6, id="full_pipeline", max_instances=1, coalesce=True) + run_pipeline(cfg, sf) + scheduler.start() + + +if __name__ == "__main__": + main() diff --git a/shorts_pipeline/modules/analytics.py b/shorts_pipeline/modules/analytics.py new file mode 100644 index 0000000000..ed6aa75970 --- /dev/null +++ b/shorts_pipeline/modules/analytics.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta + +from googleapiclient.discovery import build + +from database.models import Analytics, Script, Video + +LOGGER = logging.getLogger(__name__) + + +class AnalyticsModule: + def __init__(self, config: dict, creds): + self.cfg = config + self.api = build("youtubeAnalytics", "v2", credentials=creds) + + def run(self, session): + cutoff = datetime.utcnow() - timedelta(hours=self.cfg["analytics"]["min_age_hours"]) + rows = session.query(Video, Script).join(Script, Video.script_id == Script.id).filter(Video.status == "uploaded", Video.uploaded_at < cutoff).all() + scored = [] + for video, script in rows: + try: + # Simplified demo query; dimensions and metrics can be expanded. + resp = self.api.reports().query(ids="channel==MINE", startDate=(video.uploaded_at.date()).isoformat(), endDate=datetime.utcnow().date().isoformat(), metrics="views,averageViewDuration,averageViewPercentage", filters=f"video=={video.youtube_video_id}").execute() + vals = resp.get("rows", [[0, 0.0, 0.0]])[0] + metric = Analytics(video_id=video.id, views=int(vals[0]), avg_view_duration=float(vals[1]), retention_rate=float(vals[2]), ctr=0.0) + session.add(metric) + video.status = "analyzed" + script.status = "analyzed" + scored.append({"script_id": script.id, "niche": script.niche, "hook": script.hook, "views": metric.views, "retention_rate": metric.retention_rate}) + except Exception as exc: + LOGGER.exception("Analytics failed for video=%s err=%s", video.youtube_video_id, exc) + scored.sort(key=lambda x: (x["views"], x["retention_rate"]), reverse=True) + self._write_report(scored) + + def _write_report(self, scored: list[dict]): + with open(self.cfg["analytics"]["report_output_json"], "w", encoding="utf-8") as f: + json.dump({"generated_at": datetime.utcnow().isoformat(), "top_videos": scored[:20]}, f, ensure_ascii=False, indent=2) + with open(self.cfg["analytics"]["report_output_txt"], "w", encoding="utf-8") as f: + f.write("Weekly Shorts Performance Report\n\n") + for i, row in enumerate(scored[:20], 1): + f.write(f"{i}. Script {row['script_id']} | {row['niche']} | Views={row['views']} | Retention={row['retention_rate']:.2f}\n") diff --git a/shorts_pipeline/modules/idea_generator.py b/shorts_pipeline/modules/idea_generator.py new file mode 100644 index 0000000000..98ba41fb88 --- /dev/null +++ b/shorts_pipeline/modules/idea_generator.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from anthropic import Anthropic +from openai import OpenAI +from tenacity import retry, stop_after_attempt, wait_exponential + +from database.models import Script + +LOGGER = logging.getLogger(__name__) + + +class IdeaGenerator: + def __init__(self, config: dict[str, Any]): + self.config = config + self.ai_cfg = config["ai"] + + @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=2, max=30)) + def _llm_call(self, prompt: str) -> str: + if self.ai_cfg["provider"] == "anthropic": + client = Anthropic(api_key=self.ai_cfg["anthropic_api_key"]) + msg = client.messages.create(model="claude-3-7-sonnet-latest", max_tokens=4000, messages=[{"role": "user", "content": prompt}]) + return msg.content[0].text + client = OpenAI(api_key=self.ai_cfg["openai_api_key"]) + resp = client.responses.create(model=self.ai_cfg["model"], input=prompt) + return resp.output_text + + def generate_and_store(self, session): + for niche in self.config["niches"]["active"]: + template = self.config["niches"]["prompt_templates"][niche] + prompt = ( + f"{template}\nGenerate {self.ai_cfg['ideas_per_batch']} short video scripts in JSON array. " + "Each object keys: idea, hook, content, retention_hook. Keep content <= 25 seconds speech." + ) + try: + raw = self._llm_call(prompt) + scripts = json.loads(raw[raw.find("[") : raw.rfind("]") + 1]) + except Exception as exc: + LOGGER.exception("Failed generating scripts for niche=%s: %s", niche, exc) + continue + for row in scripts: + session.add( + Script( + niche=niche, + idea=row["idea"], + hook=row["hook"], + content=row["content"], + retention_hook=row["retention_hook"], + status="pending", + ) + ) + LOGGER.info("Stored %s scripts for niche=%s", len(scripts), niche) diff --git a/shorts_pipeline/modules/thumbnail_generator.py b/shorts_pipeline/modules/thumbnail_generator.py new file mode 100644 index 0000000000..9c85d68157 --- /dev/null +++ b/shorts_pipeline/modules/thumbnail_generator.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + + +def generate_thumbnail(text: str, out_path: str, font_path: str, color: str): + img = Image.new("RGB", (1080, 1920), color="#111111") + draw = ImageDraw.Draw(img) + font = ImageFont.truetype(font_path, 96) + draw.rounded_rectangle((60, 640, 1020, 1280), radius=35, fill=color) + draw.text((100, 760), text[:70], fill="white", font=font) + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + img.save(out_path, "PNG") diff --git a/shorts_pipeline/modules/uploader.py b/shorts_pipeline/modules/uploader.py new file mode 100644 index 0000000000..ff7d4f746c --- /dev/null +++ b/shorts_pipeline/modules/uploader.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +from google_auth_oauthlib.flow import InstalledAppFlow +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.http import MediaFileUpload +from tenacity import retry, stop_after_attempt, wait_exponential + +from database.models import Script, Video +from modules.thumbnail_generator import generate_thumbnail + +LOGGER = logging.getLogger(__name__) +SCOPES = ["https://www.googleapis.com/auth/youtube.upload", "https://www.googleapis.com/auth/yt-analytics.readonly"] + + +class Uploader: + def __init__(self, config: dict): + self.cfg = config + self.youtube = self._build_client() + + def _build_client(self): + token_file = self.cfg["upload"]["youtube_token_file"] + try: + creds = Credentials.from_authorized_user_file(token_file, SCOPES) + except Exception: + flow = InstalledAppFlow.from_client_secrets_file(self.cfg["upload"]["youtube_client_secrets_file"], SCOPES) + creds = flow.run_local_server(port=0) + with open(token_file, "w", encoding="utf-8") as f: + f.write(creds.to_json()) + return build("youtube", "v3", credentials=creds) + + def _next_schedule(self, idx: int): + now = datetime.now(timezone.utc) + hour = self.cfg["upload"]["publish_hours_utc"][idx % len(self.cfg["upload"]["publish_hours_utc"])] + dt = now.replace(hour=hour, minute=0, second=0, microsecond=0) + if dt <= now: + dt += timedelta(days=1) + return dt + + @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=2, max=60)) + def _upload_one(self, video: Video, script: Script): + media = MediaFileUpload(video.video_path, chunksize=-1, resumable=True) + title = script.hook[:95] + tags = [script.niche.replace("_", " "), "shorts", "viral", "curiosidades", "fatos"] + desc = f"{script.content}\n\n#{script.niche} #shorts" + thumb_path = f"output/thumbnails/thumb_{script.id}.png" + generate_thumbnail(title, thumb_path, self.cfg["video"]["subtitle_font"], self.cfg["video"]["primary_color"]) + req = self.youtube.videos().insert(part="snippet,status", body={"snippet": {"title": title, "description": desc, "tags": tags, "defaultLanguage": self.cfg["upload"]["default_language"]}, "status": {"privacyStatus": self.cfg["upload"]["channel_default_privacy"], "publishAt": video.scheduled_at.isoformat()}}, media_body=media) + resp = req.execute() + self.youtube.thumbnails().set(videoId=resp["id"], media_body=MediaFileUpload(thumb_path)).execute() + return resp["id"], title, desc, tags, thumb_path + + def upload_due(self, session): + queue = session.query(Video, Script).join(Script, Video.script_id == Script.id).filter(Video.status == "edited").limit(self.cfg["upload"]["max_videos_per_day"]).all() + for i, (video, script) in enumerate(queue): + video.scheduled_at = video.scheduled_at or self._next_schedule(i) + try: + yid, title, desc, tags, thumb = self._upload_one(video, script) + video.youtube_video_id = yid + video.title = title + video.description = desc + video.tags = ",".join(tags) + video.thumbnail_path = thumb + video.uploaded_at = datetime.utcnow() + video.status = "uploaded" + script.status = "uploaded" + LOGGER.info("Uploaded script=%s youtube_id=%s", script.id, yid) + except Exception as exc: + LOGGER.exception("Upload failed for script=%s err=%s", script.id, exc) diff --git a/shorts_pipeline/modules/video_assembler.py b/shorts_pipeline/modules/video_assembler.py new file mode 100644 index 0000000000..4efb632041 --- /dev/null +++ b/shorts_pipeline/modules/video_assembler.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import logging +import random +import subprocess +from pathlib import Path + +import requests +from moviepy.editor import AudioFileClip, CompositeVideoClip, TextClip, VideoFileClip, concatenate_videoclips +from tenacity import retry, stop_after_attempt, wait_exponential + +from database.models import Script, Video + +LOGGER = logging.getLogger(__name__) + + +class VideoAssembler: + def __init__(self, config: dict): + self.cfg = config + Path("output/footage").mkdir(parents=True, exist_ok=True) + Path("output/videos").mkdir(parents=True, exist_ok=True) + + @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=1, max=20)) + def _fetch_footage(self, query: str, script_id: int) -> Path: + headers = {"Authorization": self.cfg["video"]["pexels_api_key"]} + r = requests.get("https://api.pexels.com/videos/search", params={"query": query, "per_page": 1}, headers=headers, timeout=30) + r.raise_for_status() + url = r.json()["videos"][0]["video_files"][0]["link"] + out = Path("output/footage") / f"{script_id}.mp4" + out.write_bytes(requests.get(url, timeout=60).content) + return out + + def _subtitle_clip(self, text: str, duration: float): + return TextClip(text.upper(), font=self.cfg["video"]["subtitle_font"], fontsize=self.cfg["video"]["subtitle_size"], color=self.cfg["video"]["subtitle_color"], stroke_color="black", stroke_width=3).set_position(("center", "bottom")).set_duration(duration) + + def assemble_ready(self, session): + rows = session.query(Video, Script).join(Script, Video.script_id == Script.id).filter(Video.status == "voiced").limit(10).all() + for video, script in rows: + try: + footage = self._fetch_footage(script.idea, script.id) + vc = VideoFileClip(str(footage)).resize((self.cfg["video"]["width"], self.cfg["video"]["height"])) + ac = AudioFileClip(video.audio_path) + clip = vc.subclip(0, min(vc.duration, ac.duration + 1)).set_audio(ac) + sub = self._subtitle_clip(f"{script.hook} {script.retention_hook}", clip.duration) + final = CompositeVideoClip([clip, sub]) + out = Path("output/videos") / f"video_{script.id}.mp4" + tmp = out.with_suffix(".tmp.mp4") + final.write_videofile(str(tmp), fps=self.cfg["video"]["fps"], codec="libx264", audio_codec="aac", logger=None) + subprocess.run(["ffmpeg", "-y", "-i", str(tmp), "-c:v", "h264_nvenc", "-preset", self.cfg["video"]["nvenc_preset"], "-c:a", "aac", str(out)], check=True) + tmp.unlink(missing_ok=True) + video.video_path = str(out) + video.status = "edited" + script.status = "edited" + LOGGER.info("Rendered %s", out) + except Exception as exc: + LOGGER.exception("Video render failed script=%s error=%s", script.id, exc) diff --git a/shorts_pipeline/modules/voice_synthesizer.py b/shorts_pipeline/modules/voice_synthesizer.py new file mode 100644 index 0000000000..740e4fca94 --- /dev/null +++ b/shorts_pipeline/modules/voice_synthesizer.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +import aiohttp +from TTS.api import TTS +from tenacity import retry, stop_after_attempt, wait_exponential + +from database.models import Script, Video + +LOGGER = logging.getLogger(__name__) + + +class VoiceSynthesizer: + def __init__(self, config: dict): + self.cfg = config + self.out_dir = Path("output/audio") + self.out_dir.mkdir(parents=True, exist_ok=True) + self.sem = asyncio.Semaphore(config["voice"]["concurrency"]) + + @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=1, max=20)) + async def _elevenlabs(self, text: str, dst: Path): + headers = {"xi-api-key": self.cfg["voice"]["elevenlabs_api_key"], "Content-Type": "application/json"} + payload = {"text": text, "model_id": self.cfg["voice"]["elevenlabs_model_id"]} + url = f"https://api.elevenlabs.io/v1/text-to-speech/{self.cfg['voice']['elevenlabs_voice_id']}" + async with aiohttp.ClientSession() as s: + async with s.post(url, headers=headers, json=payload, timeout=120) as r: + if r.status >= 400: + raise RuntimeError(f"ElevenLabs error: {r.status} {await r.text()}") + dst.write_bytes(await r.read()) + + def _local_tts(self, text: str, dst: Path): + model = "tts_models/multilingual/multi-dataset/xtts_v2" + tts = TTS(model_name=model, gpu=True) + tts.tts_to_file(text=text, file_path=str(dst)) + + async def _process_script(self, session, script: Script): + text = f"{script.hook}. {script.content}. {script.retention_hook}." + audio_path = self.out_dir / f"script_{script.id}.mp3" + try: + async with self.sem: + await self._elevenlabs(text, audio_path) + except Exception as exc: + LOGGER.warning("ElevenLabs failed for script=%s. Falling back local TTS. error=%s", script.id, exc) + self._local_tts(text, audio_path) + script.status = "voiced" + video = session.query(Video).filter_by(script_id=script.id).one_or_none() or Video(script_id=script.id) + video.audio_path = str(audio_path) + video.status = "voiced" + session.add(video) + + async def synthesize_pending(self, session): + scripts = session.query(Script).filter(Script.status == "pending").limit(30).all() + await asyncio.gather(*[self._process_script(session, s) for s in scripts], return_exceptions=True) diff --git a/shorts_pipeline/requirements.txt b/shorts_pipeline/requirements.txt new file mode 100644 index 0000000000..ab86248a93 --- /dev/null +++ b/shorts_pipeline/requirements.txt @@ -0,0 +1,18 @@ +aiohttp==3.11.18 +APScheduler==3.10.4 +anthropic==0.54.0 +elevenlabs==1.13.1 +ffmpeg-python==0.2.0 +google-api-python-client==2.172.0 +google-auth-oauthlib==1.2.0 +moviepy==1.0.3 +openai==1.84.0 +Pillow==10.4.0 +PyYAML==6.0.2 +requests==2.32.3 +SQLAlchemy==2.0.41 +tenacity==9.0.0 +whisperx==3.1.5 +TTS==0.22.0 +pydub==0.25.1 +numpy==1.26.4