#!/usr/bin/env python3 """Génère un Reel vertical animé (1080x1920) + musique à partir d'un insight KA. HTML animé (CSS/JS) -> enregistrement vidéo Playwright (Chromium) -> ffmpeg (mp4 h264 + lit musical synthétisé). Sortie : chemin du mp4 sur stdout. """ import json, sys, os, base64, datetime, urllib.request, ssl, subprocess, glob, hashlib CTX = ssl.create_default_context(); CTX.check_hostname=False; CTX.verify_mode=ssl.CERT_NONE FFMPEG = "/opt/homebrew/bin/ffmpeg" MOIS=["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"] FONT="'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif" DUR=10.0 # secondes HREEL=int(os.environ.get("KA_REEL_H","1920")) PALETTES={ "lou-ka":{"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09"}, "immo-ka":{"a":"#e23744","b":"#ff6b76","g1":"#2a0c10","g2":"#0d0a0b"}, "food-ka":{"a":"#1f9d55","b":"#5fd08a","g1":"#07230f","g2":"#0a0d0b"}, "fabri-ka":{"a":"#c4532e","b":"#e8895f","g1":"#241009","g2":"#0d0a09"}, "resto-ka":{"a":"#f08c00","b":"#ffb84d","g1":"#241804","g2":"#0d0b08"}, "auto-ka":{"a":"#ff5a2a","b":"#ff8f6b","g1":"#26100a","g2":"#0d0a09"}, "vrai-prix":{"a":"#d9f26b","b":"#b8e04a","g1":"#16230a","g2":"#0b0d08"}, "trouve-ka":{"a":"#d9f26b","b":"#8fd3ff","g1":"#0a1a20","g2":"#080b0d"}, "sorti-ka":{"a":"#d9f26b","b":"#ff8fd0","g1":"#210a1c","g2":"#0c090c"}, "crea-ka":{"a":"#d9f26b","b":"#c08bff","g1":"#160a24","g2":"#0a090d"}, "job-ka":{"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"}, } def pal(s): return PALETTES.get(s, PALETTES["vrai-prix"]) def data_uri(url): import base64 try: req=urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0 (Macintosh)"}) with urllib.request.urlopen(req, timeout=15, context=CTX) as r: raw=r.read() mime="image/jpeg" if url.lower().endswith(".png"): mime="image/png" elif url.lower().endswith(".webp"): mime="image/webp" return "data:"+mime+";base64,"+base64.b64encode(raw).decode() except Exception: return None def esc(s): return str(s).replace("&","&").replace("<","<").replace(">",">") def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}" def build_spotlight(ins): H=HREEL; p=pal(ins["site"]); prod=ins.get("product") or {} img=prod.get("image_data",""); chips="".join(''+esc(c)+'' for c in (prod.get("meta") or [])) price=esc(prod.get("price","")); title=esc(prod.get("title","")); sub=esc(prod.get("subtitle","")) css = """ *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased} html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff;background:__G2__} .photo{position:absolute;top:0;left:0;right:0;height:__PH__px;background:url('__IMG__') center/cover;animation:kb 10s ease-out forwards} @keyframes kb{from{transform:scale(1.0)}to{transform:scale(1.12)}} .scrim{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,.15) 0%,rgba(0,0,0,0) 30%,__G2__cc 55%,__G2__ 72%)} .eyebrow{position:absolute;top:120px;left:90px;display:flex;align-items:center;gap:14px;font-weight:800;letter-spacing:.16em;font-size:26px;text-transform:uppercase;color:__A__;opacity:0;animation:fu .8s ease .2s forwards;text-shadow:0 2px 12px #000} .eyebrow .dot{width:46px;height:6px;border-radius:3px;background:__A__} .kabadge{position:absolute;top:110px;right:90px;background:__A__;color:#0d0d0d;font-weight:900;font-size:52px;padding:10px 24px;border-radius:20px;transform:rotate(-4deg) scale(.5);opacity:0;animation:pop .7s cubic-bezier(.2,1.4,.4,1) .5s forwards;box-shadow:0 16px 40px __A__66} .info{position:absolute;left:90px;right:90px;bottom:270px} .ptitle{font-size:74px;font-weight:900;line-height:1.02;letter-spacing:-.02em;text-shadow:0 4px 24px #000a;opacity:0;transform:translateY(30px);animation:fu .9s ease 1.0s forwards} .psub{font-size:36px;font-weight:600;color:#ffffffc8;margin-top:14px;opacity:0;animation:fu .9s ease 1.25s forwards} .chips{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px} .chip{background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.22);border-radius:999px;padding:12px 24px;font-size:28px;font-weight:700;opacity:0;animation:fu .8s ease 1.45s forwards} .price{font-size:92px;font-weight:900;color:__A__;margin-top:26px;letter-spacing:-.02em;filter:drop-shadow(0 10px 30px __A__66);opacity:0;transform:scale(.9);animation:popc .8s cubic-bezier(.2,1.3,.4,1) 1.7s forwards} .cta{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fu .8s ease 2.2s forwards} .cta .go{font-size:46px;font-weight:900;color:__A__} .cta .brand{font-size:30px;font-weight:700;color:#ffffff88;letter-spacing:.12em;margin-top:6px} .sig{position:absolute;left:90px;bottom:88px;font-size:26px;color:#ffffff66;font-weight:600;opacity:0;animation:fu .8s ease 2.5s forwards} @keyframes fu{to{opacity:1;transform:translateY(0)}} @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}} @keyframes popc{to{opacity:1;transform:scale(1)}} """ css = (css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__G2__",p["g2"]) .replace("__PH__",str(int(H*0.62))).replace("__IMG__",img).replace("__A__",p["a"])) body = ( '
' '
'+esc(ins["label"].upper())+' · À DÉCOUVRIR
' '
KA
' '
'+title+'
' + (('
'+sub+'
') if sub else '') + '
'+chips+'
' + (('
'+price+'
') if price else '') + '
' '
\U0001f449 www.'+ins["site"]+'.com
GROUPE ·KA
' '
✦ Publié par l\'Agent KA
' ) return ""+body+"" def build_html(ins): if ins.get('genre')=='spotlight' and (ins.get('product') or {}).get('image_data'): return build_spotlight(ins) H=HREEL pt=130 if H>=1600 else 80 pb=400 if H>=1600 else 220 p=pal(ins["site"]) tiles=ins.get("tiles") or [] hl=str(ins["headline"]); L=len(hl) numsize = 150 if L>=9 else (180 if L>=7 else (230 if L>=5 else 300)) tiles_html="".join(f'
{esc(v)}
{esc(l)}
' for i,(v,l) in enumerate(tiles)) return f"""
{esc(ins['label'].upper())} · {today().upper()}
KA
{esc(ins['label'])}
{f'
{esc(ins.get("tagline",""))}
' if ins.get('tagline') else ''}
{esc(ins['headline'])}
{esc(ins['headline_label'])}
{f'
{tiles_html}
' if tiles else ''}
👉 www.{ins['site']}.com
GROUPE ·KA
✶ Rédigé et publié par l'Agent KA
""" def record_webm(html_path, out_webm, tmpdir): from playwright.sync_api import sync_playwright with sync_playwright() as pw: b=pw.chromium.launch(args=["--no-sandbox"]) ctx=b.new_context(viewport={"width":1080,"height":HREEL}, device_scale_factor=2, record_video_dir=tmpdir, record_video_size={"width":1080,"height":HREEL}) pg=ctx.new_page() pg.goto("file://"+html_path) pg.wait_for_timeout(int(DUR*1000)) vid=pg.video ctx.close(); b.close() path=vid.path() os.replace(path, out_webm) def make_music(dur, out_wav): """Lit musical pro : progression Am-F-C-G (pad) + kick 4-temps + shaker + réverbe.""" tmpdir=os.path.dirname(out_wav) chords=[(220.00,261.63,329.63),(174.61,220.00,261.63),(261.63,329.63,392.00),(196.00,246.94,293.66)] seg=round(dur/len(chords),3) parts=[] for i,(f1,f2,f3) in enumerate(chords): w=os.path.join(tmpdir,f"ch{i}.wav") subprocess.run([FFMPEG,"-y", "-f","lavfi","-i",f"sine=frequency={f1}:duration={seg}", "-f","lavfi","-i",f"sine=frequency={f2}:duration={seg}", "-f","lavfi","-i",f"sine=frequency={f3}:duration={seg}", "-filter_complex", f"[0][1][2]amix=inputs=3:normalize=0,volume=0.22,lowpass=f=1300," f"afade=t=in:st=0:d=0.25,afade=t=out:st={seg-0.35}:d=0.35[a]", "-map","[a]",w],capture_output=True) parts.append(w) # concat des accords -> pad lst=os.path.join(tmpdir,"chords.txt") open(lst,"w").write("".join(f"file '{w}'\n" for w in parts)) pad=os.path.join(tmpdir,"pad.wav") subprocess.run([FFMPEG,"-y","-f","concat","-safe","0","-i",lst,"-c","copy",pad],capture_output=True) # kick 4-temps (120 BPM) + shaker offbeat, synthétisés kick=os.path.join(tmpdir,"kick.wav") subprocess.run([FFMPEG,"-y","-f","lavfi", "-i",f"aevalsrc=0.7*sin(2*PI*55*t)*exp(-9*mod(t\,0.5)):d={dur}:s=44100", kick],capture_output=True) hat=os.path.join(tmpdir,"hat.wav") subprocess.run([FFMPEG,"-y","-f","lavfi", "-i",f"aevalsrc=0.12*(random(0)*2-1)*exp(-45*mod(t+0.25\,0.5)):d={dur}:s=44100", "-af","highpass=f=6000",hat],capture_output=True) # mix final : pad + kick + hat, réverbe douce, master fades subprocess.run([FFMPEG,"-y","-i",pad,"-i",kick,"-i",hat, "-filter_complex", f"[0]volume=1.0[p];[1]volume=0.9[k];[2]volume=0.5[h];" f"[p][k][h]amix=inputs=3:normalize=0," f"aecho=0.8:0.85:60:0.25," f"acompressor=threshold=-16dB:ratio=3:attack=8:release=180," f"alimiter=limit=0.95," f"afade=t=in:st=0:d=0.5,afade=t=out:st={dur-1.6}:d=1.6[a]", "-map","[a]","-ar","44100",out_wav],capture_output=True) def render(ins, out_dir): os.makedirs(out_dir,exist_ok=True) prod=ins.get('product') if prod and prod.get('image') and not prod.get('image_data'): du=data_uri(prod['image']) if du: prod['image_data']=du key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8] tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True) html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins)) webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp) wav=os.path.join(tmp,"bed.wav"); make_music(DUR,wav) out=os.path.join(out_dir,f"reel-{ins['site']}-{ins.get('insight_id','x')}.mp4") subprocess.run([FFMPEG,"-y","-i",webm,"-i",wav, "-filter_complex","[0:v]scale=1080:"+str(HREEL)+":force_original_aspect_ratio=increase,crop=1080:"+str(HREEL)+",fps=30,format=yuv420p[v]", "-map","[v]","-map","1:a","-c:v","libx264","-preset","slow","-crf","18", "-c:a","aac","-b:a","160k","-pix_fmt","yuv420p","-movflags","+faststart","-shortest",out], capture_output=True) # nettoyage for f in glob.glob(os.path.join(tmp,"*")): try: os.remove(f) except: pass try: os.rmdir(tmp) except: pass if not os.path.exists(out): raise SystemExit("mp4 non produit") return out if __name__=="__main__": ins=json.load(sys.stdin) out_dir=sys.argv[1] if len(sys.argv)>1 else "/tmp/ka-reels" print(render(ins,out_dir))