From 30b969e4b89a4e501e6da0e8df7636e7530d9b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastjan=20Arti=C4=8D?= Date: Tue, 28 Apr 2026 15:28:22 +0000 Subject: [PATCH] Initial: reels clipper app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastAPI backend (auth, jobs, SSE, download) - Frontend: drag&drop + YouTube URL + jobs panel - Pipeline: yt_download β†’ find_chorus β†’ reframe β†’ subtitle - Modes: track (face follow), center, blur - Whisper for SI/DE/EN subtitles - Auto-chorus detection via Whisper + RMS energy - Docker + Coolify ready --- .env.example | 6 + .gitignore | 11 + Dockerfile | 39 +++ README.md | 73 ++++++ app/main.py | 454 +++++++++++++++++++++++++++++++++++ docker-compose.yml | 21 ++ requirements.txt | 8 + scripts/clip.py | 132 ++++++++++ scripts/find_chorus.py | 289 ++++++++++++++++++++++ scripts/reframe.py | 306 +++++++++++++++++++++++ scripts/subtitle.py | 143 +++++++++++ scripts/yt_download.py | 80 ++++++ templates/index.html | 534 +++++++++++++++++++++++++++++++++++++++++ 13 files changed, 2096 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/main.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 scripts/clip.py create mode 100644 scripts/find_chorus.py create mode 100644 scripts/reframe.py create mode 100644 scripts/subtitle.py create mode 100644 scripts/yt_download.py create mode 100644 templates/index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0195a99 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Auth (basic HTTP) +AUTH_USER=sebastjan +AUTH_PASS=zamenjaj-me-v-coolify-env + +# Upload limit (MB) +MAX_UPLOAD_MB=2000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4406aed --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +.env +*.mp4 +*.wav +*.srt +data/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8434c1d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.11-slim + +# System deps: FFmpeg, libs za OpenCV +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libsm6 \ + libxext6 \ + libgl1 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Python deps +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# App code +COPY app/ ./app/ +COPY scripts/ ./scripts/ +COPY templates/ ./templates/ +COPY static/ ./static/ + +# Data volume +RUN mkdir -p /data/uploads /data/outputs /data/jobs +VOLUME /data + +ENV DATA_DIR=/data +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8000 + +# Pre-download Whisper "small" model za faster cold start (opcijsko) +# RUN python -c "from faster_whisper import WhisperModel; WhisperModel('small', device='cpu', compute_type='int8')" + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -fsS http://localhost:8000/healthz || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad7925f --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Reels Clipper Β· biba.live + +Self-hosted Opus Clip alternativa za FOLX TV / PTC. +Pretvori 16:9 video v 9:16 reels/shorts/tiktok format z auto face tracking, podnapisi (sl/de/en) in **avto-detekcijo refrena** v glasbenih pesmih. + +## Features + +- πŸ“€ **Drag & drop upload** (do 2 GB) +- πŸ“Ί **YouTube URL paste** (yt-dlp) +- 🎯 **Smart reframe**: track (face follow), center, blur (za glasbo) +- 🎡 **Auto-chorus detection** (Whisper + energy hibrid) +- πŸ“ **Burned-in podnapisi** (faster-whisper, multi-jezik) +- 🎨 **3 stili podnapisov**: reels, yellow (MrBeast), minimal +- πŸ” **HTTP Basic Auth** +- πŸ“Š **Real-time progress** (Server-Sent Events) +- πŸ“¦ **Docker / Coolify ready** + +## Quick start (lokalno) + +```bash +docker compose up --build +# odpri http://localhost:8000 +``` + +Default login: `sebastjan` / nastavi `AUTH_PASS` v `.env`. + +## Coolify deploy + +1. V Coolify ustvari nov projekt β†’ **Docker Compose** iz tega repoja +2. Domena: `reels.biba.live` +3. Env vars: + ``` + AUTH_USER=sebastjan + AUTH_PASS= + MAX_UPLOAD_MB=2000 + ``` +4. Volume `reels_data` se ustvari avtomatsko +5. Deploy β†’ Coolify postavi Traefik reverse proxy + SSL via Let's Encrypt + +## Pipeline + +``` +Upload / YouTube + ↓ +[ yt_download.py ] ← samo če YouTube + ↓ +[ find_chorus.py ] ← samo če auto_chorus=true (Whisper + RMS analiza) + ↓ +[ reframe.py ] ← 16:9 β†’ 9:16 (track / center / blur) + ↓ +[ subtitle.py ] ← Whisper transkripcija + burn-in + ↓ + reel.mp4 +``` + +## API + +- `POST /api/upload` β€” multipart file upload, vrne `job_id` +- `POST /api/youtube` β€” JSON `{url, mode, lang, ...}` +- `POST /api/process` β€” start processing za uploaded job +- `GET /api/jobs` β€” list vseh +- `GET /api/jobs/{id}` β€” status +- `GET /api/stream/{id}` β€” SSE stream progress +- `GET /api/download/{id}` β€” final reel +- `DELETE /api/jobs/{id}` β€” pobriΕ‘i + +## Dependencies + +- FFmpeg (system) +- faster-whisper (transkripcija) +- OpenCV (face detection) +- yt-dlp (YouTube) +- FastAPI + uvicorn (server) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..9c98533 --- /dev/null +++ b/app/main.py @@ -0,0 +1,454 @@ +""" +reels.biba.live β€” FastAPI backend. + +Endpoints: + GET / β€” frontend HTML + POST /api/upload β€” naloΕΎi video file + POST /api/youtube β€” submit YouTube URL + POST /api/process/{id} β€” start processing job + GET /api/jobs β€” list vseh jobov + GET /api/jobs/{id} β€” status job-a + GET /api/stream/{id} β€” SSE progress stream + GET /api/download/{id} β€” download finalni reel + GET /api/preview/{id} β€” preview video stream + DELETE /api/jobs/{id} β€” pobriΕ‘i job + datoteke +""" +import asyncio +import json +import os +import secrets +import shutil +import subprocess +import time +import uuid +from pathlib import Path +from typing import Optional + +from fastapi import ( + FastAPI, UploadFile, File, Form, HTTPException, Depends, + BackgroundTasks, Request, status +) +from fastapi.responses import ( + FileResponse, HTMLResponse, StreamingResponse, JSONResponse +) +from fastapi.staticfiles import StaticFiles +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from pydantic import BaseModel + + +# ──────────────────────────────────────────────────────────────── +# Config +# ──────────────────────────────────────────────────────────────── +DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) +UPLOAD_DIR = DATA_DIR / "uploads" +OUTPUT_DIR = DATA_DIR / "outputs" +JOBS_DIR = DATA_DIR / "jobs" +SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" + +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) +JOBS_DIR.mkdir(parents=True, exist_ok=True) + +AUTH_USER = os.environ.get("AUTH_USER", "sebastjan") +AUTH_PASS = os.environ.get("AUTH_PASS", "change-me-in-coolify-env") + +MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "2000")) + + +# ──────────────────────────────────────────────────────────────── +# Auth +# ──────────────────────────────────────────────────────────────── +security = HTTPBasic() + + +def check_auth(creds: HTTPBasicCredentials = Depends(security)): + correct_user = secrets.compare_digest(creds.username, AUTH_USER) + correct_pass = secrets.compare_digest(creds.password, AUTH_PASS) + if not (correct_user and correct_pass): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Napačno geslo", + headers={"WWW-Authenticate": "Basic"}, + ) + return creds.username + + +# ──────────────────────────────────────────────────────────────── +# Job state (filesystem-based, persistent prek restartov) +# ──────────────────────────────────────────────────────────────── +def job_path(job_id): + return JOBS_DIR / f"{job_id}.json" + + +def load_job(job_id): + p = job_path(job_id) + if not p.exists(): + return None + return json.loads(p.read_text()) + + +def save_job(job): + job_path(job["id"]).write_text(json.dumps(job, ensure_ascii=False, indent=2)) + + +def update_job(job_id, **kwargs): + job = load_job(job_id) + if not job: + return None + job.update(kwargs) + job["updated_at"] = time.time() + save_job(job) + return job + + +def list_jobs(): + out = [] + for f in sorted(JOBS_DIR.glob("*.json"), reverse=True): + try: + out.append(json.loads(f.read_text())) + except Exception: + pass + return out + + +# ──────────────────────────────────────────────────────────────── +# Pipeline runner (background task) +# ──────────────────────────────────────────────────────────────── +def run_subprocess_logged(cmd, job_id, step_name): + """Pokliče subprocess, logi gredo v job.""" + update_job(job_id, current_step=step_name, status="processing") + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + update_job( + job_id, + status="failed", + error=f"{step_name}: {proc.stderr[-500:]}", + ) + return False + return True + + +def process_job(job_id): + """Glavni pipeline: download (če YT) β†’ find_chorus (če auto) β†’ reframe β†’ subs.""" + job = load_job(job_id) + if not job: + return + + try: + # ── 1. Source preparation ───────────────────────────── + if job["source_type"] == "youtube": + update_job(job_id, status="downloading", current_step="YouTube download") + input_path = UPLOAD_DIR / f"{job_id}_yt.mp4" + cmd = [ + "python3", str(SCRIPTS_DIR / "yt_download.py"), + job["youtube_url"], str(input_path), + ] + if not run_subprocess_logged(cmd, job_id, "YouTube download"): + return + update_job(job_id, input_path=str(input_path)) + else: + input_path = Path(job["input_path"]) + + # ── 2. Find chorus (če auto) ────────────────────────── + if job.get("auto_chorus"): + update_job(job_id, current_step="Iőčem refren (Whisper + energy)") + cmd = [ + "python3", str(SCRIPTS_DIR / "find_chorus.py"), + str(input_path), + "--duration", str(job.get("duration", 30)), + "--json", + ] + if job.get("lang"): + cmd += ["--lang", job["lang"]] + cmd += ["--model", job.get("whisper_model", "small")] + + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode == 0: + try: + chorus = json.loads(proc.stdout) + if chorus.get("candidates"): + best = chorus["candidates"][0] + update_job( + job_id, + chorus_detection=chorus, + start=best["start"], + duration=best["duration"], + ) + except json.JSONDecodeError: + update_job(job_id, chorus_error="JSON decode failed") + else: + update_job(job_id, chorus_error=proc.stderr[-300:]) + + # ── 3. Reframe + subtitles (clip.py orchestrator) ───── + output_path = OUTPUT_DIR / f"{job_id}.mp4" + update_job(job_id, current_step="Reframe + subtitles") + + cmd = [ + "python3", str(SCRIPTS_DIR / "clip.py"), + str(input_path), str(output_path), + "--mode", job.get("mode", "track"), + "--quality", job.get("quality", "medium"), + "--style", job.get("subtitle_style", "reels"), + ] + if job.get("start") is not None: + cmd += ["--start", str(job["start"])] + if job.get("duration") is not None: + cmd += ["--duration", str(job["duration"])] + if job.get("lang"): + cmd += ["--lang", job["lang"]] + if job.get("no_subs"): + cmd += ["--no-subs"] + cmd += ["--model", job.get("whisper_model", "small")] + + if not run_subprocess_logged(cmd, job_id, "Reframe + subtitles"): + return + + # ── Done ────────────────────────────────────────────── + if output_path.exists(): + update_job( + job_id, + status="done", + current_step="Končano", + output_path=str(output_path), + output_size_mb=round(output_path.stat().st_size / 1024 / 1024, 2), + ) + else: + update_job( + job_id, + status="failed", + error="Output datoteka ne obstaja po obdelavi", + ) + except Exception as e: + update_job(job_id, status="failed", error=str(e)) + + +# ──────────────────────────────────────────────────────────────── +# FastAPI app +# ──────────────────────────────────────────────────────────────── +app = FastAPI(title="Reels Clipper") +app.mount("/static", StaticFiles(directory=Path(__file__).parent.parent / "static"), name="static") + + +@app.get("/", response_class=HTMLResponse) +async def index(user: str = Depends(check_auth)): + html = (Path(__file__).parent.parent / "templates" / "index.html").read_text() + return html + + +@app.get("/healthz") +async def healthz(): + return {"ok": True} + + +# ──────────────────────────────────────────────────────────────── +# Job models +# ──────────────────────────────────────────────────────────────── +class YouTubeJobIn(BaseModel): + url: str + mode: str = "track" + lang: Optional[str] = None + auto_chorus: bool = True + start: Optional[float] = None + duration: Optional[float] = 30 + no_subs: bool = False + subtitle_style: str = "reels" + whisper_model: str = "small" + quality: str = "medium" + + +class StartJobIn(BaseModel): + job_id: str + mode: str = "track" + lang: Optional[str] = None + auto_chorus: bool = True + start: Optional[float] = None + duration: Optional[float] = 30 + no_subs: bool = False + subtitle_style: str = "reels" + whisper_model: str = "small" + quality: str = "medium" + + +# ──────────────────────────────────────────────────────────────── +# Upload (file) +# ──────────────────────────────────────────────────────────────── +@app.post("/api/upload") +async def upload_video( + file: UploadFile = File(...), + user: str = Depends(check_auth), +): + if not file.filename: + raise HTTPException(400, "Brez imena") + + job_id = uuid.uuid4().hex[:12] + ext = Path(file.filename).suffix or ".mp4" + input_path = UPLOAD_DIR / f"{job_id}{ext}" + + size = 0 + with input_path.open("wb") as f: + while chunk := await file.read(1024 * 1024): + size += len(chunk) + if size > MAX_UPLOAD_MB * 1024 * 1024: + f.close() + input_path.unlink(missing_ok=True) + raise HTTPException(413, f"Prevelika datoteka (limit {MAX_UPLOAD_MB} MB)") + f.write(chunk) + + job = { + "id": job_id, + "source_type": "upload", + "filename": file.filename, + "input_path": str(input_path), + "size_mb": round(size / 1024 / 1024, 2), + "status": "uploaded", + "current_step": "NaloΕΎeno, čaka na obdelavo", + "created_at": time.time(), + "updated_at": time.time(), + } + save_job(job) + return job + + +# ──────────────────────────────────────────────────────────────── +# YouTube submit +# ──────────────────────────────────────────────────────────────── +@app.post("/api/youtube") +async def submit_youtube( + payload: YouTubeJobIn, + background: BackgroundTasks, + user: str = Depends(check_auth), +): + job_id = uuid.uuid4().hex[:12] + job = { + "id": job_id, + "source_type": "youtube", + "youtube_url": payload.url, + "status": "queued", + "current_step": "V vrsti za YouTube prenos", + "created_at": time.time(), + "updated_at": time.time(), + "mode": payload.mode, + "lang": payload.lang, + "auto_chorus": payload.auto_chorus, + "start": payload.start, + "duration": payload.duration, + "no_subs": payload.no_subs, + "subtitle_style": payload.subtitle_style, + "whisper_model": payload.whisper_model, + "quality": payload.quality, + } + save_job(job) + background.add_task(process_job, job_id) + return job + + +# ──────────────────────────────────────────────────────────────── +# Start processing for uploaded job +# ──────────────────────────────────────────────────────────────── +@app.post("/api/process") +async def start_processing( + payload: StartJobIn, + background: BackgroundTasks, + user: str = Depends(check_auth), +): + job = load_job(payload.job_id) + if not job: + raise HTTPException(404, "Job ne obstaja") + + update_job( + payload.job_id, + status="queued", + mode=payload.mode, + lang=payload.lang, + auto_chorus=payload.auto_chorus, + start=payload.start, + duration=payload.duration, + no_subs=payload.no_subs, + subtitle_style=payload.subtitle_style, + whisper_model=payload.whisper_model, + quality=payload.quality, + current_step="V vrsti za obdelavo", + ) + background.add_task(process_job, payload.job_id) + return load_job(payload.job_id) + + +# ──────────────────────────────────────────────────────────────── +# Job queries +# ──────────────────────────────────────────────────────────────── +@app.get("/api/jobs") +async def get_jobs(user: str = Depends(check_auth)): + return {"jobs": list_jobs()} + + +@app.get("/api/jobs/{job_id}") +async def get_job(job_id: str, user: str = Depends(check_auth)): + job = load_job(job_id) + if not job: + raise HTTPException(404, "Ne obstaja") + return job + + +@app.get("/api/stream/{job_id}") +async def stream_job(job_id: str, user: str = Depends(check_auth)): + """Server-Sent Events za real-time status.""" + + async def gen(): + last_status = None + last_step = None + for _ in range(600): # max 10 min stream + job = load_job(job_id) + if not job: + yield f"data: {json.dumps({'error': 'not found'})}\n\n" + return + if job["status"] != last_status or job.get("current_step") != last_step: + yield f"data: {json.dumps(job, ensure_ascii=False)}\n\n" + last_status = job["status"] + last_step = job.get("current_step") + if job["status"] in ("done", "failed"): + return + await asyncio.sleep(1) + + return StreamingResponse(gen(), media_type="text/event-stream") + + +# ──────────────────────────────────────────────────────────────── +# Download / preview +# ──────────────────────────────────────────────────────────────── +@app.get("/api/download/{job_id}") +async def download(job_id: str, user: str = Depends(check_auth)): + job = load_job(job_id) + if not job or job.get("status") != "done": + raise HTTPException(404, "Ne pripravljen") + out = Path(job["output_path"]) + if not out.exists(): + raise HTTPException(404, "Output ne obstaja") + return FileResponse( + out, + media_type="video/mp4", + filename=f"reel_{job_id}.mp4", + ) + + +@app.get("/api/preview/{job_id}") +async def preview(job_id: str, user: str = Depends(check_auth)): + job = load_job(job_id) + if not job or job.get("status") != "done": + raise HTTPException(404, "Ne pripravljen") + out = Path(job["output_path"]) + if not out.exists(): + raise HTTPException(404, "Output ne obstaja") + return FileResponse(out, media_type="video/mp4") + + +@app.delete("/api/jobs/{job_id}") +async def delete_job(job_id: str, user: str = Depends(check_auth)): + job = load_job(job_id) + if not job: + raise HTTPException(404, "Ne obstaja") + for key in ("input_path", "output_path"): + p = job.get(key) + if p and Path(p).exists(): + Path(p).unlink(missing_ok=True) + job_path(job_id).unlink(missing_ok=True) + return {"deleted": job_id} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..37d9434 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + reels-app: + build: . + ports: + - "8000" + volumes: + - reels_data:/data + environment: + - AUTH_USER=${AUTH_USER:-sebastjan} + - AUTH_PASS=${AUTH_PASS:-change-me} + - MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-2000} + - DATA_DIR=/data + restart: unless-stopped + labels: + # Coolify tags (Traefik bo to pobral za reverse proxy) + - "coolify.managed=true" + - "coolify.name=reels-clipper" + +volumes: + reels_data: + driver: local diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..abcf937 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +python-multipart==0.0.12 +pydantic==2.9.2 +faster-whisper==1.0.3 +opencv-python-headless==4.10.0.84 +numpy==1.26.4 +yt-dlp==2024.10.7 diff --git a/scripts/clip.py b/scripts/clip.py new file mode 100644 index 0000000..abf2b3f --- /dev/null +++ b/scripts/clip.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +clip.py β€” Vse v enem: vzemi 16:9 video, izreΕΎi klip, reframe na 9:16, dodaj podnapise. + +Primer: + # Cel video β†’ 9:16 z face tracking + slovenskimi podnapisi + python3 clip.py input.mp4 reel.mp4 --lang sl + + # 30s klip od 1:20 dalje, blur ozadje, brez podnapisov + python3 clip.py input.mp4 reel.mp4 --start 80 --duration 30 --mode blur --no-subs + + # Več klipov hkrati prek timestamp seznama + python3 clip.py input.mp4 out_dir/ --clips "0:30-1:00,2:15-2:45,5:00-5:30" --lang sl +""" +import argparse +import subprocess +import sys +import os +import tempfile +from pathlib import Path + + +def parse_ts(s): + """'1:23' β†’ 83.0, '1:23.5' β†’ 83.5, '90' β†’ 90.0""" + s = s.strip() + if ":" in s: + parts = s.split(":") + if len(parts) == 2: + return int(parts[0]) * 60 + float(parts[1]) + if len(parts) == 3: + return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) + return float(s) + + +def parse_clips(spec): + """'0:30-1:00,2:15-2:45' β†’ [(30.0, 60.0), (135.0, 165.0)]""" + out = [] + for c in spec.split(","): + a, b = c.split("-") + out.append((parse_ts(a), parse_ts(b))) + return out + + +SCRIPT_DIR = Path(__file__).parent + + +def run_clip(src, dst, start, duration, mode, lang, model, style, no_subs, quality): + """Naredi en klip src β†’ dst.""" + tmp = tempfile.mkdtemp(prefix="reel_") + try: + reframed = Path(tmp) / "reframed.mp4" + + # 1. Reframe (in trim hkrati) + cmd = [ + "python3", str(SCRIPT_DIR / "reframe.py"), + str(src), str(reframed), + "--mode", mode, + "--quality", quality, + ] + if start is not None: + cmd += ["--start", str(start)] + if duration is not None: + cmd += ["--duration", str(duration)] + print(f"\nβ–Ά Klip: {dst.name}") + r = subprocess.run(cmd) + if r.returncode != 0: + print(f"❌ Reframe napaka pri {dst.name}", file=sys.stderr) + return False + + # 2. Subtitles (opcijsko) + if no_subs: + os.replace(reframed, dst) + else: + cmd = [ + "python3", str(SCRIPT_DIR / "subtitle.py"), + str(reframed), str(dst), + "--model", model, + "--style", style, + ] + if lang: + cmd += ["--lang", lang] + r = subprocess.run(cmd) + if r.returncode != 0: + print(f"❌ Subtitle napaka β€” shranim brez", file=sys.stderr) + os.replace(reframed, dst) + return True + finally: + import shutil + shutil.rmtree(tmp, ignore_errors=True) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("input") + ap.add_argument("output", help="Datoteka (en klip) ali mapa (več klipov)") + ap.add_argument("--start", type=str, default=None, help="Začetek (s ali mm:ss)") + ap.add_argument("--duration", type=float, default=None, help="Trajanje v s") + ap.add_argument("--clips", type=str, default=None, + help="Več klipov: '0:30-1:00,2:15-2:45'") + ap.add_argument("--mode", default="track", choices=["track", "center", "blur"]) + ap.add_argument("--lang", default=None, help="sl, de, en, ... (privzeto auto)") + ap.add_argument("--model", default="small", + choices=["tiny", "base", "small", "medium", "large-v3"]) + ap.add_argument("--style", default="reels", choices=["reels", "yellow", "minimal"]) + ap.add_argument("--no-subs", action="store_true") + ap.add_argument("--quality", default="medium", choices=["fast", "medium", "high"]) + args = ap.parse_args() + + src = Path(args.input) + if not src.exists(): + print(f"❌ {src} ne obstaja", file=sys.stderr) + sys.exit(1) + + if args.clips: + clips = parse_clips(args.clips) + out_dir = Path(args.output) + out_dir.mkdir(parents=True, exist_ok=True) + ok = 0 + for i, (s, e) in enumerate(clips, 1): + dst = out_dir / f"reel_{i:02d}.mp4" + if run_clip(src, dst, s, e - s, args.mode, args.lang, args.model, + args.style, args.no_subs, args.quality): + ok += 1 + print(f"\nβœ… Dokončano: {ok}/{len(clips)} klipov v {out_dir}") + else: + start = parse_ts(args.start) if args.start else None + run_clip(src, Path(args.output), start, args.duration, args.mode, + args.lang, args.model, args.style, args.no_subs, args.quality) + + +if __name__ == "__main__": + main() diff --git a/scripts/find_chorus.py b/scripts/find_chorus.py new file mode 100644 index 0000000..e1d24b6 --- /dev/null +++ b/scripts/find_chorus.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +""" +find_chorus.py β€” Avto-detekcija refrena v glasbenem videu. + +Hibridni pristop: + 1. Whisper transkribira pesem z word-level timestamps + 2. Najde ponavljajoče se vrstice (n-gram matching + Levenshtein) + 3. Energy analiza prek FFmpeg (RMS dB) β€” refren je navadno glasnejΕ‘i + 4. ZdruΕΎi: ponovljen tekst + visoka energija = refren + +Output: JSON z najboljΕ‘imi kandidati (ranked). + +Primer: + python3 find_chorus.py pesem.mp4 + python3 find_chorus.py pesem.mp4 --duration 30 --json +""" +import argparse +import json +import subprocess +import sys +import tempfile +from collections import Counter +from pathlib import Path +import re + + +def extract_audio(video, sample_rate=16000): + """Ekstrahiraj mono WAV za Whisper in energy analizo.""" + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + tmp.close() + cmd = [ + "ffmpeg", "-y", "-i", str(video), + "-vn", "-ac", "1", "-ar", str(sample_rate), + "-c:a", "pcm_s16le", tmp.name, + ] + subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL) + return tmp.name + + +def transcribe(audio_path, lang=None, model_size="small"): + """Whisper transkripcija z word-level timestamps.""" + from faster_whisper import WhisperModel + + print(f"🧠 Whisper: {model_size}, lang={lang or 'auto'}", file=sys.stderr) + model = WhisperModel(model_size, device="cpu", compute_type="int8") + segments, info = model.transcribe( + audio_path, + language=lang, + word_timestamps=True, + vad_filter=True, + ) + print(f" Detekcija: {info.language} (p={info.language_probability:.2f})", file=sys.stderr) + + # Vrne seznam line-level segmentov s timestamp-i + lines = [] + for seg in segments: + text = seg.text.strip() + if text: + lines.append({ + "start": seg.start, + "end": seg.end, + "text": text, + "duration": seg.end - seg.start, + }) + return lines, info.language + + +def normalize_text(s): + """Normalize za primerjavo: lowercase, brez punktuacije.""" + s = s.lower() + s = re.sub(r"[^\w\s]", "", s) + s = re.sub(r"\s+", " ", s).strip() + return s + + +def line_similarity(a, b): + """Jaccard similarity na bigrams besedah.""" + a_words = normalize_text(a).split() + b_words = normalize_text(b).split() + if not a_words or not b_words: + return 0.0 + + def bigrams(words): + return set(zip(words, words[1:])) if len(words) > 1 else {(words[0],)} + + a_bg = bigrams(a_words) + b_bg = bigrams(b_words) + if not a_bg or not b_bg: + return 0.0 + return len(a_bg & b_bg) / len(a_bg | b_bg) + + +def find_repeated_lines(lines, similarity_threshold=0.5): + """ + Najdi ponavljajoče se vrstice. Vrne seznam clustrov. + Vsak cluster = list[indices_v_lines] kjer so si vrstice podobne. + """ + n = len(lines) + visited = [False] * n + clusters = [] + + for i in range(n): + if visited[i]: + continue + cluster = [i] + visited[i] = True + for j in range(i + 1, n): + if visited[j]: + continue + sim = line_similarity(lines[i]["text"], lines[j]["text"]) + if sim >= similarity_threshold: + cluster.append(j) + visited[j] = True + if len(cluster) >= 2: # samo če se ponovi vsaj 2x + clusters.append(cluster) + + return clusters + + +def compute_energy(audio_path, window_sec=1.0): + """ + Vrni list (timestamp, rms_db) preko FFmpeg ebur128 filter. + """ + # Uporabi ebur128 ali astats za RMS + cmd = [ + "ffmpeg", "-i", audio_path, + "-af", f"asetnsamples=n={int(16000 * window_sec)}:p=0,astats=metadata=1:reset={window_sec}," + "ametadata=print:key=lavfi.astats.Overall.RMS_level", + "-f", "null", "-", + ] + result = subprocess.run(cmd, capture_output=True, text=True) + output = result.stderr + + energies = [] + current_pts = None + for line in output.split("\n"): + line = line.strip() + if line.startswith("frame:"): + # frame:N pts:X pts_time:Y + m = re.search(r"pts_time:(\S+)", line) + if m: + current_pts = float(m.group(1)) + elif line.startswith("lavfi.astats.Overall.RMS_level="): + val = line.split("=")[1] + try: + rms = float(val) + if current_pts is not None: + energies.append((current_pts, rms)) + except ValueError: + pass + + return energies + + +def avg_energy_in_range(energies, start, end): + """Povprečna RMS v [start, end].""" + in_range = [e for t, e in energies if start <= t <= end] + if not in_range: + return -60.0 # default tih + return sum(in_range) / len(in_range) + + +def find_chorus(video, lang=None, model_size="small", target_duration=30.0): + """ + Glavni entry point. Vrne ranked kandidate refrenov. + """ + audio = extract_audio(video) + try: + lines, detected_lang = transcribe(audio, lang=lang, model_size=model_size) + if not lines: + return {"error": "Brez transkripcije", "candidates": []} + + print(f"πŸ“ {len(lines)} vrstic transkripta", file=sys.stderr) + + clusters = find_repeated_lines(lines, similarity_threshold=0.5) + print(f"πŸ” {len(clusters)} ponavljajočih se sklopov", file=sys.stderr) + + if not clusters: + return { + "error": "Ni najdenih ponavljajočih se vrstic", + "language": detected_lang, + "candidates": [], + } + + print("πŸ”Š Analiza energije...", file=sys.stderr) + energies = compute_energy(audio) + avg_overall = sum(e for _, e in energies) / max(1, len(energies)) + print(f" Povprečje RMS: {avg_overall:.1f} dB", file=sys.stderr) + + # Za vsak cluster izračunaj score + candidates = [] + for cluster_idx, cluster in enumerate(clusters): + # Predstavnik clusterja = najdaljΕ‘a vrstica + rep = max(cluster, key=lambda i: len(lines[i]["text"])) + rep_text = lines[rep]["text"] + + # Vsaka instanca = potencialen reel start + for inst_idx in cluster: + line = lines[inst_idx] + # RazΕ‘iri okno na target_duration začenΕ‘i pri tej vrstici + start = line["start"] + end = min(start + target_duration, line["start"] + target_duration) + + # Najdi konec videa (zadnja vrstica) + video_end = max(l["end"] for l in lines) + if start + target_duration > video_end: + start = max(0, video_end - target_duration) + end = video_end + + avg_e = avg_energy_in_range(energies, start, start + target_duration) + energy_score = max(0, avg_e - avg_overall) # koliko nad povprečjem + + # Score: Ε‘tevilo ponovitev + energy + dolΕΎina vrstice + score = ( + len(cluster) * 10 # repetition weight + + energy_score * 2 # energy weight + + min(len(rep_text.split()), 10) # text richness + ) + + candidates.append({ + "start": round(start, 2), + "end": round(start + target_duration, 2), + "duration": target_duration, + "score": round(score, 2), + "repetitions": len(cluster), + "avg_rms_db": round(avg_e, 1), + "energy_above_avg_db": round(energy_score, 1), + "text_sample": rep_text[:80], + "cluster_id": cluster_idx, + }) + + # Sort by score, dedupe close candidates + candidates.sort(key=lambda c: -c["score"]) + deduped = [] + for c in candidates: + if all(abs(c["start"] - d["start"]) > 5 for d in deduped): + deduped.append(c) + if len(deduped) >= 5: + break + + return { + "language": detected_lang, + "total_lines": len(lines), + "clusters_found": len(clusters), + "candidates": deduped, + } + finally: + Path(audio).unlink(missing_ok=True) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("input") + ap.add_argument("--lang", default=None) + ap.add_argument("--model", default="small", + choices=["tiny", "base", "small", "medium", "large-v3"]) + ap.add_argument("--duration", type=float, default=30.0, + help="Ciljna dolΕΎina reel-a v s") + ap.add_argument("--json", action="store_true", help="JSON output") + args = ap.parse_args() + + src = Path(args.input) + if not src.exists(): + print(f"❌ {src} ne obstaja", file=sys.stderr) + sys.exit(1) + + result = find_chorus(src, lang=args.lang, model_size=args.model, + target_duration=args.duration) + + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + if "error" in result and not result.get("candidates"): + print(f"❌ {result['error']}", file=sys.stderr) + sys.exit(2) + print(f"\n🎡 Jezik: {result.get('language', '?')}") + print(f"πŸ“‹ {result['total_lines']} vrstic, {result['clusters_found']} ponavljanj\n") + print("πŸ† NajboljΕ‘i kandidati za refren:\n") + for i, c in enumerate(result["candidates"], 1): + mins = int(c["start"] // 60) + secs = c["start"] - mins * 60 + print(f" {i}. {mins}:{secs:05.2f} β†’ +{c['duration']:.0f}s " + f"(score={c['score']}, ponovitev={c['repetitions']}, " + f"energija={c['energy_above_avg_db']:+.1f} dB)") + print(f" '{c['text_sample']}'\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/reframe.py b/scripts/reframe.py new file mode 100644 index 0000000..fcbe660 --- /dev/null +++ b/scripts/reframe.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +reframe.py β€” Pretvori 16:9 video v 9:16 (reels/shorts/tiktok format). + +Modi: + --mode track : Pametno sledi obrazu/osebi (MediaPipe face detection) + Crop okno se gladko premika za subjektom. + --mode center : Statični center crop (najhitrejΕ‘e) + --mode blur : 9:16 platno z blur ozadjem + 16:9 video v sredini + +Primer: + python3 reframe.py input.mp4 output.mp4 --mode track + python3 reframe.py input.mp4 output.mp4 --mode track --start 10 --duration 30 +""" +import argparse +import subprocess +import sys +import os +import json +import tempfile +from pathlib import Path + +import cv2 +import numpy as np + + +def get_video_info(path): + """Vrni dict z width, height, fps, duration.""" + cmd = [ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-show_format", str(path) + ] + data = json.loads(subprocess.check_output(cmd)) + vstream = next(s for s in data["streams"] if s["codec_type"] == "video") + fps_str = vstream["r_frame_rate"] + num, den = fps_str.split("/") + fps = float(num) / float(den) + return { + "width": int(vstream["width"]), + "height": int(vstream["height"]), + "fps": fps, + "duration": float(data["format"]["duration"]), + } + + +def detect_face_centers(video_path, sample_fps=5): + """ + Vzorči video pri sample_fps in vrni seznam (timestamp, x_center_normalized). + x_center_normalized je 0..1 (0 = levi rob, 1 = desni rob). + Če obraza ni, vrne None za to vzorčenje. + + Uporablja OpenCV Haar cascade (frontalface_alt2) β€” robustno, brez external modela. + """ + cap = cv2.VideoCapture(str(video_path)) + src_fps = cap.get(cv2.CAP_PROP_FPS) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + step = max(1, int(src_fps / sample_fps)) + + cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_alt2.xml" + face_cascade = cv2.CascadeClassifier(cascade_path) + + samples = [] + frame_idx = 0 + while True: + ret, frame = cap.read() + if not ret: + break + if frame_idx % step == 0: + ts = frame_idx / src_fps + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + faces = face_cascade.detectMultiScale( + gray, scaleFactor=1.2, minNeighbors=5, minSize=(60, 60) + ) + if len(faces) > 0: + # Vzemi največji obraz + x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) + x_center = (x + w / 2) / width + samples.append((ts, x_center)) + else: + samples.append((ts, None)) + frame_idx += 1 + + cap.release() + return samples, width, height, src_fps, total_frames + + +def smooth_track(samples, total_duration, smoothing_window=2.0): + """ + Iz seznama (ts, x) naredi gladko krivuljo x(t) za vsako sekundo videa. + - None vrednosti se zapolni z zadnjo znano (ali 0.5 default). + - Drsno povprečje preko smoothing_window sekund. + """ + # Zapolni manjkajoče + last = 0.5 + filled = [] + for ts, x in samples: + if x is None: + x = last + else: + last = x + filled.append((ts, x)) + + if not filled: + return lambda t: 0.5 + + # Drsno povprečje + timestamps = np.array([t for t, _ in filled]) + values = np.array([v for _, v in filled]) + + smoothed = np.zeros_like(values) + for i, t in enumerate(timestamps): + mask = np.abs(timestamps - t) <= smoothing_window / 2 + smoothed[i] = np.mean(values[mask]) + + def x_at(t): + if t <= timestamps[0]: + return float(smoothed[0]) + if t >= timestamps[-1]: + return float(smoothed[-1]) + return float(np.interp(t, timestamps, smoothed)) + + return x_at + + +def build_track_filter(info, x_at, target_w, target_h, fps): + """ + Sestavi FFmpeg filter za track mode. + Generiramo crop expression, ki se premika z x(t). + Ker FFmpeg ne podpira poljubne funkcije časa, vzorčimo x(t) in + sestavimo piecewise linearno funkcijo prek `if(...)`. + + Bolj robustno: pre-scale na ciljno viΕ‘ino, potem crop x = f(t). + """ + src_w = info["width"] + src_h = info["height"] + + # Najprej scale: viΕ‘ina = target_h, Ε‘irina proporcionalno + scale_h = target_h + scale_w = int(src_w * (target_h / src_h)) + # Po skaliranju je crop Ε‘irina = target_w + # x_center v skaliranem prostoru + max_x = scale_w - target_w # max levo-zgornji x + + # Vzorčimo x(t) na ~5 fps (dovolj gladko po smoothingu) + duration = info["duration"] + n_samples = max(2, int(duration * 5)) + times = np.linspace(0, duration, n_samples) + x_centers_norm = [x_at(t) for t in times] + # Pretvori normaliziran center v dejanski levi-zgornji x v skaliranem oknu + x_lefts = [] + for xc in x_centers_norm: + x_left = xc * scale_w - target_w / 2 + x_left = max(0, min(max_x, x_left)) + x_lefts.append(x_left) + + # Sestavi piecewise expression: če (t < t1, x1, če (t < t2, x2, ...)) + # FFmpeg ima omejitev na dolΕΎino expression-a, zato uporabimo drugačen pristop: + # Generiramo CSV in uporabimo `sendcmd` filter ali pa preprosto + # nizkofrekvenčno linearno interpolacijo prek `if/lerp`. + # Pragmatično: zgradimo nested if. Pri 5 fps in 60s = 300 vej; deluje. + # Za daljΕ‘e videe rebajzamo na 2 fps. + if duration > 120: + n_samples = int(duration * 2) + times = np.linspace(0, duration, n_samples) + x_lefts_resampled = [] + for t in times: + x_lefts_resampled.append(np.interp(t, np.linspace(0, duration, len(x_lefts)), x_lefts)) + x_lefts = x_lefts_resampled + + # Linearna interpolacija med vzorci znotraj FFmpeg expression + # Format: če(t {fmt_ts(seg.end)}\n{seg.text.strip()}\n\n") + idx += 1 + continue + + # ZdruΕΎi v skupine po ~4 besede + group = [] + for w in words: + group.append(w) + if len(group) >= 4 or w.word.strip().endswith((".", "?", "!")): + start = group[0].start + end = group[-1].end + text = "".join(g.word for g in group).strip() + srt_path.write(f"{idx}\n{fmt_ts(start)} --> {fmt_ts(end)}\n{text}\n\n") + idx += 1 + group = [] + if group: + start = group[0].start + end = group[-1].end + text = "".join(g.word for g in group).strip() + srt_path.write(f"{idx}\n{fmt_ts(start)} --> {fmt_ts(end)}\n{text}\n\n") + idx += 1 + + srt_path.close() + print(f"πŸ“ SRT: {srt_path.name} ({idx - 1} segmentov)") + return srt_path.name + + +SUBTITLE_STYLES = { + "reels": ( + "FontName=Arial,FontSize=18,Bold=1," + "PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BackColour=&H80000000," + "Outline=2,Shadow=0,Alignment=2,MarginV=180,BorderStyle=1" + ), + "yellow": ( + "FontName=Arial,FontSize=20,Bold=1," + "PrimaryColour=&H0000FFFF,OutlineColour=&H00000000," + "Outline=3,Shadow=0,Alignment=2,MarginV=200,BorderStyle=1" + ), + "minimal": ( + "FontName=Arial,FontSize=14," + "PrimaryColour=&H00FFFFFF,OutlineColour=&H80000000," + "Outline=1,Shadow=0,Alignment=2,MarginV=80,BorderStyle=1" + ), +} + + +def burn_subtitles(video, srt, output, style="reels"): + style_str = SUBTITLE_STYLES.get(style, SUBTITLE_STYLES["reels"]) + # Escape srt path za FFmpeg subtitles filter + srt_escaped = srt.replace("\\", "\\\\").replace(":", "\\:").replace("'", r"\'") + vf = f"subtitles='{srt_escaped}':force_style='{style_str}'" + + cmd = [ + "ffmpeg", "-y", "-i", str(video), + "-vf", vf, + "-c:v", "libx264", "-preset", "medium", "-crf", "21", + "-c:a", "copy", + "-movflags", "+faststart", + str(output), + ] + print("πŸ”₯ Burn-in podnapisov...") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print("❌ FFmpeg napaka:", file=sys.stderr) + print(result.stderr[-2000:], file=sys.stderr) + sys.exit(1) + print(f"βœ… {output}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("input") + ap.add_argument("output") + ap.add_argument("--lang", default=None, help="Jezik (sl, de, en, ...) ali auto") + ap.add_argument("--model", default="small", choices=["tiny", "base", "small", "medium", "large-v3"]) + ap.add_argument("--style", default="reels", choices=list(SUBTITLE_STYLES.keys())) + ap.add_argument("--keep-srt", action="store_true", help="Ohrani .srt poleg output") + args = ap.parse_args() + + src = Path(args.input) + if not src.exists(): + print(f"❌ {src} ne obstaja", file=sys.stderr) + sys.exit(1) + + srt = transcribe(src, lang=args.lang, model_size=args.model) + burn_subtitles(src, srt, args.output, style=args.style) + + if args.keep_srt: + keep_path = Path(args.output).with_suffix(".srt") + os.rename(srt, keep_path) + print(f"πŸ’Ύ SRT shranjen: {keep_path}") + else: + os.unlink(srt) + + +if __name__ == "__main__": + main() diff --git a/scripts/yt_download.py b/scripts/yt_download.py new file mode 100644 index 0000000..e543002 --- /dev/null +++ b/scripts/yt_download.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +yt_download.py β€” Download YouTube video v 1080p (16:9) za reels pipeline. + +Primer: + python3 yt_download.py "https://youtu.be/dQw4w9WgXcQ" /data/uploads/video.mp4 +""" +import argparse +import subprocess +import sys +from pathlib import Path +import json + + +def download(url, output, max_height=1080, format_str=None): + """ + Download YT video. Privzeto: best mp4 ≀1080p z audiotrackom. + """ + if format_str is None: + format_str = ( + f"bestvideo[height<={max_height}][ext=mp4]+bestaudio[ext=m4a]/" + f"best[height<={max_height}][ext=mp4]/best" + ) + + cmd = [ + "yt-dlp", + "-f", format_str, + "--merge-output-format", "mp4", + "--no-playlist", + "--write-info-json", + "--restrict-filenames", + "-o", str(output), + url, + ] + print(f"⬇ Downloading {url}...", file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"❌ yt-dlp napaka:\n{result.stderr[-1500:]}", file=sys.stderr) + sys.exit(1) + print(f"βœ… {output}", file=sys.stderr) + return output + + +def get_info(url): + """Vrni metadata brez prenosa.""" + cmd = ["yt-dlp", "--dump-json", "--no-playlist", url] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + return None + return json.loads(result.stdout.strip().split("\n")[0]) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("url") + ap.add_argument("output") + ap.add_argument("--max-height", type=int, default=1080) + ap.add_argument("--info-only", action="store_true", + help="Samo metadata, brez prenosa") + args = ap.parse_args() + + if args.info_only: + info = get_info(args.url) + if info: + print(json.dumps({ + "title": info.get("title"), + "duration": info.get("duration"), + "uploader": info.get("uploader"), + "thumbnail": info.get("thumbnail"), + }, indent=2)) + else: + print("❌ Ne morem dobiti info", file=sys.stderr) + sys.exit(1) + return + + download(args.url, args.output, max_height=args.max_height) + + +if __name__ == "__main__": + main() diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..4eee1b8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,534 @@ + + + + + + Reels Clipper Β· biba.live + + + +
+

1] reels clipper

+ biba.live +
+ +
+ +
+

nov reel

+ +
+
Upload
+
YouTube
+
+ +
+
+ + + + + +
Klikni ali povleci video sem
+
.mp4, .mov, .webm β€” do 2 GB
+ +
+
+ + + + + + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ + +
+
+ + +
+
+ + + + + + +
+ + +
+

moji reels

+
+
Ε e ni obdelav
+
+
+
+ + + +