Initial: reels clipper app

- 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
This commit is contained in:
2026-04-28 15:28:22 +00:00
commit 30b969e4b8
13 changed files with 2096 additions and 0 deletions
+132
View File
@@ -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()
+289
View File
@@ -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()
+306
View File
@@ -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<t_i, lerp(x_{i-1}, x_i, (t-t_{i-1})/(t_i-t_{i-1})), nadaljuj)
expr = f"{x_lefts[-1]:.1f}"
for i in range(len(times) - 1, 0, -1):
t0, t1 = times[i - 1], times[i]
x0, x1 = x_lefts[i - 1], x_lefts[i]
# lerp = x0 + (x1-x0)*(t-t0)/(t1-t0)
if abs(t1 - t0) < 1e-6:
lerp = f"{x0:.1f}"
else:
lerp = f"({x0:.1f}+({x1 - x0:.1f})*(t-{t0:.3f})/{t1 - t0:.3f})"
expr = f"if(lt(t,{t1:.3f}),{lerp},{expr})"
vfilter = (
f"scale={scale_w}:{scale_h},"
f"crop={target_w}:{target_h}:'{expr}':0"
)
return vfilter
def build_center_filter(info, target_w, target_h):
src_w = info["width"]
src_h = info["height"]
scale_h = target_h
scale_w = int(src_w * (target_h / src_h))
return f"scale={scale_w}:{scale_h},crop={target_w}:{target_h}:(in_w-{target_w})/2:0"
def build_blur_filter(info, target_w, target_h):
"""
9:16 platno: spodaj/zgoraj blur kopija, v sredini originalni 16:9.
"""
# Originalna širina v 9:16 platnu = target_w, višina proporcionalno
src_w = info["width"]
src_h = info["height"]
fg_h = int(target_w * src_h / src_w)
return (
f"[0:v]scale={target_w}:{target_h}:force_original_aspect_ratio=increase,"
f"crop={target_w}:{target_h},gblur=sigma=30[bg];"
f"[0:v]scale={target_w}:{fg_h}[fg];"
f"[bg][fg]overlay=0:(H-h)/2"
)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("input")
ap.add_argument("output")
ap.add_argument("--mode", choices=["track", "center", "blur"], default="track")
ap.add_argument("--target-width", type=int, default=1080)
ap.add_argument("--target-height", type=int, default=1920)
ap.add_argument("--start", type=float, default=None, help="Začetek (s)")
ap.add_argument("--duration", type=float, default=None, help="Trajanje (s)")
ap.add_argument("--quality", default="medium", choices=["fast", "medium", "high"])
args = ap.parse_args()
src = Path(args.input)
dst = Path(args.output)
if not src.exists():
print(f"❌ Vhod ne obstaja: {src}", file=sys.stderr)
sys.exit(1)
# Če imamo --start/--duration, najprej trim z FFmpeg v temp file (hitreje)
work_input = src
tmp = None
if args.start is not None or args.duration is not None:
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
tmp.close()
cmd = ["ffmpeg", "-y"]
if args.start is not None:
cmd += ["-ss", str(args.start)]
cmd += ["-i", str(src)]
if args.duration is not None:
cmd += ["-t", str(args.duration)]
cmd += ["-c", "copy", tmp.name]
subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL)
work_input = Path(tmp.name)
print(f"✂ Trim → {work_input}")
info = get_video_info(work_input)
print(f"📹 Vhod: {info['width']}x{info['height']} @ {info['fps']:.2f}fps, {info['duration']:.1f}s")
if args.mode == "track":
print("🔍 Detektiram obraze (OpenCV)...")
samples, _, _, _, _ = detect_face_centers(work_input, sample_fps=5)
n_with_face = sum(1 for _, x in samples if x is not None)
print(f" {n_with_face}/{len(samples)} vzorcev z obrazom")
x_at = smooth_track(samples, info["duration"], smoothing_window=2.0)
vfilter = build_track_filter(info, x_at, args.target_width, args.target_height, info["fps"])
elif args.mode == "center":
vfilter = build_center_filter(info, args.target_width, args.target_height)
elif args.mode == "blur":
vfilter = build_blur_filter(info, args.target_width, args.target_height)
preset = {"fast": "veryfast", "medium": "medium", "high": "slow"}[args.quality]
crf = {"fast": "26", "medium": "21", "high": "18"}[args.quality]
if args.mode == "blur":
# blur uporablja filter_complex
cmd = [
"ffmpeg", "-y", "-i", str(work_input),
"-filter_complex", vfilter,
"-c:v", "libx264", "-preset", preset, "-crf", crf,
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
str(dst),
]
else:
cmd = [
"ffmpeg", "-y", "-i", str(work_input),
"-vf", vfilter,
"-c:v", "libx264", "-preset", preset, "-crf", crf,
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
str(dst),
]
print(f"🎬 Render ({args.mode})...")
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)
if tmp:
os.unlink(tmp.name)
out_info = get_video_info(dst)
out_size = dst.stat().st_size / 1024 / 1024
print(f"✅ {dst} — {out_info['width']}x{out_info['height']}, {out_size:.1f} MB")
if __name__ == "__main__":
main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
subtitle.py — Generiraj podnapise iz videa in jih burn-in v output.
Uporablja faster-whisper za transkripcijo, FFmpeg za burn-in.
Primer:
python3 subtitle.py video.mp4 video_sub.mp4
python3 subtitle.py video.mp4 video_sub.mp4 --lang sl --model small
python3 subtitle.py video.mp4 video_sub.mp4 --style reels # velik beli centriran tekst
"""
import argparse
import subprocess
import sys
import tempfile
import os
from pathlib import Path
def transcribe(video, lang=None, model_size="small"):
"""Vrne pot do .srt datoteke."""
from faster_whisper import WhisperModel
print(f"🧠 Whisper model: {model_size}, lang={lang or 'auto'}")
model = WhisperModel(model_size, device="cpu", compute_type="int8")
segments, info = model.transcribe(
str(video),
language=lang,
word_timestamps=True,
vad_filter=True,
)
print(f" Detekcija: {info.language} (p={info.language_probability:.2f})")
srt_path = tempfile.NamedTemporaryFile(suffix=".srt", delete=False, mode="w", encoding="utf-8")
def fmt_ts(s):
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = s % 60
return f"{h:02d}:{m:02d}:{sec:06.3f}".replace(".", ",")
# Generiramo word-level chunked podnapise: 3-5 besed naenkrat
idx = 1
for seg in segments:
words = seg.words or []
if not words:
srt_path.write(f"{idx}\n{fmt_ts(seg.start)} --> {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()
+80
View File
@@ -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()