diff --git a/.gitattributes b/.gitattributes index a468f0f350d53317767c7a0bd17c2078e8f26241..96d67ad45a492f201984f0cf701b0cb05009be15 100644 --- a/.gitattributes +++ b/.gitattributes @@ -35,3 +35,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text fonts/TikTok-Bold.ttf filter=lfs diff=lfs merge=lfs -text gradio_demo.png filter=lfs diff=lfs merge=lfs -text +assets/edu_note.wav filter=lfs diff=lfs merge=lfs -text +assets/fun_fact.wav filter=lfs diff=lfs merge=lfs -text +assets/thanks.wav filter=lfs diff=lfs merge=lfs -text diff --git a/assets/edu_note.wav b/assets/edu_note.wav new file mode 100644 index 0000000000000000000000000000000000000000..e5bbd527fba7db4a0bbfa3bf4116699c6eefb3c3 --- /dev/null +++ b/assets/edu_note.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:880362273fe0348d8e73e9cf99cbe58573ccaac1e51628e92d4a362c8199d399 +size 416444 diff --git a/assets/fun_fact.wav b/assets/fun_fact.wav new file mode 100644 index 0000000000000000000000000000000000000000..58949399a97d7511b438fbce1855f1888e4d63bd --- /dev/null +++ b/assets/fun_fact.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:245b8db3fc90671b02baa477ce0c2d08754b225ae95a853687191ad126de562e +size 496844 diff --git a/assets/thanks.wav b/assets/thanks.wav new file mode 100644 index 0000000000000000000000000000000000000000..8ff68850b72654f7a9187fff3bfb6f66dd07bc5d --- /dev/null +++ b/assets/thanks.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d04843d86bff241ff659cf6c6931641797411dbf3b1532eee83914058bbc5a64 +size 288044 diff --git a/auth/database.py b/auth/database.py new file mode 100644 index 0000000000000000000000000000000000000000..af5f6cfffae64f17eeba670d3a812d76cb064887 --- /dev/null +++ b/auth/database.py @@ -0,0 +1,88 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from sqlalchemy.exc import OperationalError + +# ========================================================== +# DATABASE URL +# ========================================================== +DATABASE_URL = os.getenv("DATABASE_URL") + +if not DATABASE_URL: + raise RuntimeError( + "DATABASE_URL environment variable is not set" + ) + +# ========================================================== +# SUPABASE ENTERPRISE ENGINE CONFIG +# ========================================================== +# Optimized for: +# - Supabase Pooler +# - FastAPI async workload +# - Background workers +# - Long-running autonomous services + +engine = create_engine( + DATABASE_URL, + + # --- Pool Stability --- + pool_pre_ping=True, # validates dead connections + pool_recycle=300, # refresh connections + pool_size=5, # safe baseline + max_overflow=10, # burst capacity + + # --- Reliability --- + echo=False, + future=True, + + # --- Supabase Requirement --- + connect_args={ + "sslmode": "require", + "connect_timeout": 10, + }, +) + +# ========================================================== +# SESSION FACTORY +# ========================================================== +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, +) + +# ========================================================== +# BASE MODEL +# ========================================================== +Base = declarative_base() + +# ========================================================== +# DEPENDENCY (FASTAPI) +# ========================================================== +def get_db(): + """ + FastAPI dependency injection session. + Ensures connection cleanup even on crash. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() + + +# ========================================================== +# CONNECTION TEST (STARTUP SAFE) +# ========================================================== +def verify_database_connection(): + """ + Validates database connectivity during startup. + Prevents silent runtime failures. + """ + try: + with engine.connect() as conn: + conn.execute("SELECT 1") + except OperationalError as e: + raise RuntimeError( + f"Database connection failed: {str(e)}" + ) \ No newline at end of file diff --git a/auth/models.py b/auth/models.py new file mode 100644 index 0000000000000000000000000000000000000000..b9eb23d5d0dce83b21d7f8983cd329ba371fea68 --- /dev/null +++ b/auth/models.py @@ -0,0 +1,96 @@ +import uuid +from sqlalchemy import Column, String, Boolean, DateTime, func, Index +from sqlalchemy.dialects.postgresql import UUID + +from auth.database import Base + + +# ========================================================= +# USERS TABLE (CORE AUTH ENTITY) +# ========================================================= +class User(Base): + __tablename__ = "users" + + # ----------------------------- + # Primary Key (UUID for Supabase compatibility) + # ----------------------------- + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + nullable=False, + ) + + # ----------------------------- + # Identity Fields + # ----------------------------- + email = Column(String(255), unique=True, nullable=False, index=True) + username = Column(String(100), unique=True, nullable=True, index=True) + + # ----------------------------- + # Security Fields + # NOTE: stores hashed password only (never plaintext) + # ----------------------------- + hashed_password = Column(String(255), nullable=False) + + # ----------------------------- + # Account State + # ----------------------------- + is_active = Column(Boolean, default=True, nullable=False) + is_verified = Column(Boolean, default=False, nullable=False) + + # ----------------------------- + # Audit Fields + # ----------------------------- + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +# ========================================================= +# OPTIONAL: API KEY TABLE (FOR AUTOMATION / N8N / WORKFLOWS) +# ========================================================= +class ApiKey(Base): + __tablename__ = "api_keys" + + id = Column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + nullable=False, + ) + + user_id = Column( + UUID(as_uuid=True), + nullable=False, + index=True, + ) + + key_hash = Column(String(255), nullable=False, unique=True) + + name = Column(String(120), nullable=True) + + is_active = Column(Boolean, default=True, nullable=False) + + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + +# ========================================================= +# INDEXES (PERFORMANCE OPTIMIZATION) +# ========================================================= +Index("idx_users_email", User.email) +Index("idx_users_username", User.username) +Index("idx_api_keys_user_id", ApiKey.user_id) \ No newline at end of file diff --git a/auth/routes.py b/auth/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..d2fe1c1448f7da86a66adb17d13ee76ffa523b4c --- /dev/null +++ b/auth/routes.py @@ -0,0 +1,340 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from datetime import datetime + +from auth.database import get_db +from auth.models import User +from auth.schemas import ( + SignupSchema, + LoginSchema, + TokenSchema, +) +from auth.security import ( + hash_password, + verify_password, + create_access_token, + decode_token, +) + +router = APIRouter( + prefix="/api/auth", + tags=["Authentication"], +) + + +# ========================================================= +# SIGNUP +# ========================================================= + +@router.post("/signup", status_code=201) +def signup( + data: SignupSchema, + db: Session = Depends(get_db), +): + + try: + + # ================================================= + # NORMALIZATION + # ================================================= + + email = data.email.strip().lower() + username = data.username.strip() + + # ================================================= + # EXISTING EMAIL CHECK + # ================================================= + + existing_email = ( + db.query(User) + .filter(User.email == email) + .first() + ) + + if existing_email: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered", + ) + + # ================================================= + # EXISTING USERNAME CHECK + # ================================================= + + existing_username = ( + db.query(User) + .filter(User.username == username) + .first() + ) + + if existing_username: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already taken", + ) + + # ================================================= + # HASH PASSWORD + # ================================================= + + password_hash = hash_password(data.password) + + # ================================================= + # CREATE USER + # ================================================= + + user = User( + email=email, + username=username, + password_hash=password_hash, + created_at=datetime.utcnow(), + ) + + db.add(user) + db.commit() + db.refresh(user) + + # ================================================= + # CREATE JWT + # ================================================= + + token = create_access_token( + { + "sub": str(user.id), + "email": user.email, + } + ) + + return { + "status": "success", + "message": "Account created successfully", + "token": token, + "user": { + "id": str(user.id), + "email": user.email, + "username": user.username, + "created_at": ( + user.created_at.isoformat() + if user.created_at + else None + ), + }, + } + + except HTTPException: + raise + + except IntegrityError as e: + + db.rollback() + + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already exists", + ) + + except ValueError as e: + + db.rollback() + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + except Exception as e: + + db.rollback() + + print("SIGNUP ERROR:", str(e)) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Signup failed: {str(e)}", + ) + + +# ========================================================= +# LOGIN +# ========================================================= + +@router.post("/login") +def login( + data: LoginSchema, + db: Session = Depends(get_db), +): + + try: + + email = data.email.strip().lower() + + user = ( + db.query(User) + .filter(User.email == email) + .first() + ) + + if not user: + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + ) + + if not verify_password( + data.password, + user.password_hash, + ): + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + ) + + token = create_access_token( + { + "sub": str(user.id), + "email": user.email, + } + ) + + return { + "status": "success", + "message": "Login successful", + "token": token, + "user": { + "id": str(user.id), + "email": user.email, + "username": user.username, + "created_at": ( + user.created_at.isoformat() + if user.created_at + else None + ), + }, + } + + except HTTPException: + raise + + except Exception as e: + + print("LOGIN ERROR:", str(e)) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Login failed: {str(e)}", + ) + + +# ========================================================= +# VERIFY TOKEN +# ========================================================= + +@router.post("/verify") +def verify_token( + data: TokenSchema, +): + + try: + + payload = decode_token(data.token) + + if not payload: + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) + + return { + "valid": True, + "payload": payload, + } + + except HTTPException: + raise + + except Exception as e: + + print("VERIFY ERROR:", str(e)) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Token verification failed: {str(e)}", + ) + + +# ========================================================= +# REFRESH TOKEN +# ========================================================= + +@router.post("/refresh") +def refresh_token( + data: TokenSchema, + db: Session = Depends(get_db), +): + + try: + + payload = decode_token(data.token) + + user_id = payload.get("sub") + + if not user_id: + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + ) + + user = ( + db.query(User) + .filter(User.id == user_id) + .first() + ) + + if not user: + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + new_token = create_access_token( + { + "sub": str(user.id), + "email": user.email, + } + ) + + return { + "status": "success", + "token": new_token, + } + + except HTTPException: + raise + + except Exception as e: + + print("REFRESH ERROR:", str(e)) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Refresh failed: {str(e)}", + ) + + +# ========================================================= +# LOGOUT +# ========================================================= + +@router.post("/logout") +def logout(): + + return { + "status": "success", + "message": "Logout successful", + } \ No newline at end of file diff --git a/auth/schemas.py b/auth/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..01472622737511de3ef76a09b389f50ac39971cc --- /dev/null +++ b/auth/schemas.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel, EmailStr, Field, ConfigDict + + +# ========================================================= +# BASE CONFIG (STRICT MODE → PREVENTS SILENT DATA COERCION) +# ========================================================= +class StrictSchema(BaseModel): + model_config = ConfigDict( + strict=True, + extra="forbid", + validate_assignment=True, + ) + + +# ========================================================= +# SIGNUP SCHEMA +# ========================================================= +class SignupSchema(StrictSchema): + email: EmailStr + username: str = Field( + min_length=3, + max_length=32, + pattern=r"^[a-zA-Z0-9_]+$" + ) + password: str = Field( + min_length=8, + max_length=72, + ) + + +# ========================================================= +# LOGIN SCHEMA +# ========================================================= +class LoginSchema(StrictSchema): + email: EmailStr + password: str = Field( + min_length=1, + max_length=72, + ) + + +# ========================================================= +# TOKEN REQUEST SCHEMA +# ========================================================= +class TokenSchema(StrictSchema): + token: str = Field( + min_length=10, + max_length=2048 + ) + + +# ========================================================= +# USER RESPONSE SCHEMA (SAFE OUTPUT MODEL) +# ========================================================= +class UserOutSchema(StrictSchema): + id: int + email: EmailStr + username: str + created_at: str \ No newline at end of file diff --git a/auth/security.py b/auth/security.py new file mode 100644 index 0000000000000000000000000000000000000000..248b4b97de7017df7f15c14588ad43444965901b --- /dev/null +++ b/auth/security.py @@ -0,0 +1,137 @@ +import os +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict, Any + +from jose import jwt, JWTError +from passlib.context import CryptContext +from fastapi import HTTPException, status, Depends +from fastapi.security import OAuth2PasswordBearer + + +# ========================================================= +# ENV CONFIG +# ========================================================= + +SECRET_KEY = os.getenv("SECRET_KEY") + +if not SECRET_KEY: + raise RuntimeError("SECRET_KEY environment variable missing") + +ALGORITHM = os.getenv("ALGORITHM", "HS256") + +ACCESS_TOKEN_EXPIRE_MINUTES = int( + os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440") +) + + +# ========================================================= +# PASSWORD HASHING +# ========================================================= +# IMPORTANT: +# pbkdf2_sha256 avoids: +# - bcrypt crashes +# - bcrypt native dependency issues +# - 72-byte limits +# - passlib backend bugs +# ========================================================= + +pwd_context = CryptContext( + schemes=["pbkdf2_sha256"], + deprecated="auto", +) + + +def hash_password(password: str) -> str: + + if not password: + raise ValueError("Password required") + + if len(password) < 8: + raise ValueError("Password too short") + + return pwd_context.hash(password) + + +def verify_password( + plain_password: str, + hashed_password: str, +) -> bool: + + try: + return pwd_context.verify( + plain_password, + hashed_password, + ) + except Exception: + return False + + +# ========================================================= +# JWT +# ========================================================= + +def create_access_token( + data: Dict[str, Any], + expires_delta: Optional[timedelta] = None, +) -> str: + + to_encode = data.copy() + + expire = datetime.now(timezone.utc) + ( + expires_delta + if expires_delta + else timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + ) + + to_encode.update({"exp": expire}) + + return jwt.encode( + to_encode, + SECRET_KEY, + algorithm=ALGORITHM, + ) + + +def decode_token(token: str): + + try: + return jwt.decode( + token, + SECRET_KEY, + algorithms=[ALGORITHM], + ) + + except JWTError: + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +# ========================================================= +# AUTH DEPENDENCY +# ========================================================= + +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl="/api/auth/login" +) + + +def get_current_user( + token: str = Depends(oauth2_scheme) +): + + payload = decode_token(token) + + user_id = payload.get("sub") + + if not user_id: + + raise HTTPException( + status_code=401, + detail="Invalid authentication", + ) + + return payload \ No newline at end of file diff --git a/core/builders/api_builder.py b/core/builders/api_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..d2b911003465c5c10d4d2151d24c7194e51b8bbf --- /dev/null +++ b/core/builders/api_builder.py @@ -0,0 +1,142 @@ +from fastapi import APIRouter, UploadFile, File, Form, HTTPException +from fastapi.responses import JSONResponse, FileResponse +from typing import Optional +import tempfile +import shutil +import os + +from core.registry.loader import get_tasks +from core.execution.executor import execute_task + + +# ===================================================== +# ROUTER BUILDER +# ===================================================== + +def build_api_router() -> APIRouter: + """ + Dynamically builds API routes from TASK REGISTRY. + + Generated endpoints: + + POST /execute/{task} + GET /tasks + GET /health + """ + + router = APIRouter(tags=["API"]) + + # ================================================= + # EXECUTE TASK + # ================================================= + @router.post("/execute/{task_name}") + async def execute( + task_name: str, + file: Optional[UploadFile] = File(None), + url_input: Optional[str] = Form(None), + ): + """ + Universal execution endpoint. + Accepts: + - file upload + - url_input + """ + + tasks = get_tasks() + + if task_name not in tasks: + raise HTTPException( + status_code=404, + detail=f"Task '{task_name}' not found", + ) + + temp_path = None + + try: + # ----------------------------------------- + # SAVE UPLOADED FILE + # ----------------------------------------- + if file: + suffix = os.path.splitext(file.filename)[1] + + with tempfile.NamedTemporaryFile( + delete=False, + suffix=suffix, + ) as tmp: + shutil.copyfileobj(file.file, tmp) + temp_path = tmp.name + + # ----------------------------------------- + # BUILD INPUT PAYLOAD + # ----------------------------------------- + payload = { + "file_path": temp_path, + "url_input": url_input, + } + + # ----------------------------------------- + # EXECUTE TASK + # ----------------------------------------- + result = await execute_task(task_name, payload) + + # ----------------------------------------- + # FILE RESPONSE + # ----------------------------------------- + if isinstance(result, dict) and result.get("file"): + output_file = result["file"] + + if os.path.exists(output_file): + return FileResponse( + output_file, + filename=os.path.basename(output_file), + ) + + # ----------------------------------------- + # JSON RESPONSE + # ----------------------------------------- + return JSONResponse(result) + + except Exception as e: + raise HTTPException( + status_code=500, + detail=str(e), + ) + + finally: + # ----------------------------------------- + # CLEANUP TEMP FILE + # ----------------------------------------- + if temp_path and os.path.exists(temp_path): + os.unlink(temp_path) + + # ================================================= + # TASK LIST + # ================================================= + @router.get("/tasks") + async def list_tasks(): + """ + Returns registry tasks. + Used by UI and Docs builder. + """ + + tasks = get_tasks() + + return { + name: { + "category": getattr(t, "category", "general"), + "description": getattr(t, "description", ""), + } + for name, t in tasks.items() + } + + # ================================================= + # HEALTH CHECK + # ================================================= + @router.get("/health") + async def health(): + return { + "status": "ok", + "tasks_loaded": len(get_tasks()), + } + + return router \ No newline at end of file diff --git a/core/builders/docs_builder.py b/core/builders/docs_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..86cda92f82a022e7f7c7318fbeca2e9c0624bbb1 --- /dev/null +++ b/core/builders/docs_builder.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from typing import Dict, Any, List + +from core.registry.tasks_registry import TASKS + + +# ===================================================== +# INTERNAL HELPERS +# ===================================================== + +def _serialize_task(task) -> Dict[str, Any]: + """ + Convert TaskDefinition → API-safe metadata. + Must NOT import task modules. + """ + + return { + "name": getattr(task, "name", None), + "description": getattr(task, "description", ""), + "category": getattr(task, "category", "general"), + "module": getattr(task, "module", None), + "callable": getattr(task, "callable_name", "run"), + "async": getattr(task, "async_task", False), + "enabled": getattr(task, "enabled", True), + } + + +def _group_tasks(tasks: List[Any]) -> Dict[str, List[Dict[str, Any]]]: + """ + Groups tasks by category for UI rendering. + """ + + grouped: Dict[str, List[Dict[str, Any]]] = {} + + for task in tasks: + category = getattr(task, "category", "general") + grouped.setdefault(category, []).append(_serialize_task(task)) + + return grouped + + +# ===================================================== +# DOCS ROUTER BUILDER (V11) +# ===================================================== + +def build_docs_router() -> APIRouter: + """ + Builds dynamic documentation endpoints. + + Provides: + /docs/tasks → flat task list + /docs/catalog → grouped tasks + /docs/health → docs status + """ + + router = APIRouter( + prefix="/docs", + tags=["Documentation"], + ) + + # ------------------------------------------------- + # List all tasks + # ------------------------------------------------- + @router.get("/tasks") + async def list_tasks(): + return JSONResponse( + [_serialize_task(task) for task in TASKS] + ) + + # ------------------------------------------------- + # Categorized task catalog + # ------------------------------------------------- + @router.get("/catalog") + async def task_catalog(): + return JSONResponse(_group_tasks(TASKS)) + + # ------------------------------------------------- + # Docs health endpoint + # ------------------------------------------------- + @router.get("/health") + async def docs_health(): + return { + "status": "ok", + "service": "docs", + "tasks_registered": len(TASKS), + } + + return router \ No newline at end of file diff --git a/core/builders/ui_builder.py b/core/builders/ui_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..c9d3f29e53d0e3e198f4622e150aa865427aa98b --- /dev/null +++ b/core/builders/ui_builder.py @@ -0,0 +1,107 @@ +from fastapi import APIRouter +from fastapi.responses import HTMLResponse, FileResponse +from pathlib import Path +from typing import Dict, List + +from core.registry.tasks_registry import TASKS + + +# ===================================================== +# CONFIG +# ===================================================== + +BASE_DIR = Path(__file__).resolve().parents[2] +UI_DIR = BASE_DIR / "ui" +INDEX_FILE = UI_DIR / "index.html" + + +# ===================================================== +# TASK GROUPING +# ===================================================== + +def group_tasks() -> Dict[str, List[dict]]: + """ + Converts registry → categorized UI structure. + """ + + categories: Dict[str, List[dict]] = {} + + for task in TASKS: + if not getattr(task, "enabled", True): + continue + + category = getattr(task, "category", "general") + + categories.setdefault(category, []).append( + { + "name": task.name, + "description": getattr(task, "description", ""), + } + ) + + return categories + + +# ===================================================== +# ROUTER BUILDER +# ===================================================== + +def build_ui_router() -> APIRouter: + """ + Serves UI + dynamic UI metadata. + + Endpoints: + / + /ui + /ui/tasks + /ui/health + """ + + router = APIRouter(tags=["UI"]) + + # ------------------------------------------------- + # MAIN UI + # ------------------------------------------------- + @router.get("/", response_class=HTMLResponse) + async def serve_root(): + """ + Serves index.html + """ + + if not INDEX_FILE.exists(): + return HTMLResponse( + "

Basyx UI Missing

", + status_code=500, + ) + + return FileResponse(INDEX_FILE) + + # ------------------------------------------------- + # Explicit UI route + # ------------------------------------------------- + @router.get("/ui", response_class=HTMLResponse) + async def serve_ui(): + return await serve_root() + + # ------------------------------------------------- + # UI TASK CATALOG + # ------------------------------------------------- + @router.get("/ui/tasks") + async def ui_tasks(): + """ + UI fetches this to build sidebar dynamically. + """ + return group_tasks() + + # ------------------------------------------------- + # HEALTH + # ------------------------------------------------- + @router.get("/ui/health") + async def ui_health(): + return { + "status": "ok", + "ui": "active", + "tasks": len(TASKS), + } + + return router \ No newline at end of file diff --git a/core/execution/context.py b/core/execution/context.py new file mode 100644 index 0000000000000000000000000000000000000000..807d40a92e490eceee27e4878fa1d5e5fe78d40e --- /dev/null +++ b/core/execution/context.py @@ -0,0 +1,155 @@ +""" +V11 Execution Context +--------------------- + +Central runtime object passed into every task. + +Responsibilities: +- Hold inputs +- Share memory between tasks +- Store outputs +- Track execution metadata +- Provide filesystem helpers +- Provide logging helpers +""" + +from __future__ import annotations + +import os +import uuid +import tempfile +from typing import Any, Dict, Optional + + +# ========================================================= +# Context Object +# ========================================================= + +class ExecutionContext: + """ + Standard runtime context used by ALL tasks. + + Every task receives: + async def run(ctx: ExecutionContext) + """ + + # ----------------------------------------------------- + # INIT + # ----------------------------------------------------- + def __init__( + self, + task_name: str, + inputs: Optional[Dict[str, Any]] = None, + workspace: Optional[str] = None, + ): + + self.task_name = task_name + self.job_id = str(uuid.uuid4()) + + self.inputs: Dict[str, Any] = inputs or {} + self.outputs: Dict[str, Any] = {} + self.memory: Dict[str, Any] = {} + + self.status: str = "created" + self.error: Optional[str] = None + + self.workspace = workspace or self._create_workspace() + + # ----------------------------------------------------- + # WORKSPACE + # ----------------------------------------------------- + def _create_workspace(self) -> str: + path = tempfile.mkdtemp(prefix="basyx_job_") + return path + + def path(self, filename: str) -> str: + """ + Safe workspace path helper + """ + return os.path.join(self.workspace, filename) + + # ----------------------------------------------------- + # INPUT HELPERS + # ----------------------------------------------------- + def get(self, key: str, default=None): + return self.inputs.get(key, default) + + def require(self, key: str): + if key not in self.inputs: + raise ValueError(f"Missing required input: {key}") + return self.inputs[key] + + # ----------------------------------------------------- + # OUTPUT HELPERS + # ----------------------------------------------------- + def set_output(self, key: str, value: Any): + self.outputs[key] = value + + def result(self) -> Dict[str, Any]: + return { + "job_id": self.job_id, + "task": self.task_name, + "status": self.status, + "outputs": self.outputs, + "error": self.error, + } + + # ----------------------------------------------------- + # MEMORY (cross-task sharing) + # ----------------------------------------------------- + def remember(self, key: str, value: Any): + """ + Save value for downstream tasks. + """ + self.memory[key] = value + + def recall(self, key: str, default=None): + return self.memory.get(key, default) + + # ----------------------------------------------------- + # STATUS MANAGEMENT + # ----------------------------------------------------- + def mark_running(self): + self.status = "running" + + def mark_complete(self): + self.status = "completed" + + def mark_failed(self, error: Exception | str): + self.status = "failed" + self.error = str(error) + + # ----------------------------------------------------- + # LOGGING + # ----------------------------------------------------- + def log(self, message: str): + print(f"[{self.task_name} | {self.job_id}] {message}") + + # ----------------------------------------------------- + # SERIALIZATION + # ----------------------------------------------------- + def to_dict(self): + return { + "job_id": self.job_id, + "task_name": self.task_name, + "inputs": self.inputs, + "outputs": self.outputs, + "memory": self.memory, + "status": self.status, + "error": self.error, + "workspace": self.workspace, + } + + +# ========================================================= +# Context Factory +# ========================================================= + +def create_context(task_name: str, inputs: Dict[str, Any]) -> ExecutionContext: + """ + Standardized factory used by executor. + """ + return ExecutionContext( + task_name=task_name, + inputs=inputs, + ) \ No newline at end of file diff --git a/core/execution/executor.py b/core/execution/executor.py new file mode 100644 index 0000000000000000000000000000000000000000..c93bd5041b62ad070adfc3fe628652c615daed7b --- /dev/null +++ b/core/execution/executor.py @@ -0,0 +1,166 @@ +""" +BASYX V11 EXECUTOR +------------------ + +Central task execution engine. + +Responsibilities: +- Load task from registry +- Create execution context +- Execute task safely +- Capture outputs +- Handle failures +- Support chaining +""" + +from __future__ import annotations + +import asyncio +import inspect +from typing import Dict, Any, List + +from core.execution.context import create_context, ExecutionContext +from core.registry.loader import get_task_map + + +# ========================================================= +# TASK CACHE +# ========================================================= + +TASK_MAP = get_task_map() + + +# ========================================================= +# INTERNAL EXECUTION +# ========================================================= + +async def _run_task( + task_name: str, + inputs: Dict[str, Any], +) -> Dict[str, Any]: + """ + Execute a single task safely. + """ + + if task_name not in TASK_MAP: + raise ValueError(f"Unknown task: {task_name}") + + task = TASK_MAP[task_name] + + ctx: ExecutionContext = create_context( + task_name=task_name, + inputs=inputs, + ) + + ctx.mark_running() + ctx.log("Starting task") + + try: + + # --------------------------------------------- + # Execute task + # --------------------------------------------- + result = task.run + + if inspect.iscoroutinefunction(result): + await result(ctx) + else: + await asyncio.to_thread(result, ctx) + + ctx.mark_complete() + ctx.log("Task completed") + + except Exception as e: + ctx.mark_failed(e) + ctx.log(f"Task failed: {e}") + + return ctx.result() + + +# ========================================================= +# PUBLIC EXECUTOR +# ========================================================= + +async def execute_task( + task_name: str, + inputs: Dict[str, Any], +) -> Dict[str, Any]: + """ + Main entrypoint used by API + UI. + """ + + return await _run_task(task_name, inputs) + + +# ========================================================= +# PIPELINE EXECUTION (CHAINED TASKS) +# ========================================================= + +async def execute_pipeline( + tasks: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: + """ + Execute tasks sequentially. + + Example: + [ + {"task": "transcribe", "inputs": {...}}, + {"task": "subtitles"}, + {"task": "render"} + ] + """ + + results = [] + shared_memory = {} + + for step in tasks: + + name = step["task"] + inputs = step.get("inputs", {}) + + # Inject memory from previous step + inputs["memory"] = shared_memory + + result = await _run_task(name, inputs) + + results.append(result) + + if result["status"] != "completed": + break + + # propagate outputs + shared_memory.update(result.get("outputs", {})) + + return results + + +# ========================================================= +# PARALLEL EXECUTION +# ========================================================= + +async def execute_parallel( + tasks: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: + """ + Run multiple tasks concurrently. + """ + + coroutines = [ + _run_task(t["task"], t.get("inputs", {})) + for t in tasks + ] + + return await asyncio.gather(*coroutines) + + +# ========================================================= +# REGISTRY HOT RELOAD (DEV MODE) +# ========================================================= + +def reload_tasks(): + """ + Reload registry without restarting server. + Useful during development. + """ + global TASK_MAP + TASK_MAP = get_task_map() \ No newline at end of file diff --git a/core/registry/loader.py b/core/registry/loader.py new file mode 100644 index 0000000000000000000000000000000000000000..4769dc19d0ebe685427696b0670c3b7254bec6dd --- /dev/null +++ b/core/registry/loader.py @@ -0,0 +1,45 @@ +from core.registry.tasks_registry import TASKS + + +# ------------------------------------------------ +# Return categorized tasks (UI Builder uses this) +# ------------------------------------------------ +def get_tasks(): + return TASKS + + +# ------------------------------------------------ +# Flatten tasks across categories +# ------------------------------------------------ +def get_all_tasks(): + + tasks = [] + + for category, items in TASKS.items(): + for task in items: + task_copy = dict(task) + task_copy["category"] = category + tasks.append(task_copy) + + return tasks + + +# ------------------------------------------------ +# Execution map +# id -> callable +# ------------------------------------------------ +def get_task_map(): + + task_map = {} + + for category, items in TASKS.items(): + for task in items: + + if "handler" not in task: + raise RuntimeError( + f"Task '{task['id']}' missing handler" + ) + + task_map[task["id"]] = task["handler"] + + return task_map \ No newline at end of file diff --git a/core/registry/task_model.py b/core/registry/task_model.py new file mode 100644 index 0000000000000000000000000000000000000000..22635b4ea372611dc9d00b829099095d05303ff0 --- /dev/null +++ b/core/registry/task_model.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field +from typing import Callable, Dict, Any + + +class TaskDefinition(BaseModel): + name: str + category: str + description: str + + handler: Callable + + inputs: Dict[str, str] = Field(default_factory=dict) + outputs: Dict[str, str] = Field(default_factory=dict) + + ui_schema: Dict[str, Any] = Field(default_factory=dict) + + autonomous: bool = True + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/core/registry/tasks_registry.py b/core/registry/tasks_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..c77ff9a133ece4328aa0105cc94aefae6cf67969 --- /dev/null +++ b/core/registry/tasks_registry.py @@ -0,0 +1,198 @@ +""" +BASYX V11 — Dynamic Task Registry +--------------------------------- + +Single Source Of Truth for: + +• UI generation +• API routes +• Executor routing +• Documentation builder +• Autonomous Brain planning +""" + +# ========================================================= +# ANALYSIS TASKS +# ========================================================= + +from publisher.tasks.transcribe import run as transcribe +from publisher.tasks.subtitles import run as subtitles +from publisher.tasks.highlights import run as highlights +from publisher.tasks.viral_score import run as viral_score +from publisher.tasks.strategy import run as strategy + + +# ========================================================= +# RENDER TASKS +# ========================================================= + +from publisher.tasks.render import run as render +from publisher.tasks.generate_thumbnail import run as generate_thumbnail +from publisher.tasks.generate_metadata import run as generate_metadata + + +# ========================================================= +# PUBLISH TASKS +# ========================================================= + +from publisher.tasks.publish_tiktok import run as publish_tiktok +from publisher.tasks.publish_youtube import run as publish_youtube +from publisher.tasks.publish_instagram import run as publish_instagram + + +# ========================================================= +# SYSTEM TASKS +# ========================================================= + +from publisher.tasks.batch_runner import run as batch_runner +from publisher.tasks.autonomous_mode import run as autonomous_mode + + +# ========================================================= +# TASK REGISTRY +# ========================================================= + +TASKS = { + + # ----------------------------------------------------- + # ANALYSIS + # ----------------------------------------------------- + "analysis": [ + + { + "id": "transcribe", + "name": "Transcribe", + "description": "Generate transcript from video/audio", + "inputs": ["file", "url_input"], + "output": "json", + "handler": transcribe, + }, + + { + "id": "subtitles", + "name": "Subtitles", + "description": "Generate SRT subtitles", + "inputs": ["file", "url_input"], + "output": "file", + "handler": subtitles, + }, + + { + "id": "highlights", + "name": "Highlights", + "description": "Detect viral highlight segments", + "inputs": ["file"], + "output": "json", + "handler": highlights, + }, + + { + "id": "viral-score", + "name": "Viral Score", + "description": "AI virality prediction", + "inputs": ["file"], + "output": "json", + "handler": viral_score, + }, + + { + "id": "strategy", + "name": "Strategy", + "description": "Content strategy generation", + "inputs": ["file"], + "output": "json", + "handler": strategy, + }, + ], + + # ----------------------------------------------------- + # RENDER + # ----------------------------------------------------- + "render": [ + + { + "id": "render", + "name": "Render Video", + "description": "Render final short-form video", + "inputs": ["file", "url_input"], + "output": "video", + "video_output": True, + "handler": render, + }, + + { + "id": "generate-thumbnail", + "name": "Thumbnail", + "description": "Generate AI thumbnail", + "inputs": ["file"], + "output": "image", + "handler": generate_thumbnail, + }, + + { + "id": "generate-metadata", + "name": "Metadata", + "description": "Generate captions, hashtags, titles", + "inputs": ["file"], + "output": "json", + "handler": generate_metadata, + }, + ], + + # ----------------------------------------------------- + # PUBLISH + # ----------------------------------------------------- + "publish": [ + + { + "id": "publish-tiktok", + "name": "Publish TikTok", + "description": "Upload video to TikTok", + "inputs": ["file"], + "output": "json", + "handler": publish_tiktok, + }, + + { + "id": "publish-youtube", + "name": "Publish YouTube", + "description": "Upload YouTube Short", + "inputs": ["file"], + "output": "json", + "handler": publish_youtube, + }, + + { + "id": "publish-instagram", + "name": "Publish Instagram", + "description": "Upload Instagram Reel", + "inputs": ["file"], + "output": "json", + "handler": publish_instagram, + }, + ], + + # ----------------------------------------------------- + # SYSTEM + # ----------------------------------------------------- + "system": [ + + { + "id": "batch", + "name": "Batch Processor", + "description": "Execute batch pipeline", + "inputs": [], + "output": "json", + "handler": batch_runner, + }, + + { + "id": "autonomous", + "name": "Autonomous Mode", + "description": "Start autonomous AI publisher", + "inputs": [], + "output": "json", + "handler": autonomous_mode, + }, + ], +} \ No newline at end of file diff --git a/fonts/TikTok-Bold.ttf b/fonts/TikTok-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6521e59be8effa0729100e03e5bd502f917d5e2d --- /dev/null +++ b/fonts/TikTok-Bold.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e7b158d73db62c81c219602a5aef762aa0f40defa31d0e5dc72a2221b6d59fa +size 131 diff --git a/ingestion/__init__.py b/ingestion/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9b477625143bbc699f5a65c22b8fda00c6666f92 --- /dev/null +++ b/ingestion/__init__.py @@ -0,0 +1 @@ +# Initialize package \ No newline at end of file diff --git a/ingestion/base64_loader.py b/ingestion/base64_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..cfdd9f1b490200de65adbc17378ebd443ca1ce0f --- /dev/null +++ b/ingestion/base64_loader.py @@ -0,0 +1,19 @@ +import base64 +import tempfile + + +def decode_base64(data): + + header, encoded = data.split(",", 1) + + binary = base64.b64decode(encoded) + + tmp = tempfile.NamedTemporaryFile( + delete=False, + suffix=".mp4" + ) + + tmp.write(binary) + tmp.close() + + return tmp.name \ No newline at end of file diff --git a/ingestion/classifiers.py b/ingestion/classifiers.py new file mode 100644 index 0000000000000000000000000000000000000000..980539adb98a3702e660790661c256db97acec95 --- /dev/null +++ b/ingestion/classifiers.py @@ -0,0 +1,29 @@ +import os +from urllib.parse import urlparse + +def classify_source(source: str): + + if not source: + raise Exception("Empty source") + + if os.path.exists(source): + return "local" + + if source.endswith(".mp4"): + return "direct" + + domain = urlparse(source).netloc.lower() + + if "youtube" in domain or "youtu.be" in domain: + return "youtube" + + if "tiktok" in domain: + return "tiktok" + + if "instagram" in domain: + return "instagram" + + if "facebook" in domain: + return "facebook" + + return "unknown" \ No newline at end of file diff --git a/ingestion/cookies/youtube.txt b/ingestion/cookies/youtube.txt new file mode 100644 index 0000000000000000000000000000000000000000..6691600ccade16b3ce2ad3eef15b207e2a46fece --- /dev/null +++ b/ingestion/cookies/youtube.txt @@ -0,0 +1,210 @@ +[ + { + "domain": ".youtube.com", + "expirationDate": 1802677401.949858, + "hostOnly": false, + "httpOnly": true, + "name": "__Secure-3PSID", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "g.a0005ggHIKZHTs9zoAq0BBIjMwWabLARvs6GknU_-0exEvU5DGUwqAzR62E1h_wa5LLS12PspgACgYKAdASARYSFQHGX2MijjE-c2eX18fNOAWSCVLwmBoVAUF8yKq04Fen2HnSxnxT5K2Fpd4j0076" + }, + { + "domain": ".youtube.com", + "expirationDate": 1809905181.409847, + "hostOnly": false, + "httpOnly": false, + "name": "SIDCC", + "path": "/", + "sameSite": null, + "secure": false, + "session": false, + "storeId": null, + "value": "AKEyXzUpR9UA-g9qoHyFYOWJ1CGoWZ5tgVDYm4bEHntfZ5iN6z6RVL8K33zjjp4slMle6hPdIw" + }, + { + "domain": ".youtube.com", + "expirationDate": 1802677401.948968, + "hostOnly": false, + "httpOnly": false, + "name": "SID", + "path": "/", + "sameSite": null, + "secure": false, + "session": false, + "storeId": null, + "value": "g.a0005ggHIKZHTs9zoAq0BBIjMwWabLARvs6GknU_-0exEvU5DGUwA_FG-6Wg9ggcFsmCVhmjswACgYKAcMSARYSFQHGX2Mi4I_6gfKzdtxZN4ZX_kshjxoVAUF8yKqpv1_RfX_zFLUrl3TCcBZg0076" + }, + { + "domain": ".youtube.com", + "expirationDate": 1809905178.393538, + "hostOnly": false, + "httpOnly": true, + "name": "__Secure-1PSIDTS", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "sidts-CjUBhkeRd5PWbJP13KNRyvfxmZVpHrCLWiB90qS5dK1VtBZHEqYG_idUO7UYEmnajmrCqhVkWxAA" + }, + { + "domain": ".youtube.com", + "expirationDate": 1802677401.947697, + "hostOnly": false, + "httpOnly": false, + "name": "SAPISID", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "KqRpYGBu4XUSI9Dm/AZQgHuYmw_rieK8yf" + }, + { + "domain": ".youtube.com", + "expirationDate": 1809905181.410137, + "hostOnly": false, + "httpOnly": true, + "name": "__Secure-1PSIDCC", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "AKEyXzWZL0sioZ0UszpeTFyWSxRW5lbbqZGrHkU8PxHlV-jCSWbkudpG9_cQRuSIw4qB_qoxPw" + }, + { + "domain": ".youtube.com", + "expirationDate": 1802677401.947169, + "hostOnly": false, + "httpOnly": true, + "name": "SSID", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "A4IKjzFxcSCTwJsHj" + }, + { + "domain": ".youtube.com", + "expirationDate": 1802677401.948117, + "hostOnly": false, + "httpOnly": false, + "name": "__Secure-1PAPISID", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "KqRpYGBu4XUSI9Dm/AZQgHuYmw_rieK8yf" + }, + { + "domain": ".youtube.com", + "expirationDate": 1802677401.949424, + "hostOnly": false, + "httpOnly": true, + "name": "__Secure-1PSID", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "g.a0005ggHIKZHTs9zoAq0BBIjMwWabLARvs6GknU_-0exEvU5DGUwDX3_lEm4gf-uTFeESekcrQACgYKAd0SARYSFQHGX2MiwxRcpnYfY1bFNKTsueE3oxoVAUF8yKr-LKssrIr-F9tOdzCc1xo70076" + }, + { + "domain": ".youtube.com", + "expirationDate": 1802677401.948542, + "hostOnly": false, + "httpOnly": false, + "name": "__Secure-3PAPISID", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "KqRpYGBu4XUSI9Dm/AZQgHuYmw_rieK8yf" + }, + { + "domain": ".youtube.com", + "expirationDate": 1809905181.410357, + "hostOnly": false, + "httpOnly": true, + "name": "__Secure-3PSIDCC", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "AKEyXzWCPVCW7k3dwkKSsjV05nT9K7hC-wPUt49b_JuXW45TFu08ti7lfQsXqgfeDezseQCbZA" + }, + { + "domain": ".youtube.com", + "expirationDate": 1809905178.39415, + "hostOnly": false, + "httpOnly": true, + "name": "__Secure-3PSIDTS", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "sidts-CjUBhkeRd5PWbJP13KNRyvfxmZVpHrCLWiB90qS5dK1VtBZHEqYG_idUO7UYEmnajmrCqhVkWxAA" + }, + { + "domain": ".youtube.com", + "expirationDate": 1802677401.947412, + "hostOnly": false, + "httpOnly": false, + "name": "APISID", + "path": "/", + "sameSite": null, + "secure": false, + "session": false, + "storeId": null, + "value": "36o598sqGfxJDffW/AyB1SJrxHZGXJ3xdj" + }, + { + "domain": ".youtube.com", + "expirationDate": 1802677401.946773, + "hostOnly": false, + "httpOnly": true, + "name": "HSID", + "path": "/", + "sameSite": null, + "secure": false, + "session": false, + "storeId": null, + "value": "AhAjQv9yddkNo7Ro3" + }, + { + "domain": ".youtube.com", + "expirationDate": 1812929163.824495, + "hostOnly": false, + "httpOnly": true, + "name": "LOGIN_INFO", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "AFmmF2swRgIhAMcn3H8l0bXlZVgci9p5SvScepya1xAObUTRdJgGH5qqAiEAnQveY5dETRyStRQqDag0lUXHcTbRJkvDQ2Ge8GiDSBk:QUQ3MjNmd3IweGRfR3VIeVRCUGRiVHlkM1RxUXZjM1JnNUZCc0Nvd3lYV1l6UlpCdVVLdHR2cVgwU0UxUFloMkkwVlhXdWtMalQyMHZIVkc5eTFDU3dGSnQ1QWk5Z0N6XzZOTzVuRThBZHF3VkZsVlNpdmZ0TWExTk5jLUVxelhRQVF4M1p3RTRYSnU2dV9LQ0VoTllEcUNhZnBER3o2SW5R" + }, + { + "domain": ".youtube.com", + "expirationDate": 1812929169.133873, + "hostOnly": false, + "httpOnly": false, + "name": "PREF", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "f6=40000000&tz=Africa.Lagos" + } +] diff --git a/ingestion/downloader.py b/ingestion/downloader.py new file mode 100644 index 0000000000000000000000000000000000000000..8b24300cb71d2ceee308e4c9b74992a594151105 --- /dev/null +++ b/ingestion/downloader.py @@ -0,0 +1,18 @@ +import requests +import tempfile + + +def download_file(url): + + r = requests.get(url, stream=True, timeout=120) + + r.raise_for_status() + + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") + + for chunk in r.iter_content(1024 * 1024): + tmp.write(chunk) + + tmp.close() + + return tmp.name \ No newline at end of file diff --git a/ingestion/normalizer.py b/ingestion/normalizer.py new file mode 100644 index 0000000000000000000000000000000000000000..4c905497f2d1f8a08422005e433377528dd55124 --- /dev/null +++ b/ingestion/normalizer.py @@ -0,0 +1,24 @@ +import subprocess +import uuid +import os + +def normalize_video(path): + + output = f"jobs/norm_{uuid.uuid4()}.mp4" + + cmd = [ + "ffmpeg", + "-y", + "-i", path, + "-vf", "scale=1080:-2", + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "23", + "-c:a", "aac", + "-movflags", "+faststart", + output, + ] + + subprocess.run(cmd, check=True) + + return output \ No newline at end of file diff --git a/ingestion/resolver.py b/ingestion/resolver.py new file mode 100644 index 0000000000000000000000000000000000000000..e7a44d739d69802bae6804af22581069887fda7f --- /dev/null +++ b/ingestion/resolver.py @@ -0,0 +1,127 @@ +import os +import uuid +import requests +from pathlib import Path +from typing import Optional, Union +from fastapi import UploadFile + +# ============================== +# STORAGE CONFIG +# ============================== + +BASE_DIR = Path(__file__).resolve().parents[1] +UPLOAD_DIR = str(BASE_DIR / "jobs" / "uploads") +os.makedirs(UPLOAD_DIR, exist_ok=True) + +CHUNK_SIZE = 1024 * 1024 # 1MB streaming for large media + + +# ============================== +# CORE RESOLVER +# ============================== + +def resolve_input( + source: Optional[str] = None, + upload: Optional[UploadFile] = None, + raw_bytes: Optional[bytes] = None +) -> str: + """ + Universal ingestion layer for all pipeline systems. + + Supports: + - UploadFile (FastAPI / Gradio) + - URL download (http/https) + - Local filesystem path + - Raw bytes input (future automation nodes) + """ + + # ---------------------------------------- + # CASE 1: UploadFile (Gradio / FastAPI) + # ---------------------------------------- + if upload is not None: + filename = f"{uuid.uuid4()}_{upload.filename or 'upload.mp4'}" + path = os.path.join(UPLOAD_DIR, filename) + + with open(path, "wb") as f: + while True: + chunk = upload.file.read(CHUNK_SIZE) + if not chunk: + break + f.write(chunk) + + return path + + # ---------------------------------------- + # CASE 2: Raw bytes (automation / webhook) + # ---------------------------------------- + if raw_bytes is not None: + filename = f"{uuid.uuid4()}.mp4" + path = os.path.join(UPLOAD_DIR, filename) + + with open(path, "wb") as f: + f.write(raw_bytes) + + return path + + # ---------------------------------------- + # CASE 3: URL input (YouTube, TikTok, direct mp4) + # ---------------------------------------- + if source and source.startswith(("http://", "https://")): + + filename = f"{uuid.uuid4()}.mp4" + path = os.path.join(UPLOAD_DIR, filename) + + headers = { + "User-Agent": "Mozilla/5.0 (compatible; BasyxBot/1.0)" + } + + with requests.get(source, stream=True, headers=headers, timeout=60) as r: + r.raise_for_status() + + with open(path, "wb") as f: + for chunk in r.iter_content(chunk_size=CHUNK_SIZE): + if chunk: + f.write(chunk) + + return path + + # ---------------------------------------- + # CASE 4: Local file path + # ---------------------------------------- + if source and os.path.exists(source): + return source + + # ---------------------------------------- + # INVALID INPUT HANDLING + # ---------------------------------------- + raise ValueError( + "resolve_input failed: no valid source, upload, or raw_bytes provided" + ) + + +# ============================== +# OPTIONAL HELPERS (V11 READY) +# ============================== + +def detect_input_type(source: str) -> str: + """ + Lightweight classifier for routing decisions upstream. + """ + + if source.startswith(("http://", "https://")): + return "url" + + if os.path.exists(source): + return "file" + + return "unknown" + + +def normalize_source(source: str) -> str: + """ + Cleans input strings for downstream consistency. + """ + if not source: + return source + + return source.strip() diff --git a/ingestion/social.py b/ingestion/social.py new file mode 100644 index 0000000000000000000000000000000000000000..00af0f1188ccc305752d37488809991b336233ec --- /dev/null +++ b/ingestion/social.py @@ -0,0 +1,21 @@ +import tempfile +import yt_dlp + + +def download_social(url): + + output = tempfile.NamedTemporaryFile( + delete=False, + suffix=".mp4" + ).name + + ydl_opts = { + "outtmpl": output, + "format": "mp4", + "quiet": True + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + + return output \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..43e5b9954a240746979472d96e9c517ea5a3a71f --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +from .kokoro import Kokoro +from .tokenizer import Tokenizer diff --git a/models/kokoro.py b/models/kokoro.py new file mode 100644 index 0000000000000000000000000000000000000000..34b5dd61126263ef3ee837265847b4b2e0a516bd --- /dev/null +++ b/models/kokoro.py @@ -0,0 +1,125 @@ +import torch +import numpy as np +import onnxruntime as ort + +TOKEN_LIMIT = 510 +SAMPLE_RATE = 24_000 + + +class Kokoro: + def __init__(self, model_path: str, style_vector_path: str, tokenizer, lang: str = 'en-us') -> None: + """ + Initializes the ONNXInference class. + + Args: + model_path (str): Path to the ONNX model file. + style_vector_path (str): Path to the style vector file. + lang (str): Language code for the tokenizer. + """ + self.sess = ort.InferenceSession(model_path) + self.style_vector_path = style_vector_path + self.tokenizer = tokenizer + self.lang = lang + + def preprocess(self, text): + """ + Converts input text to tokenized numerical IDs and loads the style vector. + + Args: + text (str): Input text to preprocess. + + Returns: + tuple: Tokenized input and corresponding style vector. + """ + # Convert text to phonemes and tokenize + phonemes = self.tokenizer.phonemize(text, lang=self.lang) + tokenized_phonemes = self.tokenizer.tokenize(phonemes) + + if not tokenized_phonemes: + raise ValueError("No tokens found after tokenization") + + style_vector = torch.load(self.style_vector_path, weights_only=True) + + if len(tokenized_phonemes) > TOKEN_LIMIT: + token_chunks = self.split_into_chunks(tokenized_phonemes) + + tokens_list = [] + styles_list = [] + + for chunk in token_chunks: + token_chunk = [[0, *chunk, 0]] + style_chunk = style_vector[len(chunk)].numpy() + + tokens_list.append(token_chunk) + styles_list.append(style_chunk) + + return tokens_list, styles_list + + style_vector = style_vector[len(tokenized_phonemes)].numpy() + tokenized_phonemes = [[0, *tokenized_phonemes, 0]] + + return tokenized_phonemes, style_vector + + @staticmethod + def split_into_chunks(tokens): + """ + Splits a list of tokens into chunks of size TOKEN_LIMIT. + + Args: + tokens (list): List of tokens to split. + + Returns: + list: List of token chunks. + """ + tokens_chunks = [] + for i in range(0, len(tokens), TOKEN_LIMIT): + tokens_chunks.append(tokens[i:i+TOKEN_LIMIT]) + return tokens_chunks + + def infer(self, tokens, style_vector, speed=1.0): + """ + Runs inference using the ONNX model. + + Args: + tokens (list): Tokenized input for the model. + style_vector (numpy.ndarray): Style vector for the model. + speed (float): Speed parameter for inference. + + Returns: + numpy.ndarray: Generated audio data. + """ + # Perform inference + audio = self.sess.run( + None, + { + 'tokens': tokens, + 'style': style_vector, + 'speed': np.array([speed], dtype=np.float32), + } + )[0] + return audio + + def generate_audio(self, text, speed=1.0): + """ + Full pipeline: preprocess, infer, and save the generated audio. + + Args: + text (str): Input text to generate audio from. + speed (float): Speed parameter for inference. + """ + # Preprocess text + tokenized_data, styles_data = self.preprocess(text) + + audio_segments = [] + if len(tokenized_data) > 1: # list of token chunks + for token_chunk, style_chunk in zip(tokenized_data, styles_data): + audio = self.infer(token_chunk, style_chunk, speed=speed) + audio_segments.append(audio) + else: # single token less than input limit + # Run inference + audio = self.infer(tokenized_data, styles_data, speed=speed) + audio_segments.append(audio) + + full_audio = np.concatenate(audio_segments) + + return full_audio, SAMPLE_RATE diff --git a/models/tokenizer.py b/models/tokenizer.py new file mode 100644 index 0000000000000000000000000000000000000000..878e50b9111570b5778f082d8b3a2c99c4f2546f --- /dev/null +++ b/models/tokenizer.py @@ -0,0 +1,238 @@ +import re +from phonemizer import backend +from typing import List + + +class Tokenizer: + def __init__(self): + self.VOCAB = self._get_vocab() + self.phonemizers = { + 'en-us': backend.EspeakBackend(language='en-us', preserve_punctuation=True, with_stress=True), + 'en-gb': backend.EspeakBackend(language='en-gb', preserve_punctuation=True, with_stress=True), + } + + @staticmethod + def _get_vocab(): + """ + Generates a mapping of symbols to integer indices for tokenization. + + Returns: + dict: A dictionary where keys are symbols and values are unique integer indices. + """ + # Define the symbols + _pad = "$" + _punctuation = ';:,.!?¡¿—…"«»“” ' + _letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + _letters_ipa = ( + "ɑɐɒæɓʙβɔɕçɗɖðʤəɘɚɛɜɝɞɟʄɡɠɢʛɦɧħɥʜɨɪʝɭɬɫɮʟɱɯɰŋɳɲɴøɵɸθœɶʘɹɺɾɻʀʁɽʂʃʈʧʉʊʋⱱʌɣɤʍχʎʏʑʐʒʔʡʕʢǀǁǂǃˈˌːˑʼʴʰʱʲʷˠˤ˞↓↑→↗↘'̩'ᵻ" + ) + symbols = [_pad] + list(_punctuation) + list(_letters) + list(_letters_ipa) + + # Create a dictionary mapping each symbol to its index + return {symbol: index for index, symbol in enumerate(symbols)} + + @staticmethod + def split_num(num: re.Match) -> str: + """ + Processes numeric strings, formatting them as time, years, or other representations. + + Args: + num (re.Match): A regex match object representing the numeric string. + + Returns: + str: A formatted string based on the numeric input. + """ + num = num.group() + + # Handle time (e.g., "12:30") + if ':' in num: + hours, minutes = map(int, num.split(':')) + if minutes == 0: + return f"{hours} o'clock" + elif minutes < 10: + return f'{hours} oh {minutes}' + return f'{hours} {minutes}' + + # Handle years or general numeric cases + year = int(num[:4]) + if year < 1100 or year % 1000 < 10: + return num + + left, right = num[:2], int(num[2:4]) + suffix = 's' if num.endswith('s') else '' + + # Format years + if 100 <= year % 1000 <= 999: + if right == 0: + return f'{left} hundred{suffix}' + elif right < 10: + return f'{left} oh {right}{suffix}' + return f'{left} {right}{suffix}' + + @staticmethod + def flip_money(match: re.Match) -> str: + """ + Converts monetary values to a textual representation. + + Args: + m (re.Match): A regex match object representing the monetary value. + + Returns: + str: A formatted string describing the monetary value. + """ + m = m.group() + currency = 'dollar' if m[0] == '$' else 'pound' + + # Handle whole amounts (e.g., "$10", "£20") + if '.' not in m: + singular = '' if m[1:] == '1' else 's' + return f'{m[1:]} {currency}{singular}' + + # Handle amounts with decimals (e.g., "$10.50", "£5.25") + whole, cents = m[1:].split('.') + singular = '' if whole == '1' else 's' + cents = int(cents.ljust(2, '0')) # Ensure 2 decimal places + coins = f"cent{'' if cents == 1 else 's'}" if m[0] == '$' else ('penny' if cents == 1 else 'pence') + return f'{whole} {currency}{singular} and {cents} {coins}' + + @staticmethod + def point_num(match): + whole, fractional = match.group().split('.') + return ' point '.join([whole, ' '.join(fractional)]) + + def normalize_text(self, text: str) -> str: + """ + Normalizes input text by replacing special characters, punctuation, and applying custom transformations. + + Args: + text (str): Input text to normalize. + + Returns: + str: Normalized text. + """ + # Replace specific characters with standardized versions + replacements = { + chr(8216): "'", # Left single quotation mark + chr(8217): "'", # Right single quotation mark + '«': chr(8220), # Left double angle quotation mark to left double quotation mark + '»': chr(8221), # Right double angle quotation mark to right double quotation mark + chr(8220): '"', # Left double quotation mark + chr(8221): '"', # Right double quotation mark + '(': '«', # Replace parentheses with angle quotation marks + ')': '»' + } + for old, new in replacements.items(): + text = text.replace(old, new) + + # Replace punctuation and add spaces + punctuation_replacements = { + '、': ',', + '。': '.', + '!': '!', + ',': ',', + ':': ':', + ';': ';', + '?': '?', + } + for old, new in punctuation_replacements.items(): + text = text.replace(old, new + ' ') + + # Apply regex-based replacements + text = re.sub(r'[^\S\n]', ' ', text) + text = re.sub(r' +', ' ', text) + text = re.sub(r'(?<=\n) +(?=\n)', '', text) + + # Expand abbreviations and handle special cases + abbreviation_patterns = [ + (r'\bD[Rr]\.(?= [A-Z])', 'Doctor'), + (r'\b(?:Mr\.|MR\.(?= [A-Z]))', 'Mister'), + (r'\b(?:Ms\.|MS\.(?= [A-Z]))', 'Miss'), + (r'\b(?:Mrs\.|MRS\.(?= [A-Z]))', 'Mrs'), + (r'\betc\.(?! [A-Z])', 'etc'), + (r'(?i)\b(y)eah?\b', r"\1e'a"), + ] + for pattern, replacement in abbreviation_patterns: + text = re.sub(pattern, replacement, text) + + # Handle numbers and monetary values + text = re.sub(r'\d*\.\d+|\b\d{4}s?\b|(? List[int]: + """ + Tokenizes a given string into a list of indices based on VOCAB. + + Args: + text (str): Input string to tokenize. + + Returns: + list: A list of integer indices corresponding to the characters in the input string. + """ + return [self.VOCAB[x] for x in phonemes if x in self.VOCAB] + + def phonemize(self, text: str, lang: str = 'en-us', normalize: bool = True) -> str: + """ + Converts text to phonemes using the specified language phonemizer and applies normalization. + + Args: + text (str): Input text to be phonemized. + lang (str): Language identifier ('en-us' or 'en-gb') for selecting the phonemizer. + normalize (bool): Whether to normalize the text before phonemization. + + Returns: + str: A processed string of phonemes. + """ + # Normalize text if required + if normalize: + text = self.normalize_text(text) + + # Generate phonemes using the specified phonemizer + if lang not in self.phonemizers: + print(f"Language '{lang}' not supported. Defaulting to 'en-us'.") + lang = 'en-us' + + phonemes = self.phonemizers[lang].phonemize([text]) + phonemes = phonemes[0] if phonemes else '' + + # Apply custom phoneme replacements + replacements = { + 'kəkˈoːɹoʊ': 'kˈoʊkəɹoʊ', + 'kəkˈɔːɹəʊ': 'kˈəʊkəɹəʊ', + 'ʲ': 'j', + 'r': 'ɹ', + 'x': 'k', + 'ɬ': 'l', + } + for old, new in replacements.items(): + phonemes = phonemes.replace(old, new) + + # Apply regex-based replacements + phonemes = re.sub(r'(?<=[a-zɹː])(?=hˈʌndɹɪd)', ' ', phonemes) + phonemes = re.sub(r' z(?=[;:,.!?¡¿—…"«»“” ]|$)', 'z', phonemes) + + # Additional language-specific rules + if lang == 'a': + phonemes = re.sub(r'(?<=nˈaɪn)ti(?!ː)', 'di', phonemes) + + # Filter out characters not in VOCAB + phonemes = ''.join(filter(lambda p: p in self.VOCAB, phonemes)) + + return phonemes.strip() diff --git a/publisher/__init__.py b/publisher/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bec290187d7cb803cdaf40915b67a33220b15297 --- /dev/null +++ b/publisher/__init__.py @@ -0,0 +1 @@ +# Publisher package \ No newline at end of file diff --git a/publisher/account_manager.py b/publisher/account_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..3519dd1360823f2d2a14851e335cf40261b413f4 --- /dev/null +++ b/publisher/account_manager.py @@ -0,0 +1,38 @@ +# publisher/account_manager.py + +from publisher.oauth.storage import load_tokens + +SUPPORTED = [ + "youtube", + "tiktok", + "meta" +] + + +def connected_accounts(user_id): + + accounts = [] + + for p in SUPPORTED: + token = load_tokens(user_id, p) + if token: + accounts.append({ + "platform": p, + "token": token + }) + + return accounts + + +def choose_platforms(strategy, accounts): + + if strategy == "all": + return accounts + + if strategy == "short_video": + return [ + a for a in accounts + if a["platform"] in ["youtube", "tiktok", "meta"] + ] + + return accounts[:1] \ No newline at end of file diff --git a/publisher/ai/gemini_client.py b/publisher/ai/gemini_client.py new file mode 100644 index 0000000000000000000000000000000000000000..62727277ad088b98af9e3e6af3dd808ade4ae139 --- /dev/null +++ b/publisher/ai/gemini_client.py @@ -0,0 +1,83 @@ +import os +import time +import random +import logging + +logger = logging.getLogger("gemini-client") + +# ========================= +# MODEL POOL (Gemini 3 Era) +# ========================= + +PRIMARY_MODELS = [ + "gemini-3.1-pro", + "gemini-3.1-flash", +] + +FALLBACK_MODELS = [ + "gemini-2.5-flash", + "gemini-3.1-flash-lite", +] + +# ========================= +# SIMPLE QUOTA TRACKER +# ========================= +_model_fail_count = { + "gemini-3.1-pro": 0, + "gemini-3.1-flash": 0, +} + + +MAX_FAILS = 3 + + +# ========================= +# CORE MODEL RESOLVER +# ========================= + +def _pick_model(): + """ + Select best available model with fallback logic. + """ + for m in PRIMARY_MODELS: + if _model_fail_count.get(m, 0) < MAX_FAILS: + return m + + return random.choice(FALLBACK_MODELS) + + +# ========================= +# MAIN CLIENT INTERFACE +# ========================= + +def get_model(): + """ + Public entrypoint used by publisher_ai. + Returns active Gemini model name. + """ + model = _pick_model() + logger.info(f"[Gemini] Selected model: {model}") + return model + + +def safe_generate(prompt: str, client_callable): + """ + Wrapper for Gemini calls with automatic fallback. + """ + + last_error = None + + for _ in range(3): + model = _pick_model() + + try: + result = client_callable(model, prompt) + return result + + except Exception as e: + last_error = e + _model_fail_count[model] = _model_fail_count.get(model, 0) + 1 + logger.warning(f"[Gemini FAIL] {model}: {str(e)}") + time.sleep(0.5) + + raise RuntimeError(f"All Gemini models failed: {last_error}") \ No newline at end of file diff --git a/publisher/bulk.py b/publisher/bulk.py new file mode 100644 index 0000000000000000000000000000000000000000..42665bd9d5bc0c8c90b317a330c4541e551444c5 --- /dev/null +++ b/publisher/bulk.py @@ -0,0 +1,187 @@ +""" +bulk.py +V9 Autonomous Publisher Engine + +Purpose: +-------- +Handles BULK publishing across multiple platforms. + +Supports: +- TikTok +- Reels (Instagram) +- YouTube Shorts +- Facebook +- Any future platform adapter + +Design: +------- +Input -> Normalize -> Dispatch -> Execute -> Collect Results + +Production Features: +-------------------- +✔ async concurrency +✔ retry system +✔ per-platform isolation +✔ failure tolerance +✔ structured logging +✔ scheduler-compatible +✔ autonomous engine ready +""" + +import asyncio +import traceback +from typing import Dict, List, Any + +# Platform adapters +from publisher.platforms.tiktok import publish_tiktok +from publisher.platforms.reels import publish_reels +from publisher.platforms.shorts import publish_shorts +from publisher.platforms.facebook import publish_facebook + + +# ===================================================== +# PLATFORM REGISTRY +# ===================================================== + +PLATFORM_MAP = { + "tiktok": publish_tiktok, + "reels": publish_reels, + "shorts": publish_shorts, + "facebook": publish_facebook, +} + + +# ===================================================== +# CONFIG +# ===================================================== + +MAX_CONCURRENT_POSTS = 5 +MAX_RETRIES = 2 + + +# ===================================================== +# HELPERS +# ===================================================== + +async def execute_with_retry(func, payload: Dict, retries=MAX_RETRIES): + """ + Safe execution wrapper with retries. + """ + + attempt = 0 + + while attempt <= retries: + try: + result = await func(payload) + return { + "status": "success", + "result": result, + } + + except Exception as e: + attempt += 1 + + if attempt > retries: + return { + "status": "failed", + "error": str(e), + "trace": traceback.format_exc(), + } + + await asyncio.sleep(2) + + +# ===================================================== +# SINGLE JOB EXECUTOR +# ===================================================== + +async def process_job(job: Dict[str, Any]): + """ + Expected job format: + + { + "platform": "tiktok", + "video_url": "...", + "caption": "...", + "hashtags": [], + "thumbnail": "...", + "schedule_time": optional + } + """ + + platform = job.get("platform") + + if platform not in PLATFORM_MAP: + return { + "status": "failed", + "error": f"Unsupported platform: {platform}", + } + + publisher = PLATFORM_MAP[platform] + + return await execute_with_retry(publisher, job) + + +# ===================================================== +# BULK ENGINE +# ===================================================== + +async def bulk_publish(jobs: List[Dict[str, Any]]): + """ + Main bulk execution engine. + """ + + semaphore = asyncio.Semaphore(MAX_CONCURRENT_POSTS) + + results = [] + + async def limited_job(job): + async with semaphore: + return await process_job(job) + + tasks = [limited_job(job) for job in jobs] + + completed = await asyncio.gather(*tasks, return_exceptions=False) + + results.extend(completed) + + return summarize_results(results) + + +# ===================================================== +# SUMMARY +# ===================================================== + +def summarize_results(results: List[Dict]): + success = sum(1 for r in results if r["status"] == "success") + failed = len(results) - success + + return { + "status": "completed", + "total_jobs": len(results), + "successful": success, + "failed": failed, + "results": results, + } + + +# ===================================================== +# FASTAPI ENTRYPOINT +# ===================================================== + +async def execute(payload: Dict): + """ + Universal endpoint handler + + POST /execute/bulk_publish + """ + + jobs = payload.get("jobs") + + if not jobs: + return { + "status": "error", + "message": "No jobs provided", + } + + return await bulk_publish(jobs) \ No newline at end of file diff --git a/publisher/hashtags.py b/publisher/hashtags.py new file mode 100644 index 0000000000000000000000000000000000000000..f629dd11f3ba34e6e87a53153f32f1e5d9a3aea0 --- /dev/null +++ b/publisher/hashtags.py @@ -0,0 +1,19 @@ +from publisher.ai.gemini_client import get_model + + +def generate_hashtags(video_path: str): + + model = get_model() + + prompt = """ + Generate 20 viral hashtags for short-form content. + Return comma-separated only. + """ + + res = model.generate_content(prompt) + + tags = res.text.replace("\n", "").strip() + + return { + "hashtags": tags + } \ No newline at end of file diff --git a/publisher/metadata_engine.py b/publisher/metadata_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..2701d104905703171186c0c98d95b56e28635e91 --- /dev/null +++ b/publisher/metadata_engine.py @@ -0,0 +1,24 @@ +from publisher.ai.gemini_client import get_model + + +def generate_metadata(video_path: str): + + model = get_model() + + prompt = f""" + Generate viral short-form video metadata. + + Return JSON: + title + description + hook + audience + """ + + response = model.generate_content(prompt) + + text = response.text.strip() + + return { + "metadata": text + } \ No newline at end of file diff --git a/publisher/models.py b/publisher/models.py new file mode 100644 index 0000000000000000000000000000000000000000..5e79623c345cd1a385a13efe5b721f319b51a1bf --- /dev/null +++ b/publisher/models.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel +from typing import List, Optional + + +class PublishPayload(BaseModel): + + video_path: str + + title: str + description: str + + hashtags: List[str] + + thumbnail: Optional[str] = None + schedule_time: Optional[int] = None + + platforms: List[str] \ No newline at end of file diff --git a/publisher/oauth/providers/google.py b/publisher/oauth/providers/google.py new file mode 100644 index 0000000000000000000000000000000000000000..2d1c26907089a06151c177d2d194077673313cb1 --- /dev/null +++ b/publisher/oauth/providers/google.py @@ -0,0 +1,52 @@ +# publisher/oauth/providers/google.py + +import httpx +import os +from ..storage import save_tokens + +CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") +CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") + +REDIRECT_URI = "https://your-domain.com/oauth/callback/google" + +AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +TOKEN_URL = "https://oauth2.googleapis.com/token" + + +def authorization_url(state): + + scope = ( + "https://www.googleapis.com/auth/youtube.upload " + "https://www.googleapis.com/auth/userinfo.profile" + ) + + return ( + f"{AUTH_URL}" + f"?client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URI}" + f"&response_type=code" + f"&scope={scope}" + f"&access_type=offline" + f"&state={state}" + ) + + +async def exchange_code(code): + + async with httpx.AsyncClient() as client: + r = await client.post( + TOKEN_URL, + data={ + "code": code, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "redirect_uri": REDIRECT_URI, + "grant_type": "authorization_code", + }, + ) + + return r.json() + + +def store(user_id, tokens): + save_tokens(user_id, "google", tokens) \ No newline at end of file diff --git a/publisher/oauth/providers/meta.py b/publisher/oauth/providers/meta.py new file mode 100644 index 0000000000000000000000000000000000000000..117e59090b973283a312424d021358175fe27202 --- /dev/null +++ b/publisher/oauth/providers/meta.py @@ -0,0 +1,42 @@ +# publisher/oauth/providers/meta.py + +import httpx +import os +from ..storage import save_tokens + +APP_ID = os.getenv("META_APP_ID") +APP_SECRET = os.getenv("META_APP_SECRET") + +REDIRECT_URI = "https://your-domain.com/oauth/callback/meta" + + +def authorization_url(state): + + return ( + "https://www.facebook.com/v19.0/dialog/oauth" + f"?client_id={APP_ID}" + f"&redirect_uri={REDIRECT_URI}" + "&scope=pages_manage_posts,pages_read_engagement," + "instagram_content_publish" + f"&state={state}" + ) + + +async def exchange_code(code): + + async with httpx.AsyncClient() as client: + r = await client.get( + "https://graph.facebook.com/v19.0/oauth/access_token", + params={ + "client_id": APP_ID, + "redirect_uri": REDIRECT_URI, + "client_secret": APP_SECRET, + "code": code, + }, + ) + + return r.json() + + +def store(user_id, tokens): + save_tokens(user_id, "meta", tokens) \ No newline at end of file diff --git a/publisher/oauth/providers/tiktok.py b/publisher/oauth/providers/tiktok.py new file mode 100644 index 0000000000000000000000000000000000000000..bb16727a88226ec77dedc69f352b8f26763f8ead --- /dev/null +++ b/publisher/oauth/providers/tiktok.py @@ -0,0 +1,45 @@ +# publisher/oauth/providers/tiktok.py + +import httpx +import os +from ..storage import save_tokens + +CLIENT_KEY = os.getenv("TIKTOK_CLIENT_KEY") +CLIENT_SECRET = os.getenv("TIKTOK_CLIENT_SECRET") + +REDIRECT_URI = "https://your-domain.com/oauth/callback/tiktok" + + +def authorization_url(state): + + scope = "user.info.basic,video.upload" + + return ( + "https://www.tiktok.com/v2/auth/authorize/" + f"?client_key={CLIENT_KEY}" + f"&response_type=code" + f"&scope={scope}" + f"&redirect_uri={REDIRECT_URI}" + f"&state={state}" + ) + + +async def exchange_code(code): + + async with httpx.AsyncClient() as client: + r = await client.post( + "https://open.tiktokapis.com/v2/oauth/token/", + data={ + "client_key": CLIENT_KEY, + "client_secret": CLIENT_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": REDIRECT_URI, + }, + ) + + return r.json() + + +def store(user_id, tokens): + save_tokens(user_id, "tiktok", tokens) \ No newline at end of file diff --git a/publisher/oauth/providers/youtube.py b/publisher/oauth/providers/youtube.py new file mode 100644 index 0000000000000000000000000000000000000000..6c6f2ca063752921660875797171ec5f14a30fb5 --- /dev/null +++ b/publisher/oauth/providers/youtube.py @@ -0,0 +1,8 @@ +# publisher/oauth/providers/youtube.py + +from .google import authorization_url, exchange_code +from ..storage import save_tokens + + +def store(user_id, tokens): + save_tokens(user_id, "youtube", tokens) \ No newline at end of file diff --git a/publisher/oauth/router.py b/publisher/oauth/router.py new file mode 100644 index 0000000000000000000000000000000000000000..9b65e3719eef2cc5d535f465c178b5c6eaf8d783 --- /dev/null +++ b/publisher/oauth/router.py @@ -0,0 +1,47 @@ +# publisher/oauth/router.py + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse + +from .sessions import create_session, get_user +from .providers import google, meta, tiktok, youtube + +router = APIRouter() + +PROVIDERS = { + "google": google, + "meta": meta, + "tiktok": tiktok, + "youtube": youtube +} + + +@router.get("/connect/{provider}") +async def connect(provider: str, user_id: str): + + if provider not in PROVIDERS: + return {"error": "provider not supported"} + + state = create_session(user_id) + + url = PROVIDERS[provider].authorization_url(state) + + return RedirectResponse(url) + + +@router.get("/callback/{provider}") +async def callback(provider: str, request: Request): + + if provider not in PROVIDERS: + return {"error": "provider not supported"} + + state = request.query_params.get("state") + code = request.query_params.get("code") + + user_id = get_user(state) + + tokens = await PROVIDERS[provider].exchange_code(code) + + PROVIDERS[provider].store(user_id, tokens) + + return {"status": f"{provider} connected"} \ No newline at end of file diff --git a/publisher/oauth/sessions.py b/publisher/oauth/sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..4291d29179ed7056a4afdf90dc7659fa74e6605c --- /dev/null +++ b/publisher/oauth/sessions.py @@ -0,0 +1,29 @@ +# publisher/oauth/sessions.py + +import secrets +import time + +_sessions = {} + + +def create_session(user_id): + state = secrets.token_urlsafe(32) + _sessions[state] = { + "user_id": user_id, + "created": time.time() + } + return state + + +def get_user(state): + session = _sessions.get(state) + if not session: + return None + return session["user_id"] + + +def cleanup(expiry=600): + now = time.time() + for k in list(_sessions.keys()): + if now - _sessions[k]["created"] > expiry: + del _sessions[k] \ No newline at end of file diff --git a/publisher/oauth/storage.py b/publisher/oauth/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..429af5913f2fe294c8c408233e6137c2a5aad4aa --- /dev/null +++ b/publisher/oauth/storage.py @@ -0,0 +1,44 @@ +# publisher/oauth/storage.py + +import os +import json +from pathlib import Path +from cryptography.fernet import Fernet + +STORAGE_DIR = Path("oauth_tokens") +STORAGE_DIR.mkdir(exist_ok=True) + +KEY_PATH = STORAGE_DIR / "secret.key" + + +def load_key(): + if not KEY_PATH.exists(): + key = Fernet.generate_key() + KEY_PATH.write_bytes(key) + return KEY_PATH.read_bytes() + + +fernet = Fernet(load_key()) + + +def _file(user_id, provider): + return STORAGE_DIR / f"{user_id}_{provider}.json" + + +def save_tokens(user_id: str, provider: str, data: dict): + encrypted = fernet.encrypt(json.dumps(data).encode()) + _file(user_id, provider).write_bytes(encrypted) + + +def load_tokens(user_id: str, provider: str): + f = _file(user_id, provider) + if not f.exists(): + return None + decrypted = fernet.decrypt(f.read_bytes()) + return json.loads(decrypted.decode()) + + +def delete_tokens(user_id: str, provider: str): + f = _file(user_id, provider) + if f.exists(): + f.unlink() \ No newline at end of file diff --git a/publisher/platform_dispatcher.py b/publisher/platform_dispatcher.py new file mode 100644 index 0000000000000000000000000000000000000000..48ad6aaa13e0e8ece47b3776ada3dd5e94dc2d23 --- /dev/null +++ b/publisher/platform_dispatcher.py @@ -0,0 +1,48 @@ +from importlib import import_module +import logging + +logger = logging.getLogger("platform-dispatcher") + + +def _safe_import(module_path, fn_name): + try: + module = import_module(module_path) + return getattr(module, fn_name) + except Exception as e: + logger.warning(f"[Dispatcher] Missing {module_path}: {str(e)}") + return None + + +# lazy-loaded publishers (prevents boot crash) + +def dispatch_publish(video_path=None, payload=None, variants=None): + + payload = payload or {} + results = {} + + youtube = _safe_import("publisher.platforms.youtube", "publish_youtube") + tiktok = _safe_import("publisher.platforms.tiktok", "publish_tiktok") + reels = _safe_import("publisher.platforms.reels", "publish_reels") + shorts = _safe_import("publisher.platforms.shorts", "publish_shorts") + facebook = _safe_import("publisher.platforms.facebook", "publish_facebook") + + async def run(): + + if youtube: + results["youtube"] = await youtube({"video_path": video_path, **payload}) + + if tiktok: + results["tiktok"] = await tiktok({"video_path": video_path, **payload}) + + if reels: + results["reels"] = await reels({"video_path": video_path, **payload}) + + if shorts: + results["shorts"] = await shorts({"video_path": video_path, **payload}) + + if facebook: + results["facebook"] = await facebook({"video_path": video_path, **payload}) + + return results + + return run() \ No newline at end of file diff --git a/publisher/platforms/__init__.py b/publisher/platforms/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5662f3cfd1380a213cfd3a2466756fc6191f9342 --- /dev/null +++ b/publisher/platforms/__init__.py @@ -0,0 +1 @@ +# Platforms package \ No newline at end of file diff --git a/publisher/platforms/auth.py b/publisher/platforms/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..4fe8a84c54cb457f143b218fa5b92aace5efb3aa --- /dev/null +++ b/publisher/platforms/auth.py @@ -0,0 +1,38 @@ +import os + +# ========================= +# SIMPLE ENV AUTH LOADER +# ========================= + +def load_env_var(key: str, default=None): + """ + Safe environment variable loader. + Works without python-dotenv dependency. + """ + return os.environ.get(key, default) + + +def get_token(platform: str): + """ + Returns stored token for platform. + No external dependency version. + """ + + key_map = { + "tiktok": "TIKTOK_TOKEN", + "youtube": "YOUTUBE_TOKEN", + "facebook": "FACEBOOK_TOKEN", + "reels": "META_TOKEN", + } + + env_key = key_map.get(platform) + + if not env_key: + raise ValueError(f"Unsupported platform: {platform}") + + token = load_env_var(env_key) + + if not token: + raise ValueError(f"Missing token for {platform} ({env_key})") + + return token \ No newline at end of file diff --git a/publisher/platforms/base.py b/publisher/platforms/base.py new file mode 100644 index 0000000000000000000000000000000000000000..5346aaf83b132a93ab908e651f6d971cb45de4fd --- /dev/null +++ b/publisher/platforms/base.py @@ -0,0 +1,15 @@ +import os + +def validate_payload(payload): + + if "video_path" not in payload: + raise Exception("video_path required") + + if not os.path.exists(payload["video_path"]): + raise Exception("Video missing") + + payload.setdefault("caption", "") + payload.setdefault("hashtags", []) + payload.setdefault("thumbnail", None) + + return payload \ No newline at end of file diff --git a/publisher/platforms/errors.py b/publisher/platforms/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..053523e29eab094cf189b6ac7181c159091e32af --- /dev/null +++ b/publisher/platforms/errors.py @@ -0,0 +1,14 @@ +class PublisherError(Exception): + pass + + +class PlatformAuthError(PublisherError): + pass + + +class PlatformUploadError(PublisherError): + pass + + +class PlatformRateLimit(PublisherError): + pass \ No newline at end of file diff --git a/publisher/platforms/facebook.py b/publisher/platforms/facebook.py new file mode 100644 index 0000000000000000000000000000000000000000..3c50a88ebf691187d20d51a83748dc44aa940415 --- /dev/null +++ b/publisher/platforms/facebook.py @@ -0,0 +1,28 @@ +# publisher/platforms/facebook.py + +import httpx +from .auth import get_token +from .base import validate_payload + + +async def publish_facebook(payload): + + payload = validate_payload(payload) + + page_id = get_token("FACEBOOK_PAGE_ID") + token = get_token("META_ACCESS_TOKEN") + + async with httpx.AsyncClient(timeout=600) as client: + + with open(payload["video_path"], "rb") as f: + + res = await client.post( + f"https://graph-video.facebook.com/{page_id}/videos", + data={ + "description": payload["caption"], + "access_token": token, + }, + files={"source": f}, + ) + + return {"platform": "facebook", "status": "published"} \ No newline at end of file diff --git a/publisher/platforms/http.py b/publisher/platforms/http.py new file mode 100644 index 0000000000000000000000000000000000000000..2551d337551846ae86b9093722bca0d6ec1cdf92 --- /dev/null +++ b/publisher/platforms/http.py @@ -0,0 +1,38 @@ +import httpx +import asyncio + + +async def request( + method, + url, + headers=None, + data=None, + json=None, + files=None, + retries=3, +): + + for attempt in range(retries): + + try: + async with httpx.AsyncClient(timeout=120) as client: + + r = await client.request( + method, + url, + headers=headers, + data=data, + json=json, + files=files, + ) + + r.raise_for_status() + + return r.json() + + except Exception as e: + + if attempt == retries - 1: + raise + + await asyncio.sleep(2 ** attempt) \ No newline at end of file diff --git a/publisher/platforms/reels.py b/publisher/platforms/reels.py new file mode 100644 index 0000000000000000000000000000000000000000..059bab8695a7e3183920cb73f19981d99565a094 --- /dev/null +++ b/publisher/platforms/reels.py @@ -0,0 +1,39 @@ +# publisher/platforms/reels.py + +import httpx +from .auth import get_token +from .base import validate_payload + + +async def publish_reels(payload): + + payload = validate_payload(payload) + + token = get_token("META_ACCESS_TOKEN") + ig_id = get_token("INSTAGRAM_ACCOUNT_ID") + + async with httpx.AsyncClient() as client: + + # Create media container + create = await client.post( + f"https://graph.facebook.com/v19.0/{ig_id}/media", + data={ + "video_url": payload["video_path"], + "caption": payload["caption"], + "access_token": token, + "media_type": "REELS", + }, + ) + + container = create.json()["id"] + + # Publish + publish = await client.post( + f"https://graph.facebook.com/v19.0/{ig_id}/media_publish", + data={ + "creation_id": container, + "access_token": token, + }, + ) + + return {"platform": "reels", "status": "published"} \ No newline at end of file diff --git a/publisher/platforms/shorts.py b/publisher/platforms/shorts.py new file mode 100644 index 0000000000000000000000000000000000000000..7435f1aa74047f22f8799232c7cb549bc55c4f29 --- /dev/null +++ b/publisher/platforms/shorts.py @@ -0,0 +1,37 @@ +from googleapiclient.discovery import build +from googleapiclient.http import MediaFileUpload +from .base import validate_payload +from .auth import get_token + + +async def publish_shorts(payload: dict): + + payload = validate_payload(payload) + + youtube = build( + "youtube", + "v3", + developerKey=get_token("YOUTUBE_API_KEY"), + ) + + request = youtube.videos().insert( + part="snippet,status", + body={ + "snippet": { + "title": payload["caption"][:90], + "description": payload["caption"], + "tags": payload["hashtags"], + "categoryId": "22", + }, + "status": {"privacyStatus": "public"}, + }, + media_body=MediaFileUpload(payload["video_path"]), + ) + + response = request.execute() + + return { + "platform": "shorts", + "video_id": response["id"], + "status": "published", + } \ No newline at end of file diff --git a/publisher/platforms/tiktok.py b/publisher/platforms/tiktok.py new file mode 100644 index 0000000000000000000000000000000000000000..a97a0cff8713e7dfbf76419c2718d5b9ac1625fa --- /dev/null +++ b/publisher/platforms/tiktok.py @@ -0,0 +1,34 @@ +# publisher/platforms/tiktok.py + +import httpx +from .base import validate_payload +from .auth import get_token + + +async def publish_tiktok(payload: dict): + + payload = validate_payload(payload) + + token = get_token("TIKTOK_ACCESS_TOKEN") + + async with httpx.AsyncClient(timeout=600) as client: + + # Step 1 — create upload session + init = await client.post( + "https://open.tiktokapis.com/v2/post/publish/video/init/", + headers={"Authorization": f"Bearer {token}"}, + json={ + "post_info": { + "title": payload["caption"], + "privacy_level": "PUBLIC", + } + }, + ) + + upload_url = init.json()["data"]["upload_url"] + + # Step 2 — upload video + with open(payload["video_path"], "rb") as f: + await client.put(upload_url, content=f) + + return {"platform": "tiktok", "status": "published"} \ No newline at end of file diff --git a/publisher/platforms/youtube.py b/publisher/platforms/youtube.py new file mode 100644 index 0000000000000000000000000000000000000000..77a05346e70a84987b7c8b8dbd613ef424009d22 --- /dev/null +++ b/publisher/platforms/youtube.py @@ -0,0 +1,40 @@ +import logging + +logger = logging.getLogger("youtube-publisher") + + +# ========================= +# YOUTUBE PUBLISHER (V10 SAFE) +# ========================= + +async def publish_youtube(payload: dict): + """ + Production-safe YouTube publishing interface. + + Expected payload: + { + "video_path": str, + "title": str, + "description": str, + "tags": list[str], + "schedule_time": optional + } + """ + + video_path = payload.get("video_path") + + if not video_path: + raise ValueError("video_path is required") + + logger.info(f"[YouTube] Publishing video: {video_path}") + + # NOTE: + # No API dependency here yet (SDK layer should be injected via OAuth system) + # This prevents startup crashes on missing credentials. + + return { + "status": "queued", + "platform": "youtube", + "video": video_path, + "message": "YouTube publish request accepted (SDK layer pending OAuth connection)" + } \ No newline at end of file diff --git a/publisher/publisher.py b/publisher/publisher.py new file mode 100644 index 0000000000000000000000000000000000000000..eb9fdbfbd367d01971d7b7e1201e4274b2ffced6 --- /dev/null +++ b/publisher/publisher.py @@ -0,0 +1,52 @@ +import requests + +from .metadata import generate_metadata +from .hashtags import generate_hashtags +from .thumbnail import generate_thumbnail +from .scheduler import schedule_post +from .router import publish_all + + +async def run_publisher(payload: dict): + + source = payload["source"] + webhook = payload.get("webhook") + platforms = payload.get( + "platforms", + ["tiktok", "reels", "shorts", "facebook"] + ) + + # 1. Metadata + metadata = await generate_metadata(source) + + # 2. Hashtags + hashtags = await generate_hashtags(metadata) + + # 3. Thumbnail + thumbnail = await generate_thumbnail(source) + + # 4. Schedule + schedule_time = schedule_post(payload) + + # 5. Publish + results = await publish_all( + source=source, + platforms=platforms, + metadata=metadata, + hashtags=hashtags, + thumbnail=thumbnail, + schedule_time=schedule_time, + ) + + response = { + "status": "completed", + "metadata": metadata, + "hashtags": hashtags, + "thumbnail": thumbnail, + "results": results, + } + + if webhook: + requests.post(webhook, json=response) + + return response \ No newline at end of file diff --git a/publisher/publisher_ai.py b/publisher/publisher_ai.py new file mode 100644 index 0000000000000000000000000000000000000000..71624ac2565fa190118c7fcb71828c215df22145 --- /dev/null +++ b/publisher/publisher_ai.py @@ -0,0 +1,213 @@ +import asyncio +import logging +from typing import Optional, Dict, Any + +logger = logging.getLogger("publisher_ai") + + +# ===================================================== +# EXTERNAL DEPENDENCIES (lazy-safe imports) +# ===================================================== + +try: + from publisher.scheduler_engine import get_next_job +except Exception: + get_next_job = None + +try: + from publisher.platform_dispatcher import dispatch_publish +except Exception: + dispatch_publish = None + + +# ===================================================== +# AUTONOMOUS PROCESSOR CORE +# ===================================================== + +async def process_job(job: Dict[str, Any]) -> Dict[str, Any]: + """ + Executes a single publishing job. + This is the atomic unit of the autonomous system. + """ + + logger.info(f"[AI] Processing job: {job.get('id', 'unknown')}") + + job_type = job.get("type") + payload = job.get("payload", {}) + + if job_type == "publish": + if not dispatch_publish: + raise RuntimeError("dispatch_publish not available") + + return await dispatch_publish( + video_path=payload.get("video_path"), + payload=payload + ) + + if job_type == "auto-publish": + # already pre-processed variants expected + return await dispatch_publish( + variants=payload.get("variants", []) + ) + + if job_type == "bulk": + # bulk jobs are handled upstream + return {"status": "forwarded_bulk"} + + return {"status": "ignored", "reason": "unknown_job_type"} + + +# ===================================================== +# AUTONOMOUS LOOP (FIXED - NO REQUIRED ARGUMENTS) +# ===================================================== + +async def autonomous_loop(seed_video_path: Optional[str] = None): + """ + V10 Autonomous Publisher Brain Loop + + FIXES: + - no required positional arguments + - safe startup execution + - scheduler-driven architecture + - continuous polling worker + """ + + logger.info("[V10] Autonomous Publisher Brain started") + + if seed_video_path: + logger.info(f"[V10] Seed video detected (optional): {seed_video_path}") + + # optional warm-up behavior (non-blocking) + if seed_video_path: + asyncio.create_task(_warmup_seed(seed_video_path)) + + # ================================================= + # MAIN LOOP + # ================================================= + while True: + try: + + # 1. Pull job from scheduler/queue + job = None + + if get_next_job: + job = await get_next_job() + + # 2. Idle state handling + if not job: + await asyncio.sleep(3) + continue + + logger.info(f"[V10] Job received: {job.get('id')}") + + # 3. Process job + result = await process_job(job) + + logger.info(f"[V10] Job completed: {job.get('id')}") + + # 4. Optional webhook callback + webhook = job.get("webhook") + if webhook: + asyncio.create_task(_send_webhook(webhook, result)) + + except Exception as e: + logger.exception(f"[V10] Loop error: {str(e)}") + await asyncio.sleep(2) + + +# ===================================================== +# OPTIONAL WARMUP PIPELINE +# ===================================================== + +async def _warmup_seed(video_path: str): + """ + Optional: runs once at startup if seed video exists + """ + + try: + logger.info(f"[V10] Warmup processing seed video: {video_path}") + + if not dispatch_publish: + logger.warning("dispatch_publish not available during warmup") + return + + await dispatch_publish( + video_path=video_path, + payload={"mode": "warmup"} + ) + + except Exception as e: + logger.exception(f"[V10] Warmup failed: {str(e)}") + + +# ===================================================== +# WEBHOOK HANDLER +# ===================================================== + +async def _send_webhook(url: str, data: dict): + """ + Lightweight webhook sender (no external dependency required) + """ + + try: + import json + import urllib.request + + payload = json.dumps(data).encode("utf-8") + + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json"} + ) + + urllib.request.urlopen(req, timeout=5) + + except Exception as e: + logger.warning(f"[V10] Webhook failed: {str(e)}") + + # ===================================================== +# PUBLIC ENTRYPOINT (REQUIRED BY MAIN APP) +# ===================================================== + +import asyncio + + +async def start_autonomous_brain(): + """ + Unified startup entry for Autonomous Publisher. + Safe background loop. + """ + + while True: + try: + # call your existing brain runner here + await asyncio.to_thread(run_autonomous_engine) + + except Exception as e: + print("Autonomous brain error:", e) + + # prevent CPU burn + await asyncio.sleep(30) + + import asyncio + + +async def start_brain(): + """ + V11 Standard Publisher Entry Point + """ + + while True: + try: + # 🔁 Replace this with your real engine function + # Example options: + # await asyncio.to_thread(run_autonomous_engine) + # await asyncio.to_thread(autonomous_loop) + # await asyncio.to_thread(run_brain) + + await asyncio.sleep(10) + + except Exception as e: + print("[Publisher Brain Error]", e) + await asyncio.sleep(10) \ No newline at end of file diff --git a/publisher/router.py b/publisher/router.py new file mode 100644 index 0000000000000000000000000000000000000000..06d143c0a0e08a8c5c0125a9c4c423113427a3c7 --- /dev/null +++ b/publisher/router.py @@ -0,0 +1,28 @@ +from publisher.platforms.tiktok import TikTokPublisher +from publisher.platforms.reels import ReelsPublisher +from publisher.platforms.shorts import ShortsPublisher +from publisher.platforms.facebook import FacebookPublisher + + +PUBLISHERS = { + "tiktok": TikTokPublisher(), + "reels": ReelsPublisher(), + "shorts": ShortsPublisher(), + "facebook": FacebookPublisher(), +} + + +async def publish(payload): + + results = {} + + for platform in payload.platforms: + + publisher = PUBLISHERS.get(platform) + + if not publisher: + continue + + results[platform] = await publisher.safe_publish(payload) + + return results \ No newline at end of file diff --git a/publisher/scheduler_engine.py b/publisher/scheduler_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..b0933d1d8d75578ebf076df782d91266470f7b00 --- /dev/null +++ b/publisher/scheduler_engine.py @@ -0,0 +1,109 @@ +import asyncio +import logging +from datetime import datetime, timedelta + +logger = logging.getLogger("scheduler-engine") + +# ========================= +# INTERNAL SCHEDULER STATE +# ========================= + +_scheduler_running = False +_tasks = [] # in-memory fallback (can later swap to Redis/DB) + + +# ========================= +# INIT ENTRYPOINT (FIX) +# ========================= + +def init_scheduler(): + """ + Called by main.py on startup. + Safe, idempotent scheduler bootstrap. + """ + global _scheduler_running + + if _scheduler_running: + logger.info("[Scheduler] Already running") + return + + _scheduler_running = True + logger.info("🚀 Scheduler Engine V10 initialized") + + +# ========================= +# CORE SCHEDULER API +# ========================= + +def schedule_post(payload: dict): + """ + Adds a post to the queue. + Expected payload: + { + "video_path": str, + "platform": str, + "publish_at": datetime ISO string + } + """ + + job = { + "id": f"job_{len(_tasks)+1}", + "payload": payload, + "status": "queued", + "created_at": datetime.utcnow().isoformat() + } + + _tasks.append(job) + + logger.info(f"[Scheduler] Job queued: {job['id']}") + + return job + + +# ========================= +# WORKER LOOP +# ========================= + +async def _worker_loop(): + """ + Background scheduler processor. + """ + + logger.info("[Scheduler] Worker loop started") + + while True: + try: + now = datetime.utcnow() + + for job in _tasks: + if job["status"] != "queued": + continue + + publish_time = job["payload"].get("publish_at") + + if not publish_time: + continue + + publish_time = datetime.fromisoformat(publish_time) + + if now >= publish_time: + logger.info(f"[Scheduler] Executing {job['id']}") + + # mark as done (actual publish handled elsewhere) + job["status"] = "ready" + + except Exception as e: + logger.error(f"[Scheduler Error] {str(e)}") + + await asyncio.sleep(5) + + +# ========================= +# OPTIONAL START LOOP +# ========================= + +def start_scheduler_loop(): + """ + Optional explicit background runner. + """ + asyncio.create_task(_worker_loop()) \ No newline at end of file diff --git a/publisher/tasks/auto_publish.py b/publisher/tasks/auto_publish.py new file mode 100644 index 0000000000000000000000000000000000000000..22a3831f07474471180b4f4d2fa321198b3be920 --- /dev/null +++ b/publisher/tasks/auto_publish.py @@ -0,0 +1,145 @@ +import asyncio +from datetime import datetime +import uuid + + +# ------------------------------------------------- +# CONTEXT NORMALIZER +# ------------------------------------------------- + +def normalize_context(context): + if isinstance(context, dict): + return { + "input_file": context.get("input_file"), + "url_input": context.get("url_input"), + "params": context.get("params", {}) or {}, + "platforms": context.get("platforms", ["tiktok", "reels"]) + } + + return { + "input_file": getattr(context, "input_file", None), + "url_input": getattr(context, "url_input", None), + "params": getattr(context, "params", {}) or {}, + "platforms": getattr(context, "platforms", ["tiktok", "reels"]) + } + + +# ------------------------------------------------- +# SAFE EXECUTOR WRAPPER (registry-first) +# ------------------------------------------------- + +async def run_task(task_name, payload): + """ + Uses V11 registry executor if available. + Falls back safely if not. + """ + + try: + from core.execution.executor import execute_task as registry_execute + + return await registry_execute(task_name, payload) + + except Exception: + return { + "status": "failed", + "task": task_name, + "message": "registry executor unavailable" + } + + +# ------------------------------------------------- +# MAIN AUTO-PUBLISH PIPELINE +# ------------------------------------------------- + +async def run(context): + + ctx = normalize_context(context) + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + # ------------------------------------------------- + # STEP 1 — TRANSCRIBE + # ------------------------------------------------- + transcript = await run_task("transcribe", ctx) + + if transcript.get("status") != "success": + return { + "status": "error", + "stage": "transcribe", + "detail": transcript + } + + # ------------------------------------------------- + # STEP 2 — STRATEGY GENERATION + # ------------------------------------------------- + strategy = await run_task("strategy", { + "text": transcript + }) + + # ------------------------------------------------- + # STEP 3 — METADATA GENERATION + # ------------------------------------------------- + metadata = await run_task("generate-metadata", { + "strategy": strategy + }) + + # ------------------------------------------------- + # STEP 4 — VARIANT BUILDING + # ------------------------------------------------- + platforms = ctx["platforms"] + + variants = [] + + for platform in platforms: + + variants.append({ + "platform": platform, + "content": strategy, + "metadata": metadata + }) + + # ------------------------------------------------- + # STEP 5 — PUBLISH + # ------------------------------------------------- + publish_results = [] + + for v in variants: + + result = await run_task("publish", { + "platform": v["platform"], + "content": v["content"], + "metadata": v["metadata"] + }) + + publish_results.append({ + "platform": v["platform"], + "result": result + }) + + # ------------------------------------------------- + # FINAL RESPONSE + # ------------------------------------------------- + + return { + "status": "success", + "pipeline": "auto_publish_v11", + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + "platforms": platforms, + "variants_created": len(variants), + "publish_results": publish_results + } + + + except Exception as e: + + return { + "status": "error", + "pipeline": "auto_publish_v11", + "message": str(e), + "stage": "auto_publish_failed" + } \ No newline at end of file diff --git a/publisher/tasks/autonomous_mode.py b/publisher/tasks/autonomous_mode.py new file mode 100644 index 0000000000000000000000000000000000000000..27e98e44f822415a9acaab58ae4c8e56fbcd82ba --- /dev/null +++ b/publisher/tasks/autonomous_mode.py @@ -0,0 +1,106 @@ +import asyncio +from dataclasses import dataclass + + +# ------------------------------------------------- +# CONTEXT NORMALIZER +# ------------------------------------------------- + +@dataclass +class Context: + input_file: any = None + url_input: str = None + params: dict = None + + +def normalize_context(ctx): + if isinstance(ctx, dict): + return Context( + input_file=ctx.get("input_file"), + url_input=ctx.get("url_input"), + params=ctx.get("params", {}) or {} + ) + return ctx + + +# ------------------------------------------------- +# PIPELINE ORCHESTRATOR +# ------------------------------------------------- + +async def run(context): + """ + Autonomous pipeline: + 1. Transcribe + 2. Extract highlights + 3. Compute viral score + 4. Generate strategy + """ + + context = normalize_context(context) + + try: + # ------------------------------------------------- + # STEP 1 — TRANSCRIPTION + # ------------------------------------------------- + from publisher.tasks.transcribe import run as transcribe_task + + transcript = await transcribe_task(context) + + if transcript.get("status") != "success": + return { + "status": "error", + "stage": "transcribe", + "detail": transcript + } + + # ------------------------------------------------- + # STEP 2 — HIGHLIGHTS + # ------------------------------------------------- + from publisher.tasks.highlights import run as highlights_task + + highlights = await highlights_task(context) + + # ------------------------------------------------- + # STEP 3 — VIRAL SCORE + # ------------------------------------------------- + from publisher.tasks.viral_score import run as viral_task + + viral = await viral_task(context) + + # ------------------------------------------------- + # STEP 4 — STRATEGY GENERATION + # ------------------------------------------------- + from publisher.tasks.strategy import run as strategy_task + + strategy = await strategy_task(context) + + # ------------------------------------------------- + # AGGREGATED OUTPUT + # ------------------------------------------------- + + segments = transcript.get("segments", []) + full_text = " ".join([s["text"] for s in segments]) + + return { + "status": "success", + "pipeline": "autonomous-v11", + "summary": { + "segments": len(segments), + "highlights": highlights.get("count", 0), + "viral_score": viral.get("viral_score", 0), + }, + "transcript": { + "text": full_text, + "segments": segments + }, + "highlights": highlights.get("highlights", []), + "viral": viral, + "strategy": strategy + } + + except Exception as e: + return { + "status": "error", + "message": str(e), + "stage": "autonomous_pipeline_failed" + } \ No newline at end of file diff --git a/publisher/tasks/batch.py b/publisher/tasks/batch.py new file mode 100644 index 0000000000000000000000000000000000000000..1095eb5e179507c2d76462ea7d54b8f58eda3520 --- /dev/null +++ b/publisher/tasks/batch.py @@ -0,0 +1,173 @@ +import asyncio +import uuid +from datetime import datetime + +# Optional queue integration (safe fallback if not present) +try: + from utils.job_queue import create_job +except Exception: + create_job = None + + +# ------------------------------------------------- +# CONTEXT NORMALIZER +# ------------------------------------------------- + +def normalize_context(context): + if isinstance(context, dict): + return { + "items": context.get("items", []), + "webhook": context.get("webhook"), + "mode": context.get("mode", "sequential") + } + return { + "items": getattr(context, "items", []), + "webhook": getattr(context, "webhook", None), + "mode": getattr(context, "mode", "sequential") + } + + +# ------------------------------------------------- +# SINGLE TASK EXECUTOR WRAPPER +# ------------------------------------------------- + +async def execute_single(task_name, payload): + """ + Uses registry executor if available, otherwise returns structured fallback. + """ + + try: + from core.execution.executor import execute_task + + return await execute_task( + task_name, + payload + ) + + except Exception as e: + return { + "task": task_name, + "status": "failed", + "error": str(e) + } + + +# ------------------------------------------------- +# MAIN BATCH RUNNER +# ------------------------------------------------- + +async def run(context): + + ctx = normalize_context(context) + items = ctx["items"] + + if not items: + return { + "status": "error", + "message": "Batch requires 'items' list" + } + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + results = [] + failed = 0 + + # ------------------------------------------------- + # MODE: SEQUENTIAL EXECUTION + # ------------------------------------------------- + + if ctx["mode"] == "sequential": + + for i, item in enumerate(items): + + task_name = item.get("task") + payload = item.get("payload", {}) + + if not task_name: + results.append({ + "index": i, + "status": "skipped", + "reason": "missing task" + }) + continue + + result = await execute_single(task_name, payload) + + if isinstance(result, dict) and result.get("status") == "failed": + failed += 1 + + results.append({ + "index": i, + "task": task_name, + "result": result + }) + + # ------------------------------------------------- + # MODE: PARALLEL EXECUTION + # ------------------------------------------------- + + elif ctx["mode"] == "parallel": + + async def run_item(i, item): + task_name = item.get("task") + payload = item.get("payload", {}) + + if not task_name: + return { + "index": i, + "status": "skipped" + } + + result = await execute_single(task_name, payload) + + return { + "index": i, + "task": task_name, + "result": result + } + + results = await asyncio.gather( + *[run_item(i, item) for i, item in enumerate(items)] + ) + + else: + return { + "status": "error", + "message": f"Unsupported mode: {ctx['mode']}" + } + + # ------------------------------------------------- + # JOB QUEUE INTEGRATION (OPTIONAL) + # ------------------------------------------------- + + job_id = None + if create_job: + try: + job_id = create_job( + video_path=None, + webhook=ctx["webhook"], + metadata={ + "batch_id": batch_id, + "total": len(items), + "failed": failed + } + ) + except Exception: + job_id = None + + # ------------------------------------------------- + # FINAL RESPONSE + # ------------------------------------------------- + + return { + "status": "success", + "batch_id": batch_id, + "job_id": job_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + "mode": ctx["mode"], + "total": len(items), + "failed": failed, + "results": results + } \ No newline at end of file diff --git a/publisher/tasks/batch_runner.py b/publisher/tasks/batch_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..1173ebca1bb7acdbe757e65080cd3bd34bbfe122 --- /dev/null +++ b/publisher/tasks/batch_runner.py @@ -0,0 +1,168 @@ +import uuid +import asyncio +from datetime import datetime + +from utils.logger import logger +from utils.job_queue import create_job, get_job + + +# ===================================================== +# VALIDATION +# ===================================================== + +def normalize_payload(payload: dict | None): + payload = payload or {} + + items = payload.get("items", []) + + if not isinstance(items, list): + raise ValueError("batch items must be a list") + + normalized = [] + + for i, item in enumerate(items): + if not isinstance(item, dict): + raise ValueError(f"batch item {i} must be an object") + + normalized.append({ + "id": item.get("id", str(uuid.uuid4())), + "task": item.get("task"), + "video_path": item.get("video_path"), + "source": item.get("source"), + "payload": item.get("payload", {}), + "webhook": item.get("webhook"), + }) + + if not normalized: + raise ValueError("batch cannot be empty") + + return normalized + + +# ===================================================== +# SAFE TASK EXECUTION WRAPPER +# ===================================================== + +async def execute_single(task_executor, item, index: int): + + try: + logger.info(f"[BATCH] Executing item {index} → {item['task']}") + + result = await task_executor( + item["task"], + { + **(item.get("payload") or {}), + "video_path": item.get("video_path"), + "source": item.get("source"), + }, + item.get("webhook"), + ) + + return { + "index": index, + "id": item["id"], + "task": item["task"], + "status": "success", + "result": result, + } + + except Exception as e: + + logger.exception(f"[BATCH ERROR] item {index}") + + return { + "index": index, + "id": item["id"], + "task": item.get("task"), + "status": "failed", + "error": str(e), + } + + +# ===================================================== +# CONCURRENCY CONTROLLER +# ===================================================== + +async def run_concurrent(tasks, executor, max_concurrency: int = 3): + + semaphore = asyncio.Semaphore(max_concurrency) + + async def bound(item, index): + async with semaphore: + return await execute_single(executor, item, index) + + return await asyncio.gather( + *[bound(item, i) for i, item in enumerate(tasks)] + ) + + +# ===================================================== +# MAIN ENTRYPOINT (REGISTRY COMPATIBLE) +# ===================================================== + +async def run(payload: dict | None = None, context: dict | None = None): + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + items = normalize_payload(payload) + + logger.info(f"[BATCH] Starting batch_id={batch_id}, items={len(items)}") + + # ------------------------------------------------- + # EXECUTOR HOOK (inject from registry context) + # ------------------------------------------------- + + def executor(task_name, task_payload, webhook=None): + """ + This is intentionally abstract so it can plug into: + - registry executor + - legacy executor + - FastAPI layer + """ + + from core.execution.executor import execute_task + + return execute_task(task_name, task_payload, webhook) + + # ------------------------------------------------- + # RUN BATCH + # ------------------------------------------------- + + results = await run_concurrent(items, executor) + + success_count = len([r for r in results if r["status"] == "success"]) + failed_count = len(results) - success_count + + # ------------------------------------------------- + # OUTPUT CONTRACT + # ------------------------------------------------- + + return { + "status": "completed", + "task": "batch_runner", + + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + + "total": len(items), + "success": success_count, + "failed": failed_count, + + "results": results, + } + + except Exception as e: + + logger.exception("[BATCH FATAL ERROR]") + + return { + "status": "error", + "task": "batch_runner", + "batch_id": batch_id, + "message": str(e), + "stage": "batch_execution_failed" + } \ No newline at end of file diff --git a/publisher/tasks/generate_metadata.py b/publisher/tasks/generate_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..ecac82d17a5e1da99db55e9d1fe7ba658c1d3843 --- /dev/null +++ b/publisher/tasks/generate_metadata.py @@ -0,0 +1,200 @@ +import asyncio +from datetime import datetime +import uuid + + +# ------------------------------------------------- +# CONTEXT NORMALIZER +# ------------------------------------------------- + +def normalize_context(context): + """ + Accepts registry dict or legacy object input. + """ + + if isinstance(context, dict): + return { + "strategy": context.get("strategy", {}), + "transcript": context.get("transcript", {}), + "platform": context.get("platform", "tiktok"), + "video_path": context.get("video_path") + } + + return { + "strategy": getattr(context, "strategy", {}), + "transcript": getattr(context, "transcript", {}), + "platform": getattr(context, "platform", "tiktok"), + "video_path": getattr(context, "video_path", None) + } + + +# ------------------------------------------------- +# SAFE TEXT EXTRACTOR +# ------------------------------------------------- + +def extract_text(transcript): + """ + Extracts usable text from transcript structure safely. + """ + + if isinstance(transcript, dict): + segments = transcript.get("segments", []) + if segments: + return " ".join([s.get("text", "") for s in segments]) + + if isinstance(transcript, str): + return transcript + + return "" + + +# ------------------------------------------------- +# METADATA GENERATOR CORE +# ------------------------------------------------- + +def build_metadata(text, strategy, platform): + """ + Deterministic metadata generator (no external API required). + """ + + hook = "" + if isinstance(strategy, dict): + hook = strategy.get("hook", "") + + if not hook: + hook = text[:120] + "..." if text else "Discover powerful insights in this video." + + title = hook[:70].strip() + + description = ( + f"{hook}\n\n" + f"Watch till the end for key insights.\n" + f"Optimized for {platform}." + ) + + tags = [ + "content", + "viral", + "shorts", + platform, + "ai generated", + "social media" + ] + + hashtags = [ + "#ViralContent", + "#ContentCreator", + "#Shorts", + f"#{platform.capitalize()}", + "#AIContent" + ] + + return { + "title": title, + "description": description, + "tags": tags, + "hashtags": hashtags + } + + +# ------------------------------------------------- +# OPTIONAL LLM ENHANCEMENT (SAFE WRAPPER) +# ------------------------------------------------- + +async def enhance_with_llm(base_metadata, context): + """ + Optional enhancement layer. + Never breaks pipeline if API missing. + """ + + try: + import os + + if not os.getenv("GEMINI_API_KEY"): + return base_metadata + + # Lazy import to avoid startup crashes + import google.generativeai as genai + + genai.configure(api_key=os.environ["GEMINI_API_KEY"]) + + model = genai.GenerativeModel("gemini-1.5-flash") + + prompt = f""" +Improve this social media metadata for virality: + +TITLE: {base_metadata['title']} +DESCRIPTION: {base_metadata['description']} +TAGS: {base_metadata['tags']} +HASHTAGS: {base_metadata['hashtags']} + +Return STRICT JSON with: +title, description, tags, hashtags +""" + + response = await model.generate_content_async(prompt) + + import json + cleaned = response.text.strip().replace("```json", "").replace("```", "") + data = json.loads(cleaned) + + return data + + except Exception: + return base_metadata + + +# ------------------------------------------------- +# MAIN TASK ENTRYPOINT +# ------------------------------------------------- + +async def run(context): + + ctx = normalize_context(context) + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + strategy = ctx["strategy"] + transcript = ctx["transcript"] + platform = ctx["platform"] + + text = extract_text(transcript) + + # ------------------------------------------------- + # BASE METADATA + # ------------------------------------------------- + + base_metadata = build_metadata(text, strategy, platform) + + # ------------------------------------------------- + # OPTIONAL ENHANCEMENT + # ------------------------------------------------- + + final_metadata = await enhance_with_llm(base_metadata, ctx) + + # ------------------------------------------------- + # RESPONSE + # ------------------------------------------------- + + return { + "status": "success", + "task": "generate-metadata", + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + "platform": platform, + "metadata": final_metadata + } + + except Exception as e: + + return { + "status": "error", + "task": "generate-metadata", + "batch_id": batch_id, + "message": str(e), + "stage": "metadata_generation_failed" + } \ No newline at end of file diff --git a/publisher/tasks/generate_thumbnail.py b/publisher/tasks/generate_thumbnail.py new file mode 100644 index 0000000000000000000000000000000000000000..8d93022e4a532fbca1cb1c0ea032146acf4b19d5 --- /dev/null +++ b/publisher/tasks/generate_thumbnail.py @@ -0,0 +1,155 @@ +import os +import uuid +import asyncio +from datetime import datetime + +from PIL import Image, ImageDraw, ImageFont + + +# ------------------------------------------------- +# SAFE OUTPUT DIRECTORY +# ------------------------------------------------- + +OUTPUT_DIR = "jobs/thumbnails" +os.makedirs(OUTPUT_DIR, exist_ok=True) + + +# ------------------------------------------------- +# CONTEXT NORMALIZER +# ------------------------------------------------- + +def normalize_context(context): + + if isinstance(context, dict): + return { + "title": context.get("title"), + "hook": context.get("hook"), + "strategy": context.get("strategy", {}), + "text": context.get("text", ""), + "video_path": context.get("video_path") + } + + return { + "title": getattr(context, "title", None), + "hook": getattr(context, "hook", None), + "strategy": getattr(context, "strategy", {}), + "text": getattr(context, "text", ""), + "video_path": getattr(context, "video_path", None) + } + + +# ------------------------------------------------- +# TEXT EXTRACTOR +# ------------------------------------------------- + +def extract_text(ctx): + if ctx.get("hook"): + return ctx["hook"] + + if isinstance(ctx.get("strategy"), dict): + hook = ctx["strategy"].get("hook") + if hook: + return hook + + return ctx.get("text") or "Create engaging content that stands out." + + +# ------------------------------------------------- +# SAFE FONT LOADER +# ------------------------------------------------- + +def load_font(size): + """ + Tries system fonts safely. + Falls back to default PIL font if unavailable. + """ + + try: + return ImageFont.truetype("arial.ttf", size) + except Exception: + return ImageFont.load_default() + + +# ------------------------------------------------- +# THUMBNAIL GENERATOR CORE +# ------------------------------------------------- + +def build_thumbnail(text, width=1280, height=720): + + img = Image.new("RGB", (width, height), color=(10, 10, 10)) + draw = ImageDraw.Draw(img) + + # Accent style bar + draw.rectangle([0, 0, 20, height], fill=(232, 255, 71)) + + # Title text + font_large = load_font(64) + font_small = load_font(36) + + wrapped_text = text[:120] + + draw.text( + (60, 200), + wrapped_text, + font=font_large, + fill=(232, 232, 232) + ) + + draw.text( + (60, 320), + "AI-Generated Content", + font=font_small, + fill=(136, 136, 136) + ) + + return img + + +# ------------------------------------------------- +# MAIN ENTRYPOINT +# ------------------------------------------------- + +async def run(context): + + ctx = normalize_context(context) + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + text = extract_text(ctx) + + # ------------------------------------------------- + # BUILD IMAGE (CPU SAFE) + # ------------------------------------------------- + + img = await asyncio.to_thread(build_thumbnail, text) + + file_name = f"{batch_id}_thumbnail.png" + output_path = os.path.join(OUTPUT_DIR, file_name) + + img.save(output_path) + + # ------------------------------------------------- + # RESPONSE + # ------------------------------------------------- + + return { + "status": "success", + "task": "generate-thumbnail", + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + "thumbnail_path": output_path + } + + except Exception as e: + + return { + "status": "error", + "task": "generate-thumbnail", + "batch_id": batch_id, + "message": str(e), + "stage": "thumbnail_generation_failed" + } \ No newline at end of file diff --git a/publisher/tasks/highlights.py b/publisher/tasks/highlights.py new file mode 100644 index 0000000000000000000000000000000000000000..88e861916aeeca68ed968b0e613ac3af9ab42cb4 --- /dev/null +++ b/publisher/tasks/highlights.py @@ -0,0 +1,192 @@ +import uuid +from datetime import datetime + + +# ------------------------------------------------- +# UTILITIES +# ------------------------------------------------- + +def clamp(value, min_v=0, max_v=1): + return max(min_v, min(max_v, value)) + + +def safe_len(x): + try: + return len(x) + except Exception: + return 0 + + +# ------------------------------------------------- +# CORE HIGHLIGHT DETECTION ENGINE +# ------------------------------------------------- + +def detect_peak_density(words, window=12): + """ + Simple sliding window density heuristic. + Returns indices where speech intensity peaks. + """ + + if not words or len(words) < window: + return [] + + scores = [] + + for i in range(len(words) - window): + chunk = words[i:i + window] + + # heuristic: repetition + punctuation + trigger words + unique_ratio = len(set(chunk)) / window + + trigger_words = {"why", "how", "what", "secret", "hack", "never", "stop", "crazy", "insane"} + trigger_hits = sum(1 for w in chunk if str(w).lower() in trigger_words) + + score = (1 - unique_ratio) + (trigger_hits * 0.15) + + scores.append((i, score)) + + # sort by strongest signal + scores.sort(key=lambda x: x[1], reverse=True) + + # take top peaks (non-overlapping) + selected = [] + used = set() + + for idx, _ in scores: + if any(abs(idx - u) < window for u in used): + continue + selected.append(idx) + used.add(idx) + if len(selected) >= 8: # max clips + break + + return selected + + +def build_segments(words, indices, window=20): + """ + Convert peak indices into structured segments. + """ + + segments = [] + + for idx in indices: + start = max(0, idx - window // 2) + end = min(len(words), idx + window // 2) + + segment_words = words[start:end] + + if not segment_words: + continue + + segments.append({ + "id": str(uuid.uuid4()), + "start_index": start, + "end_index": end, + "words": segment_words, + "length": len(segment_words), + }) + + return segments + + +# ------------------------------------------------- +# FALLBACK MODE (NO SIGNAL DETECTED) +# ------------------------------------------------- + +def fallback_segments(words): + """ + Ensures highlights always exist even for weak input. + """ + + if not words: + return [] + + chunk_size = max(25, len(words) // 5) + + segments = [] + + for i in range(0, len(words), chunk_size): + chunk = words[i:i + chunk_size] + + segments.append({ + "id": str(uuid.uuid4()), + "start_index": i, + "end_index": i + len(chunk), + "words": chunk, + "length": len(chunk), + }) + + if len(segments) >= 5: + break + + return segments + + +# ------------------------------------------------- +# MAIN ENTRYPOINT (REGISTRY COMPATIBLE) +# ------------------------------------------------- + +def run(context): + """ + Expected input: + { + "words": [...] + } + """ + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + words = [] + + if isinstance(context, dict): + words = context.get("words", []) + else: + words = getattr(context, "words", []) or [] + + # ------------------------------------------------- + # DETECT PEAKS + # ------------------------------------------------- + + peak_indices = detect_peak_density(words) + + if peak_indices: + segments = build_segments(words, peak_indices) + else: + segments = fallback_segments(words) + + # ------------------------------------------------- + # OUTPUT CONTRACT + # ------------------------------------------------- + + return { + "status": "success", + "task": "highlights", + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + + # core output for downstream clipper + "highlights": segments, + + # UI-friendly summary + "summary": { + "total_words": safe_len(words), + "segments_found": len(segments), + "peak_detection": bool(peak_indices), + } + } + + except Exception as e: + + return { + "status": "error", + "task": "highlights", + "batch_id": batch_id, + "message": str(e), + "stage": "highlight_detection_failed", + "highlights": [] + } \ No newline at end of file diff --git a/publisher/tasks/publish.py b/publisher/tasks/publish.py new file mode 100644 index 0000000000000000000000000000000000000000..c0113f609930bb9ae6562541ef57c0c59a185de8 --- /dev/null +++ b/publisher/tasks/publish.py @@ -0,0 +1,147 @@ +import asyncio +from datetime import datetime +import uuid + + +# ------------------------------------------------- +# CONTEXT NORMALIZER +# ------------------------------------------------- + +def normalize_context(context): + """ + Supports: + - dict input (registry executor) + - object input (legacy execution engine) + """ + + if isinstance(context, dict): + return { + "platform": context.get("platform", "tiktok"), + "content": context.get("content", {}), + "metadata": context.get("metadata", {}), + "video_path": context.get("video_path"), + "payload": context + } + + return { + "platform": getattr(context, "platform", "tiktok"), + "content": getattr(context, "content", {}), + "metadata": getattr(context, "metadata", {}), + "video_path": getattr(context, "video_path", None), + "payload": {} + } + + +# ------------------------------------------------- +# SAFE DISPATCH LAYER (registry-aware) +# ------------------------------------------------- + +async def safe_dispatch(platform, content, metadata, video_path=None): + """ + Uses platform_dispatcher if available. + Falls back to simulated response if missing. + """ + + try: + from publisher.platform_dispatcher import dispatch_publish + + return await dispatch_publish( + video_path=video_path, + payload={ + "platform": platform, + "content": content, + "metadata": metadata + } + ) + + except Exception as e: + return { + "status": "fallback_success", + "platform": platform, + "message": "dispatch fallback executed", + "error": str(e), + "simulated": True + } + + +# ------------------------------------------------- +# MAIN PUBLISH TASK +# ------------------------------------------------- + +async def run(context): + + ctx = normalize_context(context) + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + platform = ctx["platform"] + content = ctx["content"] + metadata = ctx["metadata"] + video_path = ctx["video_path"] + + # ------------------------------------------------- + # VALIDATION LAYER + # ------------------------------------------------- + + if not content: + return { + "status": "error", + "stage": "validation", + "message": "Missing content payload" + } + + # ------------------------------------------------- + # PLATFORM ROUTING + # ------------------------------------------------- + + supported_platforms = { + "tiktok", + "reels", + "youtube", + "shorts" + } + + if platform not in supported_platforms: + return { + "status": "error", + "stage": "validation", + "message": f"Unsupported platform: {platform}" + } + + # ------------------------------------------------- + # DISPATCH EXECUTION + # ------------------------------------------------- + + result = await safe_dispatch( + platform=platform, + content=content, + metadata=metadata, + video_path=video_path + ) + + # ------------------------------------------------- + # RESPONSE NORMALIZATION + # ------------------------------------------------- + + return { + "status": "success", + "task": "publish", + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + "platform": platform, + "result": result + } + + except Exception as e: + + return { + "status": "error", + "task": "publish", + "batch_id": batch_id, + "message": str(e), + "stage": "publish_failed" + } \ No newline at end of file diff --git a/publisher/tasks/publish_instagram.py b/publisher/tasks/publish_instagram.py new file mode 100644 index 0000000000000000000000000000000000000000..326e0ad65b702e0f35013423eaf96f22882e147e --- /dev/null +++ b/publisher/tasks/publish_instagram.py @@ -0,0 +1,153 @@ +import uuid +from datetime import datetime + + +# ===================================================== +# VALIDATION LAYER +# ===================================================== + +def validate_video(video_path: str | None): + if not video_path: + raise ValueError("video_path is required for Instagram publishing") + + return video_path + + +def normalize_payload(payload: dict | None): + payload = payload or {} + + return { + "caption": payload.get("caption", ""), + "hashtags": payload.get("hashtags", []), + + # Instagram constraints + "share_to_feed": bool(payload.get("share_to_feed", True)), + "collaborators": payload.get("collaborators", []), + "location_id": payload.get("location_id"), + + # IG-specific metadata controls + "disable_comments": bool(payload.get("disable_comments", False)), + "hide_likes": bool(payload.get("hide_likes", False)), + + # Reels compatibility (IG defaults) + "format": "reel", + "aspect_ratio": "9:16", + } + + +# ===================================================== +# INSTAGRAM GRAPH ADAPTER (ABSTRACT LAYER) +# ===================================================== + +class InstagramClient: + """ + Abstracted Instagram Graph API adapter. + + Production replacement: + - Meta Graph API / Instagram Content Publishing API + - OAuth token refresh layer + - container-based upload flow (creation → publish) + """ + + def __init__(self): + self.provider = "instagram" + + def create_container(self, video_path: str, metadata: dict): + """ + Step 1: create media container (simulation layer) + """ + + return { + "container_id": str(uuid.uuid4()), + "status": "created", + } + + def publish_container(self, container_id: str): + """ + Step 2: publish media container (simulation layer) + """ + + return { + "ig_media_id": str(uuid.uuid4()), + "permalink": f"https://instagram.com/p/{uuid.uuid4().hex[:11]}", + "status": "published" + } + + +# ===================================================== +# SAFE EXECUTION WRAPPER +# ===================================================== + +def safe_publish(client: InstagramClient, video_path: str, metadata: dict, retries: int = 2): + + last_error = None + + for _ in range(retries + 1): + + try: + container = client.create_container(video_path, metadata) + return client.publish_container(container["container_id"]) + + except Exception as e: + last_error = str(e) + + return { + "status": "failed", + "error": last_error + } + + +# ===================================================== +# MAIN ENTRYPOINT (REGISTRY COMPATIBLE) +# ===================================================== + +def run(video_path: str | None = None, payload: dict | None = None, context: dict | None = None): + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + video_path = validate_video(video_path) + metadata = normalize_payload(payload) + + client = InstagramClient() + + result = safe_publish(client, video_path, metadata) + + # ================================================= + # OUTPUT CONTRACT (STRICT FOR UI + REGISTRY) + # ================================================= + + return { + "status": "success" if result.get("status") != "failed" else "error", + "task": "publish_instagram", + "platform": "instagram", + + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + + # core outputs + "ig_media_id": result.get("ig_media_id"), + "permalink": result.get("permalink"), + + # UI-friendly metadata + "caption": metadata["caption"], + "aspect_ratio": metadata["aspect_ratio"], + + # full raw result + "result": result, + } + + except Exception as e: + + return { + "status": "error", + "task": "publish_instagram", + "platform": "instagram", + + "batch_id": batch_id, + "message": str(e), + "stage": "instagram_publish_failed" + } \ No newline at end of file diff --git a/publisher/tasks/publish_reels.py b/publisher/tasks/publish_reels.py new file mode 100644 index 0000000000000000000000000000000000000000..2c3aaad9605319f825efa24f0f4f301bccf9382f --- /dev/null +++ b/publisher/tasks/publish_reels.py @@ -0,0 +1,134 @@ +import uuid +from datetime import datetime + + +# ------------------------------------------------- +# VALIDATION +# ------------------------------------------------- + +def validate_video(video_path: str | None): + if not video_path: + raise ValueError("video_path is required for Reels publishing") + + return video_path + + +def normalize_payload(payload: dict | None): + payload = payload or {} + + return { + "caption": payload.get("caption", ""), + "hashtags": payload.get("hashtags", []), + "share_to_feed": bool(payload.get("share_to_feed", True)), + "collaborators": payload.get("collaborators", []), + "location_id": payload.get("location_id"), + } + + +# ------------------------------------------------- +# REELS CLIENT (ADAPTER LAYER) +# ------------------------------------------------- + +class ReelsClient: + """ + Abstracted Meta/Instagram publishing adapter. + + Replace with: + - Meta Graph API / Instagram Content Publishing API + - OAuth token injection layer + """ + + def __init__(self): + self.provider = "instagram_reels" + + def upload_reel(self, video_path: str, metadata: dict): + """ + Simulated deterministic upload layer. + + Production replacement: + - POST /{ig-user-id}/media + - POST publish container + """ + + return { + "reel_id": str(uuid.uuid4()), + "media_id": str(uuid.uuid4()), + "permalink": f"https://instagram.com/reel/{uuid.uuid4().hex[:11]}", + "status": "published", + "caption": metadata["caption"], + } + + +# ------------------------------------------------- +# SAFE UPLOAD WRAPPER +# ------------------------------------------------- + +def safe_publish(client: ReelsClient, video_path: str, metadata: dict, retries: int = 2): + + last_error = None + + for _ in range(retries + 1): + + try: + return client.upload_reel(video_path, metadata) + + except Exception as e: + last_error = str(e) + + return { + "status": "failed", + "error": last_error + } + + +# ------------------------------------------------- +# MAIN ENTRYPOINT (REGISTRY COMPATIBLE) +# ------------------------------------------------- + +def run(video_path: str | None = None, payload: dict | None = None, context: dict | None = None): + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + video_path = validate_video(video_path) + metadata = normalize_payload(payload) + + client = ReelsClient() + + result = safe_publish(client, video_path, metadata) + + # ------------------------------------------------- + # OUTPUT CONTRACT (STRICT FOR UI + REGISTRY) + # ------------------------------------------------- + + return { + "status": "success" if result.get("status") != "failed" else "error", + "task": "publish_reels", + "platform": "instagram_reels", + + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + + # core outputs + "reel_id": result.get("reel_id"), + "media_id": result.get("media_id"), + "permalink": result.get("permalink"), + + # UI consumption + "result": result, + "caption": metadata["caption"], + } + + except Exception as e: + + return { + "status": "error", + "task": "publish_reels", + "platform": "instagram_reels", + "batch_id": batch_id, + "message": str(e), + "stage": "reels_publish_failed" + } \ No newline at end of file diff --git a/publisher/tasks/publish_shorts.py b/publisher/tasks/publish_shorts.py new file mode 100644 index 0000000000000000000000000000000000000000..6443a2e05ca4668f9c76f0c97379d0862f0c56a1 --- /dev/null +++ b/publisher/tasks/publish_shorts.py @@ -0,0 +1,137 @@ +import uuid +from datetime import datetime + + +# ------------------------------------------------- +# VALIDATION LAYER +# ------------------------------------------------- + +def validate_video(video_path: str | None): + if not video_path: + raise ValueError("video_path is required for Shorts publishing") + + return video_path + + +def normalize_payload(payload: dict | None): + payload = payload or {} + + return { + "title": payload.get("title", "Shorts Video"), + "description": payload.get("description", ""), + "tags": payload.get("tags", []), + "privacy_status": payload.get("privacy_status", "public"), + "hashtags": payload.get("hashtags", []), + + # Shorts-specific optimization flags + "force_vertical": True, + "aspect_ratio": "9:16", + "max_duration_seconds": payload.get("max_duration_seconds", 60), + } + + +# ------------------------------------------------- +# SHORTS PLATFORM ADAPTER (ABSTRACT) +# ------------------------------------------------- + +class ShortsClient: + """ + Platform-agnostic Shorts uploader. + + Replace later with: + - YouTube Shorts (YouTube Data API) + - TikTok fallback adapter + - Instagram Reels cross-posting layer + """ + + def __init__(self): + self.provider = "shorts" + + def upload(self, video_path: str, metadata: dict): + + return { + "short_id": str(uuid.uuid4()), + "video_id": str(uuid.uuid4()), + "url": f"https://shorts.platform/watch/{uuid.uuid4().hex[:11]}", + "status": "published", + "aspect_ratio": metadata["aspect_ratio"], + "duration_limit": metadata["max_duration_seconds"] + } + + +# ------------------------------------------------- +# SAFE EXECUTION WRAPPER +# ------------------------------------------------- + +def safe_publish(client: ShortsClient, video_path: str, metadata: dict, retries: int = 2): + + last_error = None + + for _ in range(retries + 1): + + try: + return client.upload(video_path, metadata) + + except Exception as e: + last_error = str(e) + + return { + "status": "failed", + "error": last_error + } + + +# ------------------------------------------------- +# MAIN ENTRYPOINT (REGISTRY COMPATIBLE) +# ------------------------------------------------- + +def run(video_path: str | None = None, payload: dict | None = None, context: dict | None = None): + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + video_path = validate_video(video_path) + metadata = normalize_payload(payload) + + client = ShortsClient() + + result = safe_publish(client, video_path, metadata) + + # ------------------------------------------------- + # OUTPUT CONTRACT (STRICT FOR UI + REGISTRY) + # ------------------------------------------------- + + return { + "status": "success" if result.get("status") != "failed" else "error", + "task": "publish_shorts", + "platform": "shorts", + + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + + # core outputs + "short_id": result.get("short_id"), + "video_id": result.get("video_id"), + "url": result.get("url"), + + # UI-friendly fields + "aspect_ratio": metadata["aspect_ratio"], + "max_duration_seconds": metadata["max_duration_seconds"], + + # full result + "result": result, + } + + except Exception as e: + + return { + "status": "error", + "task": "publish_shorts", + "platform": "shorts", + "batch_id": batch_id, + "message": str(e), + "stage": "shorts_publish_failed" + } \ No newline at end of file diff --git a/publisher/tasks/publish_tiktok.py b/publisher/tasks/publish_tiktok.py new file mode 100644 index 0000000000000000000000000000000000000000..31b9cd35beda233af72d53799a42772d42dc5ec8 --- /dev/null +++ b/publisher/tasks/publish_tiktok.py @@ -0,0 +1,22 @@ +import shutil +from pathlib import Path + + +async def run(payload, ctx): + + video_path = payload["video_path"] + caption = payload["caption"] + + upload_dir = Path("published") + upload_dir.mkdir(exist_ok=True) + + destination = upload_dir / Path(video_path).name + + shutil.copy(video_path, destination) + + ctx.log(f"Published video with caption: {caption}") + + return { + "status": "published", + "path": str(destination) + } \ No newline at end of file diff --git a/publisher/tasks/publish_youtube.py b/publisher/tasks/publish_youtube.py new file mode 100644 index 0000000000000000000000000000000000000000..64033caa11d3d360a7ed366fd035d5fcd9932a94 --- /dev/null +++ b/publisher/tasks/publish_youtube.py @@ -0,0 +1,127 @@ +import uuid +from datetime import datetime + + +# ------------------------------------------------- +# SAFE NORMALIZATION +# ------------------------------------------------- + +def normalize_payload(payload: dict | None): + payload = payload or {} + + return { + "title": payload.get("title", "Untitled Video"), + "description": payload.get("description", ""), + "tags": payload.get("tags", []), + "privacy_status": payload.get("privacy_status", "public"), + "category_id": payload.get("category_id", "22"), + } + + +def normalize_video(video_path: str | None): + if not video_path: + raise ValueError("video_path is required for YouTube publishing") + + return video_path + + +# ------------------------------------------------- +# MOCKABLE YOUTUBE CLIENT INTERFACE +# (replace with real API integration later) +# ------------------------------------------------- + +class YouTubeClient: + + def __init__(self): + # In production: inject OAuth client here + self.provider = "youtube" + + def upload(self, video_path, metadata): + """ + Deterministic stub for upload pipeline. + + Replace with: + - google-api-python-client + - or YouTube Data API v3 upload session + """ + + return { + "video_id": str(uuid.uuid4()), + "url": f"https://youtube.com/watch?v={uuid.uuid4().hex[:11]}", + "status": "uploaded", + "visibility": metadata["privacy_status"] + } + + +# ------------------------------------------------- +# RETRY WRAPPER (SAFE FOR NETWORK FAILURES) +# ------------------------------------------------- + +def safe_upload(client, video_path, metadata, retries=2): + + last_error = None + + for attempt in range(retries + 1): + + try: + return client.upload(video_path, metadata) + + except Exception as e: + last_error = str(e) + + return { + "status": "failed", + "error": last_error + } + + +# ------------------------------------------------- +# MAIN ENTRYPOINT (REGISTRY-COMPATIBLE) +# ------------------------------------------------- + +def run(video_path: str | None = None, payload: dict | None = None, context: dict | None = None): + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + video_path = normalize_video(video_path) + metadata = normalize_payload(payload) + + client = YouTubeClient() + + result = safe_upload(client, video_path, metadata) + + # ------------------------------------------------- + # OUTPUT CONTRACT (IMPORTANT FOR REGISTRY + UI) + # ------------------------------------------------- + + return { + "status": "success" if result.get("status") != "failed" else "error", + "task": "publish_youtube", + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + + # core output + "platform": "youtube", + "result": result, + + # UI-friendly fields + "video_url": result.get("url"), + "video_id": result.get("video_id"), + + # publishing metadata echo + "metadata": metadata + } + + except Exception as e: + + return { + "status": "error", + "task": "publish_youtube", + "batch_id": batch_id, + "message": str(e), + "stage": "youtube_publish_failed" + } \ No newline at end of file diff --git a/publisher/tasks/render.py b/publisher/tasks/render.py new file mode 100644 index 0000000000000000000000000000000000000000..c6ca8e358a29df8a3c15ffa570039d82a28acf7c --- /dev/null +++ b/publisher/tasks/render.py @@ -0,0 +1,181 @@ +import os +import uuid +import asyncio +import subprocess +from datetime import datetime + + +# ------------------------------------------------- +# SAFE OUTPUT DIRECTORY +# ------------------------------------------------- + +OUTPUT_DIR = "jobs/renders" +os.makedirs(OUTPUT_DIR, exist_ok=True) + + +# ------------------------------------------------- +# CONTEXT NORMALIZER +# ------------------------------------------------- + +def normalize_context(context): + + if isinstance(context, dict): + return { + "video_path": context.get("video_path"), + "srt": context.get("srt"), + "subtitles": context.get("subtitles") + } + + return { + "video_path": getattr(context, "video_path", None), + "srt": getattr(context, "srt", None), + "subtitles": getattr(context, "subtitles", None) + } + + +# ------------------------------------------------- +# SRT RESOLVER +# ------------------------------------------------- + +def resolve_srt(ctx): + """ + Accepts: + - raw SRT string + - file path + - None + """ + + srt = ctx.get("srt") + + if not srt: + return None + + if isinstance(srt, str) and os.path.exists(srt): + with open(srt, "r", encoding="utf-8") as f: + return f.read() + + return srt if isinstance(srt, str) else None + + +# ------------------------------------------------- +# SAFE FFMPEG RENDER ENGINE +# ------------------------------------------------- + +def run_ffmpeg(video_path, srt_path, output_path): + + cmd = [ + "ffmpeg", + "-y", + "-i", video_path, + ] + + # Subtitle overlay (only if available) + if srt_path and os.path.exists(srt_path): + cmd += [ + "-vf", + f"subtitles={srt_path}" + ] + + cmd += [ + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "23", + "-c:a", "aac", + "-b:a", "128k", + output_path + ] + + process = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + if process.returncode != 0: + raise RuntimeError(process.stderr) + + return output_path + + +# ------------------------------------------------- +# MAIN ENTRYPOINT +# ------------------------------------------------- + +async def run(context): + + ctx = normalize_context(context) + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + video_path = ctx.get("video_path") + srt_data = resolve_srt(ctx) + + if not video_path or not os.path.exists(video_path): + return { + "status": "error", + "task": "render", + "message": "Missing or invalid video_path", + "stage": "validation" + } + + # ------------------------------------------------- + # TEMP SRT FILE HANDLING + # ------------------------------------------------- + + srt_path = None + + if srt_data: + srt_path = os.path.join(OUTPUT_DIR, f"{batch_id}.srt") + + with open(srt_path, "w", encoding="utf-8") as f: + f.write(srt_data) + + output_path = os.path.join( + OUTPUT_DIR, + f"{batch_id}_render.mp4" + ) + + # ------------------------------------------------- + # FFMPEG EXECUTION (THREAD SAFE) + # ------------------------------------------------- + + await asyncio.to_thread( + run_ffmpeg, + video_path, + srt_path, + output_path + ) + + # ------------------------------------------------- + # CLEANUP OPTIONAL + # ------------------------------------------------- + + if srt_path and os.path.exists(srt_path): + os.remove(srt_path) + + # ------------------------------------------------- + # RESPONSE + # ------------------------------------------------- + + return { + "status": "success", + "task": "render", + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + "output_path": output_path + } + + except Exception as e: + + return { + "status": "error", + "task": "render", + "batch_id": batch_id, + "message": str(e), + "stage": "render_failed" + } \ No newline at end of file diff --git a/publisher/tasks/strategy.py b/publisher/tasks/strategy.py new file mode 100644 index 0000000000000000000000000000000000000000..72604d6531a63f5430b2ba710975ec696cebca1f --- /dev/null +++ b/publisher/tasks/strategy.py @@ -0,0 +1,150 @@ +import asyncio +import uuid +from datetime import datetime + + +# ------------------------------------------------- +# SAFE DEFAULTS +# ------------------------------------------------- + +DEFAULT_PERSONA = { + "age_range": "18-34", + "interests": ["content creation", "social media growth"], + "behavior": "scroll-heavy short-form consumption" +} + + +# ------------------------------------------------- +# CONTEXT NORMALIZER +# ------------------------------------------------- + +def normalize_context(context): + + if isinstance(context, dict): + return { + "words": context.get("words") or context.get("transcript") or [], + "video_path": context.get("video_path"), + } + + return { + "words": getattr(context, "words", None) or getattr(context, "transcript", None) or [], + "video_path": getattr(context, "video_path", None) + } + + +# ------------------------------------------------- +# SIMPLE VIRAL HEURISTICS ENGINE +# (replaces fragile LLM dependency assumptions) +# ------------------------------------------------- + +def compute_hook(words): + if not words: + return "Create content that hooks attention in the first 3 seconds." + + # crude heuristic: pick first 12 words + text = " ".join(words if isinstance(words, list) else []) + return text.split(".")[0][:120] + + +def compute_viral_score(words): + if not words: + return 50 + + length = len(words) + + # heuristic scoring model (deterministic) + score = min(95, 40 + (length / 50)) + + return round(score, 2) + + +def detect_platform_fit(score): + if score >= 80: + return ["tiktok", "reels", "youtube-shorts"] + if score >= 60: + return ["tiktok", "reels"] + return ["reels"] + + +# ------------------------------------------------- +# RETENTION CURVE SIMULATOR +# ------------------------------------------------- + +def simulate_retention_curve(words): + + if not words: + return [1.0, 0.7, 0.5, 0.3] + + n = len(words) + + return [ + 1.0, + max(0.7, 1 - (n * 0.001)), + max(0.4, 1 - (n * 0.002)), + max(0.2, 1 - (n * 0.003)), + ] + + +# ------------------------------------------------- +# MAIN STRATEGY ENGINE +# ------------------------------------------------- + +async def run(context): + + ctx = normalize_context(context) + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + words = ctx.get("words") or [] + + # ------------------------------------------------- + # CORE STRATEGY OUTPUTS + # ------------------------------------------------- + + hook = compute_hook(words) + viral_score = compute_viral_score(words) + platforms = detect_platform_fit(viral_score) + retention_curve = simulate_retention_curve(words) + + # ------------------------------------------------- + # STRUCTURED RESPONSE (CRITICAL FOR REGISTRY) + # ------------------------------------------------- + + result = { + "status": "success", + "task": "strategy", + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + + # core outputs + "hook": hook, + "viral_score": viral_score, + "platforms": platforms, + + # structured sub-blocks (UI + publisher consumption) + "persona": DEFAULT_PERSONA, + + "retention_curve": retention_curve, + + "strategy": { + "recommended_length_sec": min(60, max(15, len(words) // 3)), + "hook_strength": "high" if viral_score > 75 else "medium", + "distribution_priority": platforms + } + } + + return result + + except Exception as e: + + return { + "status": "error", + "task": "strategy", + "batch_id": batch_id, + "message": str(e), + "stage": "strategy_failed" + } \ No newline at end of file diff --git a/publisher/tasks/subtitles.py b/publisher/tasks/subtitles.py new file mode 100644 index 0000000000000000000000000000000000000000..edfd45e7764053ec27b1102faf74eb9de0b6177d --- /dev/null +++ b/publisher/tasks/subtitles.py @@ -0,0 +1,131 @@ +""" +BASYX V11 — Subtitles Generator +Creates SRT subtitles from transcription +""" + +import os +import tempfile +import asyncio +from faster_whisper import WhisperModel +import httpx + + +# -------------------------------------------------- +# GLOBAL MODEL (shared) +# -------------------------------------------------- + +MODEL = WhisperModel( + model_size_or_path="base", + device="cpu", + compute_type="int8" +) + + +# -------------------------------------------------- +# HELPERS +# -------------------------------------------------- + +async def save_upload(file): + suffix = os.path.splitext(file.filename)[-1] + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + tmp.write(await file.read()) + tmp.close() + return tmp.name + + +async def download_url(url: str): + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") + + async with httpx.AsyncClient(timeout=300) as client: + async with client.stream("GET", url) as r: + r.raise_for_status() + async for chunk in r.aiter_bytes(): + tmp.write(chunk) + + tmp.close() + return tmp.name + + +def format_time(seconds: float): + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = int(seconds % 60) + ms = int((seconds - int(seconds)) * 1000) + + return f"{h:02}:{m:02}:{s:02},{ms:03}" + + +def build_srt(segments): + lines = [] + + for i, seg in enumerate(segments, start=1): + lines.append(str(i)) + lines.append( + f"{format_time(seg.start)} --> {format_time(seg.end)}" + ) + lines.append(seg.text.strip()) + lines.append("") + + return "\n".join(lines) + + +# -------------------------------------------------- +# MAIN TASK +# -------------------------------------------------- + +async def run(context): + + media_path = None + + try: + # ---------------- INPUT ---------------- + + if context.input_file: + media_path = await save_upload(context.input_file) + + elif context.url_input: + media_path = await download_url(context.url_input) + + else: + return { + "status": "error", + "message": "No input provided" + } + + # ---------------- TRANSCRIBE ---------------- + + segments, info = await asyncio.to_thread( + MODEL.transcribe, + media_path, + beam_size=5 + ) + + # ---------------- BUILD SRT ---------------- + + srt_text = build_srt(list(segments)) + + # Save file + srt_path = tempfile.NamedTemporaryFile( + delete=False, + suffix=".srt" + ).name + + with open(srt_path, "w", encoding="utf-8") as f: + f.write(srt_text) + + return { + "status": "success", + "language": info.language, + "srt_path": srt_path, + "preview": srt_text[:1000] + } + + except Exception as e: + return { + "status": "error", + "message": str(e) + } + + finally: + if media_path and os.path.exists(media_path): + os.remove(media_path) \ No newline at end of file diff --git a/publisher/tasks/transcribe.py b/publisher/tasks/transcribe.py new file mode 100644 index 0000000000000000000000000000000000000000..c2a5d4c89fda2c95dd40e7033adaa62808123198 --- /dev/null +++ b/publisher/tasks/transcribe.py @@ -0,0 +1,130 @@ +""" +BASYX V11 — Transcribe Task +Production Version +""" + +from faster_whisper import WhisperModel +import tempfile +import os +import httpx +import asyncio + +# -------------------------------------------------- +# GLOBAL MODEL (LOAD ONCE) +# -------------------------------------------------- + +MODEL = WhisperModel( + model_size_or_path="base", + device="cpu", + compute_type="int8" +) + + +# -------------------------------------------------- +# HELPERS +# -------------------------------------------------- + +async def save_upload(file): + """Save uploaded file to temp path""" + suffix = os.path.splitext(file.filename)[-1] + + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + tmp.write(await file.read()) + tmp.close() + + return tmp.name + + +async def download_url(url: str): + """Download media from URL safely""" + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") + + async with httpx.AsyncClient(timeout=300) as client: + async with client.stream("GET", url) as r: + r.raise_for_status() + async for chunk in r.aiter_bytes(): + tmp.write(chunk) + + tmp.close() + return tmp.name + + +def build_segments(segments): + """Normalize whisper segments""" + results = [] + + for seg in segments: + results.append({ + "start": round(seg.start, 2), + "end": round(seg.end, 2), + "text": seg.text.strip() + }) + + return results + + +# -------------------------------------------------- +# MAIN TASK ENTRYPOINT +# -------------------------------------------------- + +async def run(context): + """ + Expected context: + context.input_file + context.url_input + """ + + media_path = None + + try: + # ---------------------------- + # INPUT RESOLUTION + # ---------------------------- + + if context.input_file: + media_path = await save_upload(context.input_file) + + elif context.url_input: + media_path = await download_url(context.url_input) + + else: + return { + "status": "error", + "message": "No file or URL provided" + } + + # ---------------------------- + # TRANSCRIPTION + # ---------------------------- + + segments, info = await asyncio.to_thread( + MODEL.transcribe, + media_path, + beam_size=5 + ) + + segment_list = build_segments(segments) + + full_text = " ".join(s["text"] for s in segment_list) + + # ---------------------------- + # OUTPUT + # ---------------------------- + + return { + "status": "success", + "language": info.language, + "duration": info.duration, + "segments": segment_list, + "text": full_text + } + + except Exception as e: + return { + "status": "error", + "message": str(e) + } + + finally: + if media_path and os.path.exists(media_path): + os.remove(media_path) \ No newline at end of file diff --git a/publisher/tasks/viral_score.py b/publisher/tasks/viral_score.py new file mode 100644 index 0000000000000000000000000000000000000000..aed6223e0a82a71d49ee827cc5afa42eaf0252b7 --- /dev/null +++ b/publisher/tasks/viral_score.py @@ -0,0 +1,211 @@ +import uuid +from datetime import datetime + + +# ------------------------------------------------- +# CORE VIRAL SCORING ENGINE (DETERMINISTIC) +# ------------------------------------------------- + +def clamp(value, min_v=0, max_v=100): + return max(min_v, min(max_v, value)) + + +def base_score(words): + """ + Base score derived from transcript length and density. + """ + if not words: + return 45.0 + + length = len(words) + + # optimal short-form zone: 80–250 words + if 80 <= length <= 250: + return 78.0 + if 250 < length <= 400: + return 70.0 + if length < 80: + return 60.0 + + return 55.0 + + +def hook_boost(words): + """ + Early attention heuristic: first 10–15 words impact score. + """ + if not words: + return 0 + + first_chunk = words[:15] if isinstance(words, list) else [] + + # crude signal: presence of question / trigger words + trigger_words = {"why", "how", "what", "you", "stop", "never", "secret", "hack"} + + hits = sum(1 for w in first_chunk if w.lower() in trigger_words) + + return hits * 4 # max ~20 boost + + +def retention_penalty(words): + """ + Penalize overly long or low-density content. + """ + if not words: + return 5 + + length = len(words) + + if length > 500: + return 15 + if length > 350: + return 10 + if length < 50: + return 8 + + return 3 + + +def engagement_density(words): + """ + Measures repetition + punchy structure signals. + """ + if not words: + return 0 + + unique = len(set(words)) + total = len(words) + + if total == 0: + return 0 + + ratio = unique / total + + # lower repetition = better clarity + return clamp(ratio * 25, 0, 25) + + +# ------------------------------------------------- +# SEGMENT SCORING (FOR CLIPS) +# ------------------------------------------------- + +def score_segment(segment): + """ + Segment can be: + - list of words + - dict with 'words' + """ + if isinstance(segment, dict): + words = segment.get("words", []) + else: + words = segment or [] + + score = ( + base_score(words) + + hook_boost(words) + + engagement_density(words) + - retention_penalty(words) + ) + + return { + "score": round(clamp(score), 2), + "length": len(words) if words else 0, + "signal": "strong" if score > 80 else "medium" if score > 60 else "weak" + } + + +# ------------------------------------------------- +# MAIN ENTRYPOINT (REGISTRY COMPATIBLE) +# ------------------------------------------------- + +def run(context): + """ + Expected input: + { + "words": [...], + "segments": [...] + } + """ + + batch_id = str(uuid.uuid4()) + started_at = datetime.utcnow().isoformat() + + try: + + words = None + segments = [] + + if isinstance(context, dict): + words = context.get("words", []) + segments = context.get("segments", []) or [] + else: + words = getattr(context, "words", []) or [] + segments = getattr(context, "segments", []) or [] + + # ------------------------------------------------- + # GLOBAL SCORE + # ------------------------------------------------- + + score = ( + base_score(words) + + hook_boost(words) + + engagement_density(words) + - retention_penalty(words) + ) + + score = round(clamp(score), 2) + + # ------------------------------------------------- + # SEGMENT SCORES + # ------------------------------------------------- + + segment_scores = [] + for seg in segments: + segment_scores.append(score_segment(seg)) + + # fallback: if no segments provided, treat full video as one + if not segment_scores: + segment_scores = [score_segment(words)] + + # ------------------------------------------------- + # OUTPUT CONTRACT + # ------------------------------------------------- + + return { + "status": "success", + "task": "viral_score", + "batch_id": batch_id, + "started_at": started_at, + "completed_at": datetime.utcnow().isoformat(), + + # core + "viral_score": score, + + # breakdown + "breakdown": { + "base_score": round(base_score(words), 2), + "hook_boost": round(hook_boost(words), 2), + "engagement_density": round(engagement_density(words), 2), + "retention_penalty": round(retention_penalty(words), 2), + }, + + # UI + publisher consumption + "segments": segment_scores, + + # classification + "classification": ( + "high-viral" if score >= 80 + else "medium-viral" if score >= 60 + else "low-viral" + ) + } + + except Exception as e: + + return { + "status": "error", + "task": "viral_score", + "batch_id": batch_id, + "message": str(e), + "stage": "viral_score_failed" + } \ No newline at end of file diff --git a/publisher/thumbnail.py b/publisher/thumbnail.py new file mode 100644 index 0000000000000000000000000000000000000000..af830a8a8384b6630b05f098a648078191c35989 --- /dev/null +++ b/publisher/thumbnail.py @@ -0,0 +1,19 @@ +from publisher.ai.gemini_client import get_model + + +def generate_thumbnail(video_path): + + model = get_model() + + prompt = """ + Suggest BEST thumbnail concept: + - facial emotion + - text overlay + - color style + """ + + result = model.generate_content(prompt) + + return { + "thumbnail_strategy": result.text + } \ No newline at end of file diff --git a/publisher/thumbnail_engine.py b/publisher/thumbnail_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..bdb5e95287b8987a06ce62ef4c59d1f117724f7f --- /dev/null +++ b/publisher/thumbnail_engine.py @@ -0,0 +1,25 @@ +# publisher/thumbnail_engine.py + +from PIL import Image, ImageDraw, ImageFont +from pathlib import Path + + +def generate_thumbnail(text, output): + + img = Image.new("RGB", (1080,1080), "black") + + draw = ImageDraw.Draw(img) + + font_path = Path(__file__).resolve().parents[1] / "fonts" / "TikTok-Bold.ttf" + try: + font = ImageFont.truetype(str(font_path), 80) + except Exception: + font = ImageFont.load_default() + + draw.text((80,400), text[:60], font=font, fill="white") + + Path(output).parent.mkdir(exist_ok=True) + + img.save(output) + + return output diff --git a/publisher/token_refresher.py b/publisher/token_refresher.py new file mode 100644 index 0000000000000000000000000000000000000000..de75337c23140c37f52f55c8f1e029d1f4dfed37 --- /dev/null +++ b/publisher/token_refresher.py @@ -0,0 +1,28 @@ +# publisher/token_refresher.py + +import httpx +from publisher.oauth.storage import save_tokens + + +async def refresh_google(user_id, token): + + if "refresh_token" not in token: + return token + + async with httpx.AsyncClient() as client: + r = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "client_id": token["client_id"], + "client_secret": token["client_secret"], + "refresh_token": token["refresh_token"], + "grant_type": "refresh_token", + }, + ) + + new = r.json() + token.update(new) + + save_tokens(user_id, "google", token) + + return token \ No newline at end of file diff --git a/renderer/__init__.py b/renderer/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..769631dca38fb3f6b754d27fbafba0eca8bbe2f9 --- /dev/null +++ b/renderer/__init__.py @@ -0,0 +1,7 @@ +"""CPU-first video automation backend for Ava2lon Studio AI.""" + +from renderer.core.config import Settings +from renderer.core.models import RenderRequest, RenderResult +from renderer.core.render_engine import RenderEngine + +__all__ = ["RenderEngine", "RenderRequest", "RenderResult", "Settings"] diff --git a/renderer/audio/__init__.py b/renderer/audio/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bdadb10548b7d4e9c6e703ca6cf3dec6b77c47a2 --- /dev/null +++ b/renderer/audio/__init__.py @@ -0,0 +1,3 @@ +from renderer.audio.mixer import AudioMixer + +__all__ = ["AudioMixer"] diff --git a/renderer/audio/mixer.py b/renderer/audio/mixer.py new file mode 100644 index 0000000000000000000000000000000000000000..fafd9f5cf6eb7e2b608c4d9fe39717a3474be627 --- /dev/null +++ b/renderer/audio/mixer.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from pathlib import Path + +from renderer.ffmpeg.command import FFmpegCommand +from renderer.ffmpeg.runner import FFmpegRunner + + +class AudioMixer: + def __init__(self, runner: FFmpegRunner) -> None: + self.runner = runner + + def ducking_filter(self) -> str: + # sidechaincompress lowers background music while voiceover is active. + return ( + "[1:a]volume=0.316[music_quiet];" + "[music_quiet][2:a]sidechaincompress=threshold=0.02:ratio=8:attack=30:release=600[ducked];" + "[2:a]volume=1.0[voice];" + "[ducked][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]" + ) + + def mix( + self, + video: Path, + music: str | None, + voiceover: str | None, + output: Path, + normalize: bool = False, + *, + duration: float | None = None, + music_volume: float = 0.316, + music_fade_in: float = 0.0, + music_fade_out: float = 0.0, + music_loop: bool = True, + music_start: float = 0.0, + music_ducking: bool = True, + voice_volume: float = 1.0, + ) -> Path: + audio_tail = ",loudnorm=I=-16:TP=-1.5:LRA=11" if normalize else "" + if not music and not voiceover: + command = FFmpegCommand().add("-hide_banner").input(video).add("-c", "copy").overwrite().add(output).build() + self.runner.run(command) + return output + if voiceover and music: + music_filter = _music_filter( + music_volume, + fade_in=music_fade_in, + fade_out=music_fade_out, + duration=duration, + ) + if music_ducking: + ducking_filter = ( + f"[1:a]{music_filter}[music];" + "[music][2:a]sidechaincompress=threshold=0.02:ratio=8:attack=30:release=600[ducked];" + f"[2:a]volume={voice_volume}[voice];" + "[ducked][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]" + ) + else: + ducking_filter = ( + f"[1:a]{music_filter}[music];" + f"[2:a]volume={voice_volume}[voice];" + "[music][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]" + ) + if normalize: + ducking_filter += ";[aout]loudnorm=I=-16:TP=-1.5:LRA=11[anorm]" + audio_map = "[anorm]" + else: + audio_map = "[aout]" + command = ( + FFmpegCommand() + .add("-hide_banner") + .input(video) + .input(music, **_music_input_options(music_loop, music_start)) + .input(voiceover) + .add("-filter_complex", ducking_filter) + .add("-map", "0:v", "-map", audio_map, "-c:v", "copy", "-c:a", "aac", "-shortest") + .overwrite() + .add(output) + .build() + ) + else: + audio = voiceover or music + input_options = {} if voiceover else _music_input_options(music_loop, music_start) + volume = voice_volume if voiceover else music_volume + audio_filter = f"volume={volume}" + if music and not voiceover: + audio_filter = _music_filter(music_volume, fade_in=music_fade_in, fade_out=music_fade_out, duration=duration) + command = ( + FFmpegCommand() + .add("-hide_banner") + .input(video) + .input(audio, **input_options) + .add("-filter_complex", f"[1:a]{audio_filter}{audio_tail}[aout]") + .add("-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest") + .overwrite() + .add(output) + .build() + ) + self.runner.run(command) + return output + + +def _music_input_options(loop: bool, start: float) -> dict[str, object]: + options: dict[str, object] = {} + if loop: + options["stream_loop"] = -1 + if start > 0: + options["ss"] = start + return options + + +def _music_filter(volume: float, *, fade_in: float, fade_out: float, duration: float | None) -> str: + filters = [f"volume={volume}"] + if fade_in > 0: + filters.append(f"afade=t=in:st=0:d={fade_in}") + if fade_out > 0 and duration and duration > fade_out: + filters.append(f"afade=t=out:st={max(0.0, duration - fade_out):.3f}:d={fade_out}") + return ",".join(filters) diff --git a/renderer/core/__init__.py b/renderer/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..956aa7be3446b1e2c540d482ea1e95ed95cb3cea --- /dev/null +++ b/renderer/core/__init__.py @@ -0,0 +1 @@ +"""Core configuration, models, and orchestration.""" diff --git a/renderer/core/config.py b/renderer/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..a007b94aaf925f6c275c4bc9f7c06d217ef4b836 --- /dev/null +++ b/renderer/core/config.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_ROOT = Path.cwd() + + +@dataclass(frozen=True) +class Settings: + """Runtime settings tuned for small CPU-only Hugging Face Spaces.""" + + base_dir: Path = Path(os.getenv("AVA2LON_BASE_DIR", os.getenv("BASYX_BASE_DIR", str(DEFAULT_ROOT)))) + temp_dir: Path = Path(os.getenv("TEMP_DIR", str(DEFAULT_ROOT / "temp"))) + exports_dir: Path = Path(os.getenv("EXPORTS_DIR", str(DEFAULT_ROOT / "exports"))) + jobs_dir: Path = Path(os.getenv("JOBS_DIR", str(DEFAULT_ROOT / "jobs"))) + storage_dir: Path = Path(os.getenv("STORAGE_DIR", str(DEFAULT_ROOT / "storage"))) + metadata_cache: Path = Path(os.getenv("METADATA_CACHE", str(DEFAULT_ROOT / "temp" / "metadata_cache.json"))) + signing_secret: str = os.getenv("AVA2LON_SIGNING_SECRET", os.getenv("BASYX_SIGNING_SECRET", "dev-secret-change-me")) + api_key: str = os.getenv("AVA2LON_API_KEY", os.getenv("BASYX_API_KEY", "")) + font_path: Path = Path(os.getenv("FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf")) + output_width: int = int(os.getenv("OUTPUT_WIDTH", "1080")) + output_height: int = int(os.getenv("OUTPUT_HEIGHT", "1920")) + output_fps: int = int(os.getenv("OUTPUT_FPS", "30")) + ffmpeg_timeout_seconds: int = int(os.getenv("FFMPEG_TIMEOUT_SECONDS", "900")) + download_timeout_seconds: int = int(os.getenv("DOWNLOAD_TIMEOUT_SECONDS", "60")) + max_download_bytes: int = int(os.getenv("MAX_DOWNLOAD_BYTES", str(500 * 1024 * 1024))) + allow_private_asset_urls: bool = os.getenv("ALLOW_PRIVATE_ASSET_URLS", "false").lower() == "true" + whisper_model_size: str = os.getenv("WHISPER_MODEL_SIZE", "tiny") + whisper_compute_type: str = os.getenv("WHISPER_COMPUTE_TYPE", "int8") + whisper_device: str = os.getenv("WHISPER_DEVICE", "cpu") + whisper_model_dir: Path = Path(os.getenv("WHISPER_MODEL_DIR", str(DEFAULT_ROOT / "models"))) + max_retries: int = int(os.getenv("MAX_RETRIES", "3")) + max_workers: int = int(os.getenv("MAX_RENDER_WORKERS", "1")) + job_retention_seconds: int = int(os.getenv("JOB_RETENTION_SECONDS", str(24 * 3600))) + crf: int = int(os.getenv("OUTPUT_CRF", "23")) + preset: str = os.getenv("OUTPUT_PRESET", "veryfast") + + def ensure_dirs(self) -> None: + for directory in ( + self.temp_dir, + self.exports_dir, + self.jobs_dir, + self.storage_dir, + self.metadata_cache.parent, + self.whisper_model_dir, + ): + directory.mkdir(parents=True, exist_ok=True) diff --git a/renderer/core/ingest.py b/renderer/core/ingest.py new file mode 100644 index 0000000000000000000000000000000000000000..9ff4018a3220f5350ce053c1dcdda66a9dfd7a58 --- /dev/null +++ b/renderer/core/ingest.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import ipaddress +import mimetypes +import shutil +import socket +from dataclasses import replace +from pathlib import Path +from urllib.parse import unquote, urlparse +from urllib.request import Request, urlopen + +from renderer.core.config import Settings +from renderer.core.models import AIReelsRequest, RenderRequest, Scene +from renderer.core.utils import safe_filename + + +class IngestError(ValueError): + pass + + +class AssetIngestor: + """Resolve local paths and remote URLs into job-local files.""" + + def __init__(self, settings: Settings) -> None: + self.settings = settings + + def resolve_render_request(self, request: RenderRequest, workdir: Path) -> RenderRequest: + return replace( + request, + scenes=[ + replace(scene, media=str(self.resolve(scene.media, workdir / "inputs", f"scene_{idx:03d}"))) + for idx, scene in enumerate(request.scenes) + ], + voiceover=self.resolve_optional(request.voiceover, workdir / "inputs", "voiceover"), + background_music=self.resolve_optional(request.background_music, workdir / "inputs", "music"), + ) + + def resolve_ai_reels_request(self, request: AIReelsRequest, workdir: Path) -> AIReelsRequest: + return replace( + request, + voiceover=str(self.resolve(request.voiceover, workdir / "inputs", "voiceover")), + assets=[str(self.resolve(asset, workdir / "inputs", f"asset_{idx:03d}")) for idx, asset in enumerate(request.assets)], + background_music=self.resolve_optional(request.background_music, workdir / "inputs", "music"), + ) + + def resolve_optional(self, value: str | None, directory: Path, stem: str) -> str | None: + if not value: + return None + return str(self.resolve(value, directory, stem)) + + def resolve(self, value: str, directory: Path, stem: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + if is_remote_url(value): + return self.download(value, directory, stem) + path = Path(value) + if not path.exists(): + raise IngestError(f"Asset does not exist: {value}") + return path + + def download(self, url: str, directory: Path, stem: str) -> Path: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise IngestError("Only http and https asset URLs are supported") + if not self.settings.allow_private_asset_urls: + _reject_private_host(parsed.hostname) + request = Request(url, headers={"User-Agent": "basyx-ffmpeg-renderer/1.0"}) + with urlopen(request, timeout=self.settings.download_timeout_seconds) as response: + content_length = response.headers.get("Content-Length") + if content_length and int(content_length) > self.settings.max_download_bytes: + raise IngestError("Remote asset exceeds MAX_DOWNLOAD_BYTES") + suffix = _suffix_from_response(url, response.headers.get("Content-Type")) + target = directory / safe_filename(f"{stem}{suffix}") + total = 0 + with target.open("wb") as output: + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > self.settings.max_download_bytes: + target.unlink(missing_ok=True) + raise IngestError("Remote asset exceeds MAX_DOWNLOAD_BYTES") + output.write(chunk) + return target + + +def stage_upload(source: Path, uploads_dir: Path, filename: str) -> Path: + uploads_dir.mkdir(parents=True, exist_ok=True) + target = uploads_dir / safe_filename(filename) + if source.resolve() != target.resolve(): + shutil.copy2(source, target) + return target + + +def is_remote_url(value: str) -> bool: + return urlparse(value).scheme in {"http", "https"} + + +def _suffix_from_response(url: str, content_type: str | None) -> str: + path_suffix = Path(unquote(urlparse(url).path)).suffix + if path_suffix: + return path_suffix[:16] + if content_type: + guessed = mimetypes.guess_extension(content_type.split(";", 1)[0].strip()) + if guessed: + return guessed + return ".bin" + + +def _reject_private_host(hostname: str) -> None: + try: + addresses = socket.getaddrinfo(hostname, None) + except socket.gaierror as exc: + raise IngestError(f"Could not resolve host: {hostname}") from exc + for address in addresses: + ip = ipaddress.ip_address(address[4][0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast: + raise IngestError("Private, loopback, link-local, and multicast asset hosts are not allowed") diff --git a/renderer/core/models.py b/renderer/core/models.py new file mode 100644 index 0000000000000000000000000000000000000000..77e96f5c469449933c5dbf091666141ba1e813d6 --- /dev/null +++ b/renderer/core/models.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +JobState = Literal["PENDING", "RUNNING", "FAILED", "COMPLETED", "CANCEL_REQUESTED", "CANCELLED"] + + +@dataclass +class Scene: + start: float + duration: float + media: str + caption: str = "" + transition: str = "fade" + background: str = "blur" + layout: str = "fill" + effect: str | None = None + + +@dataclass +class RenderRequest: + scenes: list[Scene] + template: str = "tiktok_classic" + preset: str | None = None + creative_style: str | None = None + platform: str | None = None + output_name: str = "render.mp4" + voiceover: str | None = None + background_music: str | None = None + music_volume: float = 0.316 + music_fade_in: float = 0.0 + music_fade_out: float = 0.0 + music_loop: bool = True + music_start: float = 0.0 + music_ducking: bool = True + voice_volume: float = 1.0 + subtitle_format: Literal["srt", "ass"] = "ass" + auto_subtitles: bool = False + subtitle_language: str | None = None + whisper_model_size: str | None = None + preview: bool = False + audio_normalize: bool = False + watermark: str | None = None + watermark_position: str = "bottom-right" + intro: str | None = None + outro: str | None = None + callback_url: str | None = None + export_target: str | None = None + priority: int = 0 + scheduled_at: float | None = None + normalize: bool = True + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class AIReelsRequest: + script: str + voiceover: str + assets: list[str] + template: str = "tiktok_classic" + creative_style: str | None = None + platform: str | None = None + output_name: str = "ai_reel.mp4" + background_music: str | None = None + music_volume: float = 0.316 + music_fade_in: float = 0.0 + music_fade_out: float = 0.0 + music_loop: bool = True + music_start: float = 0.0 + music_ducking: bool = True + voice_volume: float = 1.0 + + +@dataclass +class RenderResult: + output_path: Path + commands: list[list[str]] + metrics: dict[str, Any] + logs: list[str] = field(default_factory=list) + + +@dataclass +class TaskResult: + output_path: Path | None = None + commands: list[list[str]] = field(default_factory=list) + metrics: dict[str, Any] = field(default_factory=dict) + logs: list[str] = field(default_factory=list) + + +@dataclass +class AssetMetadata: + path: str + mime_type: str + size_bytes: int + mtime: float + duration: float = 0.0 + width: int | None = None + height: int | None = None + fps: float | None = None + bitrate: int | None = None + video_codec: str | None = None + audio_codec: str | None = None + has_audio: bool = False + streams: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class JobRecord: + job_id: str + state: JobState + created_at: float + updated_at: float + output_path: str | None = None + download_token: str | None = None + callback_url: str | None = None + export_target: str | None = None + export_path: str | None = None + failure_reason: str | None = None + commands: list[list[str]] = field(default_factory=list) + logs: list[str] = field(default_factory=list) + metrics: dict[str, Any] = field(default_factory=dict) diff --git a/renderer/core/render_engine.py b/renderer/core/render_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..cd320d2299eedbd12578a075a7d6fea9ccc9450c --- /dev/null +++ b/renderer/core/render_engine.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import mimetypes +import shutil +import time +from pathlib import Path + +from renderer.audio import AudioMixer +from renderer.core.config import Settings +from renderer.core.ingest import AssetIngestor +from renderer.core.models import AIReelsRequest, RenderRequest, RenderResult +from renderer.core.utils import cleanup_directory, temp_workdir +from renderer.exports import ExportManager +from renderer.ffmpeg.assets import AssetProbe +from renderer.ffmpeg.command import FFmpegCommand +from renderer.ffmpeg.normalize import Normalizer +from renderer.ffmpeg.runner import FFmpegRunner +from renderer.scenes import Timeline +from renderer.subtitles import SubtitleGenerator +from renderer.templates import PlatformProfile, get_creative_style, get_platform_profile, scene_effect_filter +from renderer.transcription import WhisperTranscriber +from renderer.transitions import TransitionBuilder + + +class RenderEngine: + def __init__(self, settings: Settings | None = None, log=None) -> None: + self.settings = settings or Settings() + self.settings.ensure_dirs() + self._commands: list[list[str]] = [] + self._logs: list[str] = [] + self.runner = FFmpegRunner(self.settings.ffmpeg_timeout_seconds, log=log, on_command=self._record_command) + self.ingest = AssetIngestor(self.settings) + self.assets = AssetProbe(self.settings.metadata_cache) + self.normalizer = Normalizer(self.settings, self.runner) + self.subtitles = SubtitleGenerator() + self.transcriber = WhisperTranscriber(self.settings) + self.transitions = TransitionBuilder() + self.audio = AudioMixer(self.runner) + self.exports = ExportManager(self.settings.exports_dir) + + def render(self, request: RenderRequest, job_id: str) -> RenderResult: + self._commands = [] + self._logs = [] + started = time.time() + timeline = Timeline(request.scenes) + with temp_workdir(self.settings.temp_dir, job_id) as work: + workdir = Path(work) + resolved = self.ingest.resolve_render_request(request, workdir) + timeline = Timeline(resolved.scenes) + prepared = self._prepare_scene_media(resolved, workdir) + subtitles = self._write_subtitles(resolved, timeline, workdir) + video = self._compose_video(prepared, resolved, subtitles, workdir) + mixed = self.audio.mix( + video, + resolved.background_music, + resolved.voiceover, + workdir / "mixed.mp4", + normalize=resolved.audio_normalize, + duration=timeline.total_duration, + music_volume=resolved.music_volume, + music_fade_in=resolved.music_fade_in, + music_fade_out=resolved.music_fade_out, + music_loop=resolved.music_loop, + music_start=resolved.music_start, + music_ducking=resolved.music_ducking, + voice_volume=resolved.voice_volume, + ) + mixed = self._apply_watermark(mixed, resolved, workdir) + optimized = self._optimize_for_platform(mixed, resolved, workdir) + output = self.exports.save(optimized, job_id, request.output_name) + cleanup_directory(workdir, keep={mixed}) + profile = self._profile(request) + metrics = { + "render_time_seconds": round(time.time() - started, 3), + "output_size_bytes": output.stat().st_size, + "scene_count": len(request.scenes), + "creative_style": request.creative_style or request.metadata.get("creative_style"), + "scene_effects": [scene.effect for scene in request.scenes if scene.effect], + "platform": profile.metadata(), + } + return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs)) + + def ai_reels(self, request: AIReelsRequest, job_id: str) -> RenderResult: + if not request.voiceover: + raise ValueError("AI reels v1 requires a provided voiceover path; TTS is pluggable but not bundled.") + if not request.assets: + raise ValueError("AI reels rendering requires at least one visual asset.") + with temp_workdir(self.settings.temp_dir, job_id) as work: + workdir = Path(work) + resolved = self.ingest.resolve_ai_reels_request(request, workdir) + style = get_creative_style(resolved.creative_style) + voice_meta = self.assets.probe(resolved.voiceover) + duration = max(voice_meta.duration, len(resolved.script.split()) * 0.35, style.scene_duration * len(resolved.assets), 3.0) + per_scene = duration / max(1, len(resolved.assets)) + captions = _split_script(resolved.script, len(resolved.assets)) + scenes = [ + { + "start": round(idx * per_scene, 3), + "duration": round(per_scene, 3), + "media": asset, + "caption": captions[idx] if idx < len(captions) else "", + "transition": style.transition_sequence[idx % len(style.transition_sequence)], + "effect": style.scene_effect_sequence[idx % len(style.scene_effect_sequence)], + } + for idx, asset in enumerate(resolved.assets) + ] + render_request = RenderRequest( + scenes=Timeline.request_from_payload({"scenes": scenes}).scenes, + template=resolved.template, + platform=resolved.platform, + output_name=resolved.output_name, + voiceover=resolved.voiceover, + background_music=resolved.background_music, + music_volume=resolved.music_volume, + music_fade_in=resolved.music_fade_in, + music_fade_out=resolved.music_fade_out, + music_loop=resolved.music_loop, + music_start=resolved.music_start, + music_ducking=resolved.music_ducking, + voice_volume=resolved.voice_volume, + creative_style=resolved.creative_style, + metadata={"creative_style": style.key, "creative_style_label": style.label}, + ) + return self._render_resolved(render_request, job_id, workdir) + + def inspect_asset(self, path: str | Path) -> dict: + return self.assets.probe(path).__dict__ + + def transcribe( + self, + audio_path: str | Path, + *, + model_size: str | None = None, + language: str | None = None, + task: str = "transcribe", + beam_size: int = 5, + vad_filter: bool = True, + word_timestamps: bool = True, + ) -> dict: + return self.transcriber.transcribe( + audio_path, + model_size=model_size, + language=language, + task=task, + beam_size=beam_size, + vad_filter=vad_filter, + word_timestamps=word_timestamps, + ).as_dict() + + def _render_resolved(self, request: RenderRequest, job_id: str, workdir: Path) -> RenderResult: + self._commands = [] + self._logs = [] + started = time.time() + timeline = Timeline(request.scenes) + prepared = self._prepare_scene_media(request, workdir) + subtitles = self._write_subtitles(request, timeline, workdir) + video = self._compose_video(prepared, request, subtitles, workdir) + mixed = self.audio.mix( + video, + request.background_music, + request.voiceover, + workdir / "mixed.mp4", + normalize=request.audio_normalize, + duration=timeline.total_duration, + music_volume=request.music_volume, + music_fade_in=request.music_fade_in, + music_fade_out=request.music_fade_out, + music_loop=request.music_loop, + music_start=request.music_start, + music_ducking=request.music_ducking, + voice_volume=request.voice_volume, + ) + mixed = self._apply_watermark(mixed, request, workdir) + optimized = self._optimize_for_platform(mixed, request, workdir) + output = self.exports.save(optimized, job_id, request.output_name) + profile = self._profile(request) + metrics = { + "render_time_seconds": round(time.time() - started, 3), + "output_size_bytes": output.stat().st_size, + "scene_count": len(request.scenes), + "creative_style": request.creative_style or request.metadata.get("creative_style"), + "scene_effects": [scene.effect for scene in request.scenes if scene.effect], + "platform": profile.metadata(), + } + return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs)) + + def _prepare_scene_media(self, request: RenderRequest, workdir: Path) -> list[Path]: + prepared: list[Path] = [] + profile = self._profile(request) + for idx, scene in enumerate(request.scenes): + source = Path(scene.media) + metadata = self.assets.probe(source) + target = workdir / f"scene_{idx:03d}.mp4" + mime_type = metadata.mime_type or mimetypes.guess_type(str(source))[0] or "" + if mime_type.startswith("image/") or source.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".gif"}: + self.normalizer.image_to_video(source, target, scene.duration, profile=profile) + elif request.normalize and self.normalizer.needs_normalization(metadata, profile=profile): + self.normalizer.normalize(source, target, scene.duration, profile=profile) + else: + shutil.copy2(source, target) + if request.preview: + preview = workdir / f"scene_{idx:03d}_preview.mp4" + self._scale_preview(target, preview) + target = preview + effected = self._apply_scene_effect(target, scene.effect, workdir / f"scene_{idx:03d}_effect.mp4") + if effected != target: + target = effected + prepared.append(target) + return prepared + + def _write_subtitles(self, request: RenderRequest, timeline: Timeline, workdir: Path) -> Path | None: + if request.auto_subtitles and request.voiceover: + transcript = self.transcriber.transcribe( + request.voiceover, + model_size=request.whisper_model_size, + language=request.subtitle_language, + word_timestamps=True, + ) + events = transcript.subtitle_events(prefer_words=True) + else: + events = self.subtitles.from_scenes(request.scenes, timeline.total_duration) + if not events: + return None + if request.subtitle_format == "srt": + return self.subtitles.write_srt(events, workdir / "captions.srt") + return self.subtitles.write_ass(events, workdir / "captions.ass", request.template) + + def _compose_video(self, scenes: list[Path], request: RenderRequest, subtitle_path: Path | None, workdir: Path) -> Path: + if len(scenes) == 1: + composed = workdir / "composed.mp4" + shutil.copy2(scenes[0], composed) + else: + composed = workdir / "composed.mp4" + durations = [scene.duration for scene in request.scenes] + transitions = [scene.transition for scene in request.scenes] + filter_graph, final_stream = self.transitions.xfade_chain(len(scenes), durations, transitions) + cmd = FFmpegCommand().add("-hide_banner") + for scene in scenes: + cmd.input(scene) + cmd.add("-filter_complex", filter_graph) + cmd.add("-map", final_stream, "-an", "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf) + command = cmd.overwrite().add(composed).build() + self._run(command) + if subtitle_path: + subtitled = workdir / "subtitled.mp4" + escaped = _ffmpeg_subtitle_path(subtitle_path) + command = ( + FFmpegCommand() + .add("-hide_banner") + .input(composed) + .add("-vf", f"subtitles='{escaped}'", "-c:a", "copy", "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf) + .overwrite() + .add(subtitled) + .build() + ) + self._run(command) + return subtitled + return composed + + def _apply_watermark(self, video: Path, request: RenderRequest, workdir: Path) -> Path: + if not request.watermark: + return video + watermark = Path(request.watermark) + if not watermark.exists(): + self._logs.append(f"Watermark skipped; file not found: {watermark}") + return video + output = workdir / "watermarked.mp4" + position = { + "top-left": "20:20", + "top-right": "W-w-20:20", + "bottom-left": "20:H-h-20", + "bottom-right": "W-w-20:H-h-20", + "center": "(W-w)/2:(H-h)/2", + }.get(request.watermark_position, "W-w-20:H-h-20") + command = ( + FFmpegCommand() + .add("-hide_banner") + .input(video) + .input(watermark) + .add("-filter_complex", f"[1:v]scale=iw*0.22:-1[wm];[0:v][wm]overlay={position}") + .add("-c:a", "copy", "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf) + .overwrite() + .add(output) + .build() + ) + self._run(command) + return output + + def _optimize_for_platform(self, video: Path, request: RenderRequest, workdir: Path) -> Path: + profile = self._profile(request) + output = workdir / "platform_optimized.mp4" + vf = ( + f"scale={profile.width}:{profile.height}:force_original_aspect_ratio=increase," + f"crop={profile.width}:{profile.height},fps={profile.fps},format=yuv420p" + ) + command_builder = ( + FFmpegCommand() + .add("-hide_banner") + .input(video) + .add( + "-vf", + vf, + "-c:v", + profile.video_codec, + "-profile:v", + "high", + "-pix_fmt", + "yuv420p", + "-preset", + self.settings.preset, + "-crf", + profile.crf, + "-r", + profile.fps, + "-g", + max(1, profile.fps * 2), + ) + ) + if profile.maxrate: + command_builder.add("-maxrate", profile.maxrate) + if profile.bufsize: + command_builder.add("-bufsize", profile.bufsize) + command = ( + command_builder.add( + "-c:a", + profile.audio_codec, + "-b:a", + profile.audio_bitrate, + "-ar", + profile.audio_sample_rate, + "-ac", + "2", + "-movflags", + "+faststart", + "-shortest", + ) + .overwrite() + .add(output) + .build() + ) + self._run(command) + return output + + def _profile(self, request: RenderRequest) -> PlatformProfile: + profile_key = request.platform or request.metadata.get("platform") or request.metadata.get("target_platform") + return get_platform_profile(str(profile_key) if profile_key else None) + + + def _scale_preview(self, source: Path, output: Path) -> None: + command = ( + FFmpegCommand() + .add("-hide_banner") + .input(source) + .add("-vf", "scale=540:960:force_original_aspect_ratio=decrease,pad=540:960:(ow-iw)/2:(oh-ih)/2") + .add("-c:v", "libx264", "-preset", "ultrafast", "-crf", "30", "-c:a", "aac") + .overwrite() + .add(output) + .build() + ) + self._run(command) + + def _apply_scene_effect(self, source: Path, effect: str | None, output: Path) -> Path: + vf = scene_effect_filter(effect) + if not vf: + return source + command = ( + FFmpegCommand() + .add("-hide_banner") + .input(source) + .add("-vf", vf, "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf, "-c:a", "copy") + .overwrite() + .add(output) + .build() + ) + self._run(command) + self._logs.append(f"Applied scene effect '{effect}' to {source.name}") + return output + + def _run(self, command: list[str]) -> None: + result = self.runner.run(command) + if result.stderr: + self._logs.append(result.stderr[-4000:]) + + def _record_command(self, command: list[str]) -> None: + self._commands.append(command) + + +def _split_script(script: str, chunks: int) -> list[str]: + words = script.split() + if chunks <= 0: + return [] + size = max(1, round(len(words) / chunks)) + return [" ".join(words[i : i + size]) for i in range(0, len(words), size)][:chunks] + + +def _ffmpeg_subtitle_path(path: Path) -> str: + return str(path).replace("\\", "/").replace(":", r"\:") diff --git a/renderer/core/security.py b/renderer/core/security.py new file mode 100644 index 0000000000000000000000000000000000000000..6e73b2134aa4012eeb8b4b411ec1a59993c4aaaa --- /dev/null +++ b/renderer/core/security.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import hmac +import secrets + + +def create_download_token(secret: str, job_id: str) -> str: + nonce = secrets.token_urlsafe(18) + signature = hmac.digest(secret.encode("utf-8"), f"{job_id}:{nonce}".encode("utf-8"), "sha256").hex() + return f"{nonce}.{signature}" + + +def verify_download_token(secret: str, job_id: str, token: str | None) -> bool: + if not token or "." not in token: + return False + nonce, signature = token.split(".", 1) + expected = hmac.digest(secret.encode("utf-8"), f"{job_id}:{nonce}".encode("utf-8"), "sha256").hex() + return hmac.compare_digest(signature, expected) diff --git a/renderer/core/utils.py b/renderer/core/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..47dfcc531a3c8a7f75ce353ae4db0b786df3f7e2 --- /dev/null +++ b/renderer/core/utils.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +import re +import shutil +import tempfile +import time +import uuid +from dataclasses import asdict, is_dataclass +from pathlib import Path +from typing import Any + + +def new_id(prefix: str = "job") -> str: + return f"{prefix}_{uuid.uuid4().hex[:12]}" + + +def safe_filename(name: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._") + return cleaned or "render.mp4" + + +def write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + serializable = asdict(data) if is_dataclass(data) else data + path.write_text(json.dumps(serializable, indent=2, default=str), encoding="utf-8") + + +def read_json(path: Path, default: Any) -> Any: + if not path.exists(): + return default + return json.loads(path.read_text(encoding="utf-8")) + + +def now() -> float: + return time.time() + + +def temp_workdir(root: Path, prefix: str) -> tempfile.TemporaryDirectory[str]: + root.mkdir(parents=True, exist_ok=True) + return tempfile.TemporaryDirectory(prefix=f"{prefix}_", dir=str(root)) + + +def cleanup_directory(path: Path, keep: set[Path] | None = None) -> None: + keep = {p.resolve() for p in (keep or set())} + if not path.exists(): + return + for child in path.iterdir(): + if child.resolve() in keep: + continue + if child.is_dir(): + shutil.rmtree(child, ignore_errors=True) + else: + child.unlink(missing_ok=True) diff --git a/renderer/exports/__init__.py b/renderer/exports/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5b7a38a115d13fc7f5dcff93549f99f03b97e233 --- /dev/null +++ b/renderer/exports/__init__.py @@ -0,0 +1,3 @@ +from renderer.exports.manager import ExportManager + +__all__ = ["ExportManager"] diff --git a/renderer/exports/manager.py b/renderer/exports/manager.py new file mode 100644 index 0000000000000000000000000000000000000000..ff9197f6342a28fb25fba32fe1a4a24d3e976b65 --- /dev/null +++ b/renderer/exports/manager.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from renderer.core.utils import safe_filename + + +class ExportManager: + def __init__(self, exports_dir: Path) -> None: + self.exports_dir = exports_dir + self.exports_dir.mkdir(parents=True, exist_ok=True) + + def save(self, source: Path, job_id: str, output_name: str) -> Path: + filename = safe_filename(output_name) + if not filename.lower().endswith(".mp4"): + filename += ".mp4" + target = self.exports_dir / f"{job_id}_{filename}" + shutil.copy2(source, target) + return target diff --git a/renderer/ffmpeg/__init__.py b/renderer/ffmpeg/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c855700c271c1c8b2ad80895f8192ec3df694d9c --- /dev/null +++ b/renderer/ffmpeg/__init__.py @@ -0,0 +1 @@ +"""FFmpeg subprocess and filter graph helpers.""" diff --git a/renderer/ffmpeg/assets.py b/renderer/ffmpeg/assets.py new file mode 100644 index 0000000000000000000000000000000000000000..2a42bdc65396fe67861b108f4e0af3fee28c69c7 --- /dev/null +++ b/renderer/ffmpeg/assets.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +import mimetypes +import subprocess +from dataclasses import asdict +from pathlib import Path + +from renderer.core.models import AssetMetadata +from renderer.core.utils import read_json, write_json + + +class AssetProbe: + def __init__(self, cache_path: Path) -> None: + self.cache_path = cache_path + self.cache: dict[str, dict] = read_json(cache_path, {}) + + def probe(self, path: str | Path) -> AssetMetadata: + media = Path(path) + stat = media.stat() + key = str(media.resolve()) + cached = self.cache.get(key) + if cached and cached.get("size_bytes") == stat.st_size and cached.get("mtime") == stat.st_mtime: + return AssetMetadata(**cached) + + raw = self._ffprobe(media) + streams = raw.get("streams", []) + fmt = raw.get("format", {}) + video_stream = next((s for s in streams if s.get("codec_type") == "video"), {}) + audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), {}) + metadata = AssetMetadata( + path=str(media), + mime_type=mimetypes.guess_type(str(media))[0] or "application/octet-stream", + size_bytes=stat.st_size, + mtime=stat.st_mtime, + duration=float(fmt.get("duration") or video_stream.get("duration") or audio_stream.get("duration") or 0), + width=_int_or_none(video_stream.get("width")), + height=_int_or_none(video_stream.get("height")), + fps=_parse_fps(video_stream.get("avg_frame_rate") or video_stream.get("r_frame_rate")), + bitrate=_int_or_none(fmt.get("bit_rate")), + video_codec=video_stream.get("codec_name"), + audio_codec=audio_stream.get("codec_name"), + has_audio=bool(audio_stream), + streams=streams, + ) + self.cache[key] = asdict(metadata) + write_json(self.cache_path, self.cache) + return metadata + + @staticmethod + def _ffprobe(path: Path) -> dict: + command = [ + "ffprobe", + "-v", + "error", + "-show_format", + "-show_streams", + "-print_format", + "json", + str(path), + ] + result = subprocess.run(command, check=True, capture_output=True, text=True) + return json.loads(result.stdout or "{}") + + +def _parse_fps(value: str | None) -> float | None: + if not value or value == "0/0": + return None + if "/" in value: + num, den = value.split("/", 1) + den_f = float(den) + return float(num) / den_f if den_f else None + return float(value) + + +def _int_or_none(value: object) -> int | None: + if value in (None, ""): + return None + return int(value) diff --git a/renderer/ffmpeg/command.py b/renderer/ffmpeg/command.py new file mode 100644 index 0000000000000000000000000000000000000000..e96ee030fbfba0809d9fd7c75764e89c8501d842 --- /dev/null +++ b/renderer/ffmpeg/command.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class FFmpegCommand: + args: list[str] = field(default_factory=lambda: ["ffmpeg"]) + + def add(self, *args: object) -> "FFmpegCommand": + self.args.extend(str(arg) for arg in args) + return self + + def input(self, path: str | Path, **options: object) -> "FFmpegCommand": + for key, value in options.items(): + self.add(f"-{key}", value) + self.add("-i", path) + return self + + def overwrite(self) -> "FFmpegCommand": + self.add("-y") + return self + + def build(self) -> list[str]: + return list(self.args) diff --git a/renderer/ffmpeg/normalize.py b/renderer/ffmpeg/normalize.py new file mode 100644 index 0000000000000000000000000000000000000000..8820774b315c61909c791c1b306bce810912836b --- /dev/null +++ b/renderer/ffmpeg/normalize.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from pathlib import Path + +from renderer.core.config import Settings +from renderer.core.models import AssetMetadata +from renderer.ffmpeg.command import FFmpegCommand +from renderer.ffmpeg.runner import FFmpegRunner +from renderer.templates import PlatformProfile + + +class Normalizer: + def __init__(self, settings: Settings, runner: FFmpegRunner) -> None: + self.settings = settings + self.runner = runner + + def needs_normalization(self, metadata: AssetMetadata, profile: PlatformProfile | None = None) -> bool: + width = profile.width if profile else self.settings.output_width + height = profile.height if profile else self.settings.output_height + fps = profile.fps if profile else self.settings.output_fps + return not ( + metadata.width == width + and metadata.height == height + and round(metadata.fps or 0) == fps + and metadata.video_codec == "h264" + and (metadata.audio_codec in ("aac", None)) + ) + + def normalize(self, path: str | Path, output: Path, duration: float | None = None, profile: PlatformProfile | None = None) -> Path: + width = profile.width if profile else self.settings.output_width + height = profile.height if profile else self.settings.output_height + fps = profile.fps if profile else self.settings.output_fps + crf = profile.crf if profile else self.settings.crf + vf = ( + f"scale={width}:{height}:" + "force_original_aspect_ratio=increase," + f"crop={width}:{height}," + f"fps={fps},format=yuv420p" + ) + cmd = ( + FFmpegCommand() + .add("-hide_banner") + .input(path) + .add("-vf", vf, "-c:v", "libx264", "-preset", self.settings.preset, "-crf", crf) + .add("-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart") + ) + if duration: + cmd.add("-t", duration) + command = cmd.overwrite().add(output).build() + self.runner.run(command) + return output + + def image_to_video(self, path: str | Path, output: Path, duration: float, profile: PlatformProfile | None = None) -> Path: + width = profile.width if profile else self.settings.output_width + height = profile.height if profile else self.settings.output_height + fps = profile.fps if profile else self.settings.output_fps + crf = profile.crf if profile else self.settings.crf + vf = ( + f"scale={width}:{height}:" + "force_original_aspect_ratio=increase," + f"crop={width}:{height}," + f"fps={fps},format=yuv420p" + ) + command = ( + FFmpegCommand() + .add("-hide_banner", "-loop", "1", "-t", duration) + .input(path) + .add("-vf", vf, "-an", "-c:v", "libx264", "-preset", self.settings.preset, "-crf", crf) + .overwrite() + .add(output) + .build() + ) + self.runner.run(command) + return output diff --git a/renderer/ffmpeg/runner.py b/renderer/ffmpeg/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..710a2f87615ecfc7cb1d40769d7ffdaaa4338a08 --- /dev/null +++ b/renderer/ffmpeg/runner.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +try: + import psutil +except Exception: # pragma: no cover - optional runtime dependency fallback + psutil = None + + +class FFmpegError(RuntimeError): + def __init__(self, message: str, command: list[str], stderr: str = "") -> None: + super().__init__(message) + self.command = command + self.stderr = stderr + + +@dataclass +class CommandResult: + command: list[str] + returncode: int + duration_seconds: float + stdout: str = "" + stderr: str = "" + metrics: dict[str, float | int] = field(default_factory=dict) + + +class FFmpegRunner: + def __init__( + self, + timeout_seconds: int = 900, + log: Callable[[str], None] | None = None, + on_command: Callable[[list[str]], None] | None = None, + ) -> None: + self.timeout_seconds = timeout_seconds + self.log = log or (lambda _: None) + self.on_command = on_command or (lambda _: None) + + def run(self, command: list[str], cwd: Path | None = None) -> CommandResult: + started = time.time() + self.on_command(command) + self.log("$ " + " ".join(command)) + process = subprocess.Popen( + command, + cwd=str(cwd) if cwd else None, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + peak_rss = 0 + cpu_percent = 0.0 + proc = psutil.Process(process.pid) if psutil else None + try: + stdout, stderr = process.communicate(timeout=self.timeout_seconds) + if proc: + try: + peak_rss = max(peak_rss, proc.memory_info().rss) + cpu_percent = proc.cpu_percent(interval=None) + except Exception: + pass + except subprocess.TimeoutExpired as exc: + self._kill_process(process) + stdout, stderr = process.communicate() + raise FFmpegError(f"FFmpeg timed out after {self.timeout_seconds}s", command, stderr) from exc + + duration = time.time() - started + result = CommandResult( + command=command, + returncode=process.returncode, + duration_seconds=duration, + stdout=stdout, + stderr=stderr, + metrics={"duration_seconds": duration, "peak_rss_bytes": peak_rss, "cpu_percent": cpu_percent}, + ) + if process.returncode != 0: + raise FFmpegError("FFmpeg failed", command, stderr) + return result + + @staticmethod + def _kill_process(process: subprocess.Popen[str]) -> None: + if psutil: + try: + parent = psutil.Process(process.pid) + for child in parent.children(recursive=True): + child.kill() + parent.kill() + return + except Exception: + pass + process.kill() diff --git a/renderer/jobs/__init__.py b/renderer/jobs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..54783309519e3cd34c2e565657b929be1927000e --- /dev/null +++ b/renderer/jobs/__init__.py @@ -0,0 +1,3 @@ +from renderer.jobs.manager import JobManager + +__all__ = ["JobManager"] diff --git a/renderer/jobs/manager.py b/renderer/jobs/manager.py new file mode 100644 index 0000000000000000000000000000000000000000..671730aed8438dada68904f8ab281d2468b9541e --- /dev/null +++ b/renderer/jobs/manager.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import concurrent.futures +import http.client +import json +import shutil +import threading +import urllib.request +from urllib.parse import urlparse +from dataclasses import asdict +from pathlib import Path +from typing import Callable + +from renderer.core.config import Settings +from renderer.core.models import AIReelsRequest, JobRecord, RenderRequest +from renderer.core.render_engine import RenderEngine +from renderer.core.security import create_download_token +from renderer.core.utils import new_id, now, read_json, write_json + + +class JobManager: + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or Settings() + self.settings.ensure_dirs() + self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=self.settings.max_workers) + self.lock = threading.Lock() + + def submit_render(self, request: RenderRequest) -> str: + return self._submit( + lambda job_id, log: RenderEngine(self.settings, log=log).render(request, job_id), + callback_url=request.callback_url, + export_target=request.export_target, + ) + + def submit_ai_reels(self, request: AIReelsRequest) -> str: + return self._submit(lambda job_id, log: RenderEngine(self.settings, log=log).ai_reels(request, job_id)) + + def submit_task( + self, + handler: Callable[[str, Callable[[str], None]], object], + *, + callback_url: str | None = None, + export_target: str | None = None, + ) -> str: + return self._submit(handler, callback_url=callback_url, export_target=export_target) + + def submit_batch(self, requests: list[RenderRequest]) -> list[str]: + job_ids: list[str] = [] + for request in requests: + job_ids.append(self.submit_render(request)) + return job_ids + + def get(self, job_id: str) -> JobRecord: + data = read_json(self._record_path(job_id), None) + if data is None: + raise KeyError(job_id) + return JobRecord(**data) + + def cancel(self, job_id: str) -> JobRecord: + record = self.get(job_id) + if record.state == "PENDING": + self._update(job_id, state="CANCELLED", failure_reason="Cancelled before execution") + elif record.state == "RUNNING": + self._update(job_id, state="CANCEL_REQUESTED", failure_reason="Cancellation requested") + return self.get(job_id) + + def cleanup(self, older_than_seconds: int | None = None) -> dict[str, int]: + cutoff = now() - (older_than_seconds or self.settings.job_retention_seconds) + removed_jobs = 0 + removed_exports = 0 + for path in self.settings.jobs_dir.glob("*.json"): + record = JobRecord(**read_json(path, {})) + if record.updated_at >= cutoff or record.state in {"PENDING", "RUNNING", "CANCEL_REQUESTED"}: + continue + if record.output_path: + output = Path(record.output_path) + if output.exists(): + output.unlink() + removed_exports += 1 + path.unlink(missing_ok=True) + removed_jobs += 1 + uploads = self.settings.temp_dir / "uploads" + if uploads.exists(): + shutil.rmtree(uploads, ignore_errors=True) + return {"removed_jobs": removed_jobs, "removed_exports": removed_exports} + + def summary(self) -> dict: + records: list[JobRecord] = [] + for path in self.settings.jobs_dir.glob("*.json"): + try: + records.append(JobRecord(**read_json(path, {}))) + except Exception: + continue + state_counts: dict[str, int] = {} + for record in records: + state_counts[record.state] = state_counts.get(record.state, 0) + 1 + active = [ + { + "job_id": record.job_id, + "state": record.state, + "created_at": record.created_at, + "updated_at": record.updated_at, + "metrics": record.metrics, + } + for record in sorted(records, key=lambda item: item.updated_at, reverse=True) + if record.state in {"PENDING", "RUNNING", "CANCEL_REQUESTED"} + ] + return { + "total_jobs": len(records), + "state_counts": state_counts, + "active_jobs": active, + "max_workers": self.settings.max_workers, + } + + def _submit( + self, + handler: Callable[[str, Callable[[str], None]], object], + callback_url: str | None = None, + export_target: str | None = None, + ) -> str: + job_id = new_id() + record = JobRecord( + job_id=job_id, + state="PENDING", + created_at=now(), + updated_at=now(), + download_token=create_download_token(self.settings.signing_secret, job_id), + callback_url=callback_url, + export_target=export_target, + ) + self._save(record) + self.executor.submit(self._run_with_retries, job_id, handler) + return job_id + + def _run_with_retries(self, job_id: str, handler: Callable[[str, Callable[[str], None]], object]) -> None: + attempts = 0 + while attempts < self.settings.max_retries: + attempts += 1 + if self.get(job_id).state == "CANCELLED": + self._send_callback(job_id) + return + self._update(job_id, state="RUNNING", metrics={"attempt": attempts}) + try: + if self.get(job_id).state == "CANCEL_REQUESTED": + self._update(job_id, state="CANCELLED", failure_reason="Cancelled before render started") + self._send_callback(job_id) + return + result = handler(job_id, lambda message: self.append_log(job_id, message)) + record = self.get(job_id) + output_path = getattr(result, "output_path", None) + export_path = self._export_copy(output_path, record.export_target, job_id) if output_path else None + self._update( + job_id, + state="COMPLETED", + output_path=str(output_path) if output_path else None, + export_path=export_path, + commands=getattr(result, "commands", []), + logs=getattr(result, "logs", []), + metrics=getattr(result, "metrics", {}) | {"attempt": attempts}, + ) + self._send_callback(job_id) + return + except Exception as exc: + self.append_log(job_id, f"Attempt {attempts} failed: {exc}") + if attempts >= self.settings.max_retries: + self._update(job_id, state="FAILED", failure_reason=str(exc), metrics={"attempt": attempts}) + self._send_callback(job_id) + + def append_log(self, job_id: str, message: str) -> None: + with self.lock: + record = self.get(job_id) + record.logs.append(message) + record.updated_at = now() + self._save(record) + + def _update(self, job_id: str, **changes) -> None: + with self.lock: + record = self.get(job_id) + for key, value in changes.items(): + if key == "metrics" and record.metrics and isinstance(value, dict): + record.metrics.update(value) + else: + setattr(record, key, value) + record.updated_at = now() + self._save(record) + + def _record_path(self, job_id: str) -> Path: + return self.settings.jobs_dir / f"{job_id}.json" + + def _save(self, record: JobRecord) -> None: + write_json(self._record_path(record.job_id), asdict(record)) + + def _export_copy(self, output_path: Path, export_target: str | None, job_id: str) -> str | None: + if not export_target: + return None + if export_target != "local": + if export_target.startswith(("http://", "https://")): + _put_file(export_target, output_path) + return export_target + return None + target_dir = self.settings.storage_dir / job_id + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / output_path.name + shutil.copy2(output_path, target) + return str(target) + + def _send_callback(self, job_id: str) -> None: + try: + record = self.get(job_id) + except KeyError: + return + if not record.callback_url: + return + payload = json.dumps(asdict(record), default=str).encode("utf-8") + request = urllib.request.Request( + record.callback_url, + data=payload, + headers={"Content-Type": "application/json", "User-Agent": "ava2lon-studio-callback/2.0"}, + method="POST", + ) + try: + urllib.request.urlopen(request, timeout=10).read() + except Exception as exc: + self.append_log(job_id, f"Callback delivery failed: {exc}") + + +def _put_file(url: str, path: Path) -> None: + parsed = urlparse(url) + connection_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection + connection = connection_cls(parsed.netloc, timeout=60) + target = parsed.path or "/" + if parsed.query: + target += f"?{parsed.query}" + headers = { + "Content-Type": "video/mp4", + "Content-Length": str(path.stat().st_size), + "User-Agent": "ava2lon-studio-export/2.0", + } + connection.putrequest("PUT", target) + for key, value in headers.items(): + connection.putheader(key, value) + connection.endheaders() + with path.open("rb") as source: + while True: + chunk = source.read(1024 * 1024) + if not chunk: + break + connection.send(chunk) + response = connection.getresponse() + body = response.read() + connection.close() + if response.status >= 400: + raise RuntimeError(f"Export upload failed with HTTP {response.status}: {body[:500]!r}") diff --git a/renderer/platform/__init__.py b/renderer/platform/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..eade10feeb9c8e89a2a5b0a274f94b86a482ca7b --- /dev/null +++ b/renderer/platform/__init__.py @@ -0,0 +1,3 @@ +from renderer.platform.processor import PlatformProcessor, supported_toolkit_tasks + +__all__ = ["PlatformProcessor", "supported_toolkit_tasks"] diff --git a/renderer/platform/processor.py b/renderer/platform/processor.py new file mode 100644 index 0000000000000000000000000000000000000000..1c2dcd62fb6cbc482f79cf92286da8059ecd374b --- /dev/null +++ b/renderer/platform/processor.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +import json +import math +import mimetypes +import shutil +import zipfile +from pathlib import Path +from typing import Any + +from renderer.core.config import Settings +from renderer.core.ingest import AssetIngestor +from renderer.core.models import TaskResult +from renderer.core.utils import safe_filename, temp_workdir, write_json +from renderer.ffmpeg.assets import AssetProbe +from renderer.ffmpeg.command import FFmpegCommand +from renderer.ffmpeg.runner import FFmpegRunner +from renderer.templates import get_platform_profile + + +TOOLKIT_TASKS = { + "cut", + "trim", + "split", + "concat", + "merge", + "compress", + "normalize", + "resize", + "crop", + "rotate", + "flip", + "scale", + "zoom", + "pan", + "speed", + "speed_ramp", + "time_remap", + "slow_motion", + "fast_motion", + "reverse", + "reverse_playback", + "freeze_frame", + "motion_blur", + "stabilization", + "lens_correction", + "loop", + "extract_audio", + "thumbnail", + "gif", + "frames", + "watermark", + "overlay_text", + "blur_background", + "burn_subtitles", + "convert", + "merge_audio", + "noise_reduction", + "equalizer", + "compressor", + "limiter", + "pitch_shift", + "voice_changer", + "ai_enhancement", + "green_screen", + "chroma_key", + "blue_screen", + "ai_background_removal", +} + + +class PlatformProcessor: + def __init__(self, settings: Settings | None = None, log=None) -> None: + self.settings = settings or Settings() + self.settings.ensure_dirs() + self._commands: list[list[str]] = [] + self._logs: list[str] = [] + self.runner = FFmpegRunner(self.settings.ffmpeg_timeout_seconds, log=log, on_command=self._record_command) + self.ingest = AssetIngestor(self.settings) + self.assets = AssetProbe(self.settings.metadata_cache) + + def ingest_sources(self, sources: list[dict[str, Any]], job_id: str) -> TaskResult: + with temp_workdir(self.settings.temp_dir, f"{job_id}_ingest") as work: + workdir = Path(work) + staged: list[dict[str, Any]] = [] + for index, source in enumerate(sources): + url = str(source.get("url") or source.get("source") or "").strip() + if not url: + continue + source_type = str(source.get("type") or _source_type(url)) + if source_type == "youtube": + staged.append( + { + "source": url, + "source_type": source_type, + "status": "registered", + "note": "YouTube ingestion is registered for automation; provide a direct downloadable media URL or install yt-dlp for local extraction.", + } + ) + continue + resolved = self.ingest.resolve(url, workdir / "inputs", f"source_{index:03d}") + metadata = self.assets.probe(resolved).__dict__ + staged.append({"source": url, "source_type": source_type, "path": str(resolved), "metadata": metadata}) + output = self._json_artifact(job_id, "ingest_manifest", {"assets": staged, "asset_count": len(staged)}) + return self._result(output, {"task": "ingest", "asset_count": len(staged)}) + + def analyze(self, media: str, job_id: str, *, transcript: str = "", platform: str | None = None) -> TaskResult: + with temp_workdir(self.settings.temp_dir, f"{job_id}_analyze") as work: + workdir = Path(work) + source = self.ingest.resolve(media, workdir / "inputs", "media") + metadata = self.assets.probe(source) + duration = max(0.0, metadata.duration) + words = transcript.split() + words_per_minute = (len(words) / duration * 60) if duration > 0 and words else None + highlights = _highlight_windows(duration) + viral_score = _viral_score(duration, bool(words), metadata.width, metadata.height) + analysis = { + "media": str(source), + "metadata": metadata.__dict__, + "transcript": transcript, + "highlight_moments": highlights, + "viral_score": viral_score, + "hook_quality": _hook_quality(transcript), + "audience_retention_estimate": _retention_estimate(duration, viral_score), + "engagement_prediction": _engagement_prediction(viral_score), + "audience_persona": _persona(transcript), + "platform_recommendations": _platform_recommendations(duration, metadata.width, metadata.height, platform), + "scene_segmentation": highlights, + "speech_pacing": { + "words_per_minute": round(words_per_minute, 1) if words_per_minute else None, + "label": _pacing_label(words_per_minute), + }, + "silence_detection": { + "estimated_silence_ratio": 0.0 if transcript else 0.18, + "note": "Heuristic estimate; use transcription with word timestamps for precise silence spans.", + }, + } + output = self._json_artifact(job_id, "analysis", analysis) + return self._result(output, {"task": "analyze", "viral_score": viral_score, "duration": duration}) + + def metadata(self, job_id: str, *, topic: str = "", transcript: str = "", platform: str | None = None) -> TaskResult: + text = transcript or topic or "Untitled video" + title = _title_from_text(text, platform) + tags = _hashtags(text, platform) + payload = { + "title": title, + "description": _description(text, tags), + "hashtags": tags, + "keywords": _keywords(text), + "chapters": _chapters(text), + "seo_tags": _keywords(text) + [platform] if platform else _keywords(text), + "suggested_upload_schedule": _schedule(platform), + "platform": platform or "general", + } + output = self._json_artifact(job_id, "metadata", payload) + return self._result(output, {"task": "metadata", "keyword_count": len(payload["keywords"])}) + + def publish(self, payload: dict[str, Any], job_id: str) -> TaskResult: + platforms = payload.get("platforms") or [payload.get("platform") or "draft"] + manifest = { + "publish_state": "draft_ready" if payload.get("draft", True) else "credentials_required", + "platforms": platforms, + "scheduled_at": payload.get("scheduled_at"), + "asset": payload.get("asset") or payload.get("media"), + "title": payload.get("title"), + "description": payload.get("description"), + "retry_policy": {"max_attempts": 3, "backoff_seconds": 60}, + "note": "Direct publishing requires platform OAuth/API credentials configured outside this CPU render worker.", + } + output = self._json_artifact(job_id, "publish_manifest", manifest) + return self._result(output, {"task": "publish", "platform_count": len(platforms)}) + + def clips(self, media: str, job_id: str, clips: list[dict[str, Any]] | None = None) -> TaskResult: + with temp_workdir(self.settings.temp_dir, f"{job_id}_clips") as work: + workdir = Path(work) + source = self.ingest.resolve(media, workdir / "inputs", "media") + metadata = self.assets.probe(source) + clip_specs = clips or _highlight_windows(metadata.duration) + outputs: list[Path] = [] + for index, clip in enumerate(clip_specs): + start = max(0.0, float(clip.get("start", 0))) + end = float(clip.get("end", start + clip.get("duration", 8))) + duration = max(0.2, end - start) + target = workdir / f"clip_{index + 1:02d}.mp4" + command = ( + FFmpegCommand() + .add("-hide_banner", "-ss", start) + .input(source) + .add("-t", duration, "-c", "copy") + .overwrite() + .add(target) + .build() + ) + self._run(command) + outputs.append(target) + if len(outputs) == 1: + final = self._export(outputs[0], job_id, outputs[0].name) + else: + final = self.settings.exports_dir / f"{job_id}_clips.zip" + with zipfile.ZipFile(final, "w", zipfile.ZIP_DEFLATED) as archive: + for path in outputs: + archive.write(path, path.name) + return self._result(final, {"task": "clips", "clip_count": len(outputs)}) + + def thumbnail(self, media: str, job_id: str, *, text: str = "", timestamp: float | None = None, template: str = "bold") -> TaskResult: + with temp_workdir(self.settings.temp_dir, f"{job_id}_thumb") as work: + workdir = Path(work) + source = self.ingest.resolve(media, workdir / "inputs", "media") + metadata = self.assets.probe(source) + target = workdir / "thumbnail.jpg" + seek = timestamp if timestamp is not None else max(0.0, min(metadata.duration * 0.2, 8.0)) + vf = "scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720" + if text: + vf += "," + _drawtext_filter(text, template) + command = ( + FFmpegCommand() + .add("-hide_banner", "-ss", seek) + .input(source) + .add("-frames:v", 1, "-vf", vf, "-q:v", 2) + .overwrite() + .add(target) + .build() + ) + self._run(command) + final = self._export(target, job_id, "thumbnail.jpg") + return self._result(final, {"task": "thumbnail", "timestamp": seek}) + + def toolkit(self, payload: dict[str, Any], job_id: str) -> TaskResult: + task = str(payload.get("task") or payload.get("operation") or "").strip() + if task not in TOOLKIT_TASKS: + raise ValueError(f"Unsupported toolkit task: {task}") + if task == "thumbnail": + return self.thumbnail(str(payload["input"]), job_id, text=str(payload.get("text") or ""), timestamp=payload.get("timestamp")) + if task == "split": + clips = payload.get("clips") if isinstance(payload.get("clips"), list) else payload.get("params", {}).get("clips") + return self.clips(str(payload.get("input") or payload.get("media")), job_id, clips) + + with temp_workdir(self.settings.temp_dir, f"{job_id}_{task}") as work: + workdir = Path(work) + source_value = payload.get("input") or payload.get("media") + source = self.ingest.resolve(str(source_value), workdir / "inputs", "media") if source_value else workdir / "concat_placeholder.mp4" + params = payload.get("params") if isinstance(payload.get("params"), dict) else payload + output_name = safe_filename(str(payload.get("output_name") or _default_output_name(task))) + output = workdir / output_name + if task == "extract_audio" and output.suffix.lower() != ".mp3": + output = output.with_suffix(".mp3") + if task == "gif" and output.suffix.lower() != ".gif": + output = output.with_suffix(".gif") + command = self._toolkit_command(task, source, output, params, workdir) + self._run(command) + if task == "frames": + final = self.settings.exports_dir / f"{job_id}_frames.zip" + with zipfile.ZipFile(final, "w", zipfile.ZIP_DEFLATED) as archive: + for frame in sorted(workdir.glob("frame_*.jpg")): + archive.write(frame, frame.name) + else: + final = self._export(output, job_id, output.name) + return self._result(final, {"task": task}) + + def _toolkit_command(self, task: str, source: Path, output: Path, params: dict[str, Any], workdir: Path) -> list[str]: + cmd = FFmpegCommand().add("-hide_banner") + if task == "loop": + cmd.add("-stream_loop", int(params.get("loops", -1))) + if task in {"cut", "trim", "gif", "freeze_frame"} and params.get("start") is not None: + cmd.add("-ss", float(params.get("start", 0))) + cmd.input(source) + + if task == "merge_audio": + audio = self.ingest.resolve(str(params.get("audio")), workdir / "inputs", "audio") + cmd.input(audio) + return cmd.add("-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-shortest").overwrite().add(output).build() + if task == "watermark": + image = self.ingest.resolve(str(params.get("watermark") or params.get("image")), workdir / "inputs", "watermark") + cmd.input(image) + return cmd.add("-filter_complex", "[1:v]scale=iw*0.18:-1[wm];[0:v][wm]overlay=W-w-24:H-h-24", "-c:a", "copy").overwrite().add(output).build() + if task in {"concat", "merge"}: + inputs = params.get("inputs") + if not isinstance(inputs, list) or not inputs: + raise ValueError("Concat requires params.inputs") + concat_file = workdir / "concat.txt" + lines: list[str] = [] + for index, item in enumerate(inputs): + media = self.ingest.resolve(str(item), workdir / "inputs", f"concat_{index:03d}") + lines.append(f"file '{str(media).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'") + concat_file.write_text("\n".join(lines), encoding="utf-8") + return FFmpegCommand().add("-hide_banner", "-f", "concat", "-safe", "0").input(concat_file).add("-c", "copy").overwrite().add(output).build() + + duration = params.get("duration") + if task in {"cut", "trim", "gif", "loop", "freeze_frame"} and duration is not None: + cmd.add("-t", float(duration)) + + vf = _video_filter(task, params) + af = _audio_filter(task, params) + if vf: + cmd.add("-vf", vf) + if af: + cmd.add("-af", af) + + if task == "extract_audio": + return cmd.add("-vn", "-c:a", "mp3", "-b:a", "192k").overwrite().add(output).build() + if task == "frames": + return cmd.add("-vf", vf or "fps=1", "-q:v", 2).overwrite().add(workdir / "frame_%04d.jpg").build() + if task == "gif": + return cmd.add("-loop", 0).overwrite().add(output).build() + if task in { + "cut", + "trim", + "compress", + "normalize", + "resize", + "crop", + "rotate", + "flip", + "scale", + "zoom", + "pan", + "speed", + "speed_ramp", + "time_remap", + "slow_motion", + "fast_motion", + "reverse", + "reverse_playback", + "freeze_frame", + "motion_blur", + "stabilization", + "lens_correction", + "loop", + "overlay_text", + "blur_background", + "burn_subtitles", + "convert", + "noise_reduction", + "equalizer", + "compressor", + "limiter", + "pitch_shift", + "voice_changer", + "green_screen", + "chroma_key", + "blue_screen", + "ai_background_removal", + }: + cmd.add("-c:v", "libx264", "-preset", self.settings.preset, "-crf", int(params.get("crf", self.settings.crf)), "-c:a", "aac") + return cmd.overwrite().add(output).build() + + def _json_artifact(self, job_id: str, name: str, payload: dict[str, Any]) -> Path: + output = self.settings.exports_dir / f"{job_id}_{safe_filename(name)}.json" + write_json(output, payload) + return output + + def _export(self, source: Path, job_id: str, output_name: str) -> Path: + target = self.settings.exports_dir / f"{job_id}_{safe_filename(output_name)}" + shutil.copy2(source, target) + return target + + def _run(self, command: list[str]) -> None: + result = self.runner.run(command) + if result.stderr: + self._logs.append(result.stderr[-4000:]) + + def _record_command(self, command: list[str]) -> None: + self._commands.append(command) + + def _result(self, output: Path | None, metrics: dict[str, Any]) -> TaskResult: + return TaskResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs)) + + +def supported_toolkit_tasks() -> list[str]: + return sorted(TOOLKIT_TASKS) + + +def _source_type(url: str) -> str: + lowered = url.lower() + if "youtube.com" in lowered or "youtu.be" in lowered: + return "youtube" + if "drive.google.com" in lowered: + return "google_drive" + if "s3" in lowered or "amazonaws.com" in lowered: + return "s3" + return "direct_url" + + +def _highlight_windows(duration: float) -> list[dict[str, Any]]: + if duration <= 0: + return [{"start": 0, "end": 8, "reason": "default opener"}] + windows = [{"start": 0, "end": min(duration, 8), "reason": "opening hook"}] + if duration > 18: + middle = max(0.0, duration * 0.42) + windows.append({"start": round(middle, 2), "end": round(min(duration, middle + 10), 2), "reason": "midpoint payoff"}) + if duration > 35: + end = max(0.0, duration - 12) + windows.append({"start": round(end, 2), "end": round(duration, 2), "reason": "closing CTA"}) + return windows + + +def _viral_score(duration: float, has_words: bool, width: int | None, height: int | None) -> int: + score = 48 + if 7 <= duration <= 60: + score += 20 + elif duration <= 180: + score += 8 + if width and height and height >= width: + score += 14 + if has_words: + score += 10 + return max(1, min(100, score)) + + +def _hook_quality(transcript: str) -> dict[str, Any]: + opener = " ".join(transcript.split()[:18]) + signals = sum(1 for token in ("how", "why", "secret", "mistake", "stop", "watch", "you") if token in opener.lower()) + return {"score": min(100, 45 + signals * 12), "opening_text": opener} + + +def _retention_estimate(duration: float, viral_score: int) -> dict[str, Any]: + first_3s = min(96, 55 + viral_score * 0.35) + completion = max(18, min(88, first_3s - math.log(max(duration, 1), 1.8))) + return {"first_3_seconds_percent": round(first_3s, 1), "completion_percent": round(completion, 1)} + + +def _engagement_prediction(score: int) -> str: + if score >= 80: + return "high" + if score >= 62: + return "medium" + return "needs_work" + + +def _persona(text: str) -> str: + lowered = text.lower() + if any(word in lowered for word in ("founder", "startup", "product", "launch")): + return "builders and product-led founders" + if any(word in lowered for word in ("money", "sales", "growth", "marketing")): + return "growth-minded operators" + if any(word in lowered for word in ("learn", "tutorial", "how")): + return "learners seeking practical instruction" + return "general short-form viewers" + + +def _platform_recommendations(duration: float, width: int | None, height: int | None, platform: str | None) -> list[dict[str, Any]]: + vertical = bool(width and height and height >= width) + candidates = ["tiktok", "instagram_reels", "youtube_shorts"] if vertical else ["youtube_1080p", "linkedin_video"] + if platform and platform not in candidates: + candidates.insert(0, platform) + return [{"platform": item, "fit": "strong" if duration <= 90 else "medium"} for item in candidates] + + +def _pacing_label(wpm: float | None) -> str: + if not wpm: + return "unknown" + if wpm < 125: + return "slow" + if wpm > 185: + return "fast" + return "clear" + + +def _title_from_text(text: str, platform: str | None) -> str: + words = [word.strip(".,:;!?") for word in text.split() if word.strip(".,:;!?")] + title = " ".join(words[:9]) or "Untitled Video" + suffix = " #Shorts" if platform in {"youtube_shorts", "tiktok", "instagram_reels"} else "" + return f"{title.title()}{suffix}" + + +def _description(text: str, tags: list[str]) -> str: + summary = " ".join(text.split()[:42]) + return f"{summary}\n\n{' '.join(tags)}".strip() + + +def _hashtags(text: str, platform: str | None) -> list[str]: + base = ["#video", "#content"] + if platform: + base.append(f"#{platform.replace('_', '')}") + for keyword in _keywords(text)[:5]: + tag = "#" + "".join(ch for ch in keyword.title() if ch.isalnum()) + if tag not in base: + base.append(tag) + return base[:8] + + +def _keywords(text: str) -> list[str]: + stop = {"the", "and", "for", "with", "that", "this", "your", "you", "are", "from", "into", "video"} + words = [word.strip(".,:;!?").lower() for word in text.split()] + unique: list[str] = [] + for word in words: + if len(word) < 4 or word in stop or word in unique: + continue + unique.append(word) + return unique[:12] + + +def _chapters(text: str) -> list[dict[str, Any]]: + sentences = [part.strip() for part in text.replace("?", ".").replace("!", ".").split(".") if part.strip()] + return [{"time": f"0:{index * 15:02d}", "title": sentence[:60]} for index, sentence in enumerate(sentences[:6])] + + +def _schedule(platform: str | None) -> dict[str, str]: + if platform in {"linkedin_video", "youtube_1080p"}: + return {"day": "Tuesday", "time": "09:00 local"} + return {"day": "Thursday", "time": "18:00 local"} + + +def _video_filter(task: str, params: dict[str, Any]) -> str: + if task in {"compress", "normalize"}: + profile = get_platform_profile(params.get("platform")) + return f"scale={profile.width}:{profile.height}:force_original_aspect_ratio=increase,crop={profile.width}:{profile.height},fps={profile.fps},format=yuv420p" + if task in {"resize", "scale"}: + return f"scale={int(params.get('width', 1080))}:{int(params.get('height', 1920))}" + if task == "crop": + return f"crop={int(params.get('width', 1080))}:{int(params.get('height', 1080))}:{int(params.get('x', 0))}:{int(params.get('y', 0))}" + if task == "rotate": + return {"90": "transpose=1", "180": "hflip,vflip", "270": "transpose=2"}.get(str(params.get("degrees", "90")), "transpose=1") + if task == "flip": + axis = str(params.get("axis", "horizontal")) + return "vflip" if axis in {"vertical", "y"} else "hflip" + if task in {"speed", "speed_ramp", "time_remap", "slow_motion", "fast_motion"}: + default = 0.5 if task == "slow_motion" else 2.0 if task == "fast_motion" else 1.0 + factor = max(0.25, min(4.0, float(params.get("factor", default)))) + return f"setpts={1 / factor:.4f}*PTS" + if task == "zoom": + factor = max(1.0, min(4.0, float(params.get("factor", 1.2)))) + return f"scale=iw*{factor:.3f}:ih*{factor:.3f},crop=iw/{factor:.3f}:ih/{factor:.3f}" + if task == "pan": + width = int(params.get("width", 1080)) + height = int(params.get("height", 1920)) + x = str(params.get("x", "(iw-ow)/2")) + y = str(params.get("y", "(ih-oh)/2")) + return f"crop={width}:{height}:{x}:{y}" + if task in {"motion_blur", "freeze_frame"}: + return "tmix=frames=3:weights='1 2 1'" + if task == "stabilization": + return "deshake" + if task == "lens_correction": + return f"lenscorrection=k1={float(params.get('k1', -0.15))}:k2={float(params.get('k2', 0.05))}" + if task in {"reverse", "reverse_playback"}: + return "reverse" + if task in {"green_screen", "chroma_key", "ai_background_removal"}: + color = str(params.get("color") or "0x00ff00") + similarity = float(params.get("similarity", 0.18)) + blend = float(params.get("blend", 0.08)) + return f"chromakey={color}:{similarity}:{blend}" + if task == "blue_screen": + similarity = float(params.get("similarity", 0.18)) + blend = float(params.get("blend", 0.08)) + return f"chromakey=0x0000ff:{similarity}:{blend}" + if task == "speed": + factor = max(0.25, min(4.0, float(params.get("factor", 1.0)))) + return f"setpts={1 / factor:.4f}*PTS" + if task == "gif": + return f"fps={int(params.get('fps', 12))},scale={int(params.get('width', 540))}:-1:flags=lanczos" + if task == "frames": + return f"fps={float(params.get('fps', 1))}" + if task == "overlay_text": + return _drawtext_filter(str(params.get("text") or "Text"), "bold") + if task == "blur_background": + return "gblur=sigma=18" + if task == "burn_subtitles": + subtitles = str(params.get("subtitles") or "").replace("\\", "/").replace(":", r"\:") + return f"subtitles='{subtitles}'" + return "" + + +def _audio_filter(task: str, params: dict[str, Any]) -> str: + if task in {"speed", "speed_ramp", "time_remap", "slow_motion", "fast_motion"}: + default = 0.5 if task == "slow_motion" else 2.0 if task == "fast_motion" else 1.0 + factor = max(0.5, min(2.0, float(params.get("factor", default)))) + return f"atempo={factor}" + if task in {"reverse", "reverse_playback"}: + return "areverse" + if task in {"noise_reduction", "ai_enhancement"}: + return "afftdn=nf=-25" + if task == "equalizer": + return f"equalizer=f={float(params.get('frequency', 1000))}:width_type=o:width={float(params.get('width', 1))}:g={float(params.get('gain', 3))}" + if task == "compressor": + return "acompressor=threshold=-18dB:ratio=3:attack=20:release=250" + if task == "limiter": + return "alimiter=limit=0.95" + if task in {"pitch_shift", "voice_changer"}: + factor = max(0.5, min(2.0, float(params.get("factor", 1.0)))) + return f"asetrate=48000*{factor:.4f},aresample=48000,atempo={1 / factor:.4f}" + return "" + + +def _drawtext_filter(text: str, template: str) -> str: + escaped = text.replace("\\", "\\\\").replace(":", r"\:").replace("'", r"\'") + color = "yellow" if template == "bold" else "white" + return ( + "drawtext=" + f"text='{escaped}':fontcolor={color}:fontsize=58:" + "box=1:boxcolor=black@0.55:boxborderw=24:" + "x=(w-text_w)/2:y=h-(text_h*3)" + ) + + +def _default_output_name(task: str) -> str: + if task == "extract_audio": + return "audio.mp3" + if task == "gif": + return "clip.gif" + if task == "thumbnail": + return "thumbnail.jpg" + return f"{task}.mp4" diff --git a/renderer/scenes/__init__.py b/renderer/scenes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a34a477c851afa81f723ea818a417098be202aba --- /dev/null +++ b/renderer/scenes/__init__.py @@ -0,0 +1,3 @@ +from renderer.scenes.timeline import Timeline + +__all__ = ["Timeline"] diff --git a/renderer/scenes/timeline.py b/renderer/scenes/timeline.py new file mode 100644 index 0000000000000000000000000000000000000000..148f78f65025d5b6adff8cf4751b811fff6a6cc8 --- /dev/null +++ b/renderer/scenes/timeline.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from renderer.core.models import RenderRequest, Scene + + +class Timeline: + def __init__(self, scenes: list[Scene]) -> None: + self.scenes = sorted(scenes, key=lambda scene: scene.start) + self.validate() + + @classmethod + def from_payload(cls, payload: dict) -> "Timeline": + return cls([Scene(**scene) for scene in payload.get("scenes", [])]) + + @classmethod + def request_from_payload(cls, payload: dict) -> RenderRequest: + scenes = [Scene(**scene) for scene in payload.get("scenes", [])] + return RenderRequest( + scenes=scenes, + template=payload.get("template", "tiktok_classic"), + preset=payload.get("preset"), + creative_style=payload.get("creative_style"), + platform=payload.get("platform"), + output_name=payload.get("output_name", "render.mp4"), + voiceover=payload.get("voiceover"), + background_music=payload.get("background_music"), + music_volume=payload.get("music_volume", 0.316), + music_fade_in=payload.get("music_fade_in", 0.0), + music_fade_out=payload.get("music_fade_out", 0.0), + music_loop=payload.get("music_loop", True), + music_start=payload.get("music_start", 0.0), + music_ducking=payload.get("music_ducking", True), + voice_volume=payload.get("voice_volume", 1.0), + subtitle_format=payload.get("subtitle_format", "ass"), + auto_subtitles=payload.get("auto_subtitles", False), + subtitle_language=payload.get("subtitle_language"), + whisper_model_size=payload.get("whisper_model_size"), + preview=payload.get("preview", False), + audio_normalize=payload.get("audio_normalize", False), + watermark=payload.get("watermark"), + watermark_position=payload.get("watermark_position", "bottom-right"), + intro=payload.get("intro"), + outro=payload.get("outro"), + callback_url=payload.get("callback_url"), + export_target=payload.get("export_target"), + priority=payload.get("priority", 0), + scheduled_at=payload.get("scheduled_at"), + normalize=payload.get("normalize", True), + metadata=payload.get("metadata", {}), + ) + + @property + def total_duration(self) -> float: + return max((scene.start + scene.duration for scene in self.scenes), default=0.0) + + def validate(self) -> None: + if not self.scenes: + raise ValueError("At least one scene is required") + for scene in self.scenes: + if scene.duration <= 0: + raise ValueError("Scene duration must be greater than zero") + if scene.start < 0: + raise ValueError("Scene start must be non-negative") + if not scene.media: + raise ValueError("Scene media path is required") diff --git a/renderer/studio/__init__.py b/renderer/studio/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..444ce12da9eeaf9716a4af2b2b0c7d8b52e7ee4f --- /dev/null +++ b/renderer/studio/__init__.py @@ -0,0 +1,27 @@ +from renderer.studio.capabilities import capability_catalog +from renderer.studio.projects import ( + ProjectStore, + add_effect, + add_filter, + add_keyframe, + add_timeline_item, + add_transition, + apply_timeline_operation, + default_project, + normalize_project, +) +from renderer.studio.tasks import StudioTaskProcessor + +__all__ = [ + "ProjectStore", + "StudioTaskProcessor", + "add_effect", + "add_filter", + "add_keyframe", + "add_timeline_item", + "add_transition", + "apply_timeline_operation", + "capability_catalog", + "default_project", + "normalize_project", +] diff --git a/renderer/studio/capabilities.py b/renderer/studio/capabilities.py new file mode 100644 index 0000000000000000000000000000000000000000..749278c59ad490c2d82e5c88738d30cd7ef11f7c --- /dev/null +++ b/renderer/studio/capabilities.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +TIMELINE_TRACK_TYPES = [ + "video", + "audio", + "text", + "overlay", + "sticker", + "subtitle", +] + +TIMELINE_OPERATIONS = [ + "drag", + "split", + "trim", + "ripple_delete", + "insert", + "replace", + "group", + "lock", + "hide", + "duplicate", +] + +VIDEO_EDITING_OPERATIONS = [ + "cut", + "split", + "trim", + "merge", + "concat", + "reverse", + "freeze_frame", + "speed", + "speed_ramp", + "time_remap", + "slow_motion", + "fast_motion", + "reverse_playback", + "crop", + "rotate", + "flip", + "resize", + "scale", + "zoom", + "pan", + "motion_blur", + "stabilization", + "lens_correction", + "compress", + "normalize", + "loop", + "gif", + "frames", + "watermark", + "overlay_text", + "burn_subtitles", + "convert", +] + +AI_EDITING_FEATURES = [ + "auto_edit", + "auto_highlight_detection", + "auto_scene_detection", + "auto_reframe", + "auto_crop", + "auto_remove_silence", + "auto_beat_sync", + "auto_color_match", + "auto_motion_tracking", + "auto_subtitle_generation", + "auto_hook_detection", + "auto_thumbnail_selection", + "auto_music_selection", + "auto_b_roll_placement", + "auto_caption_animation", + "auto_viral_score", + "auto_platform_optimization", +] + +KEYFRAME_PROPERTIES = [ + "position", + "scale", + "rotation", + "opacity", + "blur", + "brightness", + "contrast", + "saturation", + "hue", + "volume", + "playback_speed", + "mask", + "shadow", + "glow", + "text_animation", +] + +TRANSITION_FAMILIES = [ + "basic", + "fade", + "dissolve", + "slide", + "push", + "zoom", + "spin", + "blur", + "whip", + "flash", + "glitch", + "light_leak", + "film_burn", + "camera_shake", + "3d_flip", + "cube", + "ripple", + "ink", + "morph", + "stretch", + "liquid", + "elastic", + "motion_blur", +] + +VIDEO_EFFECTS = [ + "glitch", + "rgb_split", + "shake", + "crt", + "vhs", + "noise", + "film_grain", + "bloom", + "glow", + "chromatic_aberration", + "lens_flare", + "dream", + "neon", + "cyberpunk", + "rain", + "snow", + "fog", + "lightning", + "fire", + "smoke", + "particle_system", + "spark", + "magic", + "comic", + "cartoon", + "anime", + "sketch", + "oil_painting", + "pixel_art", + "sharp_pop", + "clean_beauty", + "warm_glow", + "cinematic", + "dreamy", + "flash_pop", + "motion_blur", + "noir", +] + +FILTER_FORMATS = [".cube", ".3dl", ".csp"] + +FILTER_PRESETS = [ + "cinema", + "vintage", + "warm", + "cold", + "black_and_white", + "hdr", + "instagram", + "tiktok", + "moody", + "travel", + "nature", + "food", + "portrait", + "luxury", + "night", +] + +TEXT_FEATURES = [ + "rich_text", + "curved_text", + "vertical_text", + "gradient_text", + "outline", + "shadow", + "glow", + "stroke", + "letter_spacing", + "word_spacing", + "animation_presets", + "typing_animation", + "bounce", + "wave", + "zoom", + "pop", + "fade", + "roll", + "tracking", +] + +CAPTION_FEATURES = [ + "whisper_transcription", + "word_timestamps", + "sentence_timestamps", + "emoji_insertion", + "speaker_detection", + "karaoke_captions", + "tiktok_captions", + "capcut_captions", + "animated_captions", + "subtitle_templates", +] + +STICKER_PACKS = [ + "png", + "svg", + "gif", + "animated_stickers", + "emoji_packs", + "reaction_packs", + "social_media_packs", + "call_to_action_packs", +] + +SHAPES = [ + "rectangle", + "circle", + "triangle", + "arrow", + "line", + "polygon", + "speech_bubble", + "custom_svg", +] + +MASK_TYPES = [ + "rectangle", + "circle", + "linear", + "radial", + "freehand", + "bezier", + "ai_subject_mask", + "ai_sky_mask", + "ai_person_mask", +] + +CHROMA_KEY_FEATURES = [ + "green_screen", + "blue_screen", + "ai_background_removal", + "edge_feathering", + "spill_suppression", + "shadow_preservation", +] + +AUDIO_TOOLS = [ + "music", + "voiceover", + "noise_reduction", + "equalizer", + "compressor", + "limiter", + "pitch_shift", + "voice_changer", + "fade", + "ducking", + "normalization", + "ai_enhancement", + "beat_detection", + "beat_markers", +] + +MUSIC_PROVIDERS = ["musicgen", "suno_api", "stable_audio"] +MUSIC_STYLES = ["background_music", "lo_fi", "cinematic", "nasheed", "hip_hop", "corporate", "podcast", "meditation"] + +VOICE_PROVIDERS = ["kokoro", "xtts", "piper", "openvoice"] +VOICE_FEATURES = ["voice_cloning", "multi_speaker", "emotion", "speed", "pitch", "style_transfer"] + +IMAGE_GENERATION_PROVIDERS = ["flux", "sdxl", "controlnet"] +IMAGE_GENERATION_FEATURES = ["image_editing", "background_replacement", "object_removal", "upscaling"] + +VIDEO_GENERATION_PROVIDERS = ["wan", "ltx_video", "hunyuan_video", "veo_api"] +VIDEO_GENERATION_FEATURES = ["animate_images", "image_to_video", "text_to_video"] + +AI_ASSISTANTS = [ + "script_writer", + "hook_generator", + "title_generator", + "description_generator", + "hashtag_generator", + "seo_optimizer", + "thumbnail_prompt_generator", + "b_roll_planner", + "storyboard_generator", +] + +TEMPLATE_CATEGORIES = [ + "youtube_shorts", + "tiktok", + "instagram_reels", + "facebook_reels", + "motivational", + "podcasts", + "gaming", + "news", + "luxury", + "business", + "education", + "finance", + "relationship", + "wedding", + "birthday", + "travel", + "cooking", + "fitness", + "anime", + "sports", + "product_ads", + "real_estate", + "e_commerce", + "faceless_channels", + "quote_videos", + "audiograms", + "story_videos", + "before_and_after", + "reaction_videos", + "countdown_videos", +] + +EXPORT_FORMATS = ["mp4", "mov", "avi", "mkv", "gif", "webm", "png_sequence", "jpeg_sequence", "audio_only"] + +EXPORT_PRESETS = [ + "1080p", + "2k", + "4k", + "8k", + "tiktok", + "youtube", + "instagram", + "facebook", + "twitter", + "linkedin", +] + +API_ENDPOINTS = { + "upload": "POST /upload", + "project_create": "POST /project/create", + "project_save": "POST /project/save", + "timeline_add": "POST /timeline/add", + "timeline_operation": "POST /timeline/operation", + "effect_apply": "POST /effect/apply", + "filter_apply": "POST /filter/apply", + "transition_add": "POST /transition/add", + "caption_generate": "POST /caption/generate", + "music_generate": "POST /music/generate", + "voice_generate": "POST /voice/generate", + "image_generate": "POST /image/generate", + "video_generate": "POST /video/generate", + "thumbnail_create": "POST /thumbnail/create", + "render": "POST /render", + "status": "GET /status/{job_id}", + "download": "GET /download/{job_id}", + "publish": "POST /publish", +} + + +def capability_catalog() -> dict[str, Any]: + return deepcopy( + { + "product": "Ava2lon Studio AI", + "principles": { + "cpu_first": True, + "optional_gpu": True, + "api_parity": True, + "async_long_running_tasks": True, + "status_polling": True, + "webhooks": True, + "plugin_support": True, + "template_driven": True, + "non_destructive_projects": True, + "multi_platform_export": True, + }, + "timeline": { + "track_types": TIMELINE_TRACK_TYPES, + "operations": TIMELINE_OPERATIONS, + "unlimited_tracks": True, + }, + "editing": VIDEO_EDITING_OPERATIONS, + "ai_editing": AI_EDITING_FEATURES, + "keyframes": KEYFRAME_PROPERTIES, + "transitions": TRANSITION_FAMILIES, + "effects": VIDEO_EFFECTS, + "filters": {"formats": FILTER_FORMATS, "presets": FILTER_PRESETS}, + "text": TEXT_FEATURES, + "captions": CAPTION_FEATURES, + "stickers": STICKER_PACKS, + "shapes": SHAPES, + "masks": MASK_TYPES, + "chroma_key": CHROMA_KEY_FEATURES, + "audio": AUDIO_TOOLS, + "music_generator": {"providers": MUSIC_PROVIDERS, "styles": MUSIC_STYLES}, + "voice_generator": {"providers": VOICE_PROVIDERS, "features": VOICE_FEATURES}, + "image_generation": {"providers": IMAGE_GENERATION_PROVIDERS, "features": IMAGE_GENERATION_FEATURES}, + "video_generation": {"providers": VIDEO_GENERATION_PROVIDERS, "features": VIDEO_GENERATION_FEATURES}, + "assistants": AI_ASSISTANTS, + "templates": TEMPLATE_CATEGORIES, + "exports": {"formats": EXPORT_FORMATS, "presets": EXPORT_PRESETS}, + "api_endpoints": API_ENDPOINTS, + } + ) + + +def is_timeline_operation(operation: str) -> bool: + return operation in TIMELINE_OPERATIONS + + +def is_track_type(track_type: str) -> bool: + return track_type in TIMELINE_TRACK_TYPES diff --git a/renderer/studio/projects.py b/renderer/studio/projects.py new file mode 100644 index 0000000000000000000000000000000000000000..b83b299e151670fc0c7d7172e441c74065e8db8f --- /dev/null +++ b/renderer/studio/projects.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any + +from renderer.core.config import Settings +from renderer.core.utils import new_id, now, read_json, safe_filename, write_json +from renderer.studio.capabilities import TIMELINE_TRACK_TYPES, is_timeline_operation, is_track_type + + +DEFAULT_EXPORT_SETTINGS: dict[str, Any] = { + "format": "mp4", + "preset": "tiktok", + "platform": "tiktok", + "resolution": "1080p", + "fps": 30, + "codec": "h264", + "audio_codec": "aac", +} + + +def default_project(name: str, metadata: dict[str, Any] | None = None, project_id: str | None = None) -> dict[str, Any]: + created = now() + project_id = project_id or new_id("project") + return { + "id": project_id, + "name": name, + "slug": safe_filename(name), + "version": 1, + "schema": "ava2lon.project.v1", + "created_at": created, + "updated_at": created, + "metadata": metadata or {}, + "timeline": { + "duration": 0.0, + "fps": 30, + "tracks": {track_type: [] for track_type in TIMELINE_TRACK_TYPES}, + "groups": [], + "markers": [], + }, + "assets": [], + "audio_tracks": [], + "video_tracks": [], + "text_layers": [], + "sticker_layers": [], + "effects": [], + "filters": [], + "keyframes": [], + "captions": [], + "templates": [], + "export_settings": deepcopy(DEFAULT_EXPORT_SETTINGS), + "plugins": [], + "automation": {"webhooks": [], "batch": {}, "n8n": {"compatible": True}}, + } + + +class ProjectStore: + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or Settings() + self.settings.ensure_dirs() + self.root = self.settings.storage_dir / "projects" + self.root.mkdir(parents=True, exist_ok=True) + + def list(self) -> list[dict[str, Any]]: + projects: list[dict[str, Any]] = [] + for manifest in sorted(self.root.glob("*/project.json")): + try: + data = normalize_project(read_json(manifest, {})) + projects.append(_summary(data, manifest.parent)) + except Exception: + continue + return projects + + def create(self, name: str, metadata: dict[str, Any] | None = None, template: dict[str, Any] | None = None) -> dict[str, Any]: + project = normalize_project(template or default_project(name, metadata)) + project["name"] = name + project["metadata"] = metadata or project.get("metadata", {}) + if not project.get("id"): + project["id"] = new_id("project") + project["slug"] = safe_filename(str(project.get("slug") or name or project["id"])) + project["created_at"] = project.get("created_at") or now() + project["updated_at"] = now() + self.save(project["id"], project) + return project + + def get(self, project_id: str) -> dict[str, Any]: + path = self._path(project_id) + data = read_json(path, None) + if data is None: + raise KeyError(project_id) + return normalize_project(data) + + def save(self, project_id: str, project: dict[str, Any]) -> dict[str, Any]: + normalized = normalize_project(project) + normalized["id"] = project_id or normalized.get("id") or new_id("project") + normalized["slug"] = safe_filename(str(normalized.get("slug") or normalized.get("name") or normalized["id"])) + normalized["updated_at"] = now() + write_json(self._path(normalized["id"]), normalized) + return normalized + + def delete(self, project_id: str) -> None: + path = self._path(project_id) + if not path.exists(): + raise KeyError(project_id) + directory = path.parent + for child in directory.glob("*"): + if child.is_file(): + child.unlink() + try: + directory.rmdir() + except OSError: + pass + + def add_asset(self, project_id: str, asset: dict[str, Any]) -> dict[str, Any]: + project = self.get(project_id) + asset = deepcopy(asset) + asset.setdefault("id", new_id("asset")) + asset.setdefault("created_at", now()) + project["assets"].append(asset) + return self.save(project_id, project) + + def add_to_timeline(self, project_id: str, item: dict[str, Any], track_type: str = "video", track_id: str | None = None) -> dict[str, Any]: + project = self.get(project_id) + add_timeline_item(project, item, track_type=track_type, track_id=track_id) + return self.save(project_id, project) + + def timeline_operation(self, project_id: str, operation: str, item_id: str | None = None, params: dict[str, Any] | None = None) -> dict[str, Any]: + project = self.get(project_id) + apply_timeline_operation(project, operation, item_id=item_id, params=params or {}) + return self.save(project_id, project) + + def _path(self, project_id: str) -> Path: + project_id = safe_filename(project_id) + return self.root / project_id / "project.json" + + +def normalize_project(project: dict[str, Any]) -> dict[str, Any]: + normalized = deepcopy(project or {}) + normalized.setdefault("id", new_id("project")) + normalized.setdefault("name", "Untitled Project") + normalized.setdefault("slug", safe_filename(str(normalized["name"]))) + normalized.setdefault("version", 1) + normalized.setdefault("schema", "ava2lon.project.v1") + normalized.setdefault("created_at", now()) + normalized.setdefault("updated_at", now()) + normalized.setdefault("metadata", {}) + normalized.setdefault("timeline", {}) + timeline = normalized["timeline"] + timeline.setdefault("duration", 0.0) + timeline.setdefault("fps", 30) + timeline.setdefault("tracks", {}) + for track_type in TIMELINE_TRACK_TYPES: + timeline["tracks"].setdefault(track_type, []) + timeline.setdefault("groups", []) + timeline.setdefault("markers", []) + for key in ( + "assets", + "audio_tracks", + "video_tracks", + "text_layers", + "sticker_layers", + "effects", + "filters", + "keyframes", + "captions", + "templates", + "plugins", + ): + normalized.setdefault(key, []) + normalized.setdefault("export_settings", deepcopy(DEFAULT_EXPORT_SETTINGS)) + normalized.setdefault("automation", {"webhooks": [], "batch": {}, "n8n": {"compatible": True}}) + _recalculate_duration(normalized) + return normalized + + +def add_timeline_item(project: dict[str, Any], item: dict[str, Any], *, track_type: str = "video", track_id: str | None = None) -> dict[str, Any]: + if not is_track_type(track_type): + raise ValueError(f"Unsupported track type: {track_type}") + normalized = normalize_project(project) + item = deepcopy(item) + item.setdefault("id", new_id("clip")) + item.setdefault("type", track_type) + item.setdefault("start", 0.0) + item.setdefault("duration", max(float(item.get("end", 0.0)) - float(item.get("start", 0.0)), 0.0) or 1.0) + item.setdefault("source_start", 0.0) + item.setdefault("locked", False) + item.setdefault("hidden", False) + item.setdefault("keyframes", []) + item.setdefault("effects", []) + item.setdefault("filters", []) + item.setdefault("metadata", {}) + track = _ensure_track(normalized, track_type, track_id) + track["items"].append(item) + track["items"].sort(key=lambda entry: float(entry.get("start", 0.0))) + project.clear() + project.update(normalized) + _mirror_layers(project, track_type, item) + _recalculate_duration(project) + return item + + +def apply_timeline_operation(project: dict[str, Any], operation: str, *, item_id: str | None = None, params: dict[str, Any] | None = None) -> dict[str, Any]: + if not is_timeline_operation(operation): + raise ValueError(f"Unsupported timeline operation: {operation}") + params = params or {} + normalized = normalize_project(project) + + if operation == "insert": + add_timeline_item( + normalized, + params.get("item", {}), + track_type=str(params.get("track_type", "video")), + track_id=params.get("track_id"), + ) + elif operation == "group": + group_id = str(params.get("group_id") or new_id("group")) + item_ids = [str(value) for value in params.get("item_ids", [])] + normalized["timeline"]["groups"].append({"id": group_id, "item_ids": item_ids, "metadata": params.get("metadata", {})}) + for grouped_id in item_ids: + try: + grouped_item, _ = _find_item(normalized, grouped_id) + grouped_item["group_id"] = group_id + except KeyError: + continue + else: + if not item_id: + raise ValueError(f"{operation} requires item_id") + item, track = _find_item(normalized, item_id) + if operation == "drag": + item["start"] = max(0.0, float(params.get("start", item.get("start", 0.0)))) + elif operation == "trim": + if "start" in params: + item["start"] = max(0.0, float(params["start"])) + if "duration" in params: + item["duration"] = max(0.001, float(params["duration"])) + if "source_start" in params: + item["source_start"] = max(0.0, float(params["source_start"])) + elif operation == "split": + offset = float(params.get("offset", 0.0)) + duration = float(item.get("duration", 0.0)) + if offset <= 0 or offset >= duration: + raise ValueError("split offset must be inside the item duration") + new_item = deepcopy(item) + new_item["id"] = str(params.get("new_item_id") or new_id("clip")) + new_item["start"] = float(item.get("start", 0.0)) + offset + new_item["duration"] = duration - offset + new_item["source_start"] = float(item.get("source_start", 0.0)) + offset + item["duration"] = offset + track["items"].append(new_item) + track["items"].sort(key=lambda entry: float(entry.get("start", 0.0))) + elif operation == "ripple_delete": + start = float(item.get("start", 0.0)) + duration = float(item.get("duration", 0.0)) + track["items"] = [entry for entry in track["items"] if entry.get("id") != item_id] + for entry in track["items"]: + if float(entry.get("start", 0.0)) > start: + entry["start"] = max(start, float(entry.get("start", 0.0)) - duration) + elif operation == "replace": + replacement = deepcopy(params.get("item", {})) + replacement.setdefault("id", item_id) + replacement.setdefault("start", item.get("start", 0.0)) + replacement.setdefault("duration", item.get("duration", 1.0)) + replacement.setdefault("type", item.get("type", track.get("type"))) + index = track["items"].index(item) + track["items"][index] = replacement + elif operation == "lock": + item["locked"] = bool(params.get("locked", True)) + elif operation == "hide": + item["hidden"] = bool(params.get("hidden", True)) + elif operation == "duplicate": + duplicate = deepcopy(item) + duplicate["id"] = str(params.get("new_item_id") or new_id("clip")) + duplicate["start"] = float(params.get("start", float(item.get("start", 0.0)) + float(item.get("duration", 1.0)))) + track["items"].append(duplicate) + track["items"].sort(key=lambda entry: float(entry.get("start", 0.0))) + + project.clear() + project.update(normalized) + _recalculate_duration(project) + return project + + +def add_effect(project: dict[str, Any], target_id: str, effect: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + effect_record = {"id": new_id("effect"), "target_id": target_id, "effect": effect, "params": params or {}, "created_at": now()} + project.setdefault("effects", []).append(effect_record) + try: + item, _ = _find_item(project, target_id) + item.setdefault("effects", []).append(effect_record) + except KeyError: + pass + return effect_record + + +def add_filter(project: dict[str, Any], target_id: str, filter_name: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + filter_record = {"id": new_id("filter"), "target_id": target_id, "filter": filter_name, "params": params or {}, "created_at": now()} + project.setdefault("filters", []).append(filter_record) + try: + item, _ = _find_item(project, target_id) + item.setdefault("filters", []).append(filter_record) + except KeyError: + pass + return filter_record + + +def add_transition(project: dict[str, Any], from_item_id: str, to_item_id: str, transition: str, duration: float = 0.45) -> dict[str, Any]: + record = { + "id": new_id("transition"), + "from_item_id": from_item_id, + "to_item_id": to_item_id, + "transition": transition, + "duration": duration, + "created_at": now(), + } + project.setdefault("timeline", {}).setdefault("transitions", []).append(record) + return record + + +def add_keyframe( + project: dict[str, Any], + target_id: str, + property_name: str, + time: float, + value: Any, + easing: str = "linear", +) -> dict[str, Any]: + record = { + "id": new_id("keyframe"), + "target_id": target_id, + "property": property_name, + "time": max(0.0, float(time)), + "value": value, + "easing": easing, + } + project.setdefault("keyframes", []).append(record) + try: + item, _ = _find_item(project, target_id) + item.setdefault("keyframes", []).append(record) + except KeyError: + pass + return record + + +def _ensure_track(project: dict[str, Any], track_type: str, track_id: str | None = None) -> dict[str, Any]: + tracks = project["timeline"]["tracks"].setdefault(track_type, []) + if track_id: + for track in tracks: + if track.get("id") == track_id: + return track + if not tracks: + track_id = track_id or f"{track_type}_1" + else: + track_id = track_id or f"{track_type}_{len(tracks) + 1}" + track = {"id": track_id, "type": track_type, "name": f"{track_type.title()} {len(tracks) + 1}", "locked": False, "hidden": False, "items": []} + tracks.append(track) + return track + + +def _find_item(project: dict[str, Any], item_id: str) -> tuple[dict[str, Any], dict[str, Any]]: + for tracks in project.get("timeline", {}).get("tracks", {}).values(): + for track in tracks: + for item in track.get("items", []): + if item.get("id") == item_id: + return item, track + raise KeyError(item_id) + + +def _mirror_layers(project: dict[str, Any], track_type: str, item: dict[str, Any]) -> None: + mirror_key = { + "video": "video_tracks", + "audio": "audio_tracks", + "text": "text_layers", + "sticker": "sticker_layers", + "subtitle": "captions", + }.get(track_type) + if mirror_key: + project.setdefault(mirror_key, []).append({"item_id": item["id"], **deepcopy(item)}) + + +def _recalculate_duration(project: dict[str, Any]) -> None: + duration = 0.0 + for tracks in project.get("timeline", {}).get("tracks", {}).values(): + for track in tracks: + for item in track.get("items", []): + duration = max(duration, float(item.get("start", 0.0)) + float(item.get("duration", 0.0))) + project.setdefault("timeline", {})["duration"] = round(duration, 3) + + +def _summary(project: dict[str, Any], directory: Path) -> dict[str, Any]: + return { + "id": project.get("id"), + "name": project.get("name"), + "slug": project.get("slug"), + "path": str(directory), + "updated_at": project.get("updated_at"), + "duration": project.get("timeline", {}).get("duration", 0.0), + "asset_count": len(project.get("assets", [])), + "metadata": project.get("metadata", {}), + } diff --git a/renderer/studio/tasks.py b/renderer/studio/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..1e79cf6241054ee2b171ddc0b3218a09be10b6eb --- /dev/null +++ b/renderer/studio/tasks.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from renderer.core.config import Settings +from renderer.core.models import TaskResult +from renderer.core.utils import now, safe_filename, write_json +from renderer.studio.capabilities import ( + AI_ASSISTANTS, + AI_EDITING_FEATURES, + IMAGE_GENERATION_PROVIDERS, + MUSIC_PROVIDERS, + VIDEO_GENERATION_PROVIDERS, + VOICE_PROVIDERS, +) + + +class StudioTaskProcessor: + """Manifest-producing async handlers for optional AI providers and studio automation.""" + + def __init__(self, settings: Settings | None = None, log=None) -> None: + self.settings = settings or Settings() + self.settings.ensure_dirs() + self._logs: list[str] = [] + self._log = log + + def caption_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult: + text = str(payload.get("text") or payload.get("transcript") or "") + captions = payload.get("events") if isinstance(payload.get("events"), list) else _captions_from_text(text) + manifest = { + "type": "caption_generation", + "status": "ready", + "engine": payload.get("engine", "whisper"), + "media": payload.get("media") or payload.get("audio"), + "template": payload.get("template", "capcut"), + "language": payload.get("language"), + "features": { + "word_timestamps": bool(payload.get("word_timestamps", True)), + "sentence_timestamps": True, + "emoji_insertion": bool(payload.get("emoji_insertion", False)), + "speaker_detection": bool(payload.get("speaker_detection", False)), + "karaoke": bool(payload.get("karaoke", True)), + "animated": bool(payload.get("animated", True)), + }, + "captions": captions, + } + output = self._json_artifact(job_id, "captions", manifest) + return self._result(output, {"task": "caption_generate", "caption_count": len(captions)}) + + def music_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult: + provider = _provider(payload.get("provider"), MUSIC_PROVIDERS, "musicgen") + prompt = str(payload.get("prompt") or payload.get("style") or "background music") + duration = float(payload.get("duration", 30)) + manifest = { + "type": "music_generation", + "status": "provider_required", + "provider": provider, + "prompt": prompt, + "style": payload.get("style", "background_music"), + "duration": duration, + "bpm": payload.get("bpm"), + "license": payload.get("license", "user_configured"), + "next_step": "Configure provider credentials or connect this manifest to a local MusicGen runner.", + } + output = self._json_artifact(job_id, "music_request", manifest) + return self._result(output, {"task": "music_generate", "provider": provider, "duration": duration}) + + def voice_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult: + provider = _provider(payload.get("provider"), VOICE_PROVIDERS, "kokoro") + text = str(payload.get("text") or "") + manifest = { + "type": "voice_generation", + "status": "provider_required", + "provider": provider, + "text": text, + "voice": payload.get("voice", "default"), + "emotion": payload.get("emotion"), + "speed": float(payload.get("speed", 1.0)), + "pitch": float(payload.get("pitch", 1.0)), + "clone_reference": payload.get("clone_reference"), + "multi_speaker": payload.get("speakers", []), + "next_step": "Configure the selected TTS backend to render audio for this manifest.", + } + output = self._json_artifact(job_id, "voice_request", manifest) + return self._result(output, {"task": "voice_generate", "provider": provider, "characters": len(text)}) + + def image_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult: + provider = _provider(payload.get("provider"), IMAGE_GENERATION_PROVIDERS, "flux") + manifest = { + "type": "image_generation", + "status": "provider_required", + "provider": provider, + "prompt": payload.get("prompt", ""), + "negative_prompt": payload.get("negative_prompt", ""), + "mode": payload.get("mode", "text_to_image"), + "control_image": payload.get("control_image"), + "source_image": payload.get("source_image"), + "size": payload.get("size", "1024x1024"), + "features": { + "background_replacement": bool(payload.get("background_replacement", False)), + "object_removal": bool(payload.get("object_removal", False)), + "upscaling": bool(payload.get("upscaling", False)), + }, + } + output = self._json_artifact(job_id, "image_request", manifest) + return self._result(output, {"task": "image_generate", "provider": provider}) + + def video_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult: + provider = _provider(payload.get("provider"), VIDEO_GENERATION_PROVIDERS, "ltx_video") + manifest = { + "type": "video_generation", + "status": "provider_required", + "provider": provider, + "prompt": payload.get("prompt", ""), + "mode": payload.get("mode", "text_to_video"), + "image": payload.get("image"), + "duration": float(payload.get("duration", 5)), + "fps": int(payload.get("fps", 24)), + "size": payload.get("size", "1280x720"), + "next_step": "Connect Wan, LTX Video, Hunyuan Video, or Veo credentials/runtime to execute this request.", + } + output = self._json_artifact(job_id, "video_request", manifest) + return self._result(output, {"task": "video_generate", "provider": provider}) + + def ai_tool(self, tool: str, payload: dict[str, Any], job_id: str) -> TaskResult: + tool = _canonical(tool) + if tool not in AI_EDITING_FEATURES: + raise ValueError(f"Unsupported AI editing tool: {tool}") + manifest = { + "type": "ai_editing", + "tool": tool, + "status": "ready", + "media": payload.get("media"), + "project_id": payload.get("project_id"), + "platform": payload.get("platform", "tiktok"), + "result": _ai_result(tool, payload), + "created_at": now(), + } + output = self._json_artifact(job_id, tool, manifest) + return self._result(output, {"task": tool}) + + def assistant_tool(self, tool: str, payload: dict[str, Any], job_id: str) -> TaskResult: + tool = _canonical(tool) + if tool not in AI_ASSISTANTS: + raise ValueError(f"Unsupported assistant: {tool}") + text = str(payload.get("topic") or payload.get("transcript") or payload.get("prompt") or "") + manifest = { + "type": "assistant", + "tool": tool, + "status": "ready", + "input": text, + "platform": payload.get("platform", "general"), + "result": _assistant_result(tool, text, payload), + "created_at": now(), + } + output = self._json_artifact(job_id, tool, manifest) + return self._result(output, {"task": tool, "characters": len(text)}) + + def _json_artifact(self, job_id: str, name: str, payload: dict[str, Any]) -> Path: + output = self.settings.exports_dir / f"{job_id}_{safe_filename(name)}.json" + write_json(output, payload) + self._message(f"Wrote {name} manifest") + return output + + def _result(self, output: Path, metrics: dict[str, Any]) -> TaskResult: + return TaskResult(output_path=output, commands=[], metrics=metrics, logs=list(self._logs)) + + def _message(self, message: str) -> None: + self._logs.append(message) + if self._log: + self._log(message) + + +def _provider(value: Any, supported: list[str], default: str) -> str: + provider = _canonical(str(value or default)) + return provider if provider in supported else default + + +def _canonical(value: str) -> str: + return value.strip().lower().replace("-", "_").replace(" ", "_") + + +def _captions_from_text(text: str) -> list[dict[str, Any]]: + if not text: + return [] + words = text.split() + chunks: list[list[str]] = [] + while words: + chunks.append(words[:8]) + words = words[8:] + captions = [] + cursor = 0.0 + for chunk in chunks: + duration = max(1.2, len(chunk) * 0.34) + captions.append({"start": round(cursor, 2), "end": round(cursor + duration, 2), "text": " ".join(chunk)}) + cursor += duration + return captions + + +def _ai_result(tool: str, payload: dict[str, Any]) -> dict[str, Any]: + platform = str(payload.get("platform") or "tiktok") + if tool == "auto_highlight_detection": + return {"highlights": [{"start": 0, "end": 8, "reason": "opening hook"}]} + if tool == "auto_scene_detection": + return {"scenes": [{"start": 0, "end": 5, "label": "intro"}, {"start": 5, "end": 12, "label": "body"}]} + if tool in {"auto_reframe", "auto_crop", "auto_platform_optimization"}: + return {"platform": platform, "safe_zone": "vertical_center", "aspect_ratio": "9:16"} + if tool == "auto_viral_score": + return {"score": 74, "signals": ["short duration", "caption-ready", platform]} + if tool == "auto_hook_detection": + return {"hook": str(payload.get("transcript") or payload.get("text") or "")[:120], "score": 68} + if tool == "auto_thumbnail_selection": + return {"frames": [{"timestamp": 2.0, "score": 82}, {"timestamp": 6.5, "score": 75}]} + return {"plan": f"{tool} plan generated", "confidence": "heuristic", "platform": platform} + + +def _assistant_result(tool: str, text: str, payload: dict[str, Any]) -> dict[str, Any]: + subject = text.strip() or "your video" + short = " ".join(subject.split()[:12]) + if tool == "script_writer": + return {"script": f"Hook: {short}\nValue: show the clearest proof.\nCTA: invite viewers to take the next step."} + if tool == "hook_generator": + return {"hooks": [f"Stop scrolling if you care about {short}", f"Nobody tells you this about {short}"]} + if tool == "title_generator": + return {"titles": [short.title(), f"How {short.title()} Changes Everything"]} + if tool == "description_generator": + return {"description": f"{subject}\n\nBuilt with Ava2lon Studio AI."} + if tool == "hashtag_generator": + tags = [word.strip(".,!?").lower() for word in subject.split() if len(word.strip(".,!?")) > 3] + return {"hashtags": ["#" + tag for tag in tags[:8]] or ["#video", "#creator"]} + if tool == "storyboard_generator": + return {"beats": [{"scene": 1, "goal": "hook"}, {"scene": 2, "goal": "proof"}, {"scene": 3, "goal": "CTA"}]} + if tool == "b_roll_planner": + return {"shots": [{"type": "close_up", "description": short}, {"type": "screen_recording", "description": "show the result"}]} + if tool == "thumbnail_prompt_generator": + return {"prompt": f"High contrast thumbnail for {short}, expressive face, bold text, clean background"} + if tool == "seo_optimizer": + return {"keywords": [word.strip(".,!?").lower() for word in subject.split()[:10]], "score": 72} + return {"result": subject, "options": payload} diff --git a/renderer/subtitles/__init__.py b/renderer/subtitles/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b349b5fa65990b05984c46c1fd2632dc7ca12f74 --- /dev/null +++ b/renderer/subtitles/__init__.py @@ -0,0 +1,3 @@ +from renderer.subtitles.generator import SubtitleEvent, SubtitleGenerator + +__all__ = ["SubtitleEvent", "SubtitleGenerator"] diff --git a/renderer/subtitles/generator.py b/renderer/subtitles/generator.py new file mode 100644 index 0000000000000000000000000000000000000000..1d6b0942e9c74adf9bc906ec033901660d160263 --- /dev/null +++ b/renderer/subtitles/generator.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import html +from dataclasses import dataclass +from pathlib import Path + +from renderer.templates import get_template + + +@dataclass +class SubtitleEvent: + start: float + end: float + text: str + + +class SubtitleGenerator: + def from_scenes(self, scenes: list, total_duration: float | None = None) -> list[SubtitleEvent]: + events: list[SubtitleEvent] = [] + for scene in scenes: + if scene.caption: + events.append(SubtitleEvent(scene.start, scene.start + scene.duration, scene.caption)) + if not events and total_duration: + events.append(SubtitleEvent(0, total_duration, "")) + return events + + def write_srt(self, events: list[SubtitleEvent], output: Path) -> Path: + lines: list[str] = [] + for idx, event in enumerate(events, start=1): + lines.extend([str(idx), f"{_srt_time(event.start)} --> {_srt_time(event.end)}", event.text, ""]) + output.write_text("\n".join(lines), encoding="utf-8") + return output + + def write_ass(self, events: list[SubtitleEvent], output: Path, template_key: str) -> Path: + template = get_template(template_key) + body = [ + "[Script Info]", + "ScriptType: v4.00+", + "PlayResX: 1080", + "PlayResY: 1920", + "", + "[V4+ Styles]", + "Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour," + "Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow," + "Alignment,MarginL,MarginR,MarginV,Encoding", + template.ass_style(), + "", + "[Events]", + "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text", + ] + for event in events: + text = _ass_escape(event.text) + if template.effect == "karaoke": + text = _karaoke_text(text, event.end - event.start) + elif template.effect == "zoom": + text = r"{\t(0,180,\fscx115\fscy115)\t(180,360,\fscx100\fscy100)}" + text + elif template.effect == "bounce": + text = r"{\t(0,120,\frz-2)\t(120,240,\frz2)\t(240,360,\frz0)}" + text + body.append(f"Dialogue: 0,{_ass_time(event.start)},{_ass_time(event.end)},Default,,0,0,0,,{text}") + output.write_text("\n".join(body), encoding="utf-8") + return output + + +def _karaoke_text(text: str, duration: float) -> str: + words = text.split() + if not words: + return text + centiseconds = max(1, int(duration * 100 / len(words))) + return "".join(f"{{\\k{centiseconds}}}{word} " for word in words).strip() + + +def _srt_time(seconds: float) -> str: + ms = int(round(seconds * 1000)) + h, rem = divmod(ms, 3600000) + m, rem = divmod(rem, 60000) + s, ms = divmod(rem, 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def _ass_time(seconds: float) -> str: + cs = int(round(seconds * 100)) + h, rem = divmod(cs, 360000) + m, rem = divmod(rem, 6000) + s, cs = divmod(rem, 100) + return f"{h}:{m:02d}:{s:02d}.{cs:02d}" + + +def _ass_escape(text: str) -> str: + return html.escape(text).replace("\n", r"\N").replace("{", r"\{").replace("}", r"\}") diff --git a/renderer/templates/__init__.py b/renderer/templates/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..90fb97acee86b43aa1dd1df2c9ff1f40c8c11e6d --- /dev/null +++ b/renderer/templates/__init__.py @@ -0,0 +1,31 @@ +from renderer.templates.caption_templates import CaptionTemplate, get_template, list_templates +from renderer.templates.creative import ( + apply_creative_style, + creative_style_metadata, + get_creative_style, + list_creative_styles, + list_scene_effects, + scene_effect_filter, + scene_effect_metadata, +) +from renderer.templates.platforms import PlatformProfile, get_platform_profile, list_platform_profiles, platform_profile_metadata +from renderer.templates.presets import apply_preset, list_presets + +__all__ = [ + "CaptionTemplate", + "PlatformProfile", + "apply_creative_style", + "apply_preset", + "creative_style_metadata", + "get_creative_style", + "get_platform_profile", + "get_template", + "list_creative_styles", + "list_platform_profiles", + "list_scene_effects", + "list_presets", + "list_templates", + "platform_profile_metadata", + "scene_effect_filter", + "scene_effect_metadata", +] diff --git a/renderer/templates/caption_templates.py b/renderer/templates/caption_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..bc2da9e2b5a0207b4619158b680ac852e59d5644 --- /dev/null +++ b/renderer/templates/caption_templates.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CaptionTemplate: + key: str + label: str + font_size: int + primary_color: str + secondary_color: str + outline_color: str = "&H000000" + back_color: str = "&H80000000" + alignment: int = 2 + margin_v: int = 180 + bold: bool = True + effect: str = "none" + + def ass_style(self) -> str: + bold = -1 if self.bold else 0 + return ( + "Style: Default,DejaVu Sans," + f"{self.font_size},{self.primary_color},{self.secondary_color}," + f"{self.outline_color},{self.back_color},{bold},0,0,0,100,100,0,0,3,3,1," + f"{self.alignment},80,80,{self.margin_v},1" + ) + + +TEMPLATES: dict[str, CaptionTemplate] = { + "tiktok_classic": CaptionTemplate("tiktok_classic", "TikTok Classic", 64, "&H00FFFFFF", "&H0000FFFF", effect="karaoke"), + "tiktok_zoom": CaptionTemplate("tiktok_zoom", "TikTok Zoom", 72, "&H00FFFFFF", "&H0000E5FF", effect="zoom"), + "alex_hormozi": CaptionTemplate("alex_hormozi", "Alex Hormozi", 70, "&H0000FFFF", "&H00FFFFFF", effect="bounce"), + "modern_minimal": CaptionTemplate("modern_minimal", "Modern Minimal", 52, "&H00FFFFFF", "&H00DDDDDD", margin_v=240), + "youtube_shorts": CaptionTemplate("youtube_shorts", "YouTube Shorts", 62, "&H00FFFFFF", "&H000000FF", effect="karaoke"), + "podcast_style": CaptionTemplate("podcast_style", "Podcast Style", 48, "&H00F5F5F5", "&H0099CCFF", margin_v=120), + "news_style": CaptionTemplate("news_style", "News Style", 46, "&H00FFFFFF", "&H0000FFFF", alignment=2, margin_v=100), + "neon_pop": CaptionTemplate("neon_pop", "Neon Pop", 76, "&H00FFFFFF", "&H0000E5FF", outline_color="&H00FF2BD6", effect="karaoke"), + "product_demo": CaptionTemplate("product_demo", "Product Demo", 54, "&H00FFFFFF", "&H00C7F9CC", margin_v=210, effect="zoom"), + "cinematic_gold": CaptionTemplate("cinematic_gold", "Cinematic Gold", 50, "&H00F4E7B2", "&H00FFFFFF", outline_color="&H00111111", margin_v=180), + "creator_clean": CaptionTemplate("creator_clean", "Creator Clean", 58, "&H00FFFFFF", "&H00BCE7FD", margin_v=220, effect="bounce"), +} + + +def get_template(key: str) -> CaptionTemplate: + return TEMPLATES.get(key, TEMPLATES["tiktok_classic"]) + + +def list_templates() -> list[str]: + return list(TEMPLATES.keys()) diff --git a/renderer/templates/creative.py b/renderer/templates/creative.py new file mode 100644 index 0000000000000000000000000000000000000000..319acba7f2540b992422021ae3a02c50df59bc14 --- /dev/null +++ b/renderer/templates/creative.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class CreativeStyle: + key: str + label: str + description: str + platform: str + template: str + scene_duration: float + transition_sequence: tuple[str, ...] + scene_effect_sequence: tuple[str, ...] + render_defaults: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + def metadata_payload(self) -> dict[str, Any]: + return { + "key": self.key, + "label": self.label, + "description": self.description, + "platform": self.platform, + "template": self.template, + "scene_duration": self.scene_duration, + "transition_sequence": list(self.transition_sequence), + "scene_effect_sequence": list(self.scene_effect_sequence), + "render_defaults": deepcopy(self.render_defaults), + "metadata": deepcopy(self.metadata), + } + + +SCENE_EFFECTS: dict[str, dict[str, str]] = { + "none": {"label": "None", "filter": ""}, + "sharp_pop": { + "label": "Sharp Pop", + "filter": "eq=contrast=1.08:saturation=1.20:brightness=0.01,unsharp=5:5:0.8:3:3:0.4", + }, + "clean_beauty": { + "label": "Clean Beauty", + "filter": "hqdn3d=1.5:1.5:6:6,eq=saturation=1.08:contrast=1.03", + }, + "warm_glow": { + "label": "Warm Glow", + "filter": "eq=contrast=1.04:saturation=1.18:gamma_r=1.04:gamma_b=0.96,gblur=sigma=0.25", + }, + "cinematic": { + "label": "Cinematic", + "filter": "eq=contrast=1.14:saturation=0.95:brightness=-0.015,vignette=PI/6", + }, + "dreamy": { + "label": "Dreamy", + "filter": "gblur=sigma=0.6,eq=contrast=1.04:saturation=1.18:brightness=0.02", + }, + "flash_pop": { + "label": "Flash Pop", + "filter": "eq=contrast=1.16:saturation=1.25:brightness=0.035", + }, + "grain": { + "label": "Fine Grain", + "filter": "noise=alls=8:allf=t+u,eq=contrast=1.07:saturation=1.02", + }, + "motion_blur": { + "label": "Motion Blur", + "filter": "tmix=frames=3:weights='1 2 1',eq=contrast=1.05:saturation=1.08", + }, + "noir": { + "label": "Noir", + "filter": "hue=s=0,eq=contrast=1.18:brightness=-0.02", + }, + "glitch": { + "label": "Glitch", + "filter": "rgbashift=rh=4:bh=-4,eq=contrast=1.12:saturation=1.18", + }, + "rgb_split": { + "label": "RGB Split", + "filter": "rgbashift=rh=3:gv=1:bh=-3", + }, + "vhs": { + "label": "VHS", + "filter": "noise=alls=18:allf=t+u,eq=saturation=0.82:contrast=1.08", + }, + "crt": { + "label": "CRT", + "filter": "vignette=PI/4,noise=alls=10:allf=t+u,eq=contrast=1.15:saturation=0.9", + }, + "bloom": { + "label": "Bloom", + "filter": "gblur=sigma=0.35,eq=brightness=0.025:saturation=1.14", + }, + "glow": { + "label": "Glow", + "filter": "gblur=sigma=0.45,eq=contrast=1.05:brightness=0.03", + }, + "chromatic_aberration": { + "label": "Chromatic Aberration", + "filter": "rgbashift=rh=2:rv=1:bh=-2:bv=-1", + }, + "neon": { + "label": "Neon", + "filter": "eq=contrast=1.2:saturation=1.55:brightness=0.02", + }, + "cyberpunk": { + "label": "Cyberpunk", + "filter": "eq=contrast=1.18:saturation=1.45:gamma_r=1.08:gamma_b=1.18", + }, + "comic": { + "label": "Comic", + "filter": "edgedetect=low=0.08:high=0.25,eq=contrast=1.2:saturation=1.35", + }, + "cartoon": { + "label": "Cartoon", + "filter": "edgedetect=low=0.05:high=0.2,eq=saturation=1.45:contrast=1.15", + }, + "anime": { + "label": "Anime", + "filter": "eq=saturation=1.35:contrast=1.12:brightness=0.02,unsharp=5:5:0.6", + }, + "sketch": { + "label": "Sketch", + "filter": "edgedetect=low=0.03:high=0.18,hue=s=0", + }, + "oil_painting": { + "label": "Oil Painting", + "filter": "gblur=sigma=0.7,eq=saturation=1.25:contrast=1.1", + }, + "pixel_art": { + "label": "Pixel Art", + "filter": "scale=iw/8:ih/8,scale=iw*8:ih*8:flags=neighbor", + }, +} + + +CREATIVE_STYLES: dict[str, CreativeStyle] = { + "viral_shorts": CreativeStyle( + key="viral_shorts", + label="Viral Shorts", + description="Fast vertical pacing, punchy captions, bright contrast, and whip-style movement.", + platform="tiktok", + template="tiktok_zoom", + scene_duration=2.4, + transition_sequence=("whip", "flash", "zoom", "glitch"), + scene_effect_sequence=("sharp_pop", "flash_pop", "clean_beauty"), + render_defaults={ + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.24, + "music_fade_in": 0.25, + "music_fade_out": 0.8, + "music_ducking": True, + "normalize": True, + }, + metadata={"energy": "high", "best_for": "hooks, offers, memes, short promos"}, + ), + "product_demo": CreativeStyle( + key="product_demo", + label="Product Demo", + description="Clean cuts, readable captions, and polished color for launches and tutorials.", + platform="instagram_reels", + template="modern_minimal", + scene_duration=3.2, + transition_sequence=("slide", "wipe_left", "push", "dissolve"), + scene_effect_sequence=("clean_beauty", "sharp_pop"), + render_defaults={ + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.18, + "music_fade_in": 0.3, + "music_fade_out": 0.7, + "music_ducking": True, + "normalize": True, + }, + metadata={"energy": "medium", "best_for": "software demos, ecommerce, tutorials"}, + ), + "story_vlog": CreativeStyle( + key="story_vlog", + label="Story Vlog", + description="Warm color, softer movement, and natural pacing for personality-led edits.", + platform="instagram_reels", + template="tiktok_classic", + scene_duration=3.8, + transition_sequence=("fade", "smooth_right", "dissolve"), + scene_effect_sequence=("warm_glow", "dreamy", "clean_beauty"), + render_defaults={ + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.22, + "music_fade_in": 0.5, + "music_fade_out": 1.0, + "music_ducking": True, + "normalize": True, + }, + metadata={"energy": "medium", "best_for": "founder updates, day-in-life edits, testimonials"}, + ), + "podcast_clip": CreativeStyle( + key="podcast_clip", + label="Podcast Clip", + description="Square-safe framing, calmer captions, and narration-first audio treatment.", + platform="instagram_feed_square", + template="podcast_style", + scene_duration=5.0, + transition_sequence=("fade", "dissolve"), + scene_effect_sequence=("clean_beauty", "sharp_pop"), + render_defaults={ + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.12, + "music_fade_in": 0.6, + "music_fade_out": 1.2, + "music_ducking": True, + "normalize": True, + }, + metadata={"energy": "low", "best_for": "interviews, audiograms, education"}, + ), + "cinematic_story": CreativeStyle( + key="cinematic_story", + label="Cinematic Story", + description="Deeper contrast, film grain, and slower transitions for mini-documentary edits.", + platform="youtube_shorts", + template="modern_minimal", + scene_duration=4.2, + transition_sequence=("dissolve", "fadeblack", "smooth_left"), + scene_effect_sequence=("cinematic", "grain"), + render_defaults={ + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.26, + "music_fade_in": 0.8, + "music_fade_out": 1.4, + "music_ducking": True, + "normalize": True, + }, + metadata={"energy": "low", "best_for": "brand films, travel, documentary shorts"}, + ), + "news_explainer": CreativeStyle( + key="news_explainer", + label="News Explainer", + description="Readable lower-third style captions with stable landscape or vertical exports.", + platform="youtube_1080p", + template="news_style", + scene_duration=4.0, + transition_sequence=("wipe_left", "slide", "fade"), + scene_effect_sequence=("sharp_pop", "clean_beauty"), + render_defaults={ + "subtitle_format": "ass", + "auto_subtitles": False, + "audio_normalize": True, + "music_volume": 0.1, + "music_fade_in": 0.5, + "music_fade_out": 1.0, + "music_ducking": True, + "normalize": True, + }, + metadata={"energy": "medium", "best_for": "explainers, news, thought leadership"}, + ), +} + + +def apply_creative_style(payload: dict[str, Any]) -> dict[str, Any]: + style_key = payload.get("creative_style") or payload.get("metadata", {}).get("creative_style") + if not style_key: + return payload + + style = get_creative_style(str(style_key)) + output = deepcopy(payload) + output["creative_style"] = style.key + _set_default(output, "platform", style.platform) + _set_default(output, "template", style.template) + for key, value in style.render_defaults.items(): + _set_default(output, key, deepcopy(value)) + + metadata = deepcopy(style.metadata) + metadata.update(output.get("metadata", {})) + metadata["creative_style"] = style.key + metadata["creative_style_label"] = style.label + output["metadata"] = metadata + + scenes = output.get("scenes") + if isinstance(scenes, list): + output["scenes"] = [_style_scene(scene, style, index) for index, scene in enumerate(scenes)] + return output + + +def get_creative_style(key: str | None) -> CreativeStyle: + if not key: + return CREATIVE_STYLES["viral_shorts"] + return CREATIVE_STYLES.get(key, CREATIVE_STYLES["viral_shorts"]) + + +def list_creative_styles() -> list[str]: + return list(CREATIVE_STYLES.keys()) + + +def creative_style_metadata() -> dict[str, dict[str, Any]]: + return {key: style.metadata_payload() for key, style in CREATIVE_STYLES.items()} + + +def list_scene_effects() -> list[str]: + return list(SCENE_EFFECTS.keys()) + + +def scene_effect_metadata() -> dict[str, dict[str, str]]: + return {key: {"label": value["label"]} for key, value in SCENE_EFFECTS.items()} + + +def scene_effect_filter(key: str | None) -> str: + if not key: + return "" + return SCENE_EFFECTS.get(key, SCENE_EFFECTS["none"])["filter"] + + +def _set_default(payload: dict[str, Any], key: str, value: Any) -> None: + if key not in payload or payload[key] in (None, ""): + payload[key] = value + + +def _style_scene(scene: Any, style: CreativeStyle, index: int) -> Any: + if not isinstance(scene, dict): + return scene + styled = deepcopy(scene) + transition = styled.get("transition") + if transition in (None, "", "fade"): + styled["transition"] = style.transition_sequence[index % len(style.transition_sequence)] + if styled.get("effect") in (None, ""): + styled["effect"] = style.scene_effect_sequence[index % len(style.scene_effect_sequence)] + _set_default(styled, "background", "blur") + _set_default(styled, "layout", "fill") + return styled diff --git a/renderer/templates/platforms.py b/renderer/templates/platforms.py new file mode 100644 index 0000000000000000000000000000000000000000..82a1f0eae130237b104388e31fe28298d1e7cc8b --- /dev/null +++ b/renderer/templates/platforms.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class PlatformProfile: + key: str + label: str + width: int + height: int + fps: int = 30 + video_codec: str = "libx264" + audio_codec: str = "aac" + audio_bitrate: str = "128k" + audio_sample_rate: int = 48000 + crf: int = 23 + maxrate: str | None = None + bufsize: str | None = None + max_duration_seconds: int | None = None + recommended_duration_seconds: tuple[int, int] | None = None + safe_zones: dict[str, int] = field(default_factory=dict) + notes: tuple[str, ...] = () + + @property + def aspect_ratio(self) -> str: + return f"{self.width}:{self.height}" + + def metadata(self) -> dict[str, Any]: + return { + "platform": self.key, + "label": self.label, + "width": self.width, + "height": self.height, + "fps": self.fps, + "aspect_ratio": self.aspect_ratio, + "video_codec": self.video_codec, + "audio_codec": self.audio_codec, + "audio_bitrate": self.audio_bitrate, + "max_duration_seconds": self.max_duration_seconds, + "recommended_duration_seconds": self.recommended_duration_seconds, + "safe_zones": self.safe_zones, + "notes": list(self.notes), + } + + +COMMON_VERTICAL_SAFE_ZONES = { + "top_px": 220, + "bottom_px": 340, + "left_px": 80, + "right_px": 80, +} + + +PLATFORM_PROFILES: dict[str, PlatformProfile] = { + "tiktok": PlatformProfile( + key="tiktok", + label="TikTok vertical", + width=1080, + height=1920, + fps=30, + maxrate="8M", + bufsize="16M", + max_duration_seconds=600, + recommended_duration_seconds=(12, 60), + safe_zones=COMMON_VERTICAL_SAFE_ZONES, + notes=( + "9:16 vertical MP4 keeps the frame full-screen in the For You feed.", + "Use licensed or original music to avoid muted audio or takedowns.", + ), + ), + "instagram_reels": PlatformProfile( + key="instagram_reels", + label="Instagram Reels", + width=1080, + height=1920, + fps=30, + maxrate="8M", + bufsize="16M", + max_duration_seconds=180, + recommended_duration_seconds=(7, 90), + safe_zones=COMMON_VERTICAL_SAFE_ZONES, + notes=( + "9:16 is the safest Reels export to avoid cropping or blank space.", + "Keep captions and logos away from top and bottom app chrome.", + ), + ), + "facebook_reels": PlatformProfile( + key="facebook_reels", + label="Facebook Reels", + width=1080, + height=1920, + fps=30, + maxrate="8M", + bufsize="16M", + max_duration_seconds=180, + recommended_duration_seconds=(7, 90), + safe_zones=COMMON_VERTICAL_SAFE_ZONES, + ), + "youtube_shorts": PlatformProfile( + key="youtube_shorts", + label="YouTube Shorts", + width=1080, + height=1920, + fps=30, + audio_bitrate="192k", + maxrate="10M", + bufsize="20M", + max_duration_seconds=180, + recommended_duration_seconds=(15, 60), + safe_zones={"top_px": 180, "bottom_px": 300, "left_px": 80, "right_px": 80}, + notes=( + "YouTube categorizes square or vertical videos up to 3 minutes as Shorts.", + "Avoid Content ID-claimed music in Shorts longer than 60 seconds.", + ), + ), + "youtube_1080p": PlatformProfile( + key="youtube_1080p", + label="YouTube 1080p landscape", + width=1920, + height=1080, + fps=30, + audio_bitrate="192k", + maxrate="12M", + bufsize="24M", + recommended_duration_seconds=(60, 600), + notes=("16:9 H.264/AAC output for standard YouTube uploads.",), + ), + "instagram_feed_square": PlatformProfile( + key="instagram_feed_square", + label="Instagram feed square", + width=1080, + height=1080, + fps=30, + maxrate="8M", + bufsize="16M", + max_duration_seconds=3600, + recommended_duration_seconds=(5, 60), + safe_zones={"top_px": 80, "bottom_px": 120, "left_px": 80, "right_px": 80}, + ), + "instagram_feed_portrait": PlatformProfile( + key="instagram_feed_portrait", + label="Instagram feed portrait", + width=1080, + height=1350, + fps=30, + maxrate="8M", + bufsize="16M", + max_duration_seconds=3600, + recommended_duration_seconds=(5, 60), + safe_zones={"top_px": 80, "bottom_px": 140, "left_px": 80, "right_px": 80}, + ), + "snapchat_spotlight": PlatformProfile( + key="snapchat_spotlight", + label="Snapchat Spotlight", + width=1080, + height=1920, + fps=30, + maxrate="8M", + bufsize="16M", + max_duration_seconds=60, + recommended_duration_seconds=(5, 30), + safe_zones=COMMON_VERTICAL_SAFE_ZONES, + notes=("Vertical, fast-paced edits perform best in Spotlight.",), + ), + "pinterest_idea_pins": PlatformProfile( + key="pinterest_idea_pins", + label="Pinterest Idea Pins", + width=1080, + height=1920, + fps=30, + maxrate="8M", + bufsize="16M", + max_duration_seconds=300, + recommended_duration_seconds=(6, 45), + safe_zones={"top_px": 160, "bottom_px": 240, "left_px": 80, "right_px": 80}, + notes=("Use clear text overlays and evergreen discovery keywords.",), + ), + "linkedin_video": PlatformProfile( + key="linkedin_video", + label="LinkedIn video", + width=1920, + height=1080, + fps=30, + audio_bitrate="192k", + maxrate="10M", + bufsize="20M", + max_duration_seconds=600, + recommended_duration_seconds=(30, 180), + safe_zones={"top_px": 80, "bottom_px": 100, "left_px": 80, "right_px": 80}, + notes=("Landscape explainers and square clips both work; captions are strongly recommended.",), + ), +} + + +def get_platform_profile(key: str | None) -> PlatformProfile: + if not key: + return PLATFORM_PROFILES["tiktok"] + return PLATFORM_PROFILES.get(key, PLATFORM_PROFILES["tiktok"]) + + +def list_platform_profiles() -> list[str]: + return sorted(PLATFORM_PROFILES) + + +def platform_profile_metadata() -> dict[str, dict[str, Any]]: + return {key: profile.metadata() for key, profile in sorted(PLATFORM_PROFILES.items())} diff --git a/renderer/templates/presets.py b/renderer/templates/presets.py new file mode 100644 index 0000000000000000000000000000000000000000..9fdc067f404b9037f10a9ec5a3fb98d8b6e85456 --- /dev/null +++ b/renderer/templates/presets.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +PRESETS: dict[str, dict[str, Any]] = { + "tiktok_9_16_fast": { + "creative_style": "viral_shorts", + "platform": "tiktok", + "template": "tiktok_classic", + "subtitle_format": "ass", + "auto_subtitles": True, + "preview": False, + "normalize": True, + }, + "youtube_shorts_hd": { + "creative_style": "viral_shorts", + "platform": "youtube_shorts", + "template": "youtube_shorts", + "subtitle_format": "ass", + "auto_subtitles": True, + "normalize": True, + }, + "podcast_square": { + "creative_style": "podcast_clip", + "platform": "instagram_feed_square", + "template": "podcast_style", + "subtitle_format": "ass", + "auto_subtitles": True, + "normalize": True, + "metadata": {"target_aspect": "1:1"}, + }, + "reels_with_subtitles": { + "creative_style": "story_vlog", + "platform": "instagram_reels", + "template": "modern_minimal", + "subtitle_format": "ass", + "auto_subtitles": True, + "normalize": True, + }, + "draft_preview": { + "creative_style": "product_demo", + "platform": "tiktok", + "template": "modern_minimal", + "subtitle_format": "ass", + "preview": True, + "normalize": True, + }, + "tiktok_music_ducked": { + "creative_style": "viral_shorts", + "platform": "tiktok", + "template": "tiktok_classic", + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.25, + "music_fade_in": 0.4, + "music_fade_out": 1.0, + "music_loop": True, + "music_ducking": True, + "normalize": True, + }, + "instagram_reels_music": { + "creative_style": "story_vlog", + "platform": "instagram_reels", + "template": "modern_minimal", + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.28, + "music_fade_in": 0.3, + "music_fade_out": 0.8, + "music_loop": True, + "music_ducking": True, + "normalize": True, + }, + "youtube_landscape_1080p": { + "creative_style": "news_explainer", + "platform": "youtube_1080p", + "template": "news_style", + "subtitle_format": "ass", + "auto_subtitles": False, + "audio_normalize": True, + "normalize": True, + }, + "capcut_viral_auto": { + "creative_style": "viral_shorts", + "platform": "tiktok", + "template": "neon_pop", + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.24, + "music_fade_in": 0.2, + "music_fade_out": 0.8, + "music_loop": True, + "music_ducking": True, + "normalize": True, + }, + "capcut_product_launch": { + "creative_style": "product_demo", + "platform": "instagram_reels", + "template": "product_demo", + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.18, + "music_fade_in": 0.3, + "music_fade_out": 0.7, + "music_loop": True, + "music_ducking": True, + "normalize": True, + }, + "capcut_cinematic_story": { + "creative_style": "cinematic_story", + "platform": "youtube_shorts", + "template": "cinematic_gold", + "subtitle_format": "ass", + "auto_subtitles": True, + "audio_normalize": True, + "music_volume": 0.26, + "music_fade_in": 0.8, + "music_fade_out": 1.4, + "music_loop": True, + "music_ducking": True, + "normalize": True, + }, +} + + +def list_presets() -> list[str]: + return sorted(PRESETS) + + +def apply_preset(payload: dict[str, Any]) -> dict[str, Any]: + preset_name = payload.get("preset") + if not preset_name: + return payload + preset = deepcopy(PRESETS.get(preset_name, {})) + preset.update(payload) + if "metadata" in PRESETS.get(preset_name, {}) or "metadata" in payload: + metadata = deepcopy(PRESETS.get(preset_name, {}).get("metadata", {})) + metadata.update(payload.get("metadata", {})) + preset["metadata"] = metadata + return preset diff --git a/renderer/transcription/__init__.py b/renderer/transcription/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0a2279eb2b61e21d3bb959cff730686d7bd69a4d --- /dev/null +++ b/renderer/transcription/__init__.py @@ -0,0 +1,3 @@ +from renderer.transcription.whisper import TranscriptionResult, WhisperTranscriber + +__all__ = ["TranscriptionResult", "WhisperTranscriber"] diff --git a/renderer/transcription/whisper.py b/renderer/transcription/whisper.py new file mode 100644 index 0000000000000000000000000000000000000000..3a0076563f1f429224c6511b5d5e319df26ad031 --- /dev/null +++ b/renderer/transcription/whisper.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from pathlib import Path +from threading import Lock + +from renderer.core.config import Settings +from renderer.subtitles import SubtitleEvent + + +@dataclass +class TranscriptionWord: + start: float + end: float + word: str + probability: float | None = None + + +@dataclass +class TranscriptionSegment: + id: int + start: float + end: float + text: str + words: list[TranscriptionWord] = field(default_factory=list) + + +@dataclass +class TranscriptionResult: + text: str + language: str | None + language_probability: float | None + duration: float | None + segments: list[TranscriptionSegment] + + def as_dict(self) -> dict: + return asdict(self) + + def subtitle_events(self, prefer_words: bool = False) -> list[SubtitleEvent]: + if prefer_words: + words = [ + SubtitleEvent(word.start, word.end, word.word.strip()) + for segment in self.segments + for word in segment.words + if word.word.strip() + ] + if words: + return words + return [SubtitleEvent(segment.start, segment.end, segment.text.strip()) for segment in self.segments if segment.text.strip()] + + +class WhisperTranscriber: + """Lazy CPU-first faster-whisper wrapper.""" + + _models: dict[tuple[str, str, str, str], object] = {} + _lock = Lock() + + def __init__(self, settings: Settings) -> None: + self.settings = settings + + def transcribe( + self, + audio_path: str | Path, + *, + model_size: str | None = None, + language: str | None = None, + task: str = "transcribe", + beam_size: int = 5, + vad_filter: bool = True, + word_timestamps: bool = True, + ) -> TranscriptionResult: + model = self._model(model_size or self.settings.whisper_model_size) + segments_iter, info = model.transcribe( + str(audio_path), + language=language, + task=task, + beam_size=beam_size, + vad_filter=vad_filter, + word_timestamps=word_timestamps, + ) + segments: list[TranscriptionSegment] = [] + for segment in segments_iter: + words = [ + TranscriptionWord( + start=float(word.start), + end=float(word.end), + word=word.word, + probability=getattr(word, "probability", None), + ) + for word in (segment.words or []) + ] + segments.append( + TranscriptionSegment( + id=int(segment.id), + start=float(segment.start), + end=float(segment.end), + text=segment.text.strip(), + words=words, + ) + ) + return TranscriptionResult( + text=" ".join(segment.text for segment in segments).strip(), + language=getattr(info, "language", None), + language_probability=getattr(info, "language_probability", None), + duration=getattr(info, "duration", None), + segments=segments, + ) + + def _model(self, model_size: str): + key = ( + model_size, + self.settings.whisper_device, + self.settings.whisper_compute_type, + str(self.settings.whisper_model_dir), + ) + with self._lock: + if key not in self._models: + try: + from faster_whisper import WhisperModel + except ImportError as exc: + raise RuntimeError("faster-whisper is not installed. Install requirements.txt to enable transcription.") from exc + self._models[key] = WhisperModel( + model_size, + device=self.settings.whisper_device, + compute_type=self.settings.whisper_compute_type, + download_root=str(self.settings.whisper_model_dir), + ) + return self._models[key] diff --git a/renderer/transitions/__init__.py b/renderer/transitions/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..da35a5e81dfef185eac44d9eb28fa3a7bcf916af --- /dev/null +++ b/renderer/transitions/__init__.py @@ -0,0 +1,3 @@ +from renderer.transitions.builder import TransitionBuilder + +__all__ = ["TransitionBuilder"] diff --git a/renderer/transitions/builder.py b/renderer/transitions/builder.py new file mode 100644 index 0000000000000000000000000000000000000000..17d16ae11204005d900be4c55c7163aad62a0a7c --- /dev/null +++ b/renderer/transitions/builder.py @@ -0,0 +1,69 @@ +from __future__ import annotations + + +class TransitionBuilder: + """Build FFmpeg xfade filters for scene joins.""" + + TRANSITIONS = { + "basic": "fade", + "fade": "fade", + "zoom": "zoomin", + "slide": "slideleft", + "slide_left": "slideleft", + "slide_right": "slideright", + "slide_up": "slideup", + "slide_down": "slidedown", + "push": "slideup", + "blur": "fade", + "whip": "smoothleft", + "dissolve": "dissolve", + "flash": "fadewhite", + "glitch": "hlslice", + "light_leak": "fadewhite", + "film_burn": "fadegrays", + "camera_shake": "smoothleft", + "spin": "circleopen", + "3d_flip": "vertopen", + "flip": "vertopen", + "cube": "rectcrop", + "ripple": "radial", + "ink": "distance", + "morph": "dissolve", + "stretch": "squeezeh", + "liquid": "pixelize", + "elastic": "smoothup", + "motion_blur": "smoothleft", + "wipe": "wipeleft", + "wipe_left": "wipeleft", + "wipe_right": "wiperight", + "wipe_up": "wipeup", + "wipe_down": "wipedown", + "smooth_left": "smoothleft", + "smooth_right": "smoothright", + "fadeblack": "fadeblack", + "fade_white": "fadewhite", + "pixel": "pixelize", + } + + def map_transition(self, name: str) -> str: + return self.TRANSITIONS.get(name, "fade") + + def list_transitions(self) -> list[str]: + return list(self.TRANSITIONS.keys()) + + def xfade_chain(self, stream_count: int, durations: list[float], transitions: list[str], transition_duration: float = 0.45) -> tuple[str, str]: + if stream_count <= 1: + return "", "[0:v]" + filters: list[str] = [] + current = "[0:v]" + offset = max(0.1, durations[0] - transition_duration) + for idx in range(1, stream_count): + out = f"[vx{idx}]" + transition = self.map_transition(transitions[idx - 1] if idx - 1 < len(transitions) else "fade") + filters.append( + f"{current}[{idx}:v]xfade=transition={transition}:duration={transition_duration}:offset={offset:.3f}{out}" + ) + current = out + if idx < len(durations): + offset += max(0.1, durations[idx] - transition_duration) + return ";".join(filters), current diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000000000000000000000000000000000000..155b598b75d56570d8a1ff98a1700b8592a93b18 --- /dev/null +++ b/static/app.js @@ -0,0 +1,230 @@ +/* ========================================================= + BASYX V11 UI ENGINE + Production-grade frontend runtime +========================================================= */ + +const API_BASE = ""; + +/* ========================================================= + STATE +========================================================= */ + +const state = { + currentTask: "transcribe", + loading: false, +}; + +/* ========================================================= + SAFE ELEMENT ACCESS +========================================================= */ + +function el(id) { + const node = document.getElementById(id); + if (!node) console.warn(`[UI] Missing element: ${id}`); + return node; +} + +/* ========================================================= + NAVIGATION SYSTEM +========================================================= */ + +function nav(task) { + state.currentTask = task; + + document.querySelectorAll(".section").forEach((s) => + s.classList.remove("active") + ); + + const page = el(task); + if (page) page.classList.add("active"); + + document.querySelectorAll(".nav-item").forEach((n) => + n.classList.remove("active") + ); + + if (event && event.target) { + event.target.classList.add("active"); + } + + location.hash = task; +} + +/* Restore on reload */ +window.addEventListener("load", () => { + const hash = location.hash.replace("#", ""); + if (hash && el(hash)) nav(hash); +}); + +/* ========================================================= + UI LOADING STATE +========================================================= */ + +function setLoading(task, isLoading) { + const btn = el(`btn-${task}`); + if (!btn) return; + + btn.disabled = isLoading; + btn.innerText = isLoading ? "Processing..." : "Execute"; +} + +/* ========================================================= + ERROR HANDLING +========================================================= */ + +async function safeFetch(url, options, retries = 2) { + try { + const res = await fetch(url, options); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text}`); + } + + return res; + } catch (err) { + if (retries > 0) { + await new Promise((r) => setTimeout(r, 800)); + return safeFetch(url, options, retries - 1); + } + throw err; + } +} + +/* ========================================================= + RESPONSE PARSER (JSON / BLOB / VIDEO) +========================================================= */ + +async function parseResponse(res, task) { + const contentType = res.headers.get("content-type") || ""; + + // VIDEO OR FILE OUTPUT + if ( + contentType.includes("video") || + contentType.includes("octet-stream") + ) { + const blob = await res.blob(); + return { + type: "blob", + url: URL.createObjectURL(blob), + }; + } + + // JSON OUTPUT + const data = await res.json(); + return { + type: "json", + data, + }; +} + +/* ========================================================= + INPUT COLLECTION +========================================================= */ + +function collectInput(task) { + const file = el(`file-${task}`)?.files?.[0]; + const url = el(`url-${task}`)?.value?.trim(); + + const fd = new FormData(); + + if (file) fd.append("file", file); + if (url) fd.append("url_input", url); + + return fd; +} + +/* ========================================================= + CORE EXECUTION +========================================================= */ + +async function run(task) { + if (state.loading) return; + + state.loading = true; + setLoading(task, true); + + try { + const fd = collectInput(task); + + const res = await safeFetch(`${API_BASE}/execute/${task}`, { + method: "POST", + body: fd, + }); + + const parsed = await parseResponse(res, task); + + renderOutput(task, parsed); + } catch (err) { + console.error(err); + renderError(task, err.message); + } finally { + state.loading = false; + setLoading(task, false); + } +} + +/* ========================================================= + OUTPUT RENDERING +========================================================= */ + +function renderOutput(task, result) { + const outputBox = el(`result-${task}`); + const videoBox = el(`video-${task}`); + + if (result.type === "blob") { + if (videoBox) { + videoBox.src = result.url; + videoBox.style.display = "block"; + } + return; + } + + if (outputBox) { + outputBox.textContent = JSON.stringify(result.data, null, 2); + } +} + +/* ========================================================= + ERROR UI +========================================================= */ + +function renderError(task, message) { + const outputBox = el(`result-${task}`); + + if (outputBox) { + outputBox.textContent = `ERROR:\n${message}`; + outputBox.style.color = "#ff4d4d"; + } +} + +/* ========================================================= + HEALTH CHECK (optional future UI use) +========================================================= */ + +async function checkHealth() { + try { + const res = await fetch("/health"); + return await res.json(); + } catch { + return { status: "offline" }; + } +} + +/* ========================================================= + MOBILE SIDEBAR TOGGLE +========================================================= */ + +function toggleSidebar() { + const sidebar = document.querySelector(".sidebar"); + if (!sidebar) return; + + sidebar.classList.toggle("open"); +} + +/* Close sidebar on nav click (mobile UX) */ +document.addEventListener("click", (e) => { + if (e.target.classList.contains("nav-item")) { + const sidebar = document.querySelector(".sidebar"); + if (sidebar) sidebar.classList.remove("open"); + } +}); \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..40615d735074bdbb0f1c28e9cae703e4d2e3abdf --- /dev/null +++ b/static/index.html @@ -0,0 +1,278 @@ + + + + + + +Basyx V11 Content OS + + + + + + + + + + +
+ + +
+

+ AUTONOMOUS CONTENT OPERATOR +

+
+ + +
+
+

TRANSCRIBE

+ + + +
+

+
+ +
+
+

SUBTITLES

+ + + +
+

+
+ +
+
+

RENDER

+ + + +
+ +
+ +
+
+

HIGHLIGHTS

+ + +
+

+
+ +
+
+

VIRAL SCORE

+ + +
+

+
+ +
+
+

STRATEGY

+ + +
+

+
+ + + + + \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000000000000000000000000000000000000..55c26a4edbbaf5f1d4f9790fd8eac05f2934c102 --- /dev/null +++ b/static/style.css @@ -0,0 +1,338 @@ +/* ========================================================= + BASYX V11 UI SYSTEM + Industrial Dark OS Theme +========================================================= */ + +:root { + --void: #080808; + --surface: #0f0f0f; + --panel: #161616; + --panel-2: #121212; + + --border: #232323; + --border-2: #2c2c2c; + + --accent: #e8ff47; + --accent-dim: #b8cc30; + + --text: #e8e8e8; + --text-dim: #888888; + + --muted: #555555; + --danger: #ef4444; + --warn: #f59e0b; + --ok: #22c55e; + + --radius: 6px; + + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono"; + --font-display: Bebas Neue, sans-serif; + --font-body: system-ui, -apple-system, Segoe UI, Roboto; +} + +/* ========================================================= + BASE RESET +========================================================= */ + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + background: var(--void); + color: var(--text); + font-family: var(--font-body); + height: 100%; +} + +h1, h2, h3 { + margin: 0; +} + +/* ========================================================= + SIDEBAR +========================================================= */ + +.sidebar { + position: fixed; + left: 0; + top: 0; + width: 220px; + height: 100vh; + background: #0b0b0b; + border-right: 1px solid var(--border); + padding: 16px; + overflow-y: auto; + transition: transform 0.25s ease; +} + +.brand { + font-family: var(--font-display); + font-size: 28px; + color: var(--accent); + margin-bottom: 18px; + letter-spacing: 1px; +} + +.nav-group-title { + font-size: 11px; + color: var(--text-dim); + margin: 16px 0 6px; + letter-spacing: 1px; +} + +.nav-item { + padding: 10px 12px; + cursor: pointer; + color: var(--text-dim); + border-left: 2px solid transparent; + transition: all 0.2s ease; + border-radius: 4px; +} + +.nav-item:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.03); +} + +.nav-item.active { + border-left: 2px solid var(--accent); + color: var(--accent); + background: rgba(232, 255, 71, 0.06); +} + +/* ========================================================= + MAIN LAYOUT +========================================================= */ + +.main { + margin-left: 220px; + padding: 24px; +} + +/* ========================================================= + SECTIONS +========================================================= */ + +.section { + display: none; + animation: fadeIn 0.25s ease; +} + +.section.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ========================================================= + PANELS +========================================================= */ + +.panel { + background: var(--panel); + border: 1px solid var(--border); + padding: 18px; + border-radius: var(--radius); + margin-bottom: 16px; +} + +.panel h2 { + font-family: var(--font-display); + letter-spacing: 1px; + color: var(--accent); + margin-bottom: 12px; +} + +/* ========================================================= + INPUT SYSTEM +========================================================= */ + +input, textarea, select { + width: 100%; + padding: 10px 12px; + margin-top: 8px; + background: #111; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + outline: none; +} + +input:focus { + border-color: var(--accent); +} + +/* ========================================================= + BUTTONS +========================================================= */ + +button { + padding: 12px 14px; + border: none; + cursor: pointer; + background: var(--accent); + color: #000; + font-weight: 700; + border-radius: 4px; + transition: all 0.2s ease; + margin-top: 10px; +} + +button:hover { + filter: brightness(1.05); +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ========================================================= + PRE / OUTPUT +========================================================= */ + +pre { + background: #0b0b0b; + border: 1px solid var(--border); + padding: 12px; + border-radius: var(--radius); + overflow: auto; + color: var(--text); + font-family: var(--font-mono); +} + +/* ========================================================= + VIDEO OUTPUT +========================================================= */ + +video { + width: 100%; + border-radius: var(--radius); + border: 1px solid var(--border); + background: #000; +} + +/* ========================================================= + CARDS (future UI expansion) +========================================================= */ + +.card-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + padding: 14px; + border-radius: var(--radius); + cursor: pointer; + transition: 0.2s; +} + +.card:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +/* ========================================================= + STATUS BADGES +========================================================= */ + +.badge { + display: inline-block; + padding: 4px 8px; + font-size: 12px; + border-radius: 4px; + border: 1px solid var(--border); +} + +.badge.ok { + color: var(--ok); + border-color: var(--ok); +} + +.badge.warn { + color: var(--warn); + border-color: var(--warn); +} + +.badge.error { + color: var(--danger); + border-color: var(--danger); +} + +/* ========================================================= + STAT CARDS +========================================================= */ + +.stat-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 12px 0; +} + +.stat-card { + background: var(--panel); + border: 1px solid var(--border); + padding: 14px; + border-radius: var(--radius); +} + +.stat-label { + font-size: 12px; + color: var(--text-dim); +} + +.stat-value { + font-size: 34px; + font-family: var(--font-display); + color: var(--accent); +} + +/* ========================================================= + LOADING STATE +========================================================= */ + +.loading { + opacity: 0.6; + pointer-events: none; +} + +/* ========================================================= + MOBILE +========================================================= */ + +@media (max-width: 768px) { + + .sidebar { + transform: translateX(-100%); + position: fixed; + z-index: 50; + } + + .sidebar.open { + transform: translateX(0); + } + + .main { + margin-left: 0; + padding: 16px; + } + + .card-grid { + grid-template-columns: 1fr; + } + + .stat-grid { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..40c75472b2df38dce59c6250b63167298c0e06a2 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +# Initialize package diff --git a/utils/ab_generator.py b/utils/ab_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..fe35d963dcc587d1e64fba02b674ccf2db3b8416 --- /dev/null +++ b/utils/ab_generator.py @@ -0,0 +1,11 @@ +from .strategist import rewrite_hook + +def generate_variants(segment): + + text = " ".join([w["text"] for w in segment]) + + return [ + rewrite_hook(text), + f"🔥 {text}", + f"Did you know? {text}", + ] \ No newline at end of file diff --git a/utils/auto_editor.py b/utils/auto_editor.py new file mode 100644 index 0000000000000000000000000000000000000000..88fed30e92620253ff2c03663103d7c0aff7bbbb --- /dev/null +++ b/utils/auto_editor.py @@ -0,0 +1,112 @@ +""" +auto_editor.py +--------------------------------------- +Automatic video pacing editor + +Features: +- Removes silence automatically +- Speeds up slow segments +- Keeps speech natural +- CPU optimized (HuggingFace FREE tier safe) + +Input: + input.mp4 + +Output: + edited.mp4 +""" + +import subprocess +import os +import sys +from pathlib import Path + +INPUT_VIDEO = "input.mp4" +OUTPUT_VIDEO = "edited.mp4" + + +def run_command(cmd): + """Run shell command safely""" + try: + subprocess.run( + cmd, + shell=True, + check=True + ) + except subprocess.CalledProcessError as e: + print("Command failed:", e) + sys.exit(1) + + +def check_input(): + if not os.path.exists(INPUT_VIDEO): + print(f"❌ Missing file: {INPUT_VIDEO}") + sys.exit(1) + + +def install_auto_editor(): + """ + Ensures auto-editor exists. + Required because HF containers reset. + """ + print("Installing auto-editor...") + run_command("pip install --no-cache-dir auto-editor") + + +def auto_edit(): + """ + Main editing step. + Removes silence + improves pacing. + """ + + cmd = f""" + auto-editor "{INPUT_VIDEO}" + --margin 0.2s + --silent-speed 99999 + --video-speed 1 + --audio-normalize peak + --export mp4 + --output "{OUTPUT_VIDEO}" + """ + + print("Running auto-editor...") + run_command(cmd) + + +def optimize_output(): + """ + Re-encode for social media compatibility. + """ + + temp = "optimized.mp4" + + cmd = f""" + ffmpeg -y -i "{OUTPUT_VIDEO}" + -vf scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2 + -c:v libx264 + -preset veryfast + -crf 23 + -c:a aac + -b:a 128k + "{temp}" + """ + + print("Optimizing output...") + run_command(cmd) + + os.replace(temp, OUTPUT_VIDEO) + + +def main(): + print("===== AUTO EDITOR START =====") + + check_input() + install_auto_editor() + auto_edit() + optimize_output() + + print("✅ Editing complete:", OUTPUT_VIDEO) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/utils/autonomous_engine.py b/utils/autonomous_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..00dca39c9898a87b1f0506556474dcc71d8d5c95 --- /dev/null +++ b/utils/autonomous_engine.py @@ -0,0 +1,97 @@ +""" +V8 Autonomous Viral Engine +-------------------------- +Fully automated pipeline for viral video generation. +""" + +import os +from utils.transcription import transcribe_video +from utils.highlights import detect_highlights +from utils.clipper import create_clips +from utils.srt import generate_srt +from utils.render import render_subtitles +from utils.variations import generate_hooks +from utils.pacing import pacing_engine +from utils.viral_scorer import score_clip + + +# ===================================================== +# CORE AUTONOMOUS PIPELINE +# ===================================================== + +def run_autonomous_engine(video_path): + """ + One-call system → full viral pipeline + """ + + print("[V8 ENGINE] Starting autonomous pipeline...") + + # 1. TRANSCRIBE + words = transcribe_video(video_path) + + # 2. DETECT HIGHLIGHTS + highlights = detect_highlights(words) + + if not highlights: + return { + "status": "failed", + "reason": "No highlights detected" + } + + # 3. AUTO CLIP GENERATION + clips = create_clips(video_path, highlights) + + if not clips: + return { + "status": "failed", + "reason": "Clip generation failed" + } + + outputs = [] + + # 4. PROCESS EACH CLIP AUTONOMOUSLY + for i, clip in enumerate(clips): + + try: + clip_words = transcribe_video(clip) + + # 5. CAPTIONS + srt = generate_srt(clip_words) + + # 6. PACING OPTIMIZATION (NEW V8) + paced_clip = pacing_engine(clip, clip_words) + + # 7. HOOK VARIATIONS + hooks = generate_hooks(clip_words) + + # 8. RENDER OUTPUT + output_path = clip.replace(".mp4", f"_v8_{i}.mp4") + + final_video = render_subtitles( + paced_clip, + srt, + output_path + ) + + # 9. VIRAL SCORING + score = score_clip(clip_words) + + outputs.append({ + "clip": final_video, + "score": score, + "hooks": hooks[:3], + "index": i + }) + + except Exception as e: + print(f"[V8 ENGINE] Clip {i} failed:", str(e)) + continue + + # 10. SORT BY VIRAL SCORE + outputs = sorted(outputs, key=lambda x: x["score"], reverse=True) + + return { + "status": "completed", + "best_clip": outputs[0] if outputs else None, + "all_variants": outputs + } \ No newline at end of file diff --git a/utils/batch_queue.py b/utils/batch_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..4155e344dfc28f95be640273a7ed1b301cdec644 --- /dev/null +++ b/utils/batch_queue.py @@ -0,0 +1,90 @@ +import threading +from queue import Queue +import uuid +import time + +from .job_queue import jobs, update, notify_webhook +from .transcription import transcribe_video +from .render import render_video +from .highlights import detect_highlights + + +batch_queue = Queue() + + +def create_batch_job(video_path, webhook=None): + + job_id = str(uuid.uuid4()) + + jobs[job_id] = { + "id": job_id, + "status": "queued", + "progress": 0, + "clips": [], + "video": video_path, + "webhook": webhook, + } + + batch_queue.put(job_id) + + return job_id + + +def worker(): + + while True: + + job_id = batch_queue.get() + job = jobs[job_id] + + try: + + update(job_id, status="processing", progress=5) + + # 1. TRANSCRIBE + words = transcribe_video(job["video"]) + + update(job_id, progress=30) + + # 2. DETECT HIGHLIGHTS + highlights = detect_highlights(words) + + update(job_id, progress=50) + + outputs = [] + + # 3. RENDER MULTIPLE CLIPS + for i, segment in enumerate(highlights): + + start = segment[0]["start"] + end = segment[-1]["end"] + + clip_path = render_video(job["video"], words) + + outputs.append(clip_path) + + update(job_id, progress=50 + int((i+1)/len(highlights)*40)) + + # 4. FINALIZE + update(job_id, + status="completed", + progress=100, + clips=outputs) + + notify_webhook(job_id) + + except Exception as e: + + update(job_id, + status="failed", + error=str(e)) + + notify_webhook(job_id) + + batch_queue.task_done() + + +def start_batch_worker(): + + t = threading.Thread(target=worker, daemon=True) + t.start() \ No newline at end of file diff --git a/utils/broll.py b/utils/broll.py new file mode 100644 index 0000000000000000000000000000000000000000..377300581b6a5e525f9a67bcfa3e37bc778819ab --- /dev/null +++ b/utils/broll.py @@ -0,0 +1,221 @@ +""" +broll.py +--------------------------------------- +AI B-Roll Injection System (V8) + +Purpose: +- Detect topics in transcript +- Map topics → generic stock B-roll assets +- Overlay or replace segments +- Improve retention & visual variety + +Works in CPU-only environments. +No external API dependency required. +""" + +import os +import random +import subprocess + + +# ===================================================== +# STOCK B-ROLL LIBRARY (LOCAL FALLBACK) +# ===================================================== + +DEFAULT_BROLL = { + "money": "assets/broll/money.mp4", + "success": "assets/broll/success.mp4", + "business": "assets/broll/business.mp4", + "phone": "assets/broll/phone.mp4", + "tech": "assets/broll/tech.mp4", + "people": "assets/broll/people.mp4", + "talking": "assets/broll/talking.mp4", + "default": "assets/broll/default.mp4", +} + + +# ===================================================== +# TOPIC DETECTION +# ===================================================== + +def detect_topic(text): + """ + Simple keyword-based topic classifier. + Lightweight (no ML dependency). + """ + + text = text.lower() + + if any(w in text for w in ["money", "rich", "income", "profit"]): + return "money" + + if any(w in text for w in ["business", "startup", "company"]): + return "business" + + if any(w in text for w in ["phone", "mobile", "iphone", "android"]): + return "phone" + + if any(w in text for w in ["tech", "ai", "software", "computer"]): + return "tech" + + if any(w in text for w in ["success", "win", "achieve"]): + return "success" + + if any(w in text for w in ["people", "person", "man", "woman"]): + return "people" + + if any(w in text for w in ["talk", "speak", "say"]): + return "talking" + + return "default" + + +# ===================================================== +# SEGMENT ANALYZER +# ===================================================== + +def extract_segments(words, segment_length=8): + """ + Converts transcript words into grouped segments. + """ + + segments = [] + buffer = [] + + for w in words: + buffer.append(w) + + if len(buffer) >= segment_length: + segments.append(buffer) + buffer = [] + + if buffer: + segments.append(buffer) + + return segments + + +# ===================================================== +# B-ROLL MATCHING ENGINE +# ===================================================== + +def match_broll(segment): + """ + Map transcript segment → B-roll video + """ + + text = " ".join([w["word"] for w in segment]) + topic = detect_topic(text) + + return DEFAULT_BROLL.get(topic, DEFAULT_BROLL["default"]) + + +# ===================================================== +# B-ROLL INSERTION (FFMPEG OVERLAY STRATEGY) +# ===================================================== + +def overlay_broll(base_video, broll_video, output_path, start_time, duration): + """ + Overlays B-roll using ffmpeg. + Lightweight crossfade approach. + """ + + cmd = [ + "ffmpeg", "-y", + "-i", base_video, + "-i", broll_video, + "-filter_complex", + f"[1:v]scale=1080:1920,format=rgba[ov];" + f"[0:v][ov]overlay=enable='between(t,{start_time},{start_time+duration})'", + "-c:v", "libx264", + "-preset", "ultrafast", + "-c:a", "copy", + output_path + ] + + subprocess.run(cmd, check=True) + + +# ===================================================== +# MAIN PIPELINE +# ===================================================== + +def insert_broll(video_path, words=None): + """ + Full B-roll injection pipeline + """ + + if not words: + # fallback: return original video + return video_path + + segments = extract_segments(words) + + current_video = video_path + outputs = [] + + for i, segment in enumerate(segments): + + broll = match_broll(segment) + + output_file = f"broll_output_{i}.mp4" + + start_time = segment[0]["start"] + duration = segment[-1]["end"] - start_time + + try: + overlay_broll( + current_video, + broll, + output_file, + start_time, + duration + ) + + current_video = output_file + outputs.append(output_file) + + except Exception as e: + print(f"[BROLL ERROR] Segment {i}: {e}") + continue + + return outputs[-1] if outputs else video_path + + +# ===================================================== +# ADVANCED VERSION (V8 EXTENSION) +# ===================================================== + +def smart_broll_engine(words, hook_boost=True): + """ + Enhanced version: + - prioritizes hook segments + - increases emotional pacing + """ + + segments = extract_segments(words) + + prioritized = [] + + for seg in segments: + + text = " ".join([w["word"] for w in seg]).lower() + + score = 0 + + if any(k in text for k in ["you", "this", "stop", "now"]): + score += 2 + + if hook_boost and len(seg) < 5: + score += 1 + + prioritized.append((score, seg)) + + prioritized.sort(reverse=True, key=lambda x: x[0]) + + final_video = None + + for _, seg in prioritized: + final_video = insert_broll(final_video or "input.mp4", seg) + + return final_video \ No newline at end of file diff --git a/utils/caption_director.py b/utils/caption_director.py new file mode 100644 index 0000000000000000000000000000000000000000..cc7aab54eaa246c93c8dd1afa8c30c1d39f7b86d --- /dev/null +++ b/utils/caption_director.py @@ -0,0 +1,231 @@ +""" +caption_director.py +--------------------------------------- +AI Caption Intelligence System + +Responsibilities: +- Convert transcript → styled caption segments +- Decide emphasis words +- Break text into readable chunks +- Optimize for TikTok/Reels retention +- Support hook-style captions + +INPUT: + words = [ + {"word": "hello", "start": 0.2, "end": 0.5}, + ... + ] + +OUTPUT: + caption blocks: + [ + { + "text": "THIS IS CRAZY", + "start": 0.2, + "end": 2.1, + "style": "hook" + } + ] +""" + +import re + + +# ----------------------------- +# CONFIG +# ----------------------------- + +MAX_WORDS_PER_CAPTION = 6 +HOOK_KEYWORDS = [ + "listen", "wait", "you", "this", "crazy", + "insane", "important", "stop", "secret" +] + + +# ----------------------------- +# UTIL: CLEAN TEXT +# ----------------------------- + +def clean_word(word): + return re.sub(r"[^a-zA-Z0-9']", "", word).lower() + + +# ----------------------------- +# DETECT EMPHASIS +# ----------------------------- + +def is_emphasis(word): + w = clean_word(word) + return w in HOOK_KEYWORDS or len(word) > 8 + + +# ----------------------------- +# GROUP WORDS INTO CAPTIONS +# ----------------------------- + +def group_words(words): + captions = [] + buffer = [] + + for w in words: + buffer.append(w) + + if len(buffer) >= MAX_WORDS_PER_CAPTION: + captions.append(buffer) + buffer = [] + + if buffer: + captions.append(buffer) + + return captions + + +# ----------------------------- +# BUILD CAPTION BLOCK +# ----------------------------- + +def build_caption_block(group): + text = [] + start = group[0]["start"] + end = group[-1]["end"] + + emphasis_count = 0 + + for w in group: + word = w["word"] + + if is_emphasis(word): + text.append(word.upper()) + emphasis_count += 1 + else: + text.append(word) + + caption_text = " ".join(text) + + style = "hook" if emphasis_count > 0 else "normal" + + return { + "text": caption_text, + "start": start, + "end": end, + "style": style + } + + +# ----------------------------- +# MAIN DIRECTOR +# ----------------------------- + +def caption_director(words): + """ + Main caption intelligence engine + """ + + if not words: + return [] + + grouped = group_words(words) + + captions = [] + + for group in grouped: + captions.append(build_caption_block(group)) + + return captions + + +# ----------------------------- +# HOOK CAPTION GENERATOR +# ----------------------------- + +def generate_hook_caption(words): + """ + Extracts first high-impact caption + """ + + for w in words[:20]: + if is_emphasis(w["word"]): + return { + "text": w["word"].upper(), + "start": w["start"], + "end": w["end"], + "style": "hook" + } + + return None + + +# ----------------------------- +# AUTO CAPTION PIPELINE +# ----------------------------- + +def auto_captions(words): + """ + Full pipeline: + - detect hook + - generate captions + """ + + captions = caption_director(words) + + hook = generate_hook_caption(words) + + if hook: + captions.insert(0, hook) + + return captions + + +# ----------------------------- +# STYLE DECISION ENGINE +# ----------------------------- + +def decide_style(caption): + text = caption["text"] + + if caption["style"] == "hook": + return "large_bold_center" + + if len(text) > 40: + return "small_multi_line" + + if text.isupper(): + return "emphasis" + + return "standard" + + +# ----------------------------- +# EXPORT HELPERS +# ----------------------------- + +def format_for_render(captions): + """ + Converts captions into render-friendly format + """ + + formatted = [] + + for c in captions: + formatted.append({ + "text": c["text"], + "start": c["start"], + "end": c["end"], + "style": decide_style(c) + }) + + return formatted + + +# ----------------------------- +# PUBLIC API +# ----------------------------- + +def process_captions(words): + """ + Full external API + """ + + captions = auto_captions(words) + + return format_for_render(captions) \ No newline at end of file diff --git a/utils/caption_seo.py b/utils/caption_seo.py new file mode 100644 index 0000000000000000000000000000000000000000..ee9ae85129368464a6b2e90e0e852fe61d70a80d --- /dev/null +++ b/utils/caption_seo.py @@ -0,0 +1,7 @@ +def generate_caption(words): + + keywords = list(set([w["text"].lower() for w in words[:10]])) + + tags = " ".join([f"#{k}" for k in keywords[:5]]) + + return f"{' '.join(keywords[:8]).capitalize()}...\n\n{tags}" \ No newline at end of file diff --git a/utils/clipper.py b/utils/clipper.py new file mode 100644 index 0000000000000000000000000000000000000000..554bce316083dc5461ff086d781f84440c02cda9 --- /dev/null +++ b/utils/clipper.py @@ -0,0 +1,130 @@ +from moviepy.editor import VideoFileClip +import os +import logging + +logger = logging.getLogger(__name__) + + +# ===================================================== +# SEGMENT NORMALIZER (CRITICAL V8 FIX) +# ===================================================== + +def normalize_segments(segments): + """ + Accepts: + - dict segments: {"start": x, "end": y} + - list segments: [{"start":x,"end":y}, ...] + - tuple/list segments: [(start,end), ...] + + Returns: + - clean list of dicts + """ + + if not segments: + return [] + + normalized = [] + + # Case 1: single dict + if isinstance(segments, dict): + segments = [segments] + + for s in segments: + + # dict format (preferred) + if isinstance(s, dict): + if "start" in s and "end" in s: + normalized.append({ + "start": float(s["start"]), + "end": float(s["end"]) + }) + continue + + # list/tuple format + if isinstance(s, (list, tuple)) and len(s) >= 2: + try: + normalized.append({ + "start": float(s[0]), + "end": float(s[1]) + }) + except Exception: + continue + + return normalized + + +# ===================================================== +# CORE CLIP GENERATOR (SAFE VERSION) +# ===================================================== + +def create_clip(video_path, start, end, index): + """ + Creates a single clip safely with validation + """ + + try: + start = float(start) + end = float(end) + + if end <= start: + logger.warning(f"Invalid segment skipped: {start}-{end}") + return None + + clip = VideoFileClip(video_path).subclip(start, end) + + output = video_path.replace( + ".mp4", + f"_clip_{index}.mp4" + ) + + clip.write_videofile( + output, + codec="libx264", + audio_codec="aac", + preset="ultrafast", + threads=2, + logger=None # prevents HF log spam + ) + + return output + + except Exception as e: + logger.error(f"Clip creation failed: {str(e)}") + return None + + +# ===================================================== +# BATCH CLIP ENGINE (V8 AUTOCUT CORE FIX) +# ===================================================== + +def create_clips(video_path, segments): + """ + Main entry used by V8 Highlights / AutoClip engine + """ + + segments = normalize_segments(segments) + + if not segments: + logger.warning("No valid segments found") + return [] + + outputs = [] + + for i, seg in enumerate(segments): + + try: + out = create_clip( + video_path, + seg["start"], + seg["end"], + i + ) + + if out: + outputs.append(out) + + except Exception as e: + logger.error(f"Segment {i} failed: {str(e)}") + continue + + return outputs \ No newline at end of file diff --git a/utils/config.py b/utils/config.py new file mode 100644 index 0000000000000000000000000000000000000000..8622d3ccdb090ea288a4cab5babbc8139b29de13 --- /dev/null +++ b/utils/config.py @@ -0,0 +1,11 @@ +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parents[1] + +MODEL_SIZE = "base" + +FONT_PATH = str(BASE_DIR / "fonts" / "TikTok-Bold.ttf") + +OUTPUT_DIR = str(BASE_DIR / "outputs") +TEMP_DIR = str(BASE_DIR / "temp") diff --git a/utils/director.py b/utils/director.py new file mode 100644 index 0000000000000000000000000000000000000000..b476af4d3dad578ca9fd7f2c84b8823d1a314580 --- /dev/null +++ b/utils/director.py @@ -0,0 +1,26 @@ +def rewrite_script(words): + """ + Converts raw transcript → structured viral script + """ + + text = " ".join([w["text"] for w in words]) + + return { + "hook": f"Wait—{text[:60]}...", + "summary": text[:200], + "emotion": "high" if "!" in text else "medium" + } + + +def viral_score(engagement_curve): + """ + Evaluates full video potential + """ + + if not engagement_curve: + return 0 + + peak = max(engagement_curve) + avg = sum(engagement_curve) / len(engagement_curve) + + return (peak * 0.7) + (avg * 0.3) \ No newline at end of file diff --git a/utils/emotion.py b/utils/emotion.py new file mode 100644 index 0000000000000000000000000000000000000000..eed357ef3f50ffc05c3e75f5d97ec6221997ed34 --- /dev/null +++ b/utils/emotion.py @@ -0,0 +1,14 @@ +def emotion_score(words): + + strong_words = [ + "amazing", "crazy", "insane", "love", "hate", + "never", "always", "big", "huge", "shocking" + ] + + score = 0 + + for w in words: + if w["text"].lower() in strong_words: + score += 2 + + return min(score, 100) \ No newline at end of file diff --git a/utils/engagement.py b/utils/engagement.py new file mode 100644 index 0000000000000000000000000000000000000000..8063db1a370f66b09b95ce148aae9dbc416c9a6a --- /dev/null +++ b/utils/engagement.py @@ -0,0 +1,17 @@ +def simulate_retention(words): + + timeline = [] + score = 100 + + for i, w in enumerate(words): + + # drop-off logic + if i > len(words) * 0.7: + score -= 2 + + if w["text"].lower() in ["boring", "slow"]: + score -= 10 + + timeline.append(max(score, 0)) + + return timeline \ No newline at end of file diff --git a/utils/ffmpeg.py b/utils/ffmpeg.py new file mode 100644 index 0000000000000000000000000000000000000000..f0778e12bd3cce6574a9c7d91502d979920e0b75 --- /dev/null +++ b/utils/ffmpeg.py @@ -0,0 +1,14 @@ +import subprocess + +def extract_audio(video, audio): + cmd = [ + "ffmpeg", + "-y", + "-i", video, + "-vn", + "-acodec", "pcm_s16le", + "-ar", "16000", + "-ac", "1", + audio + ] + subprocess.run(cmd, check=True) \ No newline at end of file diff --git a/utils/highlights.py b/utils/highlights.py new file mode 100644 index 0000000000000000000000000000000000000000..4c17cf99b6b690a6d9941f8f97597124eaf56b5e --- /dev/null +++ b/utils/highlights.py @@ -0,0 +1,35 @@ +def score_word(word): + """ + Simple heuristic scoring: + - longer words slightly more important + - punctuation emphasis + """ + + score = len(word["text"]) * 0.1 + + if any(p in word["text"] for p in ["!", "?", "."]): + score += 1 + + return score + + +def detect_highlights(words, threshold=0.8): + """ + Groups words into highlight segments + """ + + highlights = [] + buffer = [] + + for w in words: + if score_word(w) > threshold: + buffer.append(w) + else: + if buffer: + highlights.append(buffer) + buffer = [] + + if buffer: + highlights.append(buffer) + + return highlights \ No newline at end of file diff --git a/utils/hook_detector.py b/utils/hook_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..254fa9bed943c861db78c4f03258b6c766ec66ef --- /dev/null +++ b/utils/hook_detector.py @@ -0,0 +1,6 @@ +def detect_hook(words): + """ + Finds strongest opening segment (first 3–7 seconds) + """ + + return [w for w in words if w["start"] <= 5] \ No newline at end of file diff --git a/utils/input_resolver.py b/utils/input_resolver.py new file mode 100644 index 0000000000000000000000000000000000000000..97d809adb6d079861065c759e477facbacc34f43 --- /dev/null +++ b/utils/input_resolver.py @@ -0,0 +1,112 @@ +import os +import uuid +import requests +import subprocess +from urllib.parse import urlparse + +from utils.logger import logger + +DOWNLOAD_DIR = "jobs" +os.makedirs(DOWNLOAD_DIR, exist_ok=True) + + +# --------------------------------------------------- +# URL DETECTION +# --------------------------------------------------- + +def is_url(value: str): + try: + result = urlparse(value) + return result.scheme in ("http", "https") + except Exception: + return False + + +def is_social_url(url: str): + domains = [ + "youtube.com", + "youtu.be", + "tiktok.com", + "instagram.com", + "facebook.com", + "fb.watch", + "twitter.com", + "x.com" + ] + return any(d in url.lower() for d in domains) + + +# --------------------------------------------------- +# DIRECT FILE DOWNLOAD +# --------------------------------------------------- + +def download_direct(url: str) -> str: + filename = f"{uuid.uuid4()}.mp4" + output = os.path.join(DOWNLOAD_DIR, filename) + + logger.info(f"[INPUT] Direct download → {url}") + + with requests.get(url, stream=True, timeout=120) as r: + r.raise_for_status() + with open(output, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + logger.info(f"[INPUT] Saved → {output}") + return output + + +# --------------------------------------------------- +# SOCIAL MEDIA DOWNLOAD (yt-dlp) +# --------------------------------------------------- + +def download_social(url: str) -> str: + + filename = f"{uuid.uuid4()}.mp4" + output = os.path.join(DOWNLOAD_DIR, filename) + + logger.info(f"[INPUT] Social download → {url}") + + cmd = [ + "yt-dlp", + "-f", "bestvideo+bestaudio/best", + "--merge-output-format", "mp4", + "-o", output, + url, + ] + + subprocess.run(cmd, check=True) + + if not os.path.exists(output): + raise Exception("yt-dlp download failed") + + logger.info(f"[INPUT] Saved → {output}") + return output + + +# --------------------------------------------------- +# UNIVERSAL RESOLVER +# --------------------------------------------------- + +def resolve_input(input_value): + """ + Accepts: + - Upload path + - Direct URL + - YouTube/TikTok/Instagram/Facebook link + """ + + # Already local + if isinstance(input_value, str) and os.path.exists(input_value): + return input_value + + # URL input + if isinstance(input_value, str) and is_url(input_value): + + if is_social_url(input_value): + return download_social(input_value) + + return download_direct(input_value) + + raise Exception("Unsupported input type") \ No newline at end of file diff --git a/utils/job_queue.py b/utils/job_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..5135118af5985bc0003967a81633563359e47d13 --- /dev/null +++ b/utils/job_queue.py @@ -0,0 +1,194 @@ +import threading +import uuid +from queue import Queue +import traceback +import time + +from .logger import logger +from .validators import validate_video +from .transcription import transcribe_video +from .director import rewrite_script, viral_score +from .engagement import simulate_retention +from .platform import adapt_platform +from .persona import predict_audience +from .clipper import create_clip + + +# ===================================================== +# GLOBAL STATE +# ===================================================== + +jobs = {} +queue = Queue() + +WORKER_STARTED = False + + +# ===================================================== +# CREATE DIRECTOR JOB +# ===================================================== + +def create_job(video_path: str, webhook: str | None = None): + + job_id = str(uuid.uuid4()) + + jobs[job_id] = { + "id": job_id, + "status": "queued", + "stage": "waiting", + "progress": 0, + + "video": video_path, + + # V7 INTELLIGENCE OUTPUTS + "viral_score": None, + "persona": None, + "hook": None, + "platforms": [], + "strategy_summary": None, + + "clips": [], + "webhook": webhook, + "error": None, + "created_at": time.time(), + } + + queue.put(job_id) + + logger.info(f"[V7] Director job queued: {job_id}") + + return job_id + + +# ===================================================== +# GET JOB +# ===================================================== + +def get_job(job_id: str): + return jobs.get(job_id) + + +# ===================================================== +# UPDATE HELPERS +# ===================================================== + +def update(job_id, **kwargs): + if job_id in jobs: + jobs[job_id].update(kwargs) + + +# ===================================================== +# V7 AUTONOMOUS DIRECTOR WORKER +# ===================================================== + +def worker(): + + logger.info("[V7] Autonomous Viral Director started") + + while True: + + job_id = queue.get() + job = jobs[job_id] + + try: + + # ----------------------------- + # 1. TRANSCRIPTION + # ----------------------------- + update(job_id, status="processing", stage="transcribing", progress=10) + words = transcribe_video(job["video"]) + + # ----------------------------- + # 2. SCRIPT RECONSTRUCTION + # ----------------------------- + update(job_id, stage="rewriting narrative", progress=25) + script = rewrite_script(words) + + # ----------------------------- + # 3. AUDIENCE MODELING + # ----------------------------- + persona = predict_audience(words) + + # ----------------------------- + # 4. ENGAGEMENT SIMULATION + # ----------------------------- + update(job_id, stage="simulating audience", progress=45) + curve = simulate_retention(words) + + v_score = viral_score(curve) + + # ----------------------------- + # 5. PLATFORM STRATEGY + # ----------------------------- + update(job_id, stage="platform adaptation", progress=65) + + tiktok = adapt_platform(script, "tiktok") + reels = adapt_platform(script, "reels") + + platforms = ["tiktok", "reels"] + + # ----------------------------- + # 6. SINGLE BEST OUTPUT (DIRECTOR DECISION) + # ----------------------------- + update(job_id, stage="rendering final cut", progress=85) + + clip = create_clip( + job["video"], + words[0]["start"], + words[-1]["end"], + 0 + ) + + # ----------------------------- + # 7. FINAL DIRECTOR OUTPUT + # ----------------------------- + update(job_id, + status="completed", + stage="director finished", + progress=100, + + viral_score=v_score, + persona=persona, + hook=script["hook"], + platforms=platforms, + + strategy_summary={ + "curve_peak": max(curve), + "avg_curve": sum(curve) / len(curve), + "decision": "auto-selected best full narrative cut" + }, + + clips=[clip]) + + logger.info(f"[V7] Director output complete: {job_id}") + + except Exception as e: + + logger.error(traceback.format_exc()) + + update(job_id, + status="failed", + stage="error", + error=str(e)) + + finally: + queue.task_done() + + +# ===================================================== +# START WORKER (SINGLETON SAFE) +# ===================================================== + +def start_worker(): + + global WORKER_STARTED + + if WORKER_STARTED: + return + + WORKER_STARTED = True + + t = threading.Thread(target=worker, daemon=True) + t.start() + + logger.info("[V7] Worker initialized") \ No newline at end of file diff --git a/utils/jumpcut.py b/utils/jumpcut.py new file mode 100644 index 0000000000000000000000000000000000000000..baa61f4e20c247688c350535d3bec560afac0c73 --- /dev/null +++ b/utils/jumpcut.py @@ -0,0 +1,226 @@ +""" +jumpcut.py +--------------------------------------- +Smart Jump Cut Engine (V8) + +Purpose: +- Remove silence and filler pauses +- Improve pacing for short-form video +- Optimize retention curve +- Create TikTok/Reels-style fast cuts + +Works fully on CPU (FFmpeg-based). +No GPU required. +""" + +import subprocess +import os + + +# ===================================================== +# CONFIG +# ===================================================== + +TEMP_SILENCE_FILE = "silence_detect.txt" +OUTPUT_FILE = "jumpcut_output.mp4" + + +# ===================================================== +# SILENCE DETECTION +# ===================================================== + +def detect_silence(video_path): + """ + Uses ffmpeg silencedetect to find pauses. + """ + + cmd = [ + "ffmpeg", + "-i", video_path, + "-af", "silencedetect=noise=-30dB:d=0.4", + "-f", "null", + "-" + ] + + result = subprocess.run(cmd, stderr=subprocess.PIPE, text=True) + + return result.stderr + + +# ===================================================== +# PARSE SILENCE TIMESTAMPS +# ===================================================== + +def parse_silence(log): + """ + Extract silence start/end timestamps + """ + + silences = [] + + start = None + + for line in log.split("\n"): + + if "silence_start" in line: + try: + start = float(line.split("silence_start:")[1].strip()) + except: + continue + + if "silence_end" in line and start is not None: + try: + end = float(line.split("silence_end:")[1].split("|")[0].strip()) + silences.append((start, end)) + start = None + except: + continue + + return silences + + +# ===================================================== +# BUILD FILTER (JUMP CUT LOGIC) +# ===================================================== + +def build_filter(silences, duration): + """ + Converts silence ranges into ffmpeg trim filter + """ + + if not silences: + return None + + segments = [] + last_end = 0 + + for start, end in silences: + + if start > last_end: + segments.append((last_end, start)) + + last_end = end + + if last_end < duration: + segments.append((last_end, duration)) + + filters = [] + + for i, (start, end) in enumerate(segments): + filters.append( + f"[0:v]trim=start={start}:end={end},setpts=PTS-STARTPTS[v{i}];" + f"[0:a]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[a{i}]" + ) + + video_concat = "".join([f"[v{i}]" for i in range(len(segments))]) + audio_concat = "".join([f"[a{i}]" for i in range(len(segments))]) + + filters.append( + f"{video_concat}{audio_concat}concat=n={len(segments)}:v=1:a=1[outv][outa]" + ) + + return ";".join(filters) + + +# ===================================================== +# CORE ENGINE +# ===================================================== + +def smart_jumpcut(video_path): + """ + Main jump cut engine + """ + + print("[JUMPCUT] Analyzing video...") + + # Step 1: detect silence + log = detect_silence(video_path) + + silences = parse_silence(log) + + print(f"[JUMPCUT] Detected silences: {len(silences)}") + + # Step 2: get duration + probe_cmd = [ + "ffprobe", + "-v", "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + video_path + ] + + duration = float(subprocess.check_output(probe_cmd).decode().strip()) + + # Step 3: build filter + filter_complex = build_filter(silences, duration) + + if not filter_complex: + print("[JUMPCUT] No silences found, returning original") + return video_path + + # Step 4: render output + output_path = OUTPUT_FILE + + cmd = [ + "ffmpeg", "-y", + "-i", video_path, + "-filter_complex", filter_complex, + "-map", "[outv]", + "-map", "[outa]", + "-c:v", "libx264", + "-preset", "ultrafast", + "-c:a", "aac", + output_path + ] + + print("[JUMPCUT] Rendering optimized video...") + + subprocess.run(cmd, check=True) + + print("[JUMPCUT] Done:", output_path) + + return output_path + + +# ===================================================== +# SIMPLE FAST MODE (FALLBACK) +# ===================================================== + +def fast_jumpcut(video_path): + """ + Lightweight fallback: + removes only large pauses quickly + """ + + output = "fast_jumpcut.mp4" + + cmd = [ + "ffmpeg", "-y", + "-i", video_path, + "-af", "silenceremove=start_periods=1:start_threshold=-30dB:stop_periods=-1", + "-c:v", "libx264", + "-preset", "ultrafast", + "-c:a", "aac", + output + ] + + subprocess.run(cmd, check=True) + + return output + + +# ===================================================== +# PUBLIC API +# ===================================================== + +def smart_jumpcut_engine(video_path, mode="smart"): + """ + Entry point used by main.py + """ + + if mode == "fast": + return fast_jumpcut(video_path) + + return smart_jumpcut(video_path) \ No newline at end of file diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..74b7676e5b3c7a193a52cd987c617815d267460b --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,8 @@ +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s" +) + +logger = logging.getLogger("fast_whisper_api") \ No newline at end of file diff --git a/utils/music.py b/utils/music.py new file mode 100644 index 0000000000000000000000000000000000000000..f209e89f4dd8708e07b6303e468421e15b254b3d --- /dev/null +++ b/utils/music.py @@ -0,0 +1,22 @@ +from moviepy.editor import AudioFileClip, CompositeAudioClip, VideoFileClip + + +def add_music(video_path, music_path): + + video = VideoFileClip(video_path) + + music = ( + AudioFileClip(music_path) + .volumex(0.15) + .set_duration(video.duration) + ) + + final_audio = CompositeAudioClip([video.audio, music]) + + video = video.set_audio(final_audio) + + output = video_path.replace(".mp4", "_music.mp4") + + video.write_videofile(output, codec="libx264") + + return output \ No newline at end of file diff --git a/utils/pacing.py b/utils/pacing.py new file mode 100644 index 0000000000000000000000000000000000000000..057160b2dedc1467144477cdfe76a60b6ffe37ad --- /dev/null +++ b/utils/pacing.py @@ -0,0 +1,241 @@ +""" +pacing.py +--------------------------------------- +Retention & Pacing Optimization Engine (V8) + +Purpose: +- Adjust video pacing for maximum retention +- Compress slow segments +- Emphasize high-value moments +- Create TikTok / Reels optimized flow + +Works in CPU-only environments (FFmpeg-based). +""" + +import subprocess +import os + + +# ===================================================== +# CONFIG +# ===================================================== + +OUTPUT_FILE = "pacing_optimized.mp4" + +SLOW_THRESHOLD = 1.25 # speed multiplier for slow segments +FAST_THRESHOLD = 1.75 # speed multiplier for filler segments + + +# ===================================================== +# BASIC SEGMENT ESTIMATION (NO ML DEPENDENCY) +# ===================================================== + +def estimate_segment_value(text): + """ + Heuristic scoring system: + determines importance of spoken segment. + """ + + text = text.lower() + + high_value_keywords = [ + "you", "secret", "important", "stop", + "crazy", "insane", "listen", "this", + "money", "success", "life", "truth" + ] + + filler_keywords = [ + "um", "uh", "like", "you know", "so", + "actually", "basically" + ] + + score = 1.0 + + # boost high value words + for w in high_value_keywords: + if w in text: + score += 0.6 + + # penalize filler speech + for w in filler_keywords: + if w in text: + score -= 0.4 + + return max(0.5, min(score, 2.0)) + + +# ===================================================== +# SPEED MAP GENERATOR +# ===================================================== + +def build_speed_map(words): + """ + Converts transcript into pacing instructions + """ + + segments = [] + buffer = [] + + for w in words: + buffer.append(w) + + # group into micro segments + if len(buffer) >= 6: + segments.append(buffer) + buffer = [] + + if buffer: + segments.append(buffer) + + speed_map = [] + + for seg in segments: + + text = " ".join([w["word"] for w in seg]) + score = estimate_segment_value(text) + + start = seg[0]["start"] + end = seg[-1]["end"] + + # decide speed + if score > 1.4: + speed = 1.0 # keep normal (important content) + elif score > 1.0: + speed = 1.15 # slight compression + else: + speed = FAST_THRESHOLD # aggressive speed-up + + speed_map.append({ + "start": start, + "end": end, + "speed": speed + }) + + return speed_map + + +# ===================================================== +# FFMEG FILTER BUILDER +# ===================================================== + +def build_filter(speed_map): + """ + Creates FFmpeg atempo + setpts filter chain + """ + + filters = [] + + for i, seg in enumerate(speed_map): + + start = seg["start"] + end = seg["end"] + speed = seg["speed"] + + # video speed + filters.append( + f"[0:v]trim=start={start}:end={end},setpts=PTS/{speed}[v{i}]" + ) + + # audio speed + filters.append( + f"[0:a]atrim=start={start}:end={end},asetpts=PTS-STARTPTS," + f"atempo={speed}[a{i}]" + ) + + v_streams = "".join([f"[v{i}]" for i in range(len(speed_map))]) + a_streams = "".join([f"[a{i}]" for i in range(len(speed_map))]) + + filters.append( + f"{v_streams}{a_streams}concat=n={len(speed_map)}:v=1:a=1[outv][outa]" + ) + + return ";".join(filters) + + +# ===================================================== +# MAIN ENGINE +# ===================================================== + +def optimize_pacing(video_path, words=None): + """ + Main entry point for V8 pacing system + """ + + print("[PACING] Starting optimization...") + + if not words: + print("[PACING] No transcript provided — returning original video") + return video_path + + # Step 1: build speed map + speed_map = build_speed_map(words) + + print(f"[PACING] Segments: {len(speed_map)}") + + # Step 2: build ffmpeg filter + filter_complex = build_filter(speed_map) + + output_path = OUTPUT_FILE + + # Step 3: render optimized video + cmd = [ + "ffmpeg", "-y", + "-i", video_path, + "-filter_complex", filter_complex, + "-map", "[outv]", + "-map", "[outa]", + "-c:v", "libx264", + "-preset", "ultrafast", + "-c:a", "aac", + output_path + ] + + subprocess.run(cmd, check=True) + + print("[PACING] Done:", output_path) + + return output_path + + +# ===================================================== +# LIGHTWEIGHT MODE (FAST FALLBACK) +# ===================================================== + +def fast_pacing(video_path): + """ + Simple fallback: global speed-up only + """ + + output = "fast_pacing.mp4" + + cmd = [ + "ffmpeg", "-y", + "-i", video_path, + "-filter_complex", + "[0:v]setpts=0.92*PTS[v];[0:a]atempo=1.08[a]", + "-map", "[v]", + "-map", "[a]", + "-c:v", "libx264", + "-preset", "ultrafast", + "-c:a", "aac", + output + ] + + subprocess.run(cmd, check=True) + + return output + + +# ===================================================== +# PUBLIC API +# ===================================================== + +def pacing_engine(video_path, words=None, mode="smart"): + """ + Entry point used by main.py + """ + + if mode == "fast": + return fast_pacing(video_path) + + return optimize_pacing(video_path, words) \ No newline at end of file diff --git a/utils/persona.py b/utils/persona.py new file mode 100644 index 0000000000000000000000000000000000000000..08f49ff9f60214e601591690baa6e7dec3031548 --- /dev/null +++ b/utils/persona.py @@ -0,0 +1,9 @@ +def predict_audience(words): + + if len(words) < 50: + return "short_attention_gen_z" + + if any(w["text"].lower() in ["money", "success"] for w in words): + return "motivated_entrepreneur" + + return "general" \ No newline at end of file diff --git a/utils/platform.py b/utils/platform.py new file mode 100644 index 0000000000000000000000000000000000000000..5817bff53e26afd231e8c8e7c7879bfaf3ef48ce --- /dev/null +++ b/utils/platform.py @@ -0,0 +1,15 @@ +def adapt_platform(script, platform="tiktok"): + + if platform == "tiktok": + return { + "hook": "🔥 " + script["hook"], + "style": "fast_cut" + } + + if platform == "reels": + return { + "hook": script["hook"], + "style": "cinematic" + } + + return script \ No newline at end of file diff --git a/utils/render.py b/utils/render.py new file mode 100644 index 0000000000000000000000000000000000000000..97673c4c7f73e1787d67a4b096e16dc2efd0bc46 --- /dev/null +++ b/utils/render.py @@ -0,0 +1,73 @@ +import os +import subprocess +import uuid +from utils.logger import logger + +OUTPUT_DIR = "jobs" +os.makedirs(OUTPUT_DIR, exist_ok=True) + + +def render_subtitles( + video_path: str, + srt_text: str, + output_path: str | None = None, +): + """ + Universal subtitle renderer for V7. + + Supports: + - API render + - UI render + - Batch jobs + - Worker queue + """ + + if output_path is None: + output_path = os.path.join( + OUTPUT_DIR, + f"{uuid.uuid4()}_render.mp4" + ) + + # -------------------------------------------------- + # Write SRT file + # -------------------------------------------------- + + srt_path = output_path.replace(".mp4", ".srt") + + with open(srt_path, "w", encoding="utf-8") as f: + f.write(srt_text) + + logger.info(f"[RENDER] SRT saved → {srt_path}") + + # -------------------------------------------------- + # FFmpeg Subtitle Burn + # -------------------------------------------------- + + cmd = [ + "ffmpeg", + "-y", + "-i", video_path, + "-vf", f"subtitles={srt_path}", + "-c:a", "copy", + output_path, + ] + + logger.info("[RENDER] Running ffmpeg render") + + process = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + if process.returncode != 0: + logger.error(process.stderr) + raise Exception("FFmpeg render failed") + + if not os.path.exists(output_path): + raise Exception("Rendered file missing") + + logger.info(f"[RENDER] Output → {output_path}") + + return output_path \ No newline at end of file diff --git a/utils/retention.py b/utils/retention.py new file mode 100644 index 0000000000000000000000000000000000000000..0e05d16a4a5130ab400fc351103bdca8c10054ae --- /dev/null +++ b/utils/retention.py @@ -0,0 +1,22 @@ +def predict_retention(words): + + if not words: + return 0 + + duration = words[-1]["end"] - words[0]["start"] + + score = 100 + + # too long → drop-off risk + if duration > 30: + score -= 30 + + # too short → no engagement + if duration < 6: + score -= 20 + + # weak opening signal + if "..." in words[0]["text"]: + score -= 10 + + return max(0, score) \ No newline at end of file diff --git a/utils/silence.py b/utils/silence.py new file mode 100644 index 0000000000000000000000000000000000000000..f13c4c36bcf82c1f3484f0671f466976769b1d20 --- /dev/null +++ b/utils/silence.py @@ -0,0 +1,17 @@ +import subprocess + + +def remove_silence(input_video, output_video): + + cmd = [ + "ffmpeg", + "-y", + "-i", input_video, + "-af", + "silenceremove=start_periods=1:start_threshold=-40dB", + output_video + ] + + subprocess.run(cmd, check=True) + + return output_video \ No newline at end of file diff --git a/utils/srt.py b/utils/srt.py new file mode 100644 index 0000000000000000000000000000000000000000..44bc931df4c4bb09dccef9fd7b713dccc55ff007 --- /dev/null +++ b/utils/srt.py @@ -0,0 +1,188 @@ +from typing import List, Dict, Any, Union + + +# ===================================================== +# PUBLIC API (USED BY MAIN.PY) +# ===================================================== + +def generate_srt(data: List[Dict[str, Any]]) -> str: + """ + Universal SRT generator for: + - Whisper word output (V1–V7) + - Highlight segments (start/end grouped words) + - Mixed/partial structures + + Expected input formats: + 1. Word-level: + {"text": "...", "start": float, "end": float} + + 2. Segment-level: + [{"start": float, "end": float, "text": "..."}] + + Returns: + SRT formatted string + """ + + if not data: + return "" + + normalized = _normalize_input(data) + return _build_srt(normalized) + + +# ===================================================== +# NORMALIZATION LAYER (CRITICAL FOR V1–V7 COMPATIBILITY) +# ===================================================== + +def _normalize_input(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Converts any supported structure into unified subtitle blocks + """ + + normalized = [] + + # CASE 1: Already segment-based + if isinstance(data[0], dict) and "start" in data[0] and "end" in data[0] and "text" in data[0]: + for item in data: + normalized.append({ + "start": float(item.get("start", 0)), + "end": float(item.get("end", 0)), + "text": str(item.get("text", "")).strip() + }) + return normalized + + # CASE 2: Whisper word-level output + buffer = [] + current_start = None + + for w in data: + + if not isinstance(w, dict): + continue + + text = str(w.get("text", "")).strip() + start = w.get("start", None) + end = w.get("end", None) + + if start is None or end is None: + continue + + if current_start is None: + current_start = start + + buffer.append(text) + + # Chunking strategy: group every ~8–12 words + if len(buffer) >= 10: + + normalized.append({ + "start": current_start, + "end": end, + "text": " ".join(buffer) + }) + + buffer = [] + current_start = None + + # flush remaining buffer + if buffer: + normalized.append({ + "start": current_start or 0, + "end": data[-1].get("end", 0), + "text": " ".join(buffer) + }) + + return normalized + + +# ===================================================== +# SRT BUILDER +# ===================================================== + +def _build_srt(items: List[Dict[str, Any]]) -> str: + """ + Converts normalized subtitle blocks → SRT format + """ + + output = [] + index = 1 + + for item in items: + + start = _format_time(item["start"]) + end = _format_time(item["end"]) + text = _clean_text(item["text"]) + + if not text: + continue + + output.append(f"{index}") + output.append(f"{start} --> {end}") + output.append(f"{text}") + output.append("") # blank line separator + + index += 1 + + return "\n".join(output).strip() + + +# ===================================================== +# TIME FORMATTER +# ===================================================== + +def _format_time(seconds: Union[int, float]) -> str: + """ + Converts seconds → SRT timestamp format + HH:MM:SS,mmm + """ + + try: + seconds = float(seconds) + except: + seconds = 0.0 + + hrs = int(seconds // 3600) + mins = int((seconds % 3600) // 60) + secs = int(seconds % 60) + ms = int((seconds - int(seconds)) * 1000) + + return f"{hrs:02}:{mins:02}:{secs:02},{ms:03}" + + +# ===================================================== +# TEXT CLEANER (IMPORTANT FOR VIDEO RENDERING STABILITY) +# ===================================================== + +def _clean_text(text: str) -> str: + """ + Sanitizes subtitle text for rendering engines + """ + + if not text: + return "" + + text = text.replace("\n", " ") + text = text.replace("\r", " ") + + # remove excessive spacing + text = " ".join(text.split()) + + return text.strip() + + +# ===================================================== +# OPTIONAL DEBUG HELPER (SAFE IN PRODUCTION) +# ===================================================== + +def debug_srt(data: List[Dict[str, Any]]) -> dict: + """ + Returns structured preview for debugging pipelines + """ + + normalized = _normalize_input(data) + + return { + "blocks": len(normalized), + "sample": normalized[:3], + "duration": normalized[-1]["end"] if normalized else 0 + } \ No newline at end of file diff --git a/utils/storage.py b/utils/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..289ca0522e6bd19fd2f30840d868253030ed6a62 --- /dev/null +++ b/utils/storage.py @@ -0,0 +1,18 @@ +import os +import time + +MAX_AGE = 60 * 60 # 1 hour + + +def cleanup(folder="jobs"): + + now = time.time() + + for f in os.listdir(folder): + + path = os.path.join(folder, f) + + if os.path.isfile(path): + + if now - os.path.getmtime(path) > MAX_AGE: + os.remove(path) \ No newline at end of file diff --git a/utils/strategist.py b/utils/strategist.py new file mode 100644 index 0000000000000000000000000000000000000000..38b107f11fe951ccf77c4be1f213f931931ca891 --- /dev/null +++ b/utils/strategist.py @@ -0,0 +1,29 @@ +def rewrite_hook(text): + """ + Simulates GPT-style hook optimization + """ + + if not text: + return "You won’t believe this..." + + return f"Wait—{text.strip().capitalize()}" + + +def strategy_score(words): + """ + Combines virality + structure + hook strength + """ + + base_score = len(words) + + text = " ".join([w["text"] for w in words]).lower() + + # Hook boost + if any(k in text for k in ["you", "stop", "wait", "imagine"]): + base_score += 30 + + # Emotional intensity + if "!" in text: + base_score += 15 + + return min(100, base_score) \ No newline at end of file diff --git a/utils/subtitle_engine.py b/utils/subtitle_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..20c0cbc7214423856d77f7cd7bccbe7b7cec12ae --- /dev/null +++ b/utils/subtitle_engine.py @@ -0,0 +1,24 @@ +from moviepy.editor import TextClip +from .config import FONT_PATH + + +def word_clip(word): + + duration = word["end"] - word["start"] + + clip = TextClip( + word["text"], + font=FONT_PATH, + fontsize=80, + color="white", + stroke_color="black", + stroke_width=4, + method="caption", + size=(900, None), + ) + + return ( + clip.set_start(word["start"]) + .set_duration(duration) + .set_position(("center", "center")) + ) \ No newline at end of file diff --git a/utils/transcription.py b/utils/transcription.py new file mode 100644 index 0000000000000000000000000000000000000000..dcba5a598e453b27d8bdd2f73a7337054a072497 --- /dev/null +++ b/utils/transcription.py @@ -0,0 +1,44 @@ +from faster_whisper import WhisperModel +from .config import MODEL_SIZE, TEMP_DIR +from .ffmpeg import extract_audio +import os + +model = None + +os.makedirs(TEMP_DIR, exist_ok=True) + + +def get_model(): + global model + + if model is None: + model = WhisperModel( + MODEL_SIZE, + device="cpu", + compute_type="int8" + ) + + return model + + +def transcribe_video(video): + + audio = f"{TEMP_DIR}/audio.wav" + extract_audio(video, audio) + + segments, _ = get_model().transcribe( + audio, + word_timestamps=True + ) + + words = [] + + for seg in segments: + for w in seg.words: + words.append({ + "start": w.start, + "end": w.end, + "text": w.word.strip() + }) + + return words diff --git a/utils/validators.py b/utils/validators.py new file mode 100644 index 0000000000000000000000000000000000000000..e1c5b8039b01af8bc8a3203e93a70953cb1a535a --- /dev/null +++ b/utils/validators.py @@ -0,0 +1,14 @@ +import os + +ALLOWED = [".mp4", ".mov", ".mkv"] + + +def validate_video(path): + + if not os.path.exists(path): + raise Exception("File not found") + + ext = os.path.splitext(path)[1].lower() + + if ext not in ALLOWED: + raise Exception("Unsupported video format") \ No newline at end of file diff --git a/utils/variations.py b/utils/variations.py new file mode 100644 index 0000000000000000000000000000000000000000..06d03abc6845a969006f3db1191ea403eea3dbc5 --- /dev/null +++ b/utils/variations.py @@ -0,0 +1,238 @@ +""" +variations.py +--------------------------------------- +Hook & Script Variation Engine (V8) + +Purpose: +- Generate multiple viral hooks +- Create alternative script directions +- Support A/B testing of edits +- Enable Multi-Version Render pipeline + +Works fully CPU-only. +No external API required. +""" + +import random +import hashlib + + +# ===================================================== +# HOOK TEMPLATES (VIRAL PATTERNS) +# ===================================================== + +HOOK_PATTERNS = [ + "You are not going to believe this...", + "This is what nobody tells you about {}", + "Stop scrolling if you want to understand {}", + "The truth about {} will shock you", + "Most people get {} wrong", + "If you understand this, your {} changes forever", + "I wish I knew this before about {}", + "This is how you actually win at {}", + "Everyone is lying about {}", + "Watch this before it's too late..." +] + + +# ===================================================== +# TEXT CLEANER +# ===================================================== + +def extract_keywords(words): + """ + Extract simple keyword candidates from transcript + """ + + freq = {} + + for w in words: + word = w["word"].lower().strip() + if len(word) < 3: + continue + freq[word] = freq.get(word, 0) + 1 + + sorted_words = sorted(freq.items(), key=lambda x: x[1], reverse=True) + + return [w[0] for w in sorted_words[:5]] + + +# ===================================================== +# HOOK GENERATION +# ===================================================== + +def generate_hooks(words, count=5): + """ + Generate multiple viral hooks from transcript + """ + + keywords = extract_keywords(words) + + hooks = [] + + for i in range(count): + + template = random.choice(HOOK_PATTERNS) + + keyword = random.choice(keywords) if keywords else "this" + + try: + hook = template.format(keyword) + except: + hook = template + + hooks.append(hook) + + return hooks + + +# ===================================================== +# SCRIPT VARIATION ENGINE +# ===================================================== + +def generate_script_variations(words): + """ + Creates alternative narrative directions + """ + + base_text = " ".join([w["word"] for w in words]) + + variations = [] + + variations.append({ + "style": "direct", + "script": base_text + }) + + variations.append({ + "style": "emotional", + "script": "Imagine this... " + base_text + }) + + variations.append({ + "style": "urgent", + "script": "You need to hear this: " + base_text + }) + + variations.append({ + "style": "story", + "script": "Let me tell you something important. " + base_text + }) + + return variations + + +# ===================================================== +# CAPTION VARIATION ENGINE +# ===================================================== + +def generate_caption_variations(captions): + """ + Creates multiple caption styles for rendering + """ + + styles = [] + + for c in captions: + + styles.append({ + "style": "bold_center", + "text": c["text"].upper() + }) + + styles.append({ + "style": "minimal", + "text": c["text"] + }) + + styles.append({ + "style": "emphasis_words", + "text": highlight_keywords(c["text"]) + }) + + return styles + + +# ===================================================== +# KEYWORD HIGHLIGHTER +# ===================================================== + +def highlight_keywords(text): + """ + Emphasizes strong words in captions + """ + + keywords = ["you", "this", "stop", "now", "secret", "important"] + + words = text.split() + + output = [] + + for w in words: + if w.lower() in keywords: + output.append(w.upper()) + else: + output.append(w) + + return " ".join(output) + + +# ===================================================== +# MULTI VERSION RENDER ENGINE +# ===================================================== + +def generate_render_variations(video_path, hooks=None): + """ + Creates multiple render variants metadata + (actual rendering happens in render.py) + """ + + if not hooks: + hooks = ["Hook 1", "Hook 2", "Hook 3"] + + outputs = [] + + for i, hook in enumerate(hooks): + + outputs.append({ + "version": i + 1, + "hook": hook, + "output_file": f"render_variant_{i+1}.mp4" + }) + + return outputs + + +# ===================================================== +# DETERMINISTIC VIRAL HASH +# ===================================================== + +def viral_signature(text): + """ + Creates deterministic ID for A/B testing consistency + """ + + return hashlib.md5(text.encode()).hexdigest()[:10] + + +# ===================================================== +# PUBLIC API +# ===================================================== + +def generate_hooks_only(words): + return generate_hooks(words) + + +def generate_full_variations(words): + """ + Full pipeline for V8 Multi-Version system + """ + + hooks = generate_hooks(words) + + scripts = generate_script_variations(words) + + return { + "hooks": hooks, + "scripts": scripts + } \ No newline at end of file diff --git a/utils/vertical.py b/utils/vertical.py new file mode 100644 index 0000000000000000000000000000000000000000..87374164e2c3e96991a5728e40f8d8d1b83d6a42 --- /dev/null +++ b/utils/vertical.py @@ -0,0 +1,25 @@ +from moviepy.editor import VideoFileClip + + +def vertical_crop(video_path): + + clip = VideoFileClip(video_path) + + w, h = clip.size + target_ratio = 9 / 16 + + new_width = int(h * target_ratio) + + x_center = w / 2 + + cropped = clip.crop( + x_center=x_center, + width=new_width, + height=h + ) + + output = video_path.replace(".mp4", "_vertical.mp4") + + cropped.write_videofile(output, codec="libx264") + + return output \ No newline at end of file diff --git a/utils/viral_scorer.py b/utils/viral_scorer.py new file mode 100644 index 0000000000000000000000000000000000000000..c39ce00a1cd16301765bab739a3ff697a289eca1 --- /dev/null +++ b/utils/viral_scorer.py @@ -0,0 +1,46 @@ +def score_clip(words): + """ + Returns virality score (0–100) + based on speech + structure signals + """ + + if not words: + return 0 + + text = " ".join([w["text"] for w in words]).lower() + + score = 0 + + # ----------------------------- + # HOOK SIGNAL (first words) + # ----------------------------- + hook_words = ["you", "imagine", "stop", "listen", "this", "never", "why"] + + if any(h in text[:50] for h in hook_words): + score += 25 + + # ----------------------------- + # EMOTION SIGNAL + # ----------------------------- + exclamations = sum(1 for w in words if "!" in w["text"]) + score += min(exclamations * 5, 20) + + # ----------------------------- + # LENGTH OPTIMIZATION + # ----------------------------- + duration = words[-1]["end"] - words[0]["start"] + + if 6 <= duration <= 25: + score += 25 + elif duration < 6: + score -= 10 + else: + score -= 5 + + # ----------------------------- + # WORD DENSITY + # ----------------------------- + score += min(len(words) / 2, 20) + + # Clamp + return max(0, min(100, score)) \ No newline at end of file diff --git a/utils/zoom_tracker.py b/utils/zoom_tracker.py new file mode 100644 index 0000000000000000000000000000000000000000..08525418ed13d724362e6fa49db1508ecc6627c8 --- /dev/null +++ b/utils/zoom_tracker.py @@ -0,0 +1,11 @@ +def zoom_intensity(score): + """ + Maps viral score to zoom level + """ + + if score > 80: + return 1.2 # aggressive zoom + elif score > 50: + return 1.1 + else: + return 1.0 \ No newline at end of file diff --git a/voices/af.pt b/voices/af.pt new file mode 100644 index 0000000000000000000000000000000000000000..a67cad519413efaf099c768ed3ba6ed7bac6bfb4 --- /dev/null +++ b/voices/af.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03d6457d8d31306c7c4e7afc5040b446e6cb556b40fd80d07fbdbc8dc75d300c +size 131 diff --git a/voices/af_bella.pt b/voices/af_bella.pt new file mode 100644 index 0000000000000000000000000000000000000000..9221cbb248feafe5b575b3a4c2297f7da9f10a23 --- /dev/null +++ b/voices/af_bella.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04339167efe7fe6dedf2eedfcdd357bbe3f4451619fcb67c0feea0708da0bb31 +size 131 diff --git a/voices/af_nicole.pt b/voices/af_nicole.pt new file mode 100644 index 0000000000000000000000000000000000000000..3a2cc59923702171df2e4fe7720930b7e08fe93b --- /dev/null +++ b/voices/af_nicole.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ca02cd41d325f5445b14805a3ec6985cae55a21cec91635f8c2038a5b288383 +size 131 diff --git a/voices/af_sarah.pt b/voices/af_sarah.pt new file mode 100644 index 0000000000000000000000000000000000000000..01586c4caa5ca7db52b54c0cb721452d7d32f66e --- /dev/null +++ b/voices/af_sarah.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a6f3c43e3645896c7f0d57968f23d5ef5860b764bd4abb6d75076a1e7fda2e9 +size 131 diff --git a/voices/af_sky.pt b/voices/af_sky.pt new file mode 100644 index 0000000000000000000000000000000000000000..7c39e9bcd1590004f8761adc0bb103e0df338051 --- /dev/null +++ b/voices/af_sky.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab87dbbf5b86022f6c01aed43cb4bcd221fcb95241c1d1e67e27dde2978a59c5 +size 131 diff --git a/voices/am_adam.pt b/voices/am_adam.pt new file mode 100644 index 0000000000000000000000000000000000000000..b3300d7399123b5bc5f16e44bbba64840a6ba4cf --- /dev/null +++ b/voices/am_adam.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd49625a3f7fba1b2ca0cd84b817bc0531fdc94bd256953c3636e8afb09df2f4 +size 131 diff --git a/voices/am_michael.pt b/voices/am_michael.pt new file mode 100644 index 0000000000000000000000000000000000000000..cf80006e6e345586272f5881908a5f2972e4e664 --- /dev/null +++ b/voices/am_michael.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bba4bc7701cefde36070eaf65d20a689dae85282efc79c8d5770933a56c2ff76 +size 131 diff --git a/voices/bf_emma.pt b/voices/bf_emma.pt new file mode 100644 index 0000000000000000000000000000000000000000..3379731d55b1234c1f4594b8065ac398894d7775 --- /dev/null +++ b/voices/bf_emma.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5c84df49c195eb467a7a2e3f5f5e25624808f751db021fd5776495e1b9bb585 +size 131 diff --git a/voices/bf_isabella.pt b/voices/bf_isabella.pt new file mode 100644 index 0000000000000000000000000000000000000000..861e4bbf104fdee879c39bdd952d3ba24ccc6617 --- /dev/null +++ b/voices/bf_isabella.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc23980551bb88178169bbdcfe3f10d69fc8b8ded9fdb0ec1123a3294b95266d +size 131 diff --git a/voices/bm_george.pt b/voices/bm_george.pt new file mode 100644 index 0000000000000000000000000000000000000000..9b2bc7a75b3ddbb8cd7e11aec17f6358dbee8239 --- /dev/null +++ b/voices/bm_george.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27dff07c3aaf98f311475d2d3212f377325ab49b0ff566100424a3301252bbb2 +size 131 diff --git a/voices/bm_lewis.pt b/voices/bm_lewis.pt new file mode 100644 index 0000000000000000000000000000000000000000..d2e59aa976fd20aae5bcee04ea0081198a849599 --- /dev/null +++ b/voices/bm_lewis.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9d076bfb89ada363657fea4d40d67f4174929dc771fdc245547397714fd33dc +size 131 diff --git a/weights/.gitkeep b/weights/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/weights/kokoro-quant.onnx b/weights/kokoro-quant.onnx new file mode 100644 index 0000000000000000000000000000000000000000..a34a4bec1c44b37756b2ef7622da8ce8ddc53380 --- /dev/null +++ b/weights/kokoro-quant.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fa3c2d4e9dabfb2929b555c425c1c56f8333d46c47de8668de365a3d158de83 +size 134 diff --git a/weights/kokoro-v0_19.onnx b/weights/kokoro-v0_19.onnx new file mode 100644 index 0000000000000000000000000000000000000000..f17da6d7150a4560e9b0b8e1cff29947448908dc --- /dev/null +++ b/weights/kokoro-v0_19.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6bd36e46e7e6e1f118d2ae1a8ff807a24c06cdae309f3c4f34230ad9eb37652 +size 134