SPB Git

spb/admin-ka

Public
15.8 KB · 254 lines python
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=10.0  # secondes14HREEL=int(os.environ.get("KA_REEL_H","1920"))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 data_uri(url):31    import base6432    try:33        req=urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0 (Macintosh)"})34        with urllib.request.urlopen(req, timeout=15, context=CTX) as r: raw=r.read()35        mime="image/jpeg"36        if url.lower().endswith(".png"): mime="image/png"37        elif url.lower().endswith(".webp"): mime="image/webp"38        return "data:"+mime+";base64,"+base64.b64encode(raw).decode()39    except Exception: return None40def esc(s): return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")41def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}"4243def build_spotlight(ins):44    H=HREEL; p=pal(ins["site"]); prod=ins.get("product") or {}45    img=prod.get("image_data",""); chips="".join('<span class="chip">'+esc(c)+'</span>' for c in (prod.get("meta") or []))46    price=esc(prod.get("price","")); title=esc(prod.get("title","")); sub=esc(prod.get("subtitle",""))47    css = """48    *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}49    html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff;background:__G2__}50    .photo{position:absolute;top:0;left:0;right:0;height:__PH__px;background:url('__IMG__') center/cover;animation:kb 10s ease-out forwards}51    @keyframes kb{from{transform:scale(1.0)}to{transform:scale(1.12)}}52    .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%)}53    .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}54    .eyebrow .dot{width:46px;height:6px;border-radius:3px;background:__A__}55    .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}56    .info{position:absolute;left:90px;right:90px;bottom:270px}57    .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}58    .psub{font-size:36px;font-weight:600;color:#ffffffc8;margin-top:14px;opacity:0;animation:fu .9s ease 1.25s forwards}59    .chips{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px}60    .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}61    .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}62    .cta{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fu .8s ease 2.2s forwards}63    .cta .go{font-size:46px;font-weight:900;color:__A__}64    .cta .brand{font-size:30px;font-weight:700;color:#ffffff88;letter-spacing:.12em;margin-top:6px}65    .sig{position:absolute;left:90px;bottom:88px;font-size:26px;color:#ffffff66;font-weight:600;opacity:0;animation:fu .8s ease 2.5s forwards}66    @keyframes fu{to{opacity:1;transform:translateY(0)}}67    @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}68    @keyframes popc{to{opacity:1;transform:scale(1)}}69    """70    css = (css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__G2__",p["g2"])71              .replace("__PH__",str(int(H*0.62))).replace("__IMG__",img).replace("__A__",p["a"]))72    body = (73      '<div class="photo"></div><div class="scrim"></div>'74      '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · À DÉCOUVRIR</div>'75      '<div class="kabadge">KA</div>'76      '<div class="info"><div class="ptitle">'+title+'</div>'77      + (('<div class="psub">'+sub+'</div>') if sub else '')78      + '<div class="chips">'+chips+'</div>'79      + (('<div class="price">'+price+'</div>') if price else '')80      + '</div>'81      '<div class="cta"><div class="go">\U0001f449 www.'+ins["site"]+'.com</div><div class="brand">GROUPE ·KA</div></div>'82      '<div class="sig">✦ Publié par l\'Agent KA</div>'83    )84    return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+"</body></html>"8586def build_html(ins):87    if ins.get('genre')=='spotlight' and (ins.get('product') or {}).get('image_data'):88        return build_spotlight(ins)89    H=HREEL90    pt=130 if H>=1600 else 8091    pb=400 if H>=1600 else 22092    p=pal(ins["site"])93    tiles=ins.get("tiles") or []94    hl=str(ins["headline"]); L=len(hl)95    numsize = 150 if L>=9 else (180 if L>=7 else (230 if L>=5 else 300))96    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))97    return f"""<!doctype html><html><head><meta charset="utf-8"><style>98    *{{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}}99    html,body{{width:1080px;height:{H}px;overflow:hidden;font-family:{FONT};color:#fff}}100    .stage{{position:relative;width:1080px;height:{H}px;101      background:radial-gradient(1200px 900px at 80% 0%, {p['a']}44,transparent 55%),102                 radial-gradient(1000px 1000px at -10% 100%, {p['b']}33,transparent 55%),103                 linear-gradient(160deg,{p['g1']},{p['g2']});104      background-size:140% 140%;animation:drift 12s ease-in-out infinite alternate}}105    @keyframes drift{{0%{{background-position:0% 0%}}100%{{background-position:100% 100%}}}}106    .pad{{position:absolute;inset:0;padding:{pt}px 90px {pb}px;display:flex;flex-direction:column}}107    .eyebrow{{display:flex;align-items:center;gap:16px;font-weight:800;letter-spacing:.16em;font-size:28px;108      text-transform:uppercase;color:{p['a']};opacity:0;animation:fadeUp .8s ease .2s forwards}}109    .eyebrow .dot{{width:52px;height:6px;border-radius:3px;background:{p['a']}}}110    .kabadge{{position:absolute;top:120px;right:90px;background:{p['a']};color:#0d0d0d;font-weight:900;111      font-size:56px;padding:12px 26px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;112      animation:pop .7s cubic-bezier(.2,1.4,.4,1) .4s forwards;box-shadow:0 16px 40px {p['a']}66}}113    .site{{font-size:120px;font-weight:900;letter-spacing:-.02em;margin-top:40px;opacity:0;114      animation:fadeUp .8s ease .5s forwards}}115    .tag{{font-size:40px;font-weight:600;color:#ffffffb0;margin-top:12px;opacity:0;animation:fadeUp .8s ease .7s forwards}}116    .num{{font-size:{numsize}px;font-weight:900;line-height:.9;letter-spacing:-.03em;margin-top:80px;white-space:nowrap;117      background:linear-gradient(180deg,#fff,{p['a']} 130%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;118      filter:drop-shadow(0 16px 50px {p['a']}55);opacity:0;transform:translateY(40px) scale(.9);119      animation:heroIn 1s cubic-bezier(.2,1,.3,1) 1.0s forwards}}120    .lab{{font-size:56px;font-weight:700;margin-top:30px;max-width:900px;line-height:1.12;opacity:0;121      animation:fadeUp .8s ease 1.4s forwards}}122    .tiles{{display:flex;gap:22px;margin-top:auto;margin-bottom:0}}123    .tile{{flex:1;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.14);border-radius:26px;124      padding:30px 26px;opacity:0;transform:translateY(30px);animation:fadeUp .7s ease forwards}}125    .t0{{animation-delay:1.8s}}.t1{{animation-delay:2.0s}}.t2{{animation-delay:2.2s}}.t3{{animation-delay:2.4s}}126    .tv{{font-size:60px;font-weight:900;color:{p['a']}}}127    .tl{{font-size:29px;font-weight:600;color:#ffffffa0;margin-top:8px;line-height:1.2}}128    .cta{{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fadeUp .8s ease 2.8s forwards}}129    .cta .go{{font-size:46px;font-weight:900;color:{p['a']}}}130    .cta .brand{{font-size:30px;font-weight:700;color:#ffffff88;letter-spacing:.12em;margin-top:6px}}131    .sig{{position:absolute;left:90px;bottom:90px;font-size:26px;color:#ffffff66;font-weight:600;opacity:0;132      animation:fadeUp .8s ease 3.1s forwards}}133    @keyframes fadeUp{{to{{opacity:1;transform:translateY(0)}}}}134    @keyframes pop{{to{{opacity:1;transform:rotate(-4deg) scale(1)}}}}135    @keyframes heroIn{{to{{opacity:1;transform:translateY(0) scale(1)}}}}136    </style></head><body>137    <div class="stage"><div class="pad">138      <div class="eyebrow"><span class="dot"></span>{esc(ins['label'].upper())} · {today().upper()}</div>139      <div class="kabadge">KA</div>140      <div class="site">{esc(ins['label'])}</div>141      {f'<div class="tag">{esc(ins.get("tagline",""))}</div>' if ins.get('tagline') else ''}142      <div class="num" id="num" data-target="{esc(ins['headline'])}">{esc(ins['headline'])}</div>143      <div class="lab">{esc(ins['headline_label'])}</div>144      {f'<div class="tiles">{tiles_html}</div>' if tiles else ''}145      <div class="cta"><div class="go">👉 www.{ins['site']}.com</div><div class="brand">GROUPE ·KA</div></div>146      <div class="sig">✶ Rédigé et publié par l'Agent KA</div>147    </div></div>148    <script>149    // count-up si le nombre-héros est numérique150    (function(){{151      var el=document.getElementById('num'); var raw=el.getAttribute('data-target');152      if(/[,%\\/]/.test(raw)) return;153      var m=raw.match(/^[\\d\\u00a0\\u202f ]+/); if(!m) return;154      var digits=m[0].replace(/[^\\d]/g,''); if(digits.length<3) return;155      var target=parseInt(digits,10); var suffix=raw.slice(m[0].length);156      var start=null, dur=1400, begin=1000;157      function fmt(n){{return n.toLocaleString('fr-CA').replace(/[\\u00a0\\u202f,]/g,' ')}}158      function step(ts){{ if(!start)start=ts; var t=ts-start;159        if(t<begin){{requestAnimationFrame(step);return;}}160        var k=Math.min(1,(t-begin)/dur); var e=1-Math.pow(1-k,3);161        el.textContent=fmt(Math.round(target*e))+suffix;162        if(k<1)requestAnimationFrame(step);163      }}164      el.textContent=fmt(0)+suffix; requestAnimationFrame(step);165    }})();166    </script></body></html>"""167168def record_webm(html_path, out_webm, tmpdir):169    from playwright.sync_api import sync_playwright170    with sync_playwright() as pw:171        b=pw.chromium.launch(args=["--no-sandbox"])172        ctx=b.new_context(viewport={"width":1080,"height":HREEL}, device_scale_factor=2,173                          record_video_dir=tmpdir, record_video_size={"width":1080,"height":HREEL})174        pg=ctx.new_page()175        pg.goto("file://"+html_path)176        pg.wait_for_timeout(int(DUR*1000))177        vid=pg.video178        ctx.close(); b.close()179        path=vid.path()180    os.replace(path, out_webm)181182def make_music(dur, out_wav):183    """Lit musical pro : progression Am-F-C-G (pad) + kick 4-temps + shaker + réverbe."""184    tmpdir=os.path.dirname(out_wav)185    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)]186    seg=round(dur/len(chords),3)187    parts=[]188    for i,(f1,f2,f3) in enumerate(chords):189        w=os.path.join(tmpdir,f"ch{i}.wav")190        subprocess.run([FFMPEG,"-y",191            "-f","lavfi","-i",f"sine=frequency={f1}:duration={seg}",192            "-f","lavfi","-i",f"sine=frequency={f2}:duration={seg}",193            "-f","lavfi","-i",f"sine=frequency={f3}:duration={seg}",194            "-filter_complex",195            f"[0][1][2]amix=inputs=3:normalize=0,volume=0.22,lowpass=f=1300,"196            f"afade=t=in:st=0:d=0.25,afade=t=out:st={seg-0.35}:d=0.35[a]",197            "-map","[a]",w],capture_output=True)198        parts.append(w)199    # concat des accords -> pad200    lst=os.path.join(tmpdir,"chords.txt")201    open(lst,"w").write("".join(f"file '{w}'\n" for w in parts))202    pad=os.path.join(tmpdir,"pad.wav")203    subprocess.run([FFMPEG,"-y","-f","concat","-safe","0","-i",lst,"-c","copy",pad],capture_output=True)204    # kick 4-temps (120 BPM) + shaker offbeat, synthétisés205    kick=os.path.join(tmpdir,"kick.wav")206    subprocess.run([FFMPEG,"-y","-f","lavfi",207        "-i",f"aevalsrc=0.7*sin(2*PI*55*t)*exp(-9*mod(t\,0.5)):d={dur}:s=44100",208        kick],capture_output=True)209    hat=os.path.join(tmpdir,"hat.wav")210    subprocess.run([FFMPEG,"-y","-f","lavfi",211        "-i",f"aevalsrc=0.12*(random(0)*2-1)*exp(-45*mod(t+0.25\,0.5)):d={dur}:s=44100",212        "-af","highpass=f=6000",hat],capture_output=True)213    # mix final : pad + kick + hat, réverbe douce, master fades214    subprocess.run([FFMPEG,"-y","-i",pad,"-i",kick,"-i",hat,215        "-filter_complex",216        f"[0]volume=1.0[p];[1]volume=0.9[k];[2]volume=0.5[h];"217        f"[p][k][h]amix=inputs=3:normalize=0,"218        f"aecho=0.8:0.85:60:0.25,"219        f"acompressor=threshold=-16dB:ratio=3:attack=8:release=180,"220        f"alimiter=limit=0.95,"221        f"afade=t=in:st=0:d=0.5,afade=t=out:st={dur-1.6}:d=1.6[a]",222        "-map","[a]","-ar","44100",out_wav],capture_output=True)223224def render(ins, out_dir):225    os.makedirs(out_dir,exist_ok=True)226    prod=ins.get('product')227    if prod and prod.get('image') and not prod.get('image_data'):228        du=data_uri(prod['image'])229        if du: prod['image_data']=du230    key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8]231    tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True)232    html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins))233    webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp)234    wav=os.path.join(tmp,"bed.wav"); make_music(DUR,wav)235    out=os.path.join(out_dir,f"reel-{ins['site']}-{ins.get('insight_id','x')}.mp4")236    subprocess.run([FFMPEG,"-y","-i",webm,"-i",wav,237        "-filter_complex","[0:v]scale=1080:"+str(HREEL)+":force_original_aspect_ratio=increase,crop=1080:"+str(HREEL)+",fps=30,format=yuv420p[v]",238        "-map","[v]","-map","1:a","-c:v","libx264","-preset","slow","-crf","18",239        "-c:a","aac","-b:a","160k","-pix_fmt","yuv420p","-movflags","+faststart","-shortest",out],240        capture_output=True)241    # nettoyage242    for f in glob.glob(os.path.join(tmp,"*")):243        try: os.remove(f)244        except: pass245    try: os.rmdir(tmp)246    except: pass247    if not os.path.exists(out): raise SystemExit("mp4 non produit")248    return out249250if __name__=="__main__":251    ins=json.load(sys.stdin)252    out_dir=sys.argv[1] if len(sys.argv)>1 else "/tmp/ka-reels"253    print(render(ins,out_dir))254
Files · 6e04c504767d9d1f08f3aab491074f19252cb65f
Menu

Theme