SPB Git

spb/admin-ka

Public

Reels v4 MAJEUR: multi-scenes 20-32s + moteur musical procedural

make_reel.py (reecriture complete) :
- le reel n est plus une carte animee de 10 s mais un MINI-CLIP de
  20-32 s en scenes enchainees avec transitions : intro -> stat geante
  (count-up) ou donut % anime -> tuiles chiffres -> palmares anime ->
  photos reelles (Ken Burns + titre/prix + compteur k/n) -> le
  saviez-vous (mot a mot) -> outro CTA
- plan de scenes dynamique selon les donnees (tiles/bars/list/product/
  fact), budget photos calcule pour rester <= 34 s, fondu de fin CSS
- bandeau site+date, badge KA, filigrane url et barre de progression
  persistants par-dessus les scenes

music_engine.py (nouveau) : musique procedurale numpy, UNIQUE par reel
- 6 styles (house, lofi, synthwave, epic, funk, trap) avec tempo,
  patterns batterie (kick/snare/clap/hats swing), ligne de basse
  (offbeat/synco/arp16/808), nappes detunees stereo et MELODIE generee
  par graine (site+insight+variant+date) -> jamais deux fois la meme
- kick 808 balaye, claps multi-impulsions, texture vinyle (lofi),
  montee bruitee (epic), echo ping-pong, drive doux + fondus
- style en rotation via variant (postCount) ; ~0,5 s de calcul

social.js : enrichReel() attache de vraies annonces photo aux reels
(draft, cycle auto, lots) pour nourrir les scenes photo.

Contenu deja genere purge (cards/reels/galerie) + rotation remise a 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 23, 2026) parent 7899c24 Browse files

3 changed files +699 −371

modified server/social.js +21 −0
@@ -600,6 +600,23 @@ async function decoratePlan(insight, plan) {
600 return insight; 600 return insight;
601 } 601 }
602 602
603+// enrichit un insight pour un REEL multi-scènes : toujours tenter d'attacher
604+// de vraies annonces avec photos (scènes photo) en plus du genre choisi
605+async function enrichReel(insight) {
606+ if (SPOT[insight.site] && !insight.list) {
607+ const items = await fetchItems(insight.site, 4);
608+ if (items && items.length >= 2) {
609+ insight.list = items;
610+ insight.list_title = LIST_TITLES[insight.site] || insight.headline_label;
611+ }
612+ }
613+ if (SPOT[insight.site] && !insight.list && !insight.product) {
614+ const feat = await fetchFeatured(insight.site);
615+ if (feat) insight.product = feat;
616+ }
617+ return insight;
618+}
619+
603 // rotation des genres pour les REELS auto / lots (sans prompt) 620 // rotation des genres pour les REELS auto / lots (sans prompt)
604 async function decorateForPrompt(insight) { 621 async function decorateForPrompt(insight) {
605 const n = applyVariant(insight).variant; 622 const n = applyVariant(insight).variant;
@@ -763,6 +780,7 @@ export async function generateReelDraft({ site = '', prompt = '' } = {}) {
763 insight = await pickInsight({ site }); 780 insight = await pickInsight({ site });
764 await decorateForPrompt(insight); 781 await decorateForPrompt(insight);
765 } 782 }
783+ await enrichReel(insight);
766 const video = await makeReel(insight); 784 const video = await makeReel(insight);
767 const caption = await writeCaption(insight, prompt); 785 const caption = await writeCaption(insight, prompt);
768 const videoUrl='/api/social/reel/'+path.basename(video); 786 const videoUrl='/api/social/reel/'+path.basename(video);
@@ -788,6 +806,7 @@ export async function runReelCycle(trigger = 'reel') {
788 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; } 806 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }
789 const insight = await pickInsight({}); 807 const insight = await pickInsight({});
790 await decorateForPrompt(insight); 808 await decorateForPrompt(insight);
809+ await enrichReel(insight);
791 const video = await makeReel(insight); 810 const video = await makeReel(insight);
792 const caption = await writeCaption(insight); 811 const caption = await writeCaption(insight);
793 await publishReelToPage(caption, video); 812 await publishReelToPage(caption, video);
@@ -820,6 +839,7 @@ export function startGenBatch(sites){
820 logEvent({ kind:'genbatch_item', i:i+1, total:sites.length, site }); 839 logEvent({ kind:'genbatch_item', i:i+1, total:sites.length, site });
821 const insight=await pickInsight({ site }); 840 const insight=await pickInsight({ site });
822 await decorateForPrompt(insight); 841 await decorateForPrompt(insight);
842+ await enrichReel(insight);
823 const video=await makeReel(insight); 843 const video=await makeReel(insight);
824 const caption=await writeCaption(insight); 844 const caption=await writeCaption(insight);
825 addToGallery({ kind:'reel', site, genre:insight.genre, caption, file:path.basename(video), 845 addToGallery({ kind:'reel', site, genre:insight.genre, caption, file:path.basename(video),
@@ -848,6 +868,7 @@ export function startReelBatch(sites) {
848 logEvent({ kind: 'batch_item', i: i + 1, total: sites.length, site, step: 'génération vidéo' }); 868 logEvent({ kind: 'batch_item', i: i + 1, total: sites.length, site, step: 'génération vidéo' });
849 const insight = await pickInsight({ site }); 869 const insight = await pickInsight({ site });
850 await decorateForPrompt(insight); 870 await decorateForPrompt(insight);
871+ await enrichReel(insight);
851 const video = await makeReel(insight); 872 const video = await makeReel(insight);
852 const caption = await writeCaption(insight); 873 const caption = await writeCaption(insight);
853 logEvent({ kind: 'batch_ready', i: i + 1, total: sites.length, site, genre: insight.genre, step: '⏳ CLIQUE « Suivant » puis « Publier » sur l\'écran du nœud' }); 874 logEvent({ kind: 'batch_ready', i: i + 1, total: sites.length, site, genre: insight.genre, step: '⏳ CLIQUE « Suivant » puis « Publier » sur l\'écran du nœud' });
modified server/social/make_reel.py +360 −371
@@ -1,17 +1,29 @@
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2−"""Génère un Reel vertical animé (1080x1920) + musique à partir d'un insight KA. 2+"""Reel KA v4 — mini-clip vertical MULTI-SCÈNES (1080x1920) + musique procédurale.
3 3
4−HTML animé (CSS/JS) -> enregistrement vidéo Playwright (Chromium) -> ffmpeg 4+Le reel n'est plus une seule carte animée de 10 s : c'est une séquence de
5−(mp4 h264 + lit musical synthétisé). Sortie : chemin du mp4 sur stdout. 5+scènes (~20-32 s) qui couvre plusieurs facettes de l'insight :
6+ intro → stat géante (count-up) / donut % → tuiles chiffres → palmarès animé
7+ → photos réelles (Ken Burns) → « le saviez-vous » → outro CTA.
8+Les scènes disponibles dépendent des données (tiles/bars/list/product/fact).
9+
10+Musique : music_engine.py (6 styles, mélodie/progression/tonalité par graine)
11+— deux reels n'ont jamais la même musique. Style en rotation via `variant`.
12+
13+Pipeline : HTML animé -> Playwright (Chromium, enregistrement vidéo) -> ffmpeg.
14+Sortie : chemin du mp4 sur stdout. Env : KA_REEL_H (défaut 1920).
6 """ 15 """
7−import json, sys, os, base64, datetime, urllib.request, ssl, subprocess, glob, hashlib 16+import json, sys, os, re, base64, datetime, urllib.request, ssl, subprocess, glob, hashlib
17+
18+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19+import music_engine
8 20
9 CTX = ssl.create_default_context(); CTX.check_hostname=False; CTX.verify_mode=ssl.CERT_NONE 21 CTX = ssl.create_default_context(); CTX.check_hostname=False; CTX.verify_mode=ssl.CERT_NONE
10 FFMPEG = "/opt/homebrew/bin/ffmpeg" 22 FFMPEG = "/opt/homebrew/bin/ffmpeg"
11 MOIS=["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"] 23 MOIS=["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"]
12 FONT="'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif" 24 FONT="'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif"
13−DUR=10.0 # secondes 25+H=int(os.environ.get("KA_REEL_H","1920"))
14−HREEL=int(os.environ.get("KA_REEL_H","1920")) 26+STYLES_ORDER=["house","synthwave","lofi","funk","epic","trap"]
15 27
16 PALETTES={ 28 PALETTES={
17 "lou-ka":{"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09"}, 29 "lou-ka":{"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09"},
@@ -27,8 +39,8 @@ PALETTES={
27 "job-ka":{"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"}, 39 "job-ka":{"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"},
28 } 40 }
29 def pal(s): return PALETTES.get(s, PALETTES["vrai-prix"]) 41 def pal(s): return PALETTES.get(s, PALETTES["vrai-prix"])
42+
30 def data_uri(url): 43 def data_uri(url):
31− import base64
32 try: 44 try:
33 req=urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0 (Macintosh)"}) 45 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() 46 with urllib.request.urlopen(req, timeout=15, context=CTX) as r: raw=r.read()
@@ -37,395 +49,365 @@ def data_uri(url):
37 elif url.lower().endswith(".webp"): mime="image/webp" 49 elif url.lower().endswith(".webp"): mime="image/webp"
38 return "data:"+mime+";base64,"+base64.b64encode(raw).decode() 50 return "data:"+mime+";base64,"+base64.b64encode(raw).decode()
39 except Exception: return None 51 except Exception: return None
52+
40 def esc(s): return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;") 53 def esc(s): return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
41 def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}" 54 def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}"
42−import re
43 def parse_pct(h): 55 def parse_pct(h):
44 m=re.match(r'^\+?(\d+(?:[.,]\d+)?)\s*%$', str(h or "").strip()) 56 m=re.match(r'^\+?(\d+(?:[.,]\d+)?)\s*%$', str(h or "").strip())
45 return float(m.group(1).replace(",",".")) if m else None 57 return float(m.group(1).replace(",",".")) if m else None
46 NUMRE = re.compile(r'\d[\d\u00a0\u202f ,\.]*%?\$?') 58 NUMRE = re.compile(r'\d[\d\u00a0\u202f ,\.]*%?\$?')
47 59
48−def build_spotlight(ins): 60+# ---------- scènes ----------
49− H=HREEL; p=pal(ins["site"]); prod=ins.get("product") or {} 61+# chaque constructeur renvoie (html, css, dur, hook_js_ou_None)
50− img=prod.get("image_data",""); chips="".join('<span class="chip">'+esc(c)+'</span>' for c in (prod.get("meta") or [])) 62+
51− price=esc(prod.get("price","")); title=esc(prod.get("title","")); sub=esc(prod.get("subtitle","")) 63+def sc_intro(ins,p,i):
52− css = """
53− *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}
54− html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff;background:__G2__}
55− .photo{position:absolute;top:0;left:0;right:0;height:__PH__px;background:url('__IMG__') center/cover;animation:kb 10s ease-out forwards}
56− @keyframes kb{from{transform:scale(1.0)}to{transform:scale(1.12)}}
57− .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%)}
58− .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}
59− .eyebrow .dot{width:46px;height:6px;border-radius:3px;background:__A__}
60− .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}
61− .info{position:absolute;left:90px;right:90px;bottom:270px}
62− .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}
63− .psub{font-size:36px;font-weight:600;color:#ffffffc8;margin-top:14px;opacity:0;animation:fu .9s ease 1.25s forwards}
64− .chips{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px}
65− .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}
66− .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}
67− .cta{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fu .8s ease 2.2s forwards}
68− .cta .go{font-size:46px;font-weight:900;color:__A__}
69− .cta .brand{font-size:30px;font-weight:700;color:#ffffff88;letter-spacing:.12em;margin-top:6px}
70− .sig{position:absolute;left:90px;bottom:88px;font-size:26px;color:#ffffff66;font-weight:600;opacity:0;animation:fu .8s ease 2.5s forwards}
71− @keyframes fu{to{opacity:1;transform:translateY(0)}}
72− @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}
73− @keyframes popc{to{opacity:1;transform:scale(1)}}
74− """
75− css = (css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__G2__",p["g2"])
76− .replace("__PH__",str(int(H*0.62))).replace("__IMG__",img).replace("__A__",p["a"]))
77− body = (
78− '<div class="photo"></div><div class="scrim"></div>'
79− '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · À DÉCOUVRIR</div>'
80− '<div class="kabadge">KA</div>'
81− '<div class="info"><div class="ptitle">'+title+'</div>'
82− + (('<div class="psub">'+sub+'</div>') if sub else '')
83− + '<div class="chips">'+chips+'</div>'
84− + (('<div class="price">'+price+'</div>') if price else '')
85− + '</div>'
86− '<div class="cta"><div class="go">\U0001f449 www.'+ins["site"]+'.com</div><div class="brand">GROUPE ·KA</div></div>'
87− '<div class="sig">✦ Publié par l\'Agent KA</div>'
88− )
89− return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+"</body></html>"
90−
91−def build_list(ins):
92− H=HREEL; p=pal(ins["site"]); items=ins.get("list") or []
93− rows=""
94− for idx,it in enumerate(items[:5]):
95− img=it.get("image_data") or ""
96− thumb='<div class="lthumb" style="background-image:url(\'' + img + '\')"></div>' if img else '<div class="lthumb"></div>'
97− price=esc(it.get("price","") or ""); pr=('<div class="lp">' + price + '</div>') if price else ''
98− rows+=('<div class="lrow" style="animation-delay:' + str(round(1.0+idx*0.2,2)) + 's">' + thumb
99− + '<div class="lmid"><div class="lt">' + esc(it.get("title","")) + '</div>'
100− + '<div class="ls">' + esc(it.get("subtitle","") or "") + '</div></div>' + pr + '</div>')
101− heading=esc(ins.get("list_title","") or ins.get("headline_label","") or "À la une")
102 css=""" 64 css="""
103− *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased} 65+ #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center}
104− html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff} 66+ #S .kick{font-size:30px;font-weight:800;letter-spacing:.22em;color:__A__;text-transform:uppercase;opacity:0}
105− .stage{position:relative;width:1080px;height:__H__px;overflow:hidden; 67+ .scene.on #S_ .kick{animation:fu .6s ease .15s forwards}
106− background:radial-gradient(1300px 900px at 78% -5%, __A__55,transparent 55%), 68+ #S .big{font-size:150px;font-weight:900;letter-spacing:-.03em;line-height:1;margin-top:26px;opacity:0;transform:scale(.8)}
107− radial-gradient(1100px 1000px at -12% 105%, __B__3a,transparent 55%), 69+ .scene.on #S_ .big{animation:zoomin .8s cubic-bezier(.2,1.2,.3,1) .35s forwards}
108− linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 14s ease-in-out infinite alternate} 70+ #S .bar{width:0;height:10px;border-radius:6px;background:linear-gradient(90deg,__A__,__B__);margin-top:34px}
109− @keyframes drift{0%{background-position:0% 0%}100%{background-position:100% 100%}} 71+ .scene.on #S_ .bar{animation:grow .7s ease .8s forwards}
110− .pad{position:absolute;inset:0;padding:120px 80px 210px;display:flex;flex-direction:column} 72+ #S .tag{font-size:42px;font-weight:600;color:#ffffffc8;margin-top:30px;max-width:880px;line-height:1.2;opacity:0}
111− .eyebrow{display:flex;align-items:center;gap:16px;font-weight:800;letter-spacing:.16em;font-size:27px;text-transform:uppercase;color:__A__;opacity:0;animation:fu .8s ease .15s forwards} 73+ .scene.on #S_ .tag{animation:fu .7s ease 1.05s forwards}
112− .eyebrow .dot{width:52px;height:6px;border-radius:3px;background:__A__} 74+ @keyframes zoomin{to{opacity:1;transform:scale(1)}}
113− .kabadge{position:absolute;top:118px;right:80px;background:__A__;color:#0d0d0d;font-weight:900;font-size:52px;padding:10px 24px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;animation:pop .7s cubic-bezier(.2,1.4,.4,1) .35s forwards} 75+ @keyframes grow{to{width:280px}}
114− .site{font-size:82px;font-weight:900;letter-spacing:-.02em;margin-top:28px;line-height:1;opacity:0;animation:fu .8s ease .45s forwards}
115− .ltitle{font-size:46px;font-weight:800;margin-top:10px;margin-bottom:26px;max-width:920px;line-height:1.1;opacity:0;animation:fu .8s ease .65s forwards}
116− .lwrap{display:flex;flex-direction:column;gap:18px}
117− .lrow{display:flex;align-items:center;gap:22px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:24px;padding:18px 20px;opacity:0;transform:translateY(30px);animation:fu .7s ease forwards;backdrop-filter:blur(6px)}
118− .lthumb{width:150px;height:150px;flex:0 0 150px;border-radius:18px;background-color:#222;background-size:cover;background-position:center}
119− .lmid{flex:1;min-width:0}
120− .lt{font-size:40px;font-weight:800;line-height:1.12;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
121− .ls{font-size:30px;font-weight:600;color:#ffffffa8;margin-top:8px}
122− .lp{font-size:46px;font-weight:900;color:__A__;white-space:nowrap;text-align:right}
123− .cta{position:absolute;left:80px;right:80px;bottom:150px;opacity:0;animation:fu .8s ease 2.4s forwards}
124− .cta .go{font-size:46px;font-weight:900;color:__A__}
125− .cta .brand{font-size:30px;font-weight:700;color:#ffffff8c;letter-spacing:.12em;margin-top:6px}
126− .sig{position:absolute;left:80px;bottom:92px;font-size:26px;color:#ffffff6a;font-weight:600;opacity:0;animation:fu .8s ease 2.7s forwards}
127− .prog{position:absolute;left:0;bottom:0;height:8px;background:__A__;width:0;animation:prog __DUR__s linear .2s forwards;box-shadow:0 0 20px __A__}
128− @keyframes prog{to{width:100%}}
129− @keyframes fu{to{opacity:1;transform:translateY(0)}}
130− @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}
131 """ 76 """
132− css=(css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__A__",p["a"]) 77+ html=('<div class="mid"><div class="kick">Le pouls du jour</div>'
133− .replace("__B__",p["b"]).replace("__G1__",p["g1"]).replace("__G2__",p["g2"]).replace("__DUR__",str(DUR))) 78+ '<div class="big">'+esc(ins["label"])+'</div><div class="bar"></div>'
134− body=('<div class="stage"><div class="pad">' 79+ + (('<div class="tag">'+esc(ins.get("tagline",""))+'</div>') if ins.get("tagline") else '')
135− + '<div class="eyebrow"><span class="dot"></span>' + esc(ins["label"].upper()) + ' · ' + today().upper() + '</div>' 80+ + '</div>')
136− + '<div class="kabadge">KA</div>' 81+ return html, css, 2.8, None
137− + '<div class="site">' + esc(ins["label"]) + '</div>' 82+
138− + '<div class="ltitle">' + heading + '</div>' 83+def _num_parts(headline):
139− + '<div class="lwrap">' + rows + '</div>' 84+ """-> (int_digits, dec, suffix) pour le count-up ; None si non numérique."""
140− + '<div class="cta"><div class="go">\U0001f449 www.' + ins["site"] + '.com</div><div class="brand">GROUPE ·KA</div></div>' 85+ hl=str(headline).strip()
141− + '<div class="sig">✦ Rédigé et publié par l\'Agent KA</div><div class="prog"></div>' 86+ m=re.match(r'^\+?([\d\s  ]+)(?:[.,](\d+))?\s*(.*)$', hl)
142− + '</div></div>') 87+ if not m: return None
143− return "<!doctype html><html><head><meta charset=\"utf-8\"><style>" + css + "</style></head><body>" + body + "</body></html>" 88+ digits=re.sub(r'\D','',m.group(1))
144− 89+ if not digits: return None
145−REEL_SHELL_CSS = """ 90+ return digits, (m.group(2) or ""), m.group(3) or ""
146− *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased} 91+
147− html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff} 92+def sc_stat(ins,p,i):
148− .stage{position:relative;width:1080px;height:__H__px;overflow:hidden; 93+ parts=_num_parts(ins["headline"]); hl=str(ins["headline"])
149− background:radial-gradient(1300px 900px at 78% -5%, __A__55,transparent 55%), 94+ L=len(hl); fs=170 if L>=9 else (215 if L>=7 else 260)
150− radial-gradient(1100px 1000px at -12% 105%, __B__3a,transparent 55%), 95+ css="""
151− linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 14s ease-in-out infinite alternate} 96+ #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center}
152− @keyframes drift{0%{background-position:0% 0%}100%{background-position:100% 100%}} 97+ #S .lab0{font-size:34px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:__A__;opacity:0}
153− .pad{position:absolute;inset:0;padding:120px 90px 250px;display:flex;flex-direction:column} 98+ .scene.on #S_ .lab0{animation:fu .5s ease .1s forwards}
154− .eyebrow{display:flex;align-items:center;gap:16px;font-weight:800;letter-spacing:.16em;font-size:27px;text-transform:uppercase;color:__A__;opacity:0;animation:fu .8s ease .15s forwards;padding-right:230px;line-height:1.3} 99+ #S .num{font-size:__FS__px;font-weight:900;line-height:.95;letter-spacing:-.03em;white-space:nowrap;margin-top:24px;
155− .eyebrow .dot{width:52px;height:6px;border-radius:3px;background:__A__;flex:0 0 52px} 100+ background:linear-gradient(180deg,#fff,__A__ 135%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;
156− .kabadge{position:absolute;top:118px;right:90px;background:__A__;color:#0d0d0d;font-weight:900;font-size:52px;padding:10px 24px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;animation:pop .7s cubic-bezier(.2,1.4,.4,1) .35s forwards;box-shadow:0 16px 46px __A__66} 101+ filter:drop-shadow(0 18px 55px __A__55);opacity:0;transform:translateY(36px)}
157− .site{font-size:88px;font-weight:900;letter-spacing:-.02em;margin-top:30px;line-height:1;opacity:0;animation:fu .8s ease .45s forwards} 102+ .scene.on #S_ .num{animation:fu .7s cubic-bezier(.2,1,.3,1) .3s forwards}
158− .cta{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fu .8s ease 2.5s forwards} 103+ #S .lab{font-size:56px;font-weight:700;margin-top:30px;max-width:900px;line-height:1.14;opacity:0}
159− .cta .go{font-size:46px;font-weight:900;color:__A__} 104+ .scene.on #S_ .lab{animation:fu .7s ease .8s forwards}
160− .cta .brand{font-size:30px;font-weight:700;color:#ffffff8c;letter-spacing:.12em;margin-top:6px} 105+ """.replace("__FS__",str(fs))
161− .sig{position:absolute;left:90px;bottom:92px;font-size:26px;color:#ffffff6a;font-weight:600;opacity:0;animation:fu .8s ease 2.8s forwards} 106+ hook=None
162− .prog{position:absolute;left:0;bottom:0;height:8px;background:__A__;width:0;animation:prog __DUR__s linear .2s forwards;box-shadow:0 0 20px __A__} 107+ if parts and len(parts[0])>=3 and not parts[1]:
163− @keyframes prog{to{width:100%}} 108+ hook="cuInt('S_num',"+parts[0]+","+json.dumps(" "+parts[2] if parts[2] else "")+",1500,400);"
164− @keyframes fu{to{opacity:1;transform:translateY(0)}} 109+ html=('<div class="mid"><div class="lab0">'+esc(ins["label"])+' · en direct</div>'
165− @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}} 110+ '<div class="num" id="S_num">'+esc(hl)+'</div>'
166− .vign{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% 42%, transparent 55%, rgba(0,0,0,.55) 100%)} 111+ '<div class="lab">'+esc(ins["headline_label"])+'</div></div>')
167−""" 112+ return html, css, 4.2, hook
168−def shell_css(p): 113+
169− return (REEL_SHELL_CSS.replace("__H__",str(HREEL)).replace("__FONT__",FONT) 114+def sc_donut(ins,p,i):
170− .replace("__A__",p["a"]).replace("__B__",p["b"]).replace("__G1__",p["g1"]) 115+ pct=parse_pct(ins["headline"]) or 0.0
171− .replace("__G2__",p["g2"]).replace("__DUR__",str(DUR)))
172−def shell_tail(ins):
173− return ('<div class="cta"><div class="go">\U0001f449 www.'+ins["site"]+'.com</div><div class="brand">GROUPE ·KA</div></div>'
174− '<div class="sig">✦ Rédigé et publié par l\'Agent KA</div><div class="prog"></div>')
175−
176−def build_donut(ins):
177− p=pal(ins["site"]); pct=parse_pct(ins["headline"]) or 0.0
178 C=2*3.14159*300; off=C*(1-min(100.0,pct)/100.0) 116 C=2*3.14159*300; off=C*(1-min(100.0,pct)/100.0)
179− tiles=(ins.get("tiles") or [])[:3] 117+ dec=1 if re.search(r'[.,]\d',str(ins["headline"])) else 0
180− tiles_html="".join('<div class="tile t'+str(i)+'"><div class="tv">'+esc(v)+'</div><div class="tl">'+esc(l)+'</div></div>' for i,(v,l) in enumerate(tiles)) 118+ css="""
181− dec=1 if (',' in str(ins["headline"]) or '.' in str(ins["headline"])) else 0 119+ #S .mid{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center}
182− svg=('<svg width="760" height="760" viewBox="0 0 760 760">' 120+ #S .dw{position:relative;width:740px;height:740px;opacity:0;transform:scale(.88)}
183− '<circle cx="380" cy="380" r="300" fill="none" stroke="rgba(255,255,255,.10)" stroke-width="84"/>' 121+ .scene.on #S_ .dw{animation:zoomin .7s cubic-bezier(.2,1.2,.4,1) .15s forwards}
184− '<circle class="arc" cx="380" cy="380" r="300" fill="none" stroke="url(#gd)" stroke-width="84" stroke-linecap="round" transform="rotate(-90 380 380)"/>' 122+ #S .arc{stroke-dasharray:__C__;stroke-dashoffset:__C__}
185− '<defs><linearGradient id="gd" x1="0" y1="0" x2="1" y2="1">' 123+ .scene.on #S_ .arc{animation:draw 1.7s cubic-bezier(.3,1,.4,1) .5s forwards}
186− '<stop offset="0" stop-color="'+p["a"]+'"/><stop offset="1" stop-color="'+p["b"]+'"/></linearGradient></defs></svg>')
187− css=shell_css(p)+"""
188− .mid{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:0}
189− .dwrap{position:relative;width:760px;height:760px;opacity:0;transform:scale(.9);animation:popc .8s cubic-bezier(.2,1.2,.4,1) .8s forwards}
190− @keyframes popc{to{opacity:1;transform:scale(1)}}
191− .arc{stroke-dasharray:__C__;stroke-dashoffset:__C__;animation:draw 1.8s cubic-bezier(.3,1,.4,1) 1.1s forwards}
192 @keyframes draw{to{stroke-dashoffset:__OFF__}} 124 @keyframes draw{to{stroke-dashoffset:__OFF__}}
193− .dperc{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:150px;font-weight:900;color:__A__;letter-spacing:-.02em;filter:drop-shadow(0 10px 34px __A__55)} 125+ @keyframes zoomin{to{opacity:1;transform:scale(1)}}
194− .dlab{font-size:48px;font-weight:700;line-height:1.15;text-align:center;max-width:900px;margin-top:36px;opacity:0;animation:fu .8s ease 1.9s forwards} 126+ #S .dp{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:145px;font-weight:900;color:__A__;letter-spacing:-.02em;filter:drop-shadow(0 10px 34px __A__55)}
195− .tiles{display:flex;gap:20px;margin-top:52px} 127+ #S .dl{font-size:48px;font-weight:700;line-height:1.16;text-align:center;max-width:900px;margin-top:34px;opacity:0}
196− .tile{flex:1 1 0;min-width:0;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:26px;padding:24px 20px;opacity:0;transform:translateY(30px);animation:fu .7s ease forwards;backdrop-filter:blur(6px);text-align:center} 128+ .scene.on #S_ .dl{animation:fu .7s ease 1.2s forwards}
197− .t0{animation-delay:2.2s}.t1{animation-delay:2.35s}.t2{animation-delay:2.5s} 129+ """.replace("__C__",str(round(C,1))).replace("__OFF__",str(round(off,1)))
198− .tv{font-size:50px;font-weight:900;color:__A__;white-space:nowrap;line-height:1} 130+ svg=('<svg width="740" height="740" viewBox="0 0 760 760">'
199− .tl{font-size:26px;font-weight:600;color:#ffffffa8;margin-top:8px;line-height:1.15} 131+ '<circle cx="380" cy="380" r="300" fill="none" stroke="rgba(255,255,255,.10)" stroke-width="82"/>'
200− """.replace("__C__",str(round(C,1))).replace("__OFF__",str(round(off,1))).replace("__A__",p["a"]) 132+ '<circle class="arc" cx="380" cy="380" r="300" fill="none" stroke="url(#gd'+str(i)+')" stroke-width="82" stroke-linecap="round" transform="rotate(-90 380 380)"/>'
201− body=('<div class="stage"><div class="vign"></div><div class="pad">' 133+ '<defs><linearGradient id="gd'+str(i)+'" x1="0" y1="0" x2="1" y2="1">'
202− '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · '+today().upper()+'</div>' 134+ '<stop offset="0" stop-color="__A__"/><stop offset="1" stop-color="__B__"/></linearGradient></defs></svg>')
203− '<div class="kabadge">KA</div>' 135+ html=('<div class="mid"><div class="dw">'+svg+'<div class="dp" id="S_dp">0 %</div></div>'
204− '<div class="site">'+esc(ins["label"])+'</div>' 136+ '<div class="dl">'+esc(ins["headline_label"])+'</div></div>')
205− '<div class="mid"><div class="dwrap">'+svg+'<div class="dperc" id="dperc" data-pct="'+str(pct)+'" data-dec="'+str(dec)+'">0 %</div></div>' 137+ hook="cuPct('S_dp',"+str(pct)+","+str(dec)+",1700,500);"
206− '<div class="dlab">'+esc(ins["headline_label"])+'</div>' 138+ return html, css, 4.4, hook
207− +(('<div class="tiles">'+tiles_html+'</div>') if tiles else '')+'</div>' 139+
208− +shell_tail(ins)+'</div></div>') 140+def sc_tiles(ins,p,i):
209− script="""<script>(function(){ 141+ tiles=(ins.get("tiles") or [])[:3]
210− var el=document.getElementById('dperc'); var pct=parseFloat(el.getAttribute('data-pct')); 142+ rows=""
211− var dec=parseInt(el.getAttribute('data-dec'),10); var start=null, dur=1800, begin=1100; 143+ for k,(v,l) in enumerate(tiles):
212− function fmt(n){return n.toFixed(dec).replace('.',',')+' %'} 144+ rows+=('<div class="trow" style="transition-delay:.0s"><div class="tv" data-d="'+str(k)+'">'+esc(v)+'</div>'
213− function step(ts){ if(!start)start=ts; var t=ts-start; 145+ '<div class="tl">'+esc(l)+'</div></div>')
214− if(t<begin){requestAnimationFrame(step);return;} 146+ css="""
215− var k=Math.min(1,(t-begin)/dur); var e=1-Math.pow(1-k,3); 147+ #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center;gap:26px}
216− el.textContent=fmt(pct*e); 148+ #S .h{font-size:40px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:__A__;opacity:0;margin-bottom:8px}
217− if(k<1)requestAnimationFrame(step);} 149+ .scene.on #S_ .h{animation:fu .5s ease .1s forwards}
218− requestAnimationFrame(step); 150+ #S .trow{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:30px;
219− })();</script>""" 151+ padding:40px 44px;backdrop-filter:blur(6px);opacity:0;transform:translateX(-60px)}
220− return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+script+"</body></html>" 152+ .scene.on #S_ .trow{animation:slidein .6s cubic-bezier(.2,.9,.3,1) forwards}
221− 153+ .scene.on #S_ .trow:nth-child(2){animation-delay:.25s}
222−def build_quote(ins): 154+ .scene.on #S_ .trow:nth-child(3){animation-delay:.5s}
223− p=pal(ins["site"]) 155+ .scene.on #S_ .trow:nth-child(4){animation-delay:.75s}
224− fact=str(ins.get("fact") or (str(ins["headline"])+" "+ins.get("headline_label",""))) 156+ @keyframes slidein{to{opacity:1;transform:none}}
157+ #S .tv{font-size:82px;font-weight:900;color:__A__;line-height:1;white-space:nowrap}
158+ #S .tl{font-size:34px;font-weight:600;color:#ffffffb0;margin-top:10px}
159+ """
160+ html='<div class="mid"><div class="h">En chiffres</div>'+rows+'</div>'
161+ return html, css, 3.6, None
162+
163+def sc_ranking(ins,p,i):
164+ bars=(ins.get("bars") or [])[:4]
165+ maxv=max([b[1] for b in bars] or [1])
166+ rows=""
167+ for k,b in enumerate(bars):
168+ w=max(10,int(100*b[1]/maxv))
169+ rows+=('<div class="rrow"><div class="rl"><span>'+esc(b[0])+'</span><span class="rv">'+esc(b[2])+'</span></div>'
170+ '<div class="tr2"><div class="fl" style="--w:'+str(w)+'%;animation-delay:'+str(round(0.35+k*0.28,2))+'s"></div></div></div>')
171+ css="""
172+ #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center;gap:40px}
173+ #S .h{font-size:46px;font-weight:800;line-height:1.15;max-width:900px;opacity:0;margin-bottom:6px}
174+ .scene.on #S_ .h{animation:fu .5s ease .1s forwards}
175+ #S .rrow{opacity:0}
176+ .scene.on #S_ .rrow{animation:fu .5s ease forwards}
177+ .scene.on #S_ .rrow:nth-child(2){animation-delay:.2s}
178+ .scene.on #S_ .rrow:nth-child(3){animation-delay:.4s}
179+ .scene.on #S_ .rrow:nth-child(4){animation-delay:.6s}
180+ .scene.on #S_ .rrow:nth-child(5){animation-delay:.8s}
181+ #S .rl{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:14px;font-size:40px;font-weight:700}
182+ #S .rv{font-weight:900;color:__A__;font-size:44px}
183+ #S .tr2{height:26px;border-radius:14px;background:rgba(255,255,255,.09);overflow:hidden}
184+ #S .fl{height:100%;width:0;border-radius:14px;background:linear-gradient(90deg,__A__,__B__);box-shadow:0 4px 18px __A__55}
185+ .scene.on #S_ .fl{animation:growb 1s cubic-bezier(.2,.9,.3,1) forwards}
186+ @keyframes growb{to{width:var(--w)}}
187+ """
188+ html='<div class="mid"><div class="h">'+esc(ins["headline_label"])+'</div>'+rows+'</div>'
189+ return html, css, 4.6, None
190+
191+def sc_photo(item,p,i,idx,total):
192+ img=item.get("image_data") or ""
193+ kb = "kbA" if idx%2==0 else "kbB"
194+ css="""
195+ #S{padding:0}
196+ #S .ph{position:absolute;inset:0;background:url('__IMG__') center/cover}
197+ .scene.on #S_ .ph{animation:__KB__ 3.4s ease-out forwards}
198+ @keyframes kbA{from{transform:scale(1)}to{transform:scale(1.14) translate(-1.5%,-1%)}}
199+ @keyframes kbB{from{transform:scale(1.14)}to{transform:scale(1) translate(1%,1%)}}
200+ #S .sc2{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,.25) 0%,transparent 30%,transparent 48%,__G2__e6 78%,__G2__ 92%)}
201+ #S .idx{position:absolute;top:200px;left:90px;background:__A__;color:#0d0d0d;font-weight:900;font-size:40px;
202+ padding:10px 26px;border-radius:16px;transform:rotate(-3deg) scale(.5);opacity:0}
203+ .scene.on #S_ .idx{animation:pop2 .5s cubic-bezier(.2,1.4,.4,1) .25s forwards}
204+ @keyframes pop2{to{opacity:1;transform:rotate(-3deg) scale(1)}}
205+ #S .inf{position:absolute;left:90px;right:90px;bottom:300px}
206+ #S .pt{font-size:64px;font-weight:900;line-height:1.05;letter-spacing:-.01em;text-shadow:0 4px 24px #000c;opacity:0;transform:translateY(30px)}
207+ .scene.on #S_ .pt{animation:fu .6s ease .35s forwards}
208+ #S .ps{font-size:34px;font-weight:600;color:#ffffffd0;margin-top:14px;opacity:0}
209+ .scene.on #S_ .ps{animation:fu .6s ease .55s forwards}
210+ #S .pp{font-size:84px;font-weight:900;color:__A__;margin-top:20px;letter-spacing:-.02em;filter:drop-shadow(0 8px 26px __A__66);opacity:0;transform:scale(.9)}
211+ .scene.on #S_ .pp{animation:popc2 .6s cubic-bezier(.2,1.3,.4,1) .75s forwards}
212+ @keyframes popc2{to{opacity:1;transform:scale(1)}}
213+ """.replace("__IMG__",img).replace("__KB__",kb)
214+ html=('<div class="ph"></div><div class="sc2"></div>'
215+ '<div class="idx">'+str(idx+1)+' / '+str(total)+'</div>'
216+ '<div class="inf"><div class="pt">'+esc(item.get("title",""))+'</div>'
217+ + (('<div class="ps">'+esc(item.get("subtitle",""))+'</div>') if item.get("subtitle") else '')
218+ + (('<div class="pp">'+esc(item.get("price",""))+'</div>') if item.get("price") else '')
219+ + '</div>')
220+ return html, css, 3.0, None
221+
222+def sc_quote(ins,p,i):
223+ fact=str(ins.get("fact") or "")
225 words=fact.split() 224 words=fact.split()
226− delay=min(0.13, 5.5/max(1,len(words))) 225+ delay=min(0.11, 4.6/max(1,len(words)))
227 spans="" 226 spans=""
228− for i,w in enumerate(words): 227+ for k,w in enumerate(words):
229 cls="w hl" if NUMRE.search(w) else "w" 228 cls="w hl" if NUMRE.search(w) else "w"
230− spans+='<span class="'+cls+'" style="animation-delay:'+str(round(0.9+i*delay,2))+'s">'+esc(w)+'</span> ' 229+ spans+='<span class="'+cls+'" style="animation-delay:'+str(round(0.35+k*delay,2))+'s">'+esc(w)+'</span> '
231− L=len(fact); fs=62 if L<170 else (54 if L<240 else 47) 230+ L=len(fact); fs=60 if L<170 else (53 if L<240 else 46)
232− css=shell_css(p)+""" 231+ dur=min(6.0, 1.6+len(words)*delay+2.2)
233− .mid{flex:1;display:flex;flex-direction:column;justify-content:center;min-height:0} 232+ css="""
234− .qmark{font-size:280px;line-height:.5;color:__A__;font-weight:900;font-family:Georgia,serif;height:150px;opacity:0;animation:fu .7s ease .6s forwards} 233+ #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center}
235− .qtext{font-size:__FS__px;font-weight:800;line-height:1.35;letter-spacing:-.01em;max-width:920px} 234+ #S .qm{font-size:260px;line-height:.5;color:__A__;font-weight:900;font-family:Georgia,serif;height:140px;opacity:0}
236− .w{display:inline-block;opacity:0;transform:translateY(26px);animation:fu .5s ease forwards} 235+ .scene.on #S_ .qm{animation:fu .5s ease .1s forwards}
237− .hl{color:__A__;filter:drop-shadow(0 6px 20px __A__44)} 236+ #S .qt{font-size:__FS__px;font-weight:800;line-height:1.35;letter-spacing:-.01em;max-width:920px}
238− """.replace("__A__",p["a"]).replace("__FS__",str(fs)) 237+ #S .w{display:inline-block;opacity:0;transform:translateY(24px)}
239− body=('<div class="stage"><div class="vign"></div><div class="pad">' 238+ .scene.on #S_ .w{animation:fu .45s ease forwards}
240− '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · LE SAVIEZ-VOUS ? · '+today().upper()+'</div>' 239+ #S .hl{color:__A__;filter:drop-shadow(0 6px 20px __A__44)}
241− '<div class="kabadge">KA</div>' 240+ """.replace("__FS__",str(fs))
242− '<div class="site">'+esc(ins["label"])+'</div>' 241+ html='<div class="mid"><div class="qm">“</div><div class="qt">'+spans+'</div></div>'
243− '<div class="mid"><div class="qmark">“</div><div class="qtext">'+spans+'</div></div>' 242+ return html, css, round(dur,1), None
244− +shell_tail(ins)+'</div></div>') 243+
245− return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+"</body></html>" 244+def sc_outro(ins,p,i):
245+ css="""
246+ #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
247+ #S .kb2{background:__A__;color:#0d0d0d;font-weight:900;font-size:96px;padding:18px 46px;border-radius:32px;
248+ transform:rotate(-4deg) scale(.4);opacity:0;box-shadow:0 20px 60px __A__66}
249+ .scene.on #S_ .kb2{animation:pop3 .7s cubic-bezier(.2,1.4,.4,1) .15s forwards}
250+ @keyframes pop3{to{opacity:1;transform:rotate(-4deg) scale(1)}}
251+ #S .go{font-size:62px;font-weight:900;color:__A__;margin-top:60px;opacity:0}
252+ .scene.on #S_ .go{animation:fu .6s ease .7s forwards}
253+ #S .br{font-size:34px;font-weight:700;color:#ffffff8c;letter-spacing:.14em;margin-top:16px;opacity:0}
254+ .scene.on #S_ .br{animation:fu .6s ease .95s forwards}
255+ #S .sg{font-size:26px;color:#ffffff6a;font-weight:600;margin-top:56px;opacity:0}
256+ .scene.on #S_ .sg{animation:fu .6s ease 1.2s forwards}
257+ """
258+ html=('<div class="mid"><div class="kb2">KA</div>'
259+ '<div class="go">\U0001f449 www.'+ins["site"]+'.com</div>'
260+ '<div class="br">GROUPE ·KA</div>'
261+ '<div class="sg">✦ Rédigé et publié par l\'Agent KA</div></div>')
262+ return html, css, 3.2, None
246 263
264+# ---------- plan des scènes ----------
265+def plan_scenes(ins,p):
266+ photos=[it for it in (ins.get("list") or []) if it.get("image_data")]
267+ if not photos and (ins.get("product") or {}).get("image_data"):
268+ pr=ins["product"]
269+ photos=[{"title":pr.get("title",""),"subtitle":pr.get("subtitle",""),
270+ "price":pr.get("price",""),"image_data":pr["image_data"]}]
271+ has_pct=parse_pct(ins.get("headline")) is not None
272+ genre=ins.get("genre","hero")
273+ photo_led=genre in ("spotlight","list","grid") and photos
274+
275+ def stat_block():
276+ out=[]
277+ if has_pct: out.append(("donut",sc_donut))
278+ else: out.append(("stat",sc_stat))
279+ if ins.get("tiles"): out.append(("tiles",sc_tiles))
280+ if ins.get("bars") and len(ins["bars"])>=3: out.append(("ranking",sc_ranking))
281+ return out
282+ has_quote=len(str(ins.get("fact") or ""))>=40
283+
284+ # budget photos AVANT construction (les numéros « k / n » restent exacts) :
285+ # base ≈ intro 2.8 + stat/donut 4.3 + tuiles 3.6 + palmarès 4.6 + quote 4.5 + outro 3.2
286+ base_est=2.8+4.4+ (3.6 if ins.get("tiles") else 0) + (4.6 if (ins.get("bars") and len(ins["bars"])>=3) else 0) \
287+ + (4.5 if has_quote else 0) + 3.2
288+ maxp=4 if photo_led else 3
289+ nphotos=max(0,min(len(photos),maxp,int((34.0-base_est)//3.0)))
290+ tot_p=nphotos
291+ photo_scenes=[("photo%d"%k,(lambda it,k2: (lambda ins2,p2,i: sc_photo(it,p2,i,k2,tot_p)))(it,k))
292+ for k,it in enumerate(photos[:nphotos])]
293+
294+ builders=[("intro",sc_intro)]
295+ if photo_led:
296+ builders+=photo_scenes+stat_block()
297+ else:
298+ builders+=stat_block()+photo_scenes
299+ if has_quote: builders.append(("quote",sc_quote))
300+ builders.append(("outro",sc_outro))
301+
302+ scenes=[]
303+ for i,(name,fn) in enumerate(builders):
304+ html,css,dur,hook=fn(ins,p,i)
305+ scenes.append(dict(name=name,html=html,css=css,dur=dur,hook=hook,i=i))
306+ return scenes
307+
308+# ---------- assemblage HTML ----------
247 def build_html(ins): 309 def build_html(ins):
248− g=ins.get('genre') 310+ p=pal(ins["site"])
249− if g in ('list','grid') and ins.get('list'): 311+ scenes=plan_scenes(ins,p)
250− return build_list(ins) 312+ tot=round(sum(s["dur"] for s in scenes),2)
251− if g=='spotlight' and (ins.get('product') or {}).get('image_data'): 313+ shell="""
252− return build_spotlight(ins)
253− if g=='donut' and parse_pct(ins.get('headline')) is not None:
254− return build_donut(ins)
255− if g=='quote' and len(str(ins.get('fact') or ''))>=40:
256− return build_quote(ins)
257− H=HREEL; p=pal(ins["site"])
258− tiles=ins.get("tiles") or []
259− hl=str(ins["headline"]); L=len(hl)
260− numsize = 150 if L>=9 else (185 if L>=7 else (235 if L>=5 else 300))
261− tiles_html="".join('<div class="tile t'+str(i)+'"><div class="tv" data-fit>'+esc(v)+'</div><div class="tl">'+esc(l)+'</div></div>' for i,(v,l) in enumerate(tiles))
262− css = """
263 *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased} 314 *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}
264− html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff} 315+ html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff;background:__G2__}
265 .stage{position:relative;width:1080px;height:__H__px;overflow:hidden; 316 .stage{position:relative;width:1080px;height:__H__px;overflow:hidden;
266− background:radial-gradient(1300px 900px at 78% -5%, __A__55,transparent 55%), 317+ background:radial-gradient(1300px 900px at 78% -5%, __A__50,transparent 55%),
267− radial-gradient(1100px 1000px at -12% 105%, __B__3a,transparent 55%), 318+ radial-gradient(1100px 1000px at -12% 105%, __B__36,transparent 55%),
268− linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 14s ease-in-out infinite alternate} 319+ linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 18s ease-in-out infinite alternate}
269 @keyframes drift{0%{background-position:0% 0%}100%{background-position:100% 100%}} 320 @keyframes drift{0%{background-position:0% 0%}100%{background-position:100% 100%}}
270− .mesh{position:absolute;inset:-20%;background:radial-gradient(closest-side, __A__22, transparent 70%); 321+ .vign{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% 45%, transparent 55%, rgba(0,0,0,.5) 100%);z-index:3}
271− filter:blur(30px);animation:float 16s ease-in-out infinite alternate;opacity:.7} 322+ .eyeb{position:absolute;top:104px;left:90px;right:250px;display:flex;align-items:center;gap:14px;font-weight:800;
272− @keyframes float{0%{transform:translate(-6%, -4%) scale(1)}100%{transform:translate(8%, 6%) scale(1.15)}} 323+ letter-spacing:.16em;font-size:25px;text-transform:uppercase;color:__A__;z-index:8;opacity:0;animation:fu .7s ease .2s forwards}
273− .pad{position:absolute;inset:0;padding:120px 90px 250px;display:flex;flex-direction:column} 324+ .eyeb .dot{width:46px;height:6px;border-radius:3px;background:__A__;flex:0 0 46px}
274− .eyebrow{display:flex;align-items:center;gap:16px;font-weight:800;letter-spacing:.16em;font-size:27px; 325+ .kabadge{position:absolute;top:92px;right:90px;background:__A__;color:#0d0d0d;font-weight:900;font-size:48px;
275− text-transform:uppercase;color:__A__;opacity:0;animation:fadeUp .8s ease .15s forwards} 326+ padding:10px 24px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;z-index:8;
276− .eyebrow .dot{width:52px;height:6px;border-radius:3px;background:__A__}
277− .kabadge{position:absolute;top:118px;right:90px;background:__A__;color:#0d0d0d;font-weight:900;
278− font-size:54px;padding:10px 26px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;
279 animation:pop .7s cubic-bezier(.2,1.4,.4,1) .35s forwards;box-shadow:0 16px 46px __A__66} 327 animation:pop .7s cubic-bezier(.2,1.4,.4,1) .35s forwards;box-shadow:0 16px 46px __A__66}
280− .site{font-size:112px;font-weight:900;letter-spacing:-.02em;margin-top:34px;line-height:1;opacity:0;animation:fadeUp .8s ease .45s forwards} 328+ .wm{position:absolute;left:90px;bottom:96px;font-size:27px;font-weight:800;color:#ffffff9a;z-index:8}
281− .tag{font-size:38px;font-weight:600;color:#ffffffb0;margin-top:12px;opacity:0;animation:fadeUp .8s ease .65s forwards} 329+ .wm b{color:__A__}
282− .mid{flex:1;display:flex;flex-direction:column;justify-content:center;min-height:0} 330+ .prog{position:absolute;left:0;bottom:0;height:9px;background:linear-gradient(90deg,__A__,__B__);width:0;z-index:9;
283− .numwrap{position:relative;display:inline-block;align-self:flex-start;opacity:0;transform:translateY(40px) scale(.92);animation:heroIn .95s cubic-bezier(.2,1,.3,1) .95s forwards} 331+ animation:prog __TOT__s linear .1s forwards;box-shadow:0 0 20px __A__}
284− .num{font-size:__NUM__px;font-weight:900;line-height:.92;letter-spacing:-.03em;white-space:nowrap;
285− background:linear-gradient(180deg,#fff,__A__ 135%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;
286− filter:drop-shadow(0 18px 55px __A__55)}
287− .numwrap::after{content:"";position:absolute;top:0;left:-60%;width:45%;height:100%;
288− background:linear-gradient(100deg,transparent,rgba(255,255,255,.55),transparent);transform:skewX(-18deg);
289− animation:shine 2.6s ease-in-out 1.6s infinite}
290− @keyframes shine{0%{left:-60%}55%{left:130%}100%{left:130%}}
291− .lab{font-size:54px;font-weight:700;margin-top:26px;max-width:920px;line-height:1.12;opacity:0;animation:fadeUp .8s ease 1.35s forwards}
292− .tiles{display:flex;gap:20px;margin-top:56px}
293− .tile{flex:1 1 0;min-width:0;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);
294− border-radius:26px;padding:28px 22px;opacity:0;transform:translateY(30px);animation:fadeUp .7s ease forwards;backdrop-filter:blur(6px)}
295− .t0{animation-delay:1.7s}.t1{animation-delay:1.85s}.t2{animation-delay:2.0s}.t3{animation-delay:2.15s}
296− .tv{font-size:58px;font-weight:900;color:__A__;white-space:nowrap;line-height:1}
297− .tl{font-size:27px;font-weight:600;color:#ffffffa8;margin-top:10px;line-height:1.15}
298− .cta{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fadeUp .8s ease 2.5s forwards}
299− .cta .go{font-size:46px;font-weight:900;color:__A__}
300− .cta .brand{font-size:30px;font-weight:700;color:#ffffff8c;letter-spacing:.12em;margin-top:6px}
301− .sig{position:absolute;left:90px;bottom:92px;font-size:26px;color:#ffffff6a;font-weight:600;opacity:0;animation:fadeUp .8s ease 2.8s forwards}
302− .prog{position:absolute;left:0;bottom:0;height:8px;background:__A__;width:0;animation:prog __DUR__s linear .2s forwards;box-shadow:0 0 20px __A__}
303 @keyframes prog{to{width:100%}} 332 @keyframes prog{to{width:100%}}
304− @keyframes fadeUp{to{opacity:1;transform:translateY(0)}} 333+ @keyframes fu{to{opacity:1;transform:translateY(0)}}
305 @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}} 334 @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}
306− @keyframes heroIn{to{opacity:1;transform:translateY(0) scale(1)}} 335+ .scene{position:absolute;inset:0;padding:220px 90px 210px;display:flex;flex-direction:column;z-index:4;
307− .vign{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% 42%, transparent 55%, rgba(0,0,0,.55) 100%)} 336+ opacity:0;pointer-events:none;transform:translateY(46px) scale(.985);
308− .numwrap{animation:heroIn .95s cubic-bezier(.2,1,.3,1) .95s forwards, pulse 3s ease-in-out 2s infinite} 337+ transition:opacity .5s ease,transform .62s cubic-bezier(.2,.8,.2,1)}
309− @keyframes pulse{0%,100%{filter:none}50%{filter:brightness(1.12)}} 338+ .scene.on{opacity:1;transform:none}
339+ .scene.off{opacity:0;transform:translateY(-46px) scale(1.012)}
340+ .fadeout{position:absolute;inset:0;background:#000;opacity:0;z-index:20;pointer-events:none;
341+ animation:fo .7s ease __FOT__s forwards}
342+ @keyframes fo{to{opacity:1}}
310 """ 343 """
311− css=(css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__A__",p["a"]) 344+ shell=(shell.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__A__",p["a"])
312− .replace("__B__",p["b"]).replace("__G1__",p["g1"]).replace("__G2__",p["g2"]) 345+ .replace("__B__",p["b"]).replace("__G1__",p["g1"]).replace("__G2__",p["g2"])
313− .replace("__NUM__",str(numsize)).replace("__DUR__",str(DUR))) 346+ .replace("__TOT__",str(tot)).replace("__FOT__",str(round(tot-0.7,2))))
314− tag = ('<div class="tag">'+esc(ins.get("tagline",""))+'</div>') if ins.get("tagline") else "" 347+
315− tiles_block = ('<div class="tiles">'+tiles_html+'</div>') if tiles else "" 348+ css_all=[shell]; body=[]; sc_list=[]; hooks={}
316− body = ('<div class="stage"><div class="mesh"></div><div class="vign"></div><div class="pad">' 349+ for s in scenes:
317− '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · '+today().upper()+'</div>' 350+ sid="sc"+str(s["i"])
318− '<div class="kabadge">KA</div>' 351+ css=(s["css"].replace(".scene.on #S_","#"+sid+".on").replace("#S_","#"+sid).replace("#S","#"+sid)
319− '<div class="site">'+esc(ins["label"])+'</div>'+tag+ 352+ .replace("__A__",p["a"]).replace("__B__",p["b"]).replace("__G2__",p["g2"]))
320− '<div class="mid">' 353+ # les hooks référencent des ids préfixés S_ -> remplacer par l'id de scène
321− '<div class="numwrap"><div class="num" id="num" data-target="'+esc(ins["headline"])+'">'+esc(ins["headline"])+'</div></div>' 354+ html=s["html"].replace('id="S_','id="'+sid+'_')
322− '<div class="lab">'+esc(ins["headline_label"])+'</div>'+tiles_block+ 355+ hook=(s["hook"] or "").replace("'S_","'"+sid+"_")
323− '</div>' 356+ css_all.append(css)
324− '<div class="cta"><div class="go">👉 www.'+ins["site"]+'.com</div><div class="brand">GROUPE ·KA</div></div>' 357+ body.append('<div class="scene" id="'+sid+'">'+html+'</div>')
325− '<div class="sig">✦ Rédigé et publié par l\'Agent KA</div>' 358+ sc_list.append([sid, s["dur"]])
326− '<div class="prog"></div>' 359+ if hook: hooks[sid]=hook
327− '</div></div>') 360+
328− script = """<script> 361+ hooks_js="{"+",".join('"'+k+'":function(){'+v+'}' for k,v in hooks.items())+"}"
329− (function(){ 362+ script="""<script>
330− // ajuste la taille des valeurs de tuiles pour tenir sur une ligne 363+ function fmtInt(n){return n.toLocaleString('fr-CA').replace(/[\\u00a0\\u202f,]/g,' ')}
331− document.querySelectorAll('.tv[data-fit]').forEach(function(el){ 364+ function cuInt(id,target,suffix,dur,begin){var el=document.getElementById(id);if(!el)return;var s=null;
332− var s=58; el.style.fontSize=s+'px'; 365+ function st(ts){if(!s)s=ts;var t=ts-s;if(t<begin){requestAnimationFrame(st);return}
333− while(el.scrollWidth>el.clientWidth && s>26){ s-=3; el.style.fontSize=s+'px'; } 366+ var k=Math.min(1,(t-begin)/dur);var e=1-Math.pow(1-k,3);
367+ el.textContent=fmtInt(Math.round(target*e))+suffix;if(k<1)requestAnimationFrame(st)}
368+ el.textContent=fmtInt(0)+suffix;requestAnimationFrame(st)}
369+ function cuPct(id,pct,dec,dur,begin){var el=document.getElementById(id);if(!el)return;var s=null;
370+ function f(n){return n.toFixed(dec).replace('.',',')+' %'}
371+ function st(ts){if(!s)s=ts;var t=ts-s;if(t<begin){requestAnimationFrame(st);return}
372+ var k=Math.min(1,(t-begin)/dur);var e=1-Math.pow(1-k,3);
373+ el.textContent=f(pct*e);if(k<1)requestAnimationFrame(st)}
374+ requestAnimationFrame(st)}
375+ var SCENES=__SC__;
376+ var HOOKS=__HOOKS__;
377+ (function(){var t=200;
378+ SCENES.forEach(function(s){
379+ setTimeout(function(){
380+ document.querySelectorAll('.scene.on').forEach(function(el){el.classList.remove('on');el.classList.add('off')});
381+ var el=document.getElementById(s[0]);el.classList.remove('off');el.classList.add('on');
382+ if(HOOKS[s[0]])try{HOOKS[s[0]]()}catch(e){}
383+ },t);
384+ t+=s[1]*1000;
334 }); 385 });
335− var el=document.getElementById('num'); if(!el) return; var raw=el.getAttribute('data-target');
336− if(/[,%\/]/.test(raw)) return;
337− var m=raw.match(/^[\d\u00a0\u202f ]+/); if(!m) return;
338− var digits=m[0].replace(/[^\d]/g,''); if(digits.length<3) return;
339− var target=parseInt(digits,10); var suffix=raw.slice(m[0].length);
340− var start=null, dur=1400, begin=1000;
341− function fmt(n){return n.toLocaleString('fr-CA').replace(/[\u00a0\u202f,]/g,' ')}
342− function step(ts){ if(!start)start=ts; var t=ts-start;
343− if(t<begin){requestAnimationFrame(step);return;}
344− var k=Math.min(1,(t-begin)/dur); var e=1-Math.pow(1-k,3);
345− el.textContent=fmt(Math.round(target*e))+suffix;
346− if(k<1)requestAnimationFrame(step);
347− }
348− el.textContent=fmt(0)+suffix; requestAnimationFrame(step);
349 })(); 386 })();
350− </script>""" 387+ </script>""".replace("__SC__",json.dumps(sc_list)).replace("__HOOKS__",hooks_js)
351− return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+script+"</body></html>" 388+
389+ eyeb='<div class="eyeb"><span class="dot"></span>'+esc(ins["label"].upper())+' · '+today().upper()+'</div>'
390+ wm='<div class="wm"><b>www.'+ins["site"]+'.com</b> · Agent KA</div>'
391+ html=("<!doctype html><html><head><meta charset=\"utf-8\"><style>"+"\n".join(css_all)+"</style></head><body>"
392+ '<div class="stage">'+eyeb+'<div class="kabadge">KA</div>'+"".join(body)
393+ +wm+'<div class="prog"></div><div class="vign"></div><div class="fadeout"></div></div>'+script+"</body></html>")
394+ return html, tot
352 395
353−def record_webm(html_path, out_webm, tmpdir): 396+# ---------- enregistrement + musique + mux ----------
397+def record_webm(html_path, out_webm, tmpdir, dur):
354 from playwright.sync_api import sync_playwright 398 from playwright.sync_api import sync_playwright
355 with sync_playwright() as pw: 399 with sync_playwright() as pw:
356 b=pw.chromium.launch(args=["--no-sandbox"]) 400 b=pw.chromium.launch(args=["--no-sandbox"])
357− ctx=b.new_context(viewport={"width":1080,"height":HREEL}, device_scale_factor=2, 401+ ctx=b.new_context(viewport={"width":1080,"height":H}, device_scale_factor=2,
358− record_video_dir=tmpdir, record_video_size={"width":1080,"height":HREEL}) 402+ record_video_dir=tmpdir, record_video_size={"width":1080,"height":H})
359 pg=ctx.new_page() 403 pg=ctx.new_page()
360 pg.goto("file://"+html_path) 404 pg.goto("file://"+html_path)
361− pg.wait_for_timeout(int(DUR*1000)) 405+ pg.wait_for_timeout(int((dur+0.3)*1000))
362 vid=pg.video 406 vid=pg.video
363 ctx.close(); b.close() 407 ctx.close(); b.close()
364 path=vid.path() 408 path=vid.path()
365 os.replace(path, out_webm) 409 os.replace(path, out_webm)
366 410
367−# 4 ambiances musicales en rotation (progression, basse, tempo du kick, notes d'arpège)
368−MOODS=[
369− {"name":"uplift","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)],
370− "roots":[110.00,87.31,130.81,98.00],"beat":0.5,"arp":(880.0,659.25)}, # Am F C G
371− {"name":"chill","chords":[(261.63,329.63,392.00),(220.00,261.63,329.63),(174.61,220.00,261.63),(196.00,246.94,293.66)],
372− "roots":[130.81,110.00,87.31,98.00],"beat":0.62,"arp":(783.99,659.25)}, # C Am F G
373− {"name":"energique","chords":[(164.81,196.00,246.94),(130.81,164.81,196.00),(196.00,246.94,293.66),(146.83,185.00,220.00)],
374− "roots":[82.41,65.41,98.00,73.42],"beat":0.4,"arp":(987.77,739.99)}, # Em C G D
375− {"name":"chaleureux","chords":[(174.61,220.00,261.63),(196.00,246.94,293.66),(220.00,261.63,329.63),(220.00,277.18,329.63)],
376− "roots":[87.31,98.00,110.00,110.00],"beat":0.55,"arp":(880.0,698.46)}, # F G Am Am
377−]
378−
379−def make_music(dur, out_wav, mood=0):
380− """Lit musical riche : progression d'accords (pad) + basse + kick + shaker
381− + arpège/pluck brillant, avec réverbe/compresseur/limiteur. `mood` choisit l'ambiance."""
382− tmpdir=os.path.dirname(out_wav)
383− M=MOODS[int(mood)%len(MOODS)]
384− chords=M["chords"]; roots=M["roots"]; beat=M["beat"]; arpf=M["arp"]
385− seg=round(dur/len(chords),3)
386− def concat(paths,outp):
387− lst=outp+".txt"; open(lst,"w").write("".join("file '"+x+"'\n" for x in paths))
388− subprocess.run([FFMPEG,"-y","-f","concat","-safe","0","-i",lst,"-c","copy",outp],capture_output=True)
389− # pad (accords)
390− padparts=[]
391− for i,(f1,f2,f3) in enumerate(chords):
392− w=os.path.join(tmpdir,"ch%d.wav"%i)
393− subprocess.run([FFMPEG,"-y","-f","lavfi","-i","sine=frequency=%s:duration=%s"%(f1,seg),
394− "-f","lavfi","-i","sine=frequency=%s:duration=%s"%(f2,seg),
395− "-f","lavfi","-i","sine=frequency=%s:duration=%s"%(f3,seg),
396− "-filter_complex","[0][1][2]amix=inputs=3:normalize=0,volume=0.20,lowpass=f=1200,afade=t=in:st=0:d=0.3,afade=t=out:st=%s:d=0.4[a]"%(seg-0.4),
397− "-map","[a]",w],capture_output=True); padparts.append(w)
398− pad=os.path.join(tmpdir,"pad.wav"); concat(padparts,pad)
399− # basse (fondamentale par accord)
400− bassparts=[]
401− for i,r in enumerate(roots):
402− w=os.path.join(tmpdir,"b%d.wav"%i)
403− subprocess.run([FFMPEG,"-y","-f","lavfi","-i","sine=frequency=%s:duration=%s"%(r,seg),
404− "-af","volume=0.34,lowpass=f=320,afade=t=in:st=0:d=0.06,afade=t=out:st=%s:d=0.12"%(seg-0.15),
405− w],capture_output=True); bassparts.append(w)
406− bass=os.path.join(tmpdir,"bass.wav"); concat(bassparts,bass)
407− # kick (tempo selon l'ambiance)
408− kick=os.path.join(tmpdir,"kick.wav")
409− subprocess.run([FFMPEG,"-y","-f","lavfi","-i","aevalsrc=0.75*sin(2*PI*55*t)*exp(-9*mod(t\,%s)):d=%s:s=44100"%(beat,dur),kick],capture_output=True)
410− # shaker/hat contretemps
411− hat=os.path.join(tmpdir,"hat.wav")
412− subprocess.run([FFMPEG,"-y","-f","lavfi","-i","aevalsrc=0.10*(random(0)*2-1)*exp(-45*mod(t+%s\,%s)):d=%s:s=44100"%(beat/2,beat,dur),"-af","highpass=f=6500",hat],capture_output=True)
413− # arpège/pluck brillant (croches, deux notes en alternance)
414− arp=os.path.join(tmpdir,"arp.wav"); e=beat/2; e2=beat/4
415− subprocess.run([FFMPEG,"-y","-f","lavfi",
416− "-i","aevalsrc=0.13*sin(2*PI*%s*t)*exp(-13*mod(t\,%s))+0.10*sin(2*PI*%s*(t-%s))*exp(-13*mod(t+%s\,%s)):d=%s:s=44100"%(arpf[0],e,arpf[1],e2,e2,e,dur),
417− "-af","highpass=f=350,lowpass=f=6000",arp],capture_output=True)
418− # mix final
419− subprocess.run([FFMPEG,"-y","-i",pad,"-i",bass,"-i",kick,"-i",hat,"-i",arp,
420− "-filter_complex",
421− "[0]volume=1.0[p];[1]volume=1.0[b];[2]volume=0.9[k];[3]volume=0.5[h];[4]volume=0.55[ar];"
422− "[p][b][k][h][ar]amix=inputs=5:normalize=0,"
423− "aecho=0.8:0.88:70:0.22,"
424− "acompressor=threshold=-18dB:ratio=3.5:attack=6:release=160,"
425− "highpass=f=32,alimiter=limit=0.94,"
426− "afade=t=in:st=0:d=0.5,afade=t=out:st=%s:d=1.6[a]"%(dur-1.6),
427− "-map","[a]","-ar","44100",out_wav],capture_output=True)
428−
429 def render(ins, out_dir): 411 def render(ins, out_dir):
430 os.makedirs(out_dir,exist_ok=True) 412 os.makedirs(out_dir,exist_ok=True)
431 prod=ins.get('product') 413 prod=ins.get('product')
@@ -436,21 +418,28 @@ def render(ins, out_dir):
436 if it.get('image') and not it.get('image_data'): 418 if it.get('image') and not it.get('image_data'):
437 du=data_uri(it['image']) 419 du=data_uri(it['image'])
438 if du: it['image_data']=du 420 if du: it['image_data']=du
439− key=hashlib.md5((ins['site']+str(ins.get('insight_id',''))+str(ins.get('genre',''))+'reel').encode()).hexdigest()[:8] 421+
422+ key=hashlib.md5((ins['site']+str(ins.get('insight_id',''))+str(ins.get('genre',''))+'reelv4').encode()).hexdigest()[:8]
440 tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True) 423 tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True)
441− html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins)) 424+ html,dur=build_html(ins)
442− webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp) 425+ html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(html)
443− mood=ins.get('variant') 426+ webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp,dur)
444− if mood is None: 427+
445− mood=int(hashlib.md5((ins['site']+str(ins.get('insight_id',''))).encode()).hexdigest(),16) 428+ # musique : style en rotation via variant, contenu par graine (unique)
446− wav=os.path.join(tmp,"bed.wav"); make_music(DUR,wav,int(mood)) 429+ variant=ins.get('variant')
430+ seed=ins['site']+"|"+str(ins.get('insight_id',''))+"|"+str(variant)+"|"+datetime.date.today().isoformat()
431+ style=STYLES_ORDER[int(variant)%len(STYLES_ORDER)] if variant is not None else None
432+ wav=os.path.join(tmp,"bed.wav")
433+ used=music_engine.compose(dur+0.2, seed, wav, style)
434+ sys.stderr.write("musique: style=%s seed=%s dur=%.1fs\n"%(used,seed,dur))
435+
447 out=os.path.join(out_dir,f"reel-{ins['site']}-{ins.get('insight_id','x')}.mp4") 436 out=os.path.join(out_dir,f"reel-{ins['site']}-{ins.get('insight_id','x')}.mp4")
448 subprocess.run([FFMPEG,"-y","-i",webm,"-i",wav, 437 subprocess.run([FFMPEG,"-y","-i",webm,"-i",wav,
449− "-filter_complex","[0:v]scale=1080:"+str(HREEL)+":force_original_aspect_ratio=increase,crop=1080:"+str(HREEL)+",fps=30,format=yuv420p[v]", 438+ "-filter_complex","[0:v]scale=1080:"+str(H)+":force_original_aspect_ratio=increase,crop=1080:"+str(H)+",fps=30,format=yuv420p[v];"
450− "-map","[v]","-map","1:a","-c:v","libx264","-preset","slow","-crf","18", 439+ "[1:a]acompressor=threshold=-16dB:ratio=3:attack=8:release=180,alimiter=limit=0.95[a]",
451− "-c:a","aac","-b:a","160k","-pix_fmt","yuv420p","-movflags","+faststart","-shortest",out], 440+ "-map","[v]","-map","[a]","-c:v","libx264","-preset","slow","-crf","18",
441+ "-c:a","aac","-b:a","192k","-pix_fmt","yuv420p","-movflags","+faststart","-shortest",out],
452 capture_output=True) 442 capture_output=True)
453− # nettoyage
454 for f in glob.glob(os.path.join(tmp,"*")): 443 for f in glob.glob(os.path.join(tmp,"*")):
455 try: os.remove(f) 444 try: os.remove(f)
456 except: pass 445 except: pass
added server/social/music_engine.py +318 −0
@@ -0,0 +1,318 @@
1+#!/usr/bin/env python3
2+"""Moteur musical procédural KA — compose un lit musical UNIQUE par reel.
3+
4+6 styles (house, lofi, synthwave, epic, funk, trap) × plusieurs progressions
5+d'accords × tonalité, ligne de basse, batterie et MÉLODIE générées à partir
6+d'une graine : deux reels n'ont jamais la même musique.
7+
8+API : compose(dur, seed, out_wav, style=None) -> style utilisé
9+CLI : music_engine.py <dur> <graine> <out.wav> [style]
10+Sortie : WAV stéréo 44,1 kHz 16 bits (fondu d'entrée/sortie inclus).
11+"""
12+import sys, math, random, wave
13+import numpy as np
14+
15+SR = 44100
16+
17+# ---------- filtres (magnitude ordre 1, via FFT — rapide et sans scipy) ----------
18+def _fftfilt(x, H_of_f):
19+ n = len(x)
20+ X = np.fft.rfft(x)
21+ f = np.fft.rfftfreq(n, 1.0/SR)
22+ return np.fft.irfft(X * H_of_f(f), n)
23+
24+def lowpass(x, fc):
25+ fc = max(30.0, fc)
26+ return _fftfilt(x, lambda f: 1.0/np.sqrt(1.0+(f/fc)**2))
27+
28+def highpass(x, fc):
29+ fc = max(30.0, fc)
30+ return _fftfilt(x, lambda f: (f/fc)/np.sqrt(1.0+(f/fc)**2))
31+
32+# ---------- oscillateurs / percussions ----------
33+def _t(n): return np.arange(n)/SR
34+def sine(f, n, ph=0.0): return np.sin(2*np.pi*f*_t(n)+ph)
35+def saw(f, n): return 2.0*((f*_t(n)) % 1.0)-1.0
36+def tri(f, n): return 2.0*np.abs(2.0*((f*_t(n)) % 1.0)-1.0)-1.0
37+
38+def expdec(n, tau): return np.exp(-_t(n)/max(1e-4, tau))
39+
40+def kick(dur=0.5, f0=150.0, f1=45.0, punch=1.0, rng=None):
41+ n = int(dur*SR); t = _t(n)
42+ f = f1+(f0-f1)*np.exp(-t*28.0)
43+ x = np.sin(2*np.pi*np.cumsum(f)/SR)*np.exp(-t*7.5)*punch
44+ nz = (rng or np.random).standard_normal(n)*np.exp(-t*400.0)
45+ return x + highpass(nz, 3500)*0.35
46+
47+def sub808(f, dur, rng=None):
48+ n = int(dur*SR); t = _t(n)
49+ fr = f*(1.0+0.35*np.exp(-t*22.0)) # petit plongeon de hauteur
50+ x = np.sin(2*np.pi*np.cumsum(fr)/SR)
51+ x = np.tanh(x*2.2)*np.exp(-t*1.6)
52+ x[:int(0.004*SR)] *= np.linspace(0, 1, int(0.004*SR))
53+ return x
54+
55+def snare(dur=0.22, rng=None):
56+ n = int(dur*SR)
57+ nz = (rng or np.random).standard_normal(n)
58+ body = sine(190, n)*expdec(n, 0.035)*0.5
59+ return highpass(nz, 1600)*expdec(n, 0.055) + body
60+
61+def clap(rng=None):
62+ n = int(0.30*SR)
63+ out = np.zeros(n)
64+ r = rng or np.random
65+ for i, off in enumerate((0.0, 0.012, 0.026)):
66+ o = int(off*SR); m = n-o
67+ out[o:] += highpass(r.standard_normal(m), 1200)*expdec(m, 0.05)*(0.8-0.15*i)
68+ return out
69+
70+def hat(dur=0.05, opened=False, rng=None):
71+ d = 0.30 if opened else dur
72+ n = int(max(d, 0.03)*SR)
73+ nz = (rng or np.random).standard_normal(n)
74+ return highpass(nz, 8200)*expdec(n, 0.11 if opened else 0.018)
75+
76+def bass_note(f, dur, kind="saw"):
77+ n = int(dur*SR)
78+ if kind == "sub":
79+ x = sine(f, n)+0.35*sine(2*f, n)
80+ else:
81+ x = lowpass(saw(f, n), f*4.0)*0.9 + sine(f, n)*0.5
82+ e = np.minimum(1.0, _t(n)/0.006)*np.exp(-_t(n)/max(0.05, dur*0.7))
83+ return x*e
84+
85+def pluck(f, dur, bright=5.0):
86+ n = int(dur*SR)
87+ x = 0.6*saw(f, n)+0.4*tri(f, n)
88+ x = lowpass(x, f*bright)
89+ return x*expdec(n, max(0.04, dur*0.35))*np.minimum(1.0, _t(n)/0.003)
90+
91+def pad_chord(freqs, dur, detune=0.004, cutoff=1900.0):
92+ n = int(dur*SR)
93+ L = np.zeros(n); R = np.zeros(n)
94+ for f in freqs:
95+ L += saw(f*(1+detune), n)+0.5*sine(f, n)
96+ R += saw(f*(1-detune), n)+0.5*sine(f, n, ph=0.7)
97+ a = np.minimum(1.0, _t(n)/0.35)
98+ rel = np.minimum(1.0, (dur-_t(n))/0.25)
99+ e = a*np.clip(rel, 0, 1)
100+ return lowpass(L, cutoff)*e, lowpass(R, cutoff)*e
101+
102+# ---------- théorie : gammes, accords ----------
103+NOTE_HZ = {"F2": 87.31, "G2": 98.00, "A2": 110.00, "B2": 123.47, "C3": 130.81, "D3": 146.83, "E3": 164.81}
104+SCALES = {"minor": [0, 2, 3, 5, 7, 8, 10], "major": [0, 2, 4, 5, 7, 9, 11], "dorian": [0, 2, 3, 5, 7, 9, 10]}
105+
106+def deg_semi(scale, d):
107+ return scale[d % 7] + 12*(d//7)
108+
109+def chord_freqs(root_hz, scale, degree, spread=(0, 2, 4)):
110+ return [root_hz*2**(deg_semi(scale, degree+s)/12.0) for s in spread]
111+
112+# ---------- définition des styles ----------
113+# patterns = 16 pas (doubles-croches) par mesure 4/4 ; valeurs = vélocité
114+S = {
115+ "house": dict(
116+ bpm=(120, 126), scale="minor", roots=["A2", "G2", "F2", "C3"],
117+ progs=[[0, 5, 3, 4], [0, 5, 2, 6], [5, 3, 0, 4], [0, 2, 5, 6]],
118+ kick=[1, 0, 0, 0]*4, snare=None,
119+ clap=[0]*4+[1]+[0]*7+[1]+[0]*3,
120+ hats=[0, 0, .8, 0]*4, openhat=[0, 0, 1, 0]*4,
121+ bass="offbeat8", pad="sustain", mel_density=0.55, mel_oct=2,
122+ g=dict(kick=.95, clap=.5, hat=.35, bass=.62, pad=.30, mel=.42), swing=0.0),
123+ "lofi": dict(
124+ bpm=(78, 88), scale="dorian", roots=["F2", "G2", "A2"],
125+ progs=[[0, 3, 5, 4], [0, 2, 3, 4], [5, 4, 0, 3]],
126+ kick=[1]+[0]*6+[.7]+[0]*2+[.9]+[0]*5, snare=[0]*4+[.8]+[0]*7+[.8]+[0]*3, clap=None,
127+ hats=[.5, 0, .35, 0]*4, openhat=None,
128+ bass="half", pad="sustain", mel_density=0.35, mel_oct=1,
129+ g=dict(kick=.8, snare=.4, hat=.3, bass=.6, pad=.36, mel=.5), swing=0.12, vinyl=True),
130+ "synthwave": dict(
131+ bpm=(104, 112), scale="minor", roots=["A2", "C3", "G2"],
132+ progs=[[0, 5, 3, 6], [0, 6, 5, 4], [0, 3, 6, 4]],
133+ kick=[1, 0, 0, 0]*4, snare=[0]*4+[1]+[0]*7+[1]+[0]*3, clap=None,
134+ hats=[.35]*16, openhat=None,
135+ bass="arp16", pad="big", mel_density=0.4, mel_oct=2,
136+ g=dict(kick=.9, snare=.5, hat=.22, bass=.5, pad=.4, mel=.4), swing=0.0),
137+ "epic": dict(
138+ bpm=(96, 104), scale="minor", roots=["C3", "A2", "G2"],
139+ progs=[[0, 6, 3, 5], [0, 3, 6, 5], [0, 5, 6, 3]],
140+ kick=[1]+[0]*7+[0, 0, .9, 0]+[0]*4, snare=None, clap=[0]*12+[.6]+[0]*3,
141+ hats=None, openhat=None,
142+ bass="pulse8", pad="big", mel_density=0.45, mel_oct=2,
143+ g=dict(kick=.95, clap=.35, bass=.55, pad=.5, mel=.45), swing=0.0, swell=True),
144+ "funk": dict(
145+ bpm=(106, 114), scale="dorian", roots=["G2", "A2", "F2"],
146+ progs=[[0, 0, 3, 4], [0, 3, 0, 4], [0, 4, 3, 4]],
147+ kick=[1, 0, 0, 0, 0, 0, .8, 0, 0, .7, 0, 0, 1, 0, 0, 0],
148+ snare=[0]*4+[1]+[0]*7+[1]+[0, 0, .5], clap=None,
149+ hats=[.5, .2, .4, .2]*4, openhat=None,
150+ bass="synco", pad="stab", mel_density=0.5, mel_oct=1,
151+ g=dict(kick=.9, snare=.5, hat=.3, bass=.7, pad=.25, mel=.45), swing=0.08),
152+ "trap": dict(
153+ bpm=(136, 146), scale="minor", roots=["F2", "G2", "A2"],
154+ progs=[[0, 0, 5, 5], [0, 0, 6, 6], [0, 0, 3, 3]],
155+ kick=[1]+[0]*6+[.8]+[0]*8, snare=[0]*8+[1]+[0]*7, clap=None,
156+ hats=[.5, 0, .4, .4]*2+[.5, .3, .3, .3, .5, 0, .8, .4], openhat=None,
157+ bass="e808", pad="sustain", mel_density=0.3, mel_oct=2,
158+ g=dict(kick=.85, snare=.55, hat=.3, bass=.75, pad=.22, mel=.4), swing=0.0),
159+}
160+
161+# ---------- séquenceur ----------
162+def _place(dst, sig, i0, gain):
163+ if gain <= 0: return
164+ i1 = min(len(dst), i0+len(sig))
165+ if i1 <= i0: return
166+ dst[i0:i1] += sig[:i1-i0]*gain
167+
168+def _bass_events(kind, bar, rng):
169+ """-> liste (pas, longueur_en_pas, offset_degré, octave+)"""
170+ if kind == "offbeat8": return [(s, 2, 0, 0) for s in (2, 6, 10, 14)]
171+ if kind == "half": return [(0, 8, 0, 0), (8, 8, 0, 0)]
172+ if kind == "arp16": return [(s, 1, 0, (s % 4 == 2)) for s in range(16)]
173+ if kind == "pulse8": return [(s, 2, 0, 0) for s in range(0, 16, 2)]
174+ if kind == "e808": return [(0, 10, 0, 0), (10, 6, 0, 0)] if bar % 2 == 0 else [(0, 12, 0, 0), (13, 3, -2, 0)]
175+ if kind == "synco":
176+ pat = [(0, 2, 0, 0), (3, 1, 0, 1), (6, 2, 0, 0), (10, 1, 4, 0), (11, 1, 0, 1), (14, 2, 0, 0)]
177+ return pat if bar % 2 == 0 else pat[:-1]+[(14, 1, 0, 1), (15, 1, 0, 0)]
178+ return [(0, 4, 0, 0)]
179+
180+def _melody_bar(rng, density, prev_deg, chord_deg):
181+ """Rythme + hauteurs (degrés de gamme) pour une mesure. -> [(pas,long,deg)]"""
182+ events = []
183+ step = 0
184+ cur = prev_deg
185+ while step < 16:
186+ ln = rng.choice((2, 2, 2, 4, 4, 1, 3))
187+ if rng.random() < density:
188+ if rng.random() < 0.5: # note d'accord
189+ cur = chord_deg + rng.choice((0, 2, 4, 7))
190+ else: # marche par degrés
191+ cur = max(chord_deg-1, min(chord_deg+9, cur+rng.choice((-2, -1, -1, 1, 1, 2))))
192+ events.append((step, ln, cur))
193+ step += ln
194+ return events, cur
195+
196+def compose(dur, seed, out_wav, style=None):
197+ rng = random.Random(str(seed))
198+ nrng = np.random.RandomState(abs(hash(str(seed))) % (2**31))
199+ name = style if style in S else rng.choice(sorted(S.keys()))
200+ st = S[name]
201+ bpm = rng.uniform(*st["bpm"])
202+ spb = 60.0/bpm
203+ stepd = spb/4.0
204+ scale = SCALES[st["scale"]]
205+ root = NOTE_HZ[rng.choice(st["roots"])]
206+ prog = rng.choice(st["progs"])
207+ swing = st.get("swing", 0.0)
208+
209+ total = int((dur+0.2)*SR)
210+ L = np.zeros(total); R = np.zeros(total)
211+ g = st["g"]
212+
213+ # percussions pré-rendues
214+ kick_s = kick(rng=nrng)
215+ snare_s = snare(rng=nrng) if st.get("snare") else None
216+ clap_s = clap(rng=nrng) if st.get("clap") else None
217+ hat_c = hat(rng=nrng); hat_o = hat(opened=True, rng=nrng)
218+
219+ nbars = int(math.ceil(dur/(4*spb)))+1
220+ prev_deg = 7
221+ for bar in range(nbars):
222+ bar_t = bar*4*spb
223+ cdeg = prog[bar % len(prog)]
224+ cfreqs = chord_freqs(root*2, scale, cdeg)
225+
226+ # pad / nappes
227+ if g.get("pad", 0) > 0:
228+ pl, pr = pad_chord(cfreqs, 4*spb*1.05,
229+ detune=0.006 if st["pad"] == "big" else 0.003,
230+ cutoff=2400 if st["pad"] == "big" else 1700)
231+ if st["pad"] == "stab": # stabs funk : accord plaqué sur 2 offbeats
232+ for s in (2, 10):
233+ n = int(spb*0.9*SR)
234+ stab = sum(pluck(f, spb*0.9, bright=4) for f in cfreqs)
235+ _place(L, stab, int((bar_t+s*stepd)*SR), g["pad"]*0.8)
236+ _place(R, stab, int((bar_t+s*stepd)*SR), g["pad"]*0.8)
237+ else:
238+ _place(L, pl, int(bar_t*SR), g["pad"])
239+ _place(R, pr, int(bar_t*SR), g["pad"])
240+
241+ # batterie
242+ for s in range(16):
243+ tt = bar_t+s*stepd+(stepd*swing if s % 2 == 1 else 0.0)
244+ i0 = int(tt*SR)
245+ v = st["kick"][s] if st.get("kick") else 0
246+ if v: _place(L, kick_s, i0, g["kick"]*v); _place(R, kick_s, i0, g["kick"]*v)
247+ if snare_s is not None and st["snare"][s]:
248+ _place(L, snare_s, i0, g["snare"]*st["snare"][s]); _place(R, snare_s, i0, g["snare"]*st["snare"][s])
249+ if clap_s is not None and st.get("clap") and st["clap"][s]:
250+ _place(L, clap_s, i0, g["clap"]*st["clap"][s]); _place(R, clap_s, i0, g["clap"]*st["clap"][s])
251+ if st.get("hats") and st["hats"][s]:
252+ hs = hat_o if (st.get("openhat") and st["openhat"][s]) else hat_c
253+ vv = g["hat"]*st["hats"][s]*(0.8+0.4*rng.random())
254+ _place(L, hs, i0, vv*0.9); _place(R, hs, i0, vv*1.1)
255+
256+ # basse
257+ bkind = st["bass"]
258+ for (s, ln, doff, octu) in _bass_events(bkind, bar, rng):
259+ f = root*2**(deg_semi(scale, cdeg+doff)/12.0)*(2 if octu else 1)
260+ d = ln*stepd
261+ sig = sub808(f/2 if bkind == "e808" else f, d*1.1) if bkind == "e808" else \
262+ bass_note(f, d*0.95, kind="sub" if bkind in ("half", "pulse8") else "saw")
263+ i0 = int((bar_t+s*stepd)*SR)
264+ _place(L, sig, i0, g["bass"]); _place(R, sig, i0, g["bass"])
265+
266+ # mélodie (graine → jamais deux fois pareille)
267+ if g.get("mel", 0) > 0:
268+ events, prev_deg = _melody_bar(rng, st["mel_density"], prev_deg, cdeg)
269+ for (s, ln, deg) in events:
270+ f = root*2**(deg_semi(scale, deg)/12.0)*(2**st["mel_oct"])
271+ d = ln*stepd
272+ sig = pluck(f, min(d*1.4, 1.2), bright=6.0)
273+ tt = bar_t+s*stepd+(stepd*swing if s % 2 == 1 else 0.0)
274+ i0 = int(tt*SR)
275+ pan = 0.15*math.sin(bar+s) # léger mouvement stéréo
276+ _place(L, sig, i0, g["mel"]*(1-pan)); _place(R, sig, i0, g["mel"]*(1+pan))
277+
278+ # montée bruitée vers chaque 4e mesure (styles amples)
279+ if st.get("swell") and bar % 4 == 3:
280+ n = int(4*spb*SR)
281+ sw = highpass(nrng.standard_normal(n), 1200)*np.linspace(0, 1, n)**2.5
282+ _place(L, sw, int(bar_t*SR), 0.10); _place(R, sw, int(bar_t*SR), 0.10)
283+
284+ # texture vinyle (lofi)
285+ if st.get("vinyl"):
286+ hiss = lowpass(nrng.standard_normal(total), 4000)*0.006
287+ crackle = np.zeros(total)
288+ for _ in range(int(dur*7)):
289+ i = rng.randrange(total-200)
290+ crackle[i:i+120] += nrng.standard_normal(120)*expdec(120, 0.001)*0.05
291+ L += hiss+crackle; R += hiss+crackle
292+
293+ # écho discret sur le mix (profondeur)
294+ dly = int(0.375*spb/0.5*SR*0.5) or 1
295+ eL = np.zeros(total); eR = np.zeros(total)
296+ eL[dly:] = R[:-dly]*0.18; eR[dly:] = L[:-dly]*0.18 # ping-pong léger
297+ L += eL; R += eR
298+
299+ # master : drive doux, normalisation, fondus
300+ mix = np.stack([L, R])
301+ mix = np.tanh(mix*1.25)
302+ peak = np.max(np.abs(mix)) or 1.0
303+ mix = mix/peak*0.88
304+ nfi = int(0.5*SR); nfo = int(1.4*SR)
305+ mix[:, :nfi] *= np.linspace(0, 1, nfi)
306+ if total > nfo: mix[:, -nfo:] *= np.linspace(1, 0, nfo)
307+ mix = mix[:, :int(dur*SR)]
308+
309+ data = (np.clip(mix.T, -1, 1)*32767).astype(np.int16)
310+ with wave.open(out_wav, "wb") as w:
311+ w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR)
312+ w.writeframes(data.tobytes())
313+ return name
314+
315+if __name__ == "__main__":
316+ dur = float(sys.argv[1]); seed = sys.argv[2]; out = sys.argv[3]
317+ style = sys.argv[4] if len(sys.argv) > 4 else None
318+ print(compose(dur, seed, out, style))
319
Menu

Theme