1#!/usr/bin/env python32"""Génère un Reel vertical animé (1080x1920) + musique à partir d'un insight KA.34HTML animé (CSS/JS) -> enregistrement vidéo Playwright (Chromium) -> ffmpeg5(mp4 h264 + lit musical synthétisé). Sortie : chemin du mp4 sur stdout.6"""7import json, sys, os, base64, datetime, urllib.request, ssl, subprocess, glob, hashlib89CTX = ssl.create_default_context(); CTX.check_hostname=False; CTX.verify_mode=ssl.CERT_NONE10FFMPEG = "/opt/homebrew/bin/ffmpeg"11MOIS=["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"]12FONT="'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif"13DUR=9.0 # secondes14HREEL=int(os.environ.get("KA_REEL_H","1350"))1516PALETTES={17 "lou-ka":{"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09"},18 "immo-ka":{"a":"#e23744","b":"#ff6b76","g1":"#2a0c10","g2":"#0d0a0b"},19 "food-ka":{"a":"#1f9d55","b":"#5fd08a","g1":"#07230f","g2":"#0a0d0b"},20 "fabri-ka":{"a":"#c4532e","b":"#e8895f","g1":"#241009","g2":"#0d0a09"},21 "resto-ka":{"a":"#f08c00","b":"#ffb84d","g1":"#241804","g2":"#0d0b08"},22 "auto-ka":{"a":"#ff5a2a","b":"#ff8f6b","g1":"#26100a","g2":"#0d0a09"},23 "vrai-prix":{"a":"#d9f26b","b":"#b8e04a","g1":"#16230a","g2":"#0b0d08"},24 "trouve-ka":{"a":"#d9f26b","b":"#8fd3ff","g1":"#0a1a20","g2":"#080b0d"},25 "sorti-ka":{"a":"#d9f26b","b":"#ff8fd0","g1":"#210a1c","g2":"#0c090c"},26 "crea-ka":{"a":"#d9f26b","b":"#c08bff","g1":"#160a24","g2":"#0a090d"},27 "job-ka":{"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"},28}29def pal(s): return PALETTES.get(s, PALETTES["vrai-prix"])30def esc(s): return str(s).replace("&","&").replace("<","<").replace(">",">")31def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}"3233def build_html(ins):34 H=HREEL35 pt=130 if H>=1600 else 8036 pb=400 if H>=1600 else 22037 p=pal(ins["site"])38 tiles=ins.get("tiles") or []39 hl=str(ins["headline"]); L=len(hl)40 numsize = 150 if L>=9 else (180 if L>=7 else (230 if L>=5 else 300))41 tiles_html="".join(f'<div class="tile t{i}"><div class="tv">{esc(v)}</div><div class="tl">{esc(l)}</div></div>' for i,(v,l) in enumerate(tiles))42 return f"""<!doctype html><html><head><meta charset="utf-8"><style>43 *{{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}}44 html,body{{width:1080px;height:{H}px;overflow:hidden;font-family:{FONT};color:#fff}}45 .stage{{position:relative;width:1080px;height:{H}px;46 background:radial-gradient(1200px 900px at 80% 0%, {p['a']}44,transparent 55%),47 radial-gradient(1000px 1000px at -10% 100%, {p['b']}33,transparent 55%),48 linear-gradient(160deg,{p['g1']},{p['g2']});49 background-size:140% 140%;animation:drift 12s ease-in-out infinite alternate}}50 @keyframes drift{{0%{{background-position:0% 0%}}100%{{background-position:100% 100%}}}}51 .pad{{position:absolute;inset:0;padding:{pt}px 90px {pb}px;display:flex;flex-direction:column}}52 .eyebrow{{display:flex;align-items:center;gap:16px;font-weight:800;letter-spacing:.16em;font-size:28px;53 text-transform:uppercase;color:{p['a']};opacity:0;animation:fadeUp .8s ease .2s forwards}}54 .eyebrow .dot{{width:52px;height:6px;border-radius:3px;background:{p['a']}}}55 .kabadge{{position:absolute;top:120px;right:90px;background:{p['a']};color:#0d0d0d;font-weight:900;56 font-size:56px;padding:12px 26px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;57 animation:pop .7s cubic-bezier(.2,1.4,.4,1) .4s forwards;box-shadow:0 16px 40px {p['a']}66}}58 .site{{font-size:120px;font-weight:900;letter-spacing:-.02em;margin-top:40px;opacity:0;59 animation:fadeUp .8s ease .5s forwards}}60 .tag{{font-size:40px;font-weight:600;color:#ffffffb0;margin-top:12px;opacity:0;animation:fadeUp .8s ease .7s forwards}}61 .num{{font-size:{numsize}px;font-weight:900;line-height:.9;letter-spacing:-.03em;margin-top:80px;white-space:nowrap;62 background:linear-gradient(180deg,#fff,{p['a']} 130%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;63 filter:drop-shadow(0 16px 50px {p['a']}55);opacity:0;transform:translateY(40px) scale(.9);64 animation:heroIn 1s cubic-bezier(.2,1,.3,1) 1.0s forwards}}65 .lab{{font-size:56px;font-weight:700;margin-top:30px;max-width:900px;line-height:1.12;opacity:0;66 animation:fadeUp .8s ease 1.4s forwards}}67 .tiles{{display:flex;gap:22px;margin-top:auto;margin-bottom:0}}68 .tile{{flex:1;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.14);border-radius:26px;69 padding:30px 26px;opacity:0;transform:translateY(30px);animation:fadeUp .7s ease forwards}}70 .t0{{animation-delay:1.8s}}.t1{{animation-delay:2.0s}}.t2{{animation-delay:2.2s}}.t3{{animation-delay:2.4s}}71 .tv{{font-size:60px;font-weight:900;color:{p['a']}}}72 .tl{{font-size:29px;font-weight:600;color:#ffffffa0;margin-top:8px;line-height:1.2}}73 .cta{{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fadeUp .8s ease 2.8s forwards}}74 .cta .go{{font-size:46px;font-weight:900;color:{p['a']}}}75 .cta .brand{{font-size:30px;font-weight:700;color:#ffffff88;letter-spacing:.12em;margin-top:6px}}76 .sig{{position:absolute;left:90px;bottom:90px;font-size:26px;color:#ffffff66;font-weight:600;opacity:0;77 animation:fadeUp .8s ease 3.1s forwards}}78 @keyframes fadeUp{{to{{opacity:1;transform:translateY(0)}}}}79 @keyframes pop{{to{{opacity:1;transform:rotate(-4deg) scale(1)}}}}80 @keyframes heroIn{{to{{opacity:1;transform:translateY(0) scale(1)}}}}81 </style></head><body>82 <div class="stage"><div class="pad">83 <div class="eyebrow"><span class="dot"></span>{esc(ins['label'].upper())} · {today().upper()}</div>84 <div class="kabadge">KA</div>85 <div class="site">{esc(ins['label'])}</div>86 {f'<div class="tag">{esc(ins.get("tagline",""))}</div>' if ins.get('tagline') else ''}87 <div class="num" id="num" data-target="{esc(ins['headline'])}">{esc(ins['headline'])}</div>88 <div class="lab">{esc(ins['headline_label'])}</div>89 {f'<div class="tiles">{tiles_html}</div>' if tiles else ''}90 <div class="cta"><div class="go">👉 www.{ins['site']}.com</div><div class="brand">GROUPE ·KA</div></div>91 <div class="sig">✶ Rédigé et publié par l'Agent KA</div>92 </div></div>93 <script>94 // count-up si le nombre-héros est numérique95 (function(){{96 var el=document.getElementById('num'); var raw=el.getAttribute('data-target');97 var m=raw.match(/^[\\d .,]+/); if(!m) return;98 var digits=m[0].replace(/[^\\d]/g,''); if(digits.length<2) return;99 var target=parseInt(digits,10); var suffix=raw.slice(m[0].length);100 var prefix=''; var start=null, dur=1400, begin=1000;101 function fmt(n){{return n.toLocaleString('fr-CA').replace(/,/g,' ')}}102 function step(ts){{ if(!start)start=ts; var t=ts-start;103 if(t<begin){{requestAnimationFrame(step);return;}}104 var k=Math.min(1,(t-begin)/dur); var e=1-Math.pow(1-k,3);105 el.textContent=fmt(Math.round(target*e))+suffix;106 if(k<1)requestAnimationFrame(step);107 }}108 el.textContent=fmt(0)+suffix; requestAnimationFrame(step);109 }})();110 </script></body></html>"""111112def record_webm(html_path, out_webm, tmpdir):113 from playwright.sync_api import sync_playwright114 with sync_playwright() as pw:115 b=pw.chromium.launch(args=["--no-sandbox","--force-device-scale-factor=1"])116 ctx=b.new_context(viewport={"width":1080,"height":HREEL},117 record_video_dir=tmpdir, record_video_size={"width":1080,"height":HREEL})118 pg=ctx.new_page()119 pg.goto("file://"+html_path)120 pg.wait_for_timeout(int(DUR*1000))121 vid=pg.video122 ctx.close(); b.close()123 path=vid.path()124 os.replace(path, out_webm)125126def make_music(dur, out_wav):127 # nappe d'accord douce (Am : A3+C4+E4) + tremolo + passe-bas128 subprocess.run([FFMPEG,"-y",129 "-f","lavfi","-i",f"sine=frequency=220:duration={dur}",130 "-f","lavfi","-i",f"sine=frequency=261.63:duration={dur}",131 "-f","lavfi","-i",f"sine=frequency=329.63:duration={dur}",132 "-f","lavfi","-i",f"sine=frequency=110:duration={dur}",133 "-filter_complex",134 "[0][1][2]amix=inputs=3:normalize=0,volume=0.18,tremolo=f=4.5:d=0.4,lowpass=f=1400[pad];"135 "[3]volume=0.10,tremolo=f=2:d=0.6[sub];"136 "[pad][sub]amix=inputs=2:normalize=0,afade=t=in:st=0:d=1.2,afade=t=out:st="+str(dur-1.5)+":d=1.5[a]",137 "-map","[a]",out_wav],capture_output=True)138139def render(ins, out_dir):140 os.makedirs(out_dir,exist_ok=True)141 key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8]142 tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True)143 html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins))144 webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp)145 wav=os.path.join(tmp,"bed.wav"); make_music(DUR,wav)146 out=os.path.join(out_dir,f"reel-{ins['site']}-{ins.get('insight_id','x')}.mp4")147 subprocess.run([FFMPEG,"-y","-i",webm,"-i",wav,148 "-filter_complex","[0:v]scale=1080:"+str(HREEL)+":force_original_aspect_ratio=increase,crop=1080:"+str(HREEL)+",fps=30,format=yuv420p[v]",149 "-map","[v]","-map","1:a","-c:v","libx264","-preset","medium","-crf","20",150 "-c:a","aac","-b:a","160k","-pix_fmt","yuv420p","-movflags","+faststart","-shortest",out],151 capture_output=True)152 # nettoyage153 for f in glob.glob(os.path.join(tmp,"*")):154 try: os.remove(f)155 except: pass156 try: os.rmdir(tmp)157 except: pass158 if not os.path.exists(out): raise SystemExit("mp4 non produit")159 return out160161if __name__=="__main__":162 ins=json.load(sys.stdin)163 out_dir=sys.argv[1] if len(sys.argv)>1 else "/tmp/ka-reels"164 print(render(ins,out_dir))165