aralawrence11/tiny-talking-face-cpu-lovable
0
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)