CoolFace
Apppublic

aralawrence11/tiny-talking-face-cpu-lovable

sourceHugging Facemitupdated 16d agoView on Hugging Face
0likes
app.py1836 linesDownload Raw Back to root
1import os, subprocess, uuid, random, tempfile, shutil2from fastapi import FastAPI, HTTPException, Request3from fastapi.responses import FileResponse, Response4from pydantic import BaseModel5import requests6import numpy as np7import soundfile as sf8 9app = FastAPI()10 11RENDERS_DIR = "/tmp/renders"12os.makedirs(RENDERS_DIR, exist_ok=True)13 14# ---------------------------------------------------------------------------15# Fonts16# ---------------------------------------------------------------------------17FONT_CANDIDATES = {18    "montserrat": ["/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"],19    "poppins":    ["/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"],20    "anton":      ["/usr/share/fonts/truetype/freefont/FreeSansBold.ttf"],21    "impact":     ["/usr/share/fonts/truetype/freefont/FreeSansBold.ttf"],22    "oswald":     ["/usr/share/fonts/truetype/liberation/LiberationSansNarrow-Bold.ttf"],23    "bebas":      ["/usr/share/fonts/truetype/liberation/LiberationSansNarrow-Bold.ttf"],24    "roboto":     ["/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"],25    "dejavu sans":["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"],26    "serif":      ["/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"],27}28FALLBACK_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"29 30def resolve_font(name):31    key = (name or "").strip().lower()32    for cand in FONT_CANDIDATES.get(key, []):33        if os.path.exists(cand):34            return cand35    return FALLBACK_FONT36 37# --- Kokoro TTS (lazy-loaded singleton) ---38_kokoro = {"pipe": None}39def get_kokoro(lang_code="a"):40    if _kokoro["pipe"] is None:41        from kokoro import KPipeline42        _kokoro["pipe"] = KPipeline(lang_code=lang_code)43    return _kokoro["pipe"]44 45class TTSReq(BaseModel):46    text: str47    voice: str | None = "af_bella"48    speed: float | None = 1.049 50class RenderReq(BaseModel):51    voice_url: str52    script: str53    hook: str | None = None54    onscreen_cta: str | None = None55    # Style56    font: str | None = "Montserrat"57    caption_style: str | None = "bold_center"58    caption_color: str | None = "#FFFFFF"59    title_color: str | None = "#FFFFFF"60    cta_color: str | None = "#FFD54A"61    accent_color: str | None = "#FFD54A"62    background: str | None = "black"63    subtitle_size: int | None = 6464    title_size: int | None = 8865    cta_size: int | None = 5666    stroke_width: float | None = 567    shadow: bool | None = True68    box_opacity: float | None = 0.5569    uppercase: bool | None = False70    animation: str | None = "subtle_zoom"71    # Caption reveal: "phrase" (one go) | "word" (one word at a time) | "karaoke" (words build up)72    caption_animation: str | None = "phrase"73    # Placement (fractions of the 1920px-tall canvas)74    title_position: float | None = 0.1375    caption_position: float | None = 0.6376    cta_position: float | None = 0.5677    safe_top: float | None = 0.1078    safe_bottom: float | None = 0.2479    max_seconds: int | None = 6080    # Section branding outro81    brand_link: str | None = None82    brand_text: str | None = None83    brand_duration: int | None = 084    seed: int | None = None85 86@app.get("/")87def root():88    return {"ok": True, "service": "shorts-renderer", "version": 4,89            "caption_animations": ["phrase", "word", "karaoke"]}90 91@app.get("/health")92def health():93    return {"ok": True}94 95@app.post("/tts")96def tts(req: TTSReq):97    voice = req.voice or "af_bella"98    lang = voice[0] if voice else "a"99    try:100        pipe = get_kokoro(lang_code=lang)101        chunks = []102        for _gs, _ps, audio in pipe(req.text, voice=voice, speed=req.speed or 1.0):103            arr = audio.detach().cpu().numpy() if hasattr(audio, "detach") else np.asarray(audio)104            chunks.append(arr)105        if not chunks:106            raise HTTPException(500, "Kokoro produced no audio")107        wav = np.concatenate(chunks).astype(np.float32)108        job = str(uuid.uuid4())109        wav_path = os.path.join(RENDERS_DIR, f"{job}.wav")110        mp3_path = os.path.join(RENDERS_DIR, f"{job}.mp3")111        sf.write(wav_path, wav, 24000)112        subprocess.run(["ffmpeg","-y","-i",wav_path,"-b:a","128k",mp3_path],113                       check=True, capture_output=True, timeout=120)114        with open(mp3_path,"rb") as f:115            data = f.read()116        try: os.remove(wav_path)117        except OSError: pass118        return Response(content=data, media_type="audio/mpeg")119    except HTTPException:120        raise121    except Exception as e:122        raise HTTPException(500, f"tts failed: {type(e).__name__}: {str(e)[:400]}")123 124def probe_duration(path):125    try:126        out = subprocess.check_output([127            "ffprobe","-v","error","-show_entries","format=duration",128            "-of","default=noprint_wrappers=1:nokey=1", path129        ], timeout=60).decode().strip()130        return float(out or "0")131    except Exception:132        return 0.0133 134# ---- color helpers ----135def hex_to_ff(color, default="0xFFFFFF"):136    if not color: return default137    c = color.strip()138    if c.startswith("#") and len(c) == 7:139        return "0x" + c[1:].upper()140    if c.lower() in ("black","white","red","yellow"):141        return c.lower()142    return default143 144def hex_to_ass(color, default="&H00FFFFFF"):145    if not color: return default146    c = color.strip().lstrip("#")147    if len(c) != 6: return default148    r, g, b = c[0:2], c[2:4], c[4:6]149    return f"&H00{b}{g}{r}".upper()150 151def solid_bg(background):152    b = (background or "black").strip().lower()153    if b == "white": return "white"154    if b.startswith("#") and len(b) == 7: return "0x" + b[1:].upper()155    if b in ("black","gray","navy"): return b156    return "black"157 158def build_caption_entries(script, duration, upper, mode):159    """Return timed caption entries covering the WHOLE narration so nothing is cut.160 161    modes:162      - "phrase" (default): 3-4 words shown together in one go.163      - "word":            one word at a time.164      - "karaoke":         words build up inside a line, then the line resets.165    Word timings are proportional to word length for tighter audio sync.166    """167    words = script.split()168    if upper: words = [w.upper() for w in words]169    if not words: words = [script.strip() or " "]170    mode = (mode or "phrase").strip().lower()171 172    weights = [max(2, len(w)) for w in words]173    wsum = float(sum(weights)) or 1.0174    starts, t = [], 0.0175    for wt in weights:176        starts.append(t); t += duration * wt / wsum177    ends = starts[1:] + [duration]178 179    entries = []  # (start, end, text)180    if mode == "word":181        for i, w in enumerate(words):182            entries.append((starts[i], ends[i], w))183    elif mode == "karaoke":184        LINE = 5185        i = 0186        while i < len(words):187            grp_start = i188            grp_end = min(i + LINE, len(words))189            for gi in range(grp_start, grp_end):190                entries.append((starts[gi], ends[gi], " ".join(words[grp_start:gi + 1])))191            i += LINE192    else:  # phrase / one-go193        SIZE = 4194        i = 0195        while i < len(words):196            grp_end = min(i + SIZE, len(words))197            entries.append((starts[i], ends[grp_end - 1], " ".join(words[i:grp_end])))198            i += SIZE199    # Guarantee a minimum visible window and no gaps/overlaps.200    fixed = []201    for idx, (s, e, txt) in enumerate(entries):202        e = max(e, s + 0.25)203        fixed.append((round(s, 3), round(e, 3), txt))204    return fixed205 206 207def esc(t): return t.replace("\\","\\\\").replace(":","\\:").replace("'","\u2019")208 209@app.post("/render")210def render(req: RenderReq):211    job = str(uuid.uuid4())212    voice_path = os.path.join(RENDERS_DIR, f"{job}.mp3")213    main_path = os.path.join(RENDERS_DIR, f"{job}_main.mp4")214    out_path = os.path.join(RENDERS_DIR, f"{job}.mp4")215    srt_path = os.path.join(RENDERS_DIR, f"{job}.srt")216 217    try:218        r = requests.get(req.voice_url, timeout=60)219    except Exception as e:220        raise HTTPException(400, f"voice fetch error: {str(e)[:200]}")221    if r.status_code != 200 or len(r.content) < 500:222        raise HTTPException(400, f"voice fetch failed {r.status_code} ({len(r.content)} bytes)")223    with open(voice_path,"wb") as f: f.write(r.content)224 225    audio_dur = probe_duration(voice_path)226    if audio_dur <= 0:227        raise HTTPException(400, "voice_url did not return decodable audio")228    # Cover the FULL narration (never truncate captions); only a generous hard cap.229    hard_cap = float(req.max_seconds or 60)230    hard_cap = max(hard_cap, 60.0)231    duration = max(3.0, min(hard_cap, audio_dur))232 233    upper = bool(req.uppercase)234    entries = build_caption_entries(req.script, duration, upper, req.caption_animation)235 236    font = resolve_font(req.font)237    title_c = hex_to_ff(req.title_color, "0xFFFFFF")238    cta_c = hex_to_ff(req.cta_color, "0xFFD54A")239    cap_c = hex_to_ff(req.caption_color, "0xFFFFFF")240    box_op = max(0.0, min(1.0, float(req.box_opacity if req.box_opacity is not None else 0.55)))241    stroke = int(max(0, min(12, float(req.stroke_width or 4))))242 243    title_size = int(req.title_size or 88)244    sub_size = int(req.subtitle_size or 64)245    cta_size = int(req.cta_size or 56)246    y_cap = int(max(0.0, min(1.0, float(req.caption_position or 0.63))) * 1920)247    y_title = int(max(0.0, min(1.0, float(req.title_position or 0.13))) * 1920)248    y_cta = int(max(0.0, min(1.0, float(req.cta_position or 0.56))) * 1920)249 250    hook = (req.hook or "").strip()251    cta = (req.onscreen_cta or "").strip()252    if upper:253        hook, cta = hook.upper(), cta.upper()254 255    drawtexts = []256    if hook:257        # Fixed headline for the whole clip while captions flow underneath.258        drawtexts.append(259            f"drawtext=fontfile={font}:text='{esc(hook)}':fontcolor={title_c}:fontsize={title_size}:"260            f"borderw={stroke}:bordercolor=black:x=(w-text_w)/2:y={y_title}:"261            f"box=1:boxcolor=black@{box_op}:boxborderw=24:line_spacing=10"262        )263    if cta:264        drawtexts.append(265            f"drawtext=fontfile={font}:text='{esc(cta)}':fontcolor={cta_c}:fontsize={cta_size}:"266            f"borderw={stroke}:bordercolor=black:x=(w-text_w)/2:y={y_cta}:"267            f"box=1:boxcolor=black@{box_op}:boxborderw=18"268        )269    # Flowing captions as time-gated drawtext (reliable: no libass/subtitles dependency).270    for (s, e, txt) in entries:271        drawtexts.append(272            f"drawtext=fontfile={font}:text='{esc(txt)}':fontcolor={cap_c}:fontsize={sub_size}:"273            f"borderw={max(1,stroke)}:bordercolor=black:x=(w-text_w)/2:y={y_cap}:"274            f"box=1:boxcolor=black@{box_op}:boxborderw=18:enable='between(t,{s},{e})'"275        )276    vf = ",".join(drawtexts) if drawtexts else "null"277 278 279    bg = solid_bg(req.background)280    anim = (req.animation or "").lower()281    zoom = "zoompan=z='min(zoom+0.0008,1.15)':d=1:s=1080x1920" if "zoom" in anim else "scale=1080:1920"282    bg_filter = f"color=c={bg}:s=1080x1920:d={duration},{zoom}"283 284    cmd = [285        "ffmpeg","-y","-f","lavfi","-i", bg_filter,"-i", voice_path,286        "-vf", vf,"-c:v","libx264","-preset","veryfast","-pix_fmt","yuv420p",287        "-c:a","aac","-b:a","128k","-shortest","-t", f"{duration}","-r","30", main_path,288    ]289    try:290        subprocess.run(cmd, check=True, capture_output=True, timeout=300)291    except subprocess.CalledProcessError as e:292        raise HTTPException(500, f"ffmpeg failed: {e.stderr.decode()[-500:]}")293 294    # ---- Optional section-branding outro (black screen + link) ----295    brand_secs = int(req.brand_duration or 0)296    if brand_secs > 0 and (req.brand_text or req.brand_link):297        outro_path = os.path.join(RENDERS_DIR, f"{job}_outro.mp4")298        line1 = esc((req.brand_text or "").strip().upper() if upper else (req.brand_text or "").strip())299        line2 = esc((req.brand_link or "").strip())300        dts = []301        if line1:302            dts.append(f"drawtext=fontfile={font}:text='{line1}':fontcolor=white:fontsize=64:"303                       f"borderw=3:bordercolor=black:x=(w-text_w)/2:y=(h/2)-90")304        if line2:305            dts.append(f"drawtext=fontfile={font}:text='{line2}':fontcolor={hex_to_ff(req.accent_color,'0xFFD54A')}:"306                       f"fontsize=52:borderw=3:bordercolor=black:x=(w-text_w)/2:y=(h/2)+20")307        ovf = ",".join(["fade=t=in:st=0:d=0.4"] + dts) if dts else "fade=t=in:st=0:d=0.4"308        ocmd = [309            "ffmpeg","-y",310            "-f","lavfi","-i", f"color=c=black:s=1080x1920:d={brand_secs}:r=30",311            "-f","lavfi","-i", f"anullsrc=channel_layout=stereo:sample_rate=44100",312            "-vf", ovf,"-c:v","libx264","-preset","veryfast","-pix_fmt","yuv420p",313            "-c:a","aac","-b:a","128k","-t", f"{brand_secs}", outro_path,314        ]315        try:316            subprocess.run(ocmd, check=True, capture_output=True, timeout=120)317            concat_txt = os.path.join(RENDERS_DIR, f"{job}_list.txt")318            with open(concat_txt,"w") as f:319                f.write(f"file '{main_path}'\nfile '{outro_path}'\n")320            subprocess.run(321                ["ffmpeg","-y","-f","concat","-safe","0","-i",concat_txt,322                 "-c:v","libx264","-preset","veryfast","-pix_fmt","yuv420p",323                 "-c:a","aac","-b:a","128k", out_path],324                check=True, capture_output=True, timeout=180,325            )326        except subprocess.CalledProcessError:327            os.replace(main_path, out_path)328    else:329        os.replace(main_path, out_path)330 331    return FileResponse(out_path, media_type="video/mp4", filename=f"{job}.mp4")332 333@app.get("/file/{name}")334def get_file(name: str):335    p = os.path.join(RENDERS_DIR, name)336    if not os.path.exists(p): raise HTTPException(404)337    return FileResponse(p, media_type="video/mp4")338 339 340# ==========================================================================341# Template-video engine (merged from template-generator). Adds /templates and342# /render-template without touching the existing /render, /tts, /health routes.343# ==========================================================================344from pathlib import Path345from typing import Optional346import re347import time as _time348import json as _json349import io350from PIL import Image, ImageDraw, ImageFont351 352ROOT = Path(__file__).resolve().parent353FONT_DIR = ROOT / "fonts"354W, H = 1080, 1920355FPS = 20356 357KOKORO_VOICES = [358    "af_heart", "af_bella", "af_nicole", "af_sarah", "af_sky", "af_alloy",359    "af_aoede", "af_jessica", "af_kore", "af_nova", "af_river",360    "am_adam", "am_michael", "am_echo", "am_eric", "am_fenrir",361    "am_liam", "am_onyx", "am_puck", "am_santa",362    "bf_emma", "bf_isabella", "bf_alice", "bf_lily",363    "bm_george", "bm_lewis", "bm_daniel", "bm_fable",364]365DEFAULT_VOICE = "af_heart"366 367TEMPLATES = [368    "atmospheric_text_hook",369    "tweet_thread",370    "notes_app",371    "fullscreen_title_hook",372]373 374YELLOW = (255, 212, 0)375NAVY = (10, 14, 30)376NAVY2 = (18, 24, 48)377CREAM = (247, 240, 222)378 379# (single FastAPI instance defined above; do not re-create it here)380 381# ---------------------------------------------------------------------------382# Auth (optional bearer for private-Space friendliness; open if no key set)383# ---------------------------------------------------------------------------384SPACE_KEY = os.environ.get("SPACE_AUTH_KEY", "").strip()385 386 387def _check_auth(req: Request) -> bool:388    if not SPACE_KEY:389        return True390    auth = req.headers.get("authorization", "")391    return auth.replace("Bearer ", "").strip() == SPACE_KEY392 393 394# ---------------------------------------------------------------------------395# Fonts396# ---------------------------------------------------------------------------397def _font(name: str, size: int) -> ImageFont.FreeTypeFont:398    path = FONT_DIR / name399    try:400        if path.exists():401            return ImageFont.truetype(str(path), size)402    except Exception:403        pass404    for fallback in ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"]:405        if Path(fallback).exists():406            return ImageFont.truetype(fallback, size)407    return ImageFont.load_default()408 409 410def anton(size):411    return _font("Anton.ttf", size)412 413 414def bebas(size):415    return _font("BebasNeue.ttf", size)416 417 418def archivo(size):419    return _font("ArchivoBlack.ttf", size)420 421 422def inter(size):423    f = FONT_DIR / "Inter-Bold.ttf"424    return _font("Inter-Bold.ttf" if f.exists() else "ArchivoBlack.ttf", size)425 426 427# ---------------------------------------------------------------------------428# Kokoro TTS429# ---------------------------------------------------------------------------430def synth(text: str, voice: str) -> tuple[np.ndarray, int]:431    """Synthesize one segment of text -> (float32 mono samples, sample_rate).432    Reuses the shared Kokoro pipeline singleton (get_kokoro) from the base app."""433    text = (text or "").strip()434    if not text:435        return np.zeros(int(24000 * 0.3), dtype=np.float32), 24000436    v = voice if voice in KOKORO_VOICES else DEFAULT_VOICE437    try:438        pipe = get_kokoro(lang_code=v[0] if v else "a")439        chunks = []440        for _, _, audio in pipe(text, voice=v):441            arr = audio.detach().cpu().numpy() if hasattr(audio, "detach") else np.asarray(audio)442            chunks.append(arr.astype(np.float32))443        if not chunks:444            raise RuntimeError("empty kokoro output")445        return np.concatenate(chunks), 24000446    except Exception as e:447        print("Kokoro TTS (template) failed, using silence fallback:", e, flush=True)448        approx = max(0.6, len(text.split()) / 2.5)449        return np.zeros(int(24000 * approx), dtype=np.float32), 24000450 451 452def synth_segments(lines: list[str], voice: str, gap_s: float = 0.18) -> tuple[np.ndarray, int, list[float]]:453    """Synthesize each line separately so segment durations are exact.454    Returns (full_audio, sr, per_segment_duration_including_gap)."""455    sr = 24000456    audio_parts = []457    durations = []458    gap = np.zeros(int(sr * gap_s), dtype=np.float32)459    for i, line in enumerate(lines):460        a, sr = synth(line, voice)461        dur = len(a) / sr462        audio_parts.append(a)463        if i < len(lines) - 1:464            audio_parts.append(gap)465            dur += gap_s466        durations.append(dur)467    full = np.concatenate(audio_parts) if audio_parts else np.zeros(1, dtype=np.float32)468    return full, sr, durations469 470 471# ---------------------------------------------------------------------------472# Text helpers473# ---------------------------------------------------------------------------474def split_sentences(text: str, max_items: int) -> list[str]:475    text = (text or "").strip()476    if not text:477        return []478    parts = [p.strip() for p in re.split(r"(?<=[.!?])\s+|\n+", text) if p.strip()]479    if not parts:480        parts = [text]481    return parts[:max_items]482 483 484def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw) -> list[str]:485    words = text.split()486    lines, cur = [], ""487    for w in words:488        test = (cur + " " + w).strip()489        if draw.textlength(test, font=font) <= max_width or not cur:490            cur = test491        else:492            lines.append(cur)493            cur = w494    if cur:495        lines.append(cur)496    return lines497 498 499def draw_text_block(draw, xy_center, lines, font, fill, max_width, line_spacing=1.08,500                     stroke_width=0, stroke_fill=None, align="center"):501    cx, cy = xy_center502    sizes = [draw.textbbox((0, 0), ln, font=font, stroke_width=stroke_width) for ln in lines]503    heights = [b[3] - b[1] for b in sizes]504    total_h = sum(heights) * line_spacing505    y = cy - total_h / 2506    for ln, h in zip(lines, heights):507        w = draw.textlength(ln, font=font)508        x = cx - w / 2509        draw.text((x, y), ln, font=font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill)510        y += h * line_spacing511    return y512 513 514def gradient_bg(top, bottom):515    img = Image.new("RGB", (W, H), top)516    px = img.load()517    for y in range(H):518        t = y / H519        r = int(top[0] + (bottom[0] - top[0]) * t)520        g = int(top[1] + (bottom[1] - top[1]) * t)521        b = int(top[2] + (bottom[2] - top[2]) * t)522        for x in range(0, W, 4):523            px[x, y] = (r, g, b)524    # cheap horizontal fill (speed): stretch every 4th column525    arr = np.array(img)526    for i in range(1, 4):527        arr[:, i::4] = arr[:, 0::4][:, : arr[:, i::4].shape[1]]528    return Image.fromarray(arr)529 530 531def load_background_image(url: Optional[str]) -> Optional[Image.Image]:532    if not url:533        return None534    try:535        r = requests.get(url, timeout=20)536        r.raise_for_status()537        img = Image.open(io.BytesIO(r.content)).convert("RGB")538        # cover-fit into 1080x1920539        scale = max(W / img.width, H / img.height)540        nw, nh = int(img.width * scale), int(img.height * scale)541        img = img.resize((nw, nh))542        left = (nw - W) // 2543        top = (nh - H) // 2544        return img.crop((left, top, left + W, top + H))545    except Exception as e:546        print("bg image load failed:", e, flush=True)547        return None548 549 550def dim(img: Image.Image, amount=0.45) -> Image.Image:551    overlay = Image.new("RGB", img.size, (0, 0, 0))552    return Image.blend(img, overlay, amount)553 554 555# ---------------------------------------------------------------------------556# Template 1: atmospheric_text_hook557# ---------------------------------------------------------------------------558def render_atmospheric(headline: str, hook_lines: list[str], bg_img, active: int) -> Image.Image:559    base = dim(bg_img, 0.5) if bg_img is not None else gradient_bg((30, 30, 34), (5, 5, 8))560    img = base.copy()561    draw = ImageDraw.Draw(img)562    # dark gradient overlay top/bottom for legibility563    grad = Image.new("L", (1, H), 0)564    for y in range(H):565        v = 0566        if y < H * 0.28:567            v = int(160 * (1 - y / (H * 0.28)))568        elif y > H * 0.78:569            v = int(200 * ((y - H * 0.78) / (H * 0.22)))570        grad.putpixel((0, y), v)571    grad = grad.resize((W, H))572    black = Image.new("RGB", (W, H), (0, 0, 0))573    img = Image.composite(black, img, grad)574    draw = ImageDraw.Draw(img)575 576    f_head = archivo(78)577    head_lines = wrap_text(headline.upper(), f_head, W - 140, draw)578    draw_text_block(draw, (W / 2, 190), head_lines, f_head, "white", W - 140,579                     stroke_width=6, stroke_fill="black")580 581    f_hook = anton(96)582    y = 780583    for i, line in enumerate(hook_lines):584        if i > active:585            continue586        wrapped = wrap_text(line.upper(), f_hook, W - 100, draw)587        color = YELLOW if i % 2 == 0 else "white"588        yb = draw_text_block(draw, (W / 2, y + 70), wrapped, f_hook, color, W - 100,589                              stroke_width=8, stroke_fill="black")590        y = yb + 30591 592    if active >= len(hook_lines) - 1:593        f_cta = archivo(58)594        draw_text_block(draw, (W / 2, H - 190), ["SAVE THIS POST!"], f_cta, YELLOW, W - 120,595                         stroke_width=6, stroke_fill="black")596    return img597 598 599# ---------------------------------------------------------------------------600# Template 2: tweet_thread601# ---------------------------------------------------------------------------602def draw_tweet_card(canvas: Image.Image, top_y: int, handle: str, text: str, timestamp: str, idx: int):603    card_w = 900604    x0 = (W - card_w) // 2605    draw = ImageDraw.Draw(canvas)606    f_user = inter(34)607    f_handle = inter(26)608    f_body = inter(36)609    f_time = inter(22)610 611    tmp = Image.new("RGB", (10, 10))612    tdraw = ImageDraw.Draw(tmp)613    body_lines = wrap_text(text, f_body, card_w - 90, tdraw)614    card_h = 170 + len(body_lines) * 48 + 40615 616    card = Image.new("RGBA", (card_w, card_h), (255, 255, 255, 255))617    cd = ImageDraw.Draw(card)618    cd.rounded_rectangle([0, 0, card_w - 1, card_h - 1], radius=28, outline=(210, 210, 215), width=2)619    cd.text((28, 20), "โ€น", font=inter(30), fill=(60, 100, 220))620    cd.text((28, 66), "Tweet", font=inter(30), fill=(20, 20, 24))621    # avatar622    cd.ellipse([28, 118, 88, 178], fill=(120, 150, 230))623    cd.text((100, 118), "Username:", font=f_user, fill=(20, 20, 24))624    cd.text((100, 156), "@ContentMaster", font=f_handle, fill=(120, 130, 145))625    ty = 210626    for ln in body_lines:627        cd.text((28, ty), ln, font=f_body, fill=(15, 15, 20))628        ty += 48629    cd.text((28, ty + 14), timestamp, font=f_time, fill=(150, 155, 165))630 631    canvas.paste(card, (x0, top_y), card)632    return card_h633 634 635def render_tweet_thread(tweets: list[str], active: int) -> Image.Image:636    img = gradient_bg(NAVY, (4, 6, 14))637    y = 260638    shown = tweets[: active + 1]639    # cascade: show last 3 stacked, offset each by +40px x/y640    start = max(0, len(shown) - 4)641    for i, t in enumerate(shown[start:]):642        idx = start + i643        offset = i * 46644        top = y + offset645        h = draw_tweet_card(img, top, "@ContentMaster", t, f"3:15 PM ยท Jan 9, 2021 ยท {120 + idx*40}K Views", idx)646    draw = ImageDraw.Draw(img)647    draw_text_block(draw, (W / 2, 130), ["MY VIRAL THREAD"], archivo(56), "white", W - 140,648                     stroke_width=4, stroke_fill="black")649    return img650 651 652# ---------------------------------------------------------------------------653# Template 3: notes_app654# ---------------------------------------------------------------------------655def render_notes_app(lines: list[str], active: int) -> Image.Image:656    img = gradient_bg((14, 16, 22), (4, 4, 6))657    draw = ImageDraw.Draw(img)658 659    phone_w, phone_h = 760, 1560660    px0, py0 = (W - phone_w) // 2, (H - phone_h) // 2661    draw.rounded_rectangle([px0 - 18, py0 - 18, px0 + phone_w + 18, py0 + phone_h + 18],662                            radius=90, fill=(30, 30, 32))663    draw.rounded_rectangle([px0, py0, px0 + phone_w, py0 + phone_h], radius=72, fill=CREAM)664 665    # status bar666    f_status = inter(24)667    draw.text((px0 + 40, py0 + 30), "9:31", font=f_status, fill=(30, 30, 30))668    draw.text((px0 + phone_w - 150, py0 + 30), "๐Ÿ”‹ ๐Ÿ“ถ", font=f_status, fill=(30, 30, 30))669    draw.text((px0 + 40, py0 + 90), "โ€น Notes", font=inter(30), fill=(230, 160, 40))670 671    f_body = archivo(46)672    y = py0 + 190673    for i, ln in enumerate(lines):674        if i > active:675            continue676        wrapped = wrap_text(ln.upper(), f_body, phone_w - 100, draw)677        for wln in wrapped:678            draw.text((px0 + 50, y), wln, font=f_body, fill=(15, 15, 15))679            y += 60680        y += 26681    return img682 683 684# ---------------------------------------------------------------------------685# Template 4: fullscreen_title_hook686# ---------------------------------------------------------------------------687def render_fullscreen_title(lines: list[str], active: int) -> Image.Image:688    img = gradient_bg((16, 20, 46), (4, 5, 14))689    draw = ImageDraw.Draw(img)690    n = len(lines)691    y = H * 0.30692    for i, ln in enumerate(lines):693        if i > active:694            continue695        size = 150 if i == 0 else 84696        f = anton(size)697        color = YELLOW if i == n - 1 else "white"698        wrapped = wrap_text(ln.upper(), f, W - 100, draw)699        yb = draw_text_block(draw, (W / 2, y + size * 0.6), wrapped, f, color, W - 100,700                              stroke_width=7 if i == 0 else 5, stroke_fill="black")701        y = yb + 30702    return img703 704 705# ---------------------------------------------------------------------------706# Render orchestration707# ---------------------------------------------------------------------------708def build_content(template: str, headline: Optional[str], script: str, lines_override: Optional[list[str]]):709    if lines_override:710        lines = [l.strip() for l in lines_override if l.strip()]711    else:712        if template == "tweet_thread":713            lines = split_sentences(script, 5)714        elif template == "notes_app":715            lines = split_sentences(script, 5)716        elif template == "fullscreen_title_hook":717            lines = split_sentences(script, 4)718        else:719            lines = split_sentences(script, 4)720    if not lines:721        lines = [headline or "Watch this"]722    return lines723 724 725def render_frames(template: str, headline: str, lines: list[str], bg_img) -> list[Image.Image]:726    frames = []727    n = len(lines)728    for i in range(n):729        if template == "atmospheric_text_hook":730            frames.append(render_atmospheric(headline, lines, bg_img, i))731        elif template == "tweet_thread":732            frames.append(render_tweet_thread(lines, i))733        elif template == "notes_app":734            frames.append(render_notes_app(lines, i))735        else:736            frames.append(render_fullscreen_title(lines, i))737    return frames738 739 740def assemble_video(frames: list[Image.Image], durations: list[float], audio: np.ndarray, sr: int) -> bytes:741    with tempfile.TemporaryDirectory() as tmp:742        tmp = Path(tmp)743        list_path = tmp / "list.txt"744        with open(list_path, "w") as f:745            for i, (frame, dur) in enumerate(zip(frames, durations)):746                p = tmp / f"f{i:03d}.png"747                frame.convert("RGB").save(p, "PNG")748                f.write(f"file '{p.name}'\nduration {max(dur, 0.35):.3f}\n")749            # ffmpeg concat needs the last file repeated w/o duration750            last = tmp / f"f{len(frames)-1:03d}.png"751            f.write(f"file '{last.name}'\n")752 753        audio_path = tmp / "audio.wav"754        sf.write(str(audio_path), audio, sr)755 756        video_noaudio = tmp / "video_noaudio.mp4"757        cmd1 = [758            "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(list_path),759            "-vf", f"fps={FPS},scale={W}:{H}", "-pix_fmt", "yuv420p", str(video_noaudio),760        ]761        subprocess.run(cmd1, cwd=tmp, check=True, capture_output=True)762 763        out_path = tmp / "out.mp4"764        cmd2 = [765            "ffmpeg", "-y", "-i", str(video_noaudio), "-i", str(audio_path),766            "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",767            "-c:a", "aac", "-b:a", "160k", "-shortest", str(out_path),768        ]769        subprocess.run(cmd2, check=True, capture_output=True)770        return out_path.read_bytes()771 772 773def do_render(payload: dict) -> bytes:774    template = payload.get("template", "atmospheric_text_hook")775    if template not in TEMPLATES:776        template = "atmospheric_text_hook"777    voice = payload.get("voice", DEFAULT_VOICE)778    script = (payload.get("script") or payload.get("voice_script") or "").strip()779    headline = (payload.get("headline") or "").strip() or (script.split(".")[0][:60] if script else "Watch This")780    lines_override = payload.get("lines")781    bg_url = payload.get("background_image_url") or payload.get("background_image")782 783    lines = build_content(template, headline, script, lines_override)784    bg_img = load_background_image(bg_url) if template == "atmospheric_text_hook" else None785    if template == "atmospheric_text_hook" and bg_img is None:786        bg_img = gradient_bg((40, 32, 26), (10, 8, 8))787 788    # narrate: headline (for atmospheric/fullscreen) + each line as its own segment789    narrate_lines = lines if script else lines790    audio, sr, seg_durs = synth_segments(narrate_lines, voice)791 792    frames = render_frames(template, headline, lines, bg_img)793    mp4_bytes = assemble_video(frames, seg_durs, audio, sr)794    return mp4_bytes795 796 797 798 799# ---------------------------------------------------------------------------800# Template video routes (additive; does not affect /render, /tts, /health)801# ---------------------------------------------------------------------------802class TemplateRenderReq(BaseModel):803    template: str | None = "atmospheric_text_hook"804    script: str | None = None805    voice_script: str | None = None806    voice: str | None = DEFAULT_VOICE807    headline: str | None = None808    lines: list[str] | None = None809    background_image: str | None = None810    background_image_url: str | None = None811 812 813@app.get("/templates")814def list_templates():815    return {816        "templates": [817            {"id": "atmospheric_text_hook", "label": "Atmospheric text hook",818             "description": "Moody photo background with bold yellow/white hook lines and a Save-this-post CTA."},819            {"id": "tweet_thread", "label": "Tweet thread",820             "description": "Cascading fake-tweet cards revealing a thread, one tweet per beat."},821            {"id": "notes_app", "label": "Notes app",822             "description": "iPhone Notes screenshot look, lines appearing like a typed note."},823            {"id": "fullscreen_title_hook", "label": "Fullscreen title hook",824             "description": "Huge stacked title cards, escalating size, classic scroll-stopper."},825        ],826        "voices": KOKORO_VOICES,827    }828 829 830@app.post("/render-template")831def render_template(req: TemplateRenderReq):832    payload = {833        "template": req.template or "atmospheric_text_hook",834        "script": req.script or req.voice_script or "",835        "voice": req.voice or DEFAULT_VOICE,836        "headline": req.headline,837        "lines": req.lines,838        "background_image_url": req.background_image_url or req.background_image,839    }840    try:841        mp4 = do_render(payload)842        return Response(content=mp4, media_type="video/mp4")843    except Exception as e:844        import traceback845        traceback.print_exc()846        raise HTTPException(500, f"template render failed: {type(e).__name__}: {str(e)[:400]}")847 848 849# ===========================================================================850# v5: animated templates, music bed, custom uploaded media851# ===========================================================================852import io as _io853import math as _math854from pathlib import Path as _Path855 856import anim_templates as anim857 858 859def _download(url: str, suffix: str) -> str:860    r = requests.get(url, timeout=120)861    r.raise_for_status()862    p = os.path.join(tempfile.mkdtemp(), f"dl{suffix}")863    with open(p, "wb") as f:864        f.write(r.content)865    return p866 867 868def _media_frames(url: str, total: float, fps: int) -> list:869    """Decode an uploaded image or video into `total*fps` cover-fitted frames."""870    if not url:871        return []872    low = url.split("?")[0].lower()873    is_video = any(low.endswith(x) for x in (".mp4", ".mov", ".webm", ".m4v", ".avi"))874    n = max(int(total * fps), 1)875    if not is_video:876        try:877            r = requests.get(url, timeout=60)878            r.raise_for_status()879            img = anim.cover(Image.open(_io.BytesIO(r.content)).convert("RGB"))880            return [anim.ken_burns(img, k / n) for k in range(n)]881        except Exception as e:882            print("media image failed:", e, flush=True)883            return []884    try:885        src = _download(url, ".mp4")886        outdir = tempfile.mkdtemp()887        subprocess.run(888            ["ffmpeg", "-y", "-stream_loop", "-1", "-t", f"{total:.2f}", "-i", src,889             "-vf", f"fps={fps},scale={anim.SW}:{anim.SH}:force_original_aspect_ratio=increase,"890                    f"crop={anim.SW}:{anim.SH}",891             "-an", os.path.join(outdir, "m%05d.png")],892            check=True, capture_output=True,893        )894        files = sorted(_Path(outdir).glob("m*.png"))[:n]895        frames = [Image.open(f).convert("RGB") for f in files]896        while frames and len(frames) < n:897            frames.append(frames[-1])898        return frames899    except Exception as e:900        print("media video failed:", e, flush=True)901        return []902 903 904def _assemble_animated(frames, fps, audio, sr, music_url=None, music_volume=0.18,905                       media_audio_url=None, media_audio_volume=0.0,906                       brand_link=None, brand_text=None, brand_duration=0.0) -> bytes:907    with tempfile.TemporaryDirectory() as tmp:908        tmp = _Path(tmp)909        for i, fr in enumerate(frames):910            fr.convert("RGB").save(tmp / f"f{i:05d}.png", "PNG", compress_level=1)911        audio_path = tmp / "voice.wav"912        sf.write(str(audio_path), audio, sr)913 914        silent = tmp / "silent.mp4"915        subprocess.run(916            ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(tmp / "f%05d.png"),917             "-vf", "scale=1080:1920:flags=lanczos", "-pix_fmt", "yuv420p",918             "-c:v", "libx264", "-preset", "veryfast", "-crf", "22", str(silent)],919            check=True, capture_output=True,920        )921 922        inputs = ["-i", str(silent), "-i", str(audio_path)]923        parts, mix_labels = [], ["[1:a]"]924        idx = 2925        if music_url:926            try:927                mp = _download(music_url, ".mp3")928                inputs += ["-i", mp]929                parts.append(f"[{idx}:a]volume={max(0.0, min(music_volume, 1.0)):.3f},"930                             f"afade=t=in:st=0:d=1.2,aloop=loop=-1:size=2e9[mus]")931                mix_labels.append("[mus]")932                idx += 1933            except Exception as e:934                print("music failed:", e, flush=True)935        if media_audio_url and media_audio_volume > 0:936            try:937                ap = _download(media_audio_url, ".mp4")938                inputs += ["-i", ap]939                parts.append(f"[{idx}:a]volume={min(media_audio_volume, 1.0):.3f}[mda]")940                mix_labels.append("[mda]")941                idx += 1942            except Exception as e:943                print("media audio failed:", e, flush=True)944 945        out = tmp / "out.mp4"946        cmd = ["ffmpeg", "-y"] + inputs947        if len(mix_labels) > 1:948            fc = ";".join(parts + [949                "".join(mix_labels) + f"amix=inputs={len(mix_labels)}:duration=first:"950                                      f"dropout_transition=0,alimiter=limit=0.95[aout]"951            ])952            cmd += ["-filter_complex", fc, "-map", "0:v", "-map", "[aout]"]953        else:954            cmd += ["-map", "0:v", "-map", "1:a"]955        cmd += ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", str(out)]956        subprocess.run(cmd, check=True, capture_output=True)957 958        if brand_duration and brand_duration > 0:959            outro = tmp / "outro.mp4"960            txt = (brand_text or brand_link or "").replace(":", "\\:").replace("'", "\u2019")961            link = (brand_link or "").replace(":", "\\:").replace("'", "\u2019")962            font = FALLBACK_FONT963            vf = (f"drawtext=fontfile={font}:text='{txt}':fontcolor=white:fontsize=64:"964                  f"x=(w-text_w)/2:y=(h-text_h)/2-70,"965                  f"drawtext=fontfile={font}:text='{link}':fontcolor=0xFFD400:fontsize=48:"966                  f"x=(w-text_w)/2:y=(h-text_h)/2+60")967            subprocess.run(968                ["ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=black:s=1080x1920:d={brand_duration}",969                 "-f", "lavfi", "-i", f"anullsrc=r=44100:cl=stereo:d={brand_duration}",970                 "-vf", vf, "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast",971                 "-c:a", "aac", "-shortest", str(outro)], check=True, capture_output=True)972            joined = tmp / "joined.mp4"973            lst = tmp / "cat.txt"974            lst.write_text(f"file '{out}'\nfile '{outro}'\n")975            subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(lst),976                            "-c:v", "libx264", "-preset", "veryfast", "-crf", "22",977                            "-c:a", "aac", str(joined)], check=True, capture_output=True)978            return joined.read_bytes()979        return out.read_bytes()980 981 982class AnimatedReq(BaseModel):983    template: str = "depth_parallax"984    script: str | None = None985    voice_script: str | None = None986    headline: str | None = None987    lines: list[str] | None = None988    voice: str | None = DEFAULT_VOICE989    max_lines: int | None = 6990    background_image_url: str | None = None991    media_url: str | None = None          # uploaded clip or image (custom templates)992    media_audio: bool | None = False993    media_audio_volume: float | None = 0.35994    music_url: str | None = None995    music_volume: float | None = 0.18996    brand_link: str | None = None997    brand_text: str | None = None998    brand_duration: float | None = 0.0999    fps: int | None = 241000 1001 1002@app.get("/animated-templates")1003def animated_templates():1004    return {"templates": anim.META, "voices": KOKORO_VOICES}1005 1006 1007@app.post("/render-animated")1008def render_animated(req: AnimatedReq):1009    try:1010        script = (req.script or req.voice_script or "").strip()1011        lines = [l.strip() for l in (req.lines or []) if l.strip()]1012        if not lines:1013            lines = split_sentences(script, max(2, min(req.max_lines or 6, 8)))1014        if not lines:1015            lines = [req.headline or "Watch this"]1016        headline = (req.headline or "").strip() or lines[0][:60]1017 1018        audio, sr, seg_durs = synth_segments(lines, req.voice or DEFAULT_VOICE)1019        total = max(sum(seg_durs), 3.0)1020        fps = max(12, min(req.fps or 24, 30))1021 1022        bg = None1023        if req.background_image_url:1024            try:1025                r = requests.get(req.background_image_url, timeout=40)1026                r.raise_for_status()1027                bg = anim.cover(Image.open(_io.BytesIO(r.content)).convert("RGB"))1028            except Exception as e:1029                print("bg failed:", e, flush=True)1030 1031        media_frames = _media_frames(req.media_url, total, fps) if req.media_url else []1032        template = req.template if req.template in anim.ANIMATED else (1033            "custom_media" if media_frames else "depth_parallax")1034 1035        frames = anim.render_animation(template, headline, lines, seg_durs, total,1036                                       bg=bg, media_frames=media_frames, fps=fps)1037        mp4 = _assemble_animated(1038            frames, fps, audio, sr,1039            music_url=req.music_url,1040            music_volume=req.music_volume if req.music_volume is not None else 0.18,1041            media_audio_url=req.media_url if req.media_audio else None,1042            media_audio_volume=(req.media_audio_volume or 0.35) if req.media_audio else 0.0,1043            brand_link=req.brand_link, brand_text=req.brand_text,1044            brand_duration=req.brand_duration or 0.0,1045        )1046        return Response(content=mp4, media_type="video/mp4")1047    except Exception as e:1048        import traceback1049        traceback.print_exc()1050        raise HTTPException(500, f"animated render failed: {type(e).__name__}: {str(e)[:400]}")1051 1052# ===========================================================================1053# Automation 2 โ€” source-clip pipeline1054#   /clip/probe   : resolve a source URL -> duration, title, timed subtitles1055#   /clip/render  : cut one segment, reframe to 9:16, burn captions,1056#                   optionally mute the source and lay a Kokoro voice over it.1057# Downloads are cached per video id in /tmp so a 100-clip batch downloads once.1058# ===========================================================================1059import glob as _glob1060import hashlib as _hashlib1061 1062CLIP_CACHE = "/tmp/clipcache"1063os.makedirs(CLIP_CACHE, exist_ok=True)1064 1065 1066COOKIE_FILE = "/tmp/yt_cookies.txt"1067 1068 1069def _cookie_file():1070    raw = os.environ.get("YTDLP_COOKIES", "").strip()1071    if not raw:1072        return None1073    if not os.path.exists(COOKIE_FILE):1074        with open(COOKIE_FILE, "w", encoding="utf-8") as f:1075            f.write(raw.replace("\\n", "\n"))1076    return COOKIE_FILE1077 1078 1079def _ydl_opts(extra: dict) -> dict:1080    base = {1081        "quiet": True,1082        "no_warnings": True,1083        "noplaylist": True,1084        "cachedir": "/tmp/ytdlcache",1085        "retries": 1,1086        "fragment_retries": 2,1087        "extractor_retries": 1,1088        "socket_timeout": 12,1089        "geo_bypass": True,1090        "http_headers": {1091            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "1092                          "(KHTML, like Gecko) Chrome/124.0 Safari/537.36",1093            "Accept-Language": "en-US,en;q=0.9",1094        },1095    }1096    ck = _cookie_file()1097    if ck:1098        base["cookiefile"] = ck1099    proxy = os.environ.get("YTDLP_PROXY", "").strip()1100    if proxy:1101        base["proxy"] = proxy1102    base.update(extra)1103    return base1104 1105 1106# --- multi-strategy YouTube access ------------------------------------------1107# Datacenter IPs get throttled/blocked by the default web client, so we walk a1108# ladder of player clients and remember the first one that works.1109YT_STRATEGIES = [1110    ("android_vr", {"youtube": {"player_client": ["android_vr"]}}),1111    ("tv", {"youtube": {"player_client": ["tv"]}}),1112    ("ios", {"youtube": {"player_client": ["ios"]}}),1113    ("mweb", {"youtube": {"player_client": ["mweb"]}}),1114    ("web_safari", {"youtube": {"player_client": ["web_safari"]}}),1115    ("web_embedded", {"youtube": {"player_client": ["web_embedded"]}}),1116    ("android", {"youtube": {"player_client": ["android"]}}),1117    ("default", None),1118]1119STRATEGY_MEMO = "/tmp/yt_strategy.txt"1120 1121 1122def _is_youtube(url: str) -> bool:1123    return bool(re.search(r"(youtube\.com|youtu\.be)", url or "", re.I))1124 1125 1126def _normalize_url(url: str) -> str:1127    u = (url or "").strip()1128    if "dropbox.com" in u:1129        u = u.split("?")[0] + "?dl=1"1130    if "drive.google.com" in u:1131        m = re.search(r"/d/([A-Za-z0-9_-]+)", u) or re.search(r"[?&]id=([A-Za-z0-9_-]+)", u)1132        if m:1133            u = f"https://drive.google.com/uc?export=download&id={m.group(1)}"1134    return u1135 1136 1137def _ordered_strategies():1138    order = list(YT_STRATEGIES)1139    try:1140        with open(STRATEGY_MEMO) as f:1141            win = f.read().strip()1142        order.sort(key=lambda s: 0 if s[0] == win else 1)1143    except Exception:1144        pass1145    return order1146 1147 1148def _remember(name: str):1149    try:1150        with open(STRATEGY_MEMO, "w") as f:1151            f.write(name)1152    except Exception:1153        pass1154 1155 1156 1157def _ffprobe_json(target: str) -> dict:1158    try:1159        out = subprocess.check_output(1160            ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", target],1161            timeout=120,1162        )1163        return _json.loads(out.decode("utf-8", "ignore"))1164    except Exception as e:1165        raise HTTPException(400, f"ffprobe could not open this source: {type(e).__name__}: {str(e)[:200]}")1166 1167 1168def _direct_probe(url: str) -> dict:1169    """Duration/title for a plain media URL (upload, signed URL, CDN mp4)."""1170    try:1171        meta = _ffprobe_json(url)1172    except Exception:1173        local = _direct_download(url, os.path.join(CLIP_CACHE, _src_key(url)))1174        meta = _ffprobe_json(local)1175    dur = 0.01176    try:1177        dur = float((meta.get("format") or {}).get("duration") or 0)1178    except Exception:1179        dur = 0.01180    if dur <= 0:1181        for s in meta.get("streams", []):1182            try:1183                dur = max(dur, float(s.get("duration") or 0))1184            except Exception:1185                pass1186    name = (meta.get("format") or {}).get("tags", {}).get("title")1187    if not name:1188        name = os.path.basename((url.split("?")[0] or "source")) or "Source video"1189    return {"id": None, "title": name[:120], "duration": dur}1190 1191 1192def _direct_download(url: str, dest_noext: str) -> str:1193    """Stream a plain media URL to disk (no yt-dlp)."""1194    dest = dest_noext + ".mp4"1195    with requests.get(url, stream=True, timeout=120, headers={"User-Agent": "Mozilla/5.0"}) as r:1196        r.raise_for_status()1197        with open(dest, "wb") as f:1198            for chunk in r.iter_content(1024 * 512):1199                if chunk:1200                    f.write(chunk)

Showing the first 1,200 of 1836 lines. Download the file for the rest.