SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%

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 1 mo ago (Aug 23, 2026) parent 7899c24

3 changed files +699 −371

modified server/social.js +21 −0
@@ -600,6 +600,23 @@ async function decoratePlan(insight, plan) {
600 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 620 // rotation des genres pour les REELS auto / lots (sans prompt)
604 621 async function decorateForPrompt(insight) {
605 622 const n = applyVariant(insight).variant;
@@ -763,6 +780,7 @@ export async function generateReelDraft({ site = '', prompt = '' } = {}) {
763 780 insight = await pickInsight({ site });
764 781 await decorateForPrompt(insight);
765 782 }
783 + await enrichReel(insight);
766 784 const video = await makeReel(insight);
767 785 const caption = await writeCaption(insight, prompt);
768 786 const videoUrl='/api/social/reel/'+path.basename(video);
@@ -788,6 +806,7 @@ export async function runReelCycle(trigger = 'reel') {
788 806 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }
789 807 const insight = await pickInsight({});
790 808 await decorateForPrompt(insight);
809 + await enrichReel(insight);
791 810 const video = await makeReel(insight);
792 811 const caption = await writeCaption(insight);
793 812 await publishReelToPage(caption, video);
@@ -820,6 +839,7 @@ export function startGenBatch(sites){
820 839 logEvent({ kind:'genbatch_item', i:i+1, total:sites.length, site });
821 840 const insight=await pickInsight({ site });
822 841 await decorateForPrompt(insight);
842 + await enrichReel(insight);
823 843 const video=await makeReel(insight);
824 844 const caption=await writeCaption(insight);
825 845 addToGallery({ kind:'reel', site, genre:insight.genre, caption, file:path.basename(video),
@@ -848,6 +868,7 @@ export function startReelBatch(sites) {
848 868 logEvent({ kind: 'batch_item', i: i + 1, total: sites.length, site, step: 'génération vidéo' });
849 869 const insight = await pickInsight({ site });
850 870 await decorateForPrompt(insight);
871 + await enrichReel(insight);
851 872 const video = await makeReel(insight);
852 873 const caption = await writeCaption(insight);
853 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 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
5 (mp4 h264 + lit musical synthétisé). Sortie : chemin du mp4 sur stdout.
4 +Le reel n'est plus une seule carte animée de 10 s : c'est une séquence de
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 21 CTX = ssl.create_default_context(); CTX.check_hostname=False; CTX.verify_mode=ssl.CERT_NONE
10 22 FFMPEG = "/opt/homebrew/bin/ffmpeg"
11 23 MOIS=["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"]
12 24 FONT="'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif"
13 DUR=10.0 # secondes
14 HREEL=int(os.environ.get("KA_REEL_H","1920"))
25 +H=int(os.environ.get("KA_REEL_H","1920"))
26 +STYLES_ORDER=["house","synthwave","lofi","funk","epic","trap"]
15 27
16 28 PALETTES={
17 29 "lou-ka":{"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09"},
@@ -27,8 +39,8 @@ PALETTES={
27 39 "job-ka":{"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"},
28 40 }
29 41 def pal(s): return PALETTES.get(s, PALETTES["vrai-prix"])
42 +
30 43 def data_uri(url):
31 import base64
32 44 try:
33 45 req=urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0 (Macintosh)"})
34 46 with urllib.request.urlopen(req, timeout=15, context=CTX) as r: raw=r.read()
@@ -37,395 +49,365 @@ def data_uri(url):
37 49 elif url.lower().endswith(".webp"): mime="image/webp"
38 50 return "data:"+mime+";base64,"+base64.b64encode(raw).decode()
39 51 except Exception: return None
52 +
40 53 def esc(s): return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
41 54 def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}"
42 import re
43 55 def parse_pct(h):
44 56 m=re.match(r'^\+?(\d+(?:[.,]\d+)?)\s*%$', str(h or "").strip())
45 57 return float(m.group(1).replace(",",".")) if m else None
46 58 NUMRE = re.compile(r'\d[\d\u00a0\u202f ,\.]*%?\$?')
47 59
48 def build_spotlight(ins):
49 H=HREEL; p=pal(ins["site"]); prod=ins.get("product") or {}
50 img=prod.get("image_data",""); chips="".join('<span class="chip">'+esc(c)+'</span>' for c in (prod.get("meta") or []))
51 price=esc(prod.get("price","")); title=esc(prod.get("title","")); sub=esc(prod.get("subtitle",""))
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")
60 +# ---------- scènes ----------
61 +# chaque constructeur renvoie (html, css, dur, hook_js_ou_None)
62 +
63 +def sc_intro(ins,p,i):
102 64 css="""
103 *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}
104 html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff}
105 .stage{position:relative;width:1080px;height:__H__px;overflow:hidden;
106 background:radial-gradient(1300px 900px at 78% -5%, __A__55,transparent 55%),
107 radial-gradient(1100px 1000px at -12% 105%, __B__3a,transparent 55%),
108 linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 14s ease-in-out infinite alternate}
109 @keyframes drift{0%{background-position:0% 0%}100%{background-position:100% 100%}}
110 .pad{position:absolute;inset:0;padding:120px 80px 210px;display:flex;flex-direction:column}
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}
112 .eyebrow .dot{width:52px;height:6px;border-radius:3px;background:__A__}
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}
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)}}
65 + #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center}
66 + #S .kick{font-size:30px;font-weight:800;letter-spacing:.22em;color:__A__;text-transform:uppercase;opacity:0}
67 + .scene.on #S_ .kick{animation:fu .6s ease .15s forwards}
68 + #S .big{font-size:150px;font-weight:900;letter-spacing:-.03em;line-height:1;margin-top:26px;opacity:0;transform:scale(.8)}
69 + .scene.on #S_ .big{animation:zoomin .8s cubic-bezier(.2,1.2,.3,1) .35s forwards}
70 + #S .bar{width:0;height:10px;border-radius:6px;background:linear-gradient(90deg,__A__,__B__);margin-top:34px}
71 + .scene.on #S_ .bar{animation:grow .7s ease .8s forwards}
72 + #S .tag{font-size:42px;font-weight:600;color:#ffffffc8;margin-top:30px;max-width:880px;line-height:1.2;opacity:0}
73 + .scene.on #S_ .tag{animation:fu .7s ease 1.05s forwards}
74 + @keyframes zoomin{to{opacity:1;transform:scale(1)}}
75 + @keyframes grow{to{width:280px}}
131 76 """
132 css=(css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__A__",p["a"])
133 .replace("__B__",p["b"]).replace("__G1__",p["g1"]).replace("__G2__",p["g2"]).replace("__DUR__",str(DUR)))
134 body=('<div class="stage"><div class="pad">'
135 + '<div class="eyebrow"><span class="dot"></span>' + esc(ins["label"].upper()) + ' · ' + today().upper() + '</div>'
136 + '<div class="kabadge">KA</div>'
137 + '<div class="site">' + esc(ins["label"]) + '</div>'
138 + '<div class="ltitle">' + heading + '</div>'
139 + '<div class="lwrap">' + rows + '</div>'
140 + '<div class="cta"><div class="go">\U0001f449 www.' + ins["site"] + '.com</div><div class="brand">GROUPE ·KA</div></div>'
141 + '<div class="sig">✦ Rédigé et publié par l\'Agent KA</div><div class="prog"></div>'
142 + '</div></div>')
143 return "<!doctype html><html><head><meta charset=\"utf-8\"><style>" + css + "</style></head><body>" + body + "</body></html>"
144
145 REEL_SHELL_CSS = """
146 *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}
147 html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff}
148 .stage{position:relative;width:1080px;height:__H__px;overflow:hidden;
149 background:radial-gradient(1300px 900px at 78% -5%, __A__55,transparent 55%),
150 radial-gradient(1100px 1000px at -12% 105%, __B__3a,transparent 55%),
151 linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 14s ease-in-out infinite alternate}
152 @keyframes drift{0%{background-position:0% 0%}100%{background-position:100% 100%}}
153 .pad{position:absolute;inset:0;padding:120px 90px 250px;display:flex;flex-direction:column}
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}
155 .eyebrow .dot{width:52px;height:6px;border-radius:3px;background:__A__;flex:0 0 52px}
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}
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}
158 .cta{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fu .8s ease 2.5s forwards}
159 .cta .go{font-size:46px;font-weight:900;color:__A__}
160 .cta .brand{font-size:30px;font-weight:700;color:#ffffff8c;letter-spacing:.12em;margin-top:6px}
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}
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__}
163 @keyframes prog{to{width:100%}}
164 @keyframes fu{to{opacity:1;transform:translateY(0)}}
165 @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}
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%)}
167 """
168 def shell_css(p):
169 return (REEL_SHELL_CSS.replace("__H__",str(HREEL)).replace("__FONT__",FONT)
170 .replace("__A__",p["a"]).replace("__B__",p["b"]).replace("__G1__",p["g1"])
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
77 + html=('<div class="mid"><div class="kick">Le pouls du jour</div>'
78 + '<div class="big">'+esc(ins["label"])+'</div><div class="bar"></div>'
79 + + (('<div class="tag">'+esc(ins.get("tagline",""))+'</div>') if ins.get("tagline") else '')
80 + + '</div>')
81 + return html, css, 2.8, None
82 +
83 +def _num_parts(headline):
84 + """-> (int_digits, dec, suffix) pour le count-up ; None si non numérique."""
85 + hl=str(headline).strip()
86 + m=re.match(r'^\+?([\d\s  ]+)(?:[.,](\d+))?\s*(.*)$', hl)
87 + if not m: return None
88 + digits=re.sub(r'\D','',m.group(1))
89 + if not digits: return None
90 + return digits, (m.group(2) or ""), m.group(3) or ""
91 +
92 +def sc_stat(ins,p,i):
93 + parts=_num_parts(ins["headline"]); hl=str(ins["headline"])
94 + L=len(hl); fs=170 if L>=9 else (215 if L>=7 else 260)
95 + css="""
96 + #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center}
97 + #S .lab0{font-size:34px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:__A__;opacity:0}
98 + .scene.on #S_ .lab0{animation:fu .5s ease .1s forwards}
99 + #S .num{font-size:__FS__px;font-weight:900;line-height:.95;letter-spacing:-.03em;white-space:nowrap;margin-top:24px;
100 + background:linear-gradient(180deg,#fff,__A__ 135%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;
101 + filter:drop-shadow(0 18px 55px __A__55);opacity:0;transform:translateY(36px)}
102 + .scene.on #S_ .num{animation:fu .7s cubic-bezier(.2,1,.3,1) .3s forwards}
103 + #S .lab{font-size:56px;font-weight:700;margin-top:30px;max-width:900px;line-height:1.14;opacity:0}
104 + .scene.on #S_ .lab{animation:fu .7s ease .8s forwards}
105 + """.replace("__FS__",str(fs))
106 + hook=None
107 + if parts and len(parts[0])>=3 and not parts[1]:
108 + hook="cuInt('S_num',"+parts[0]+","+json.dumps(" "+parts[2] if parts[2] else "")+",1500,400);"
109 + html=('<div class="mid"><div class="lab0">'+esc(ins["label"])+' · en direct</div>'
110 + '<div class="num" id="S_num">'+esc(hl)+'</div>'
111 + '<div class="lab">'+esc(ins["headline_label"])+'</div></div>')
112 + return html, css, 4.2, hook
113 +
114 +def sc_donut(ins,p,i):
115 + pct=parse_pct(ins["headline"]) or 0.0
178 116 C=2*3.14159*300; off=C*(1-min(100.0,pct)/100.0)
179 tiles=(ins.get("tiles") or [])[:3]
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))
181 dec=1 if (',' in str(ins["headline"]) or '.' in str(ins["headline"])) else 0
182 svg=('<svg width="760" height="760" viewBox="0 0 760 760">'
183 '<circle cx="380" cy="380" r="300" fill="none" stroke="rgba(255,255,255,.10)" stroke-width="84"/>'
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)"/>'
185 '<defs><linearGradient id="gd" x1="0" y1="0" x2="1" y2="1">'
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}
117 + dec=1 if re.search(r'[.,]\d',str(ins["headline"])) else 0
118 + css="""
119 + #S .mid{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center}
120 + #S .dw{position:relative;width:740px;height:740px;opacity:0;transform:scale(.88)}
121 + .scene.on #S_ .dw{animation:zoomin .7s cubic-bezier(.2,1.2,.4,1) .15s forwards}
122 + #S .arc{stroke-dasharray:__C__;stroke-dashoffset:__C__}
123 + .scene.on #S_ .arc{animation:draw 1.7s cubic-bezier(.3,1,.4,1) .5s forwards}
192 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)}
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}
195 .tiles{display:flex;gap:20px;margin-top:52px}
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}
197 .t0{animation-delay:2.2s}.t1{animation-delay:2.35s}.t2{animation-delay:2.5s}
198 .tv{font-size:50px;font-weight:900;color:__A__;white-space:nowrap;line-height:1}
199 .tl{font-size:26px;font-weight:600;color:#ffffffa8;margin-top:8px;line-height:1.15}
200 """.replace("__C__",str(round(C,1))).replace("__OFF__",str(round(off,1))).replace("__A__",p["a"])
201 body=('<div class="stage"><div class="vign"></div><div class="pad">'
202 '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · '+today().upper()+'</div>'
203 '<div class="kabadge">KA</div>'
204 '<div class="site">'+esc(ins["label"])+'</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>'
206 '<div class="dlab">'+esc(ins["headline_label"])+'</div>'
207 +(('<div class="tiles">'+tiles_html+'</div>') if tiles else '')+'</div>'
208 +shell_tail(ins)+'</div></div>')
209 script="""<script>(function(){
210 var el=document.getElementById('dperc'); var pct=parseFloat(el.getAttribute('data-pct'));
211 var dec=parseInt(el.getAttribute('data-dec'),10); var start=null, dur=1800, begin=1100;
212 function fmt(n){return n.toFixed(dec).replace('.',',')+' %'}
213 function step(ts){ if(!start)start=ts; var t=ts-start;
214 if(t<begin){requestAnimationFrame(step);return;}
215 var k=Math.min(1,(t-begin)/dur); var e=1-Math.pow(1-k,3);
216 el.textContent=fmt(pct*e);
217 if(k<1)requestAnimationFrame(step);}
218 requestAnimationFrame(step);
219 })();</script>"""
220 return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+script+"</body></html>"
221
222 def build_quote(ins):
223 p=pal(ins["site"])
224 fact=str(ins.get("fact") or (str(ins["headline"])+" "+ins.get("headline_label","")))
125 + @keyframes zoomin{to{opacity:1;transform:scale(1)}}
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)}
127 + #S .dl{font-size:48px;font-weight:700;line-height:1.16;text-align:center;max-width:900px;margin-top:34px;opacity:0}
128 + .scene.on #S_ .dl{animation:fu .7s ease 1.2s forwards}
129 + """.replace("__C__",str(round(C,1))).replace("__OFF__",str(round(off,1)))
130 + svg=('<svg width="740" height="740" viewBox="0 0 760 760">'
131 + '<circle cx="380" cy="380" r="300" fill="none" stroke="rgba(255,255,255,.10)" stroke-width="82"/>'
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)"/>'
133 + '<defs><linearGradient id="gd'+str(i)+'" x1="0" y1="0" x2="1" y2="1">'
134 + '<stop offset="0" stop-color="__A__"/><stop offset="1" stop-color="__B__"/></linearGradient></defs></svg>')
135 + html=('<div class="mid"><div class="dw">'+svg+'<div class="dp" id="S_dp">0 %</div></div>'
136 + '<div class="dl">'+esc(ins["headline_label"])+'</div></div>')
137 + hook="cuPct('S_dp',"+str(pct)+","+str(dec)+",1700,500);"
138 + return html, css, 4.4, hook
139 +
140 +def sc_tiles(ins,p,i):
141 + tiles=(ins.get("tiles") or [])[:3]
142 + rows=""
143 + for k,(v,l) in enumerate(tiles):
144 + rows+=('<div class="trow" style="transition-delay:.0s"><div class="tv" data-d="'+str(k)+'">'+esc(v)+'</div>'
145 + '<div class="tl">'+esc(l)+'</div></div>')
146 + css="""
147 + #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center;gap:26px}
148 + #S .h{font-size:40px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:__A__;opacity:0;margin-bottom:8px}
149 + .scene.on #S_ .h{animation:fu .5s ease .1s forwards}
150 + #S .trow{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:30px;
151 + padding:40px 44px;backdrop-filter:blur(6px);opacity:0;transform:translateX(-60px)}
152 + .scene.on #S_ .trow{animation:slidein .6s cubic-bezier(.2,.9,.3,1) forwards}
153 + .scene.on #S_ .trow:nth-child(2){animation-delay:.25s}
154 + .scene.on #S_ .trow:nth-child(3){animation-delay:.5s}
155 + .scene.on #S_ .trow:nth-child(4){animation-delay:.75s}
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 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 226 spans=""
228 for i,w in enumerate(words):
227 + for k,w in enumerate(words):
229 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> '
231 L=len(fact); fs=62 if L<170 else (54 if L<240 else 47)
232 css=shell_css(p)+"""
233 .mid{flex:1;display:flex;flex-direction:column;justify-content:center;min-height:0}
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}
235 .qtext{font-size:__FS__px;font-weight:800;line-height:1.35;letter-spacing:-.01em;max-width:920px}
236 .w{display:inline-block;opacity:0;transform:translateY(26px);animation:fu .5s ease forwards}
237 .hl{color:__A__;filter:drop-shadow(0 6px 20px __A__44)}
238 """.replace("__A__",p["a"]).replace("__FS__",str(fs))
239 body=('<div class="stage"><div class="vign"></div><div class="pad">'
240 '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · LE SAVIEZ-VOUS ? · '+today().upper()+'</div>'
241 '<div class="kabadge">KA</div>'
242 '<div class="site">'+esc(ins["label"])+'</div>'
243 '<div class="mid"><div class="qmark">“</div><div class="qtext">'+spans+'</div></div>'
244 +shell_tail(ins)+'</div></div>')
245 return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+"</body></html>"
229 + spans+='<span class="'+cls+'" style="animation-delay:'+str(round(0.35+k*delay,2))+'s">'+esc(w)+'</span> '
230 + L=len(fact); fs=60 if L<170 else (53 if L<240 else 46)
231 + dur=min(6.0, 1.6+len(words)*delay+2.2)
232 + css="""
233 + #S .mid{flex:1;display:flex;flex-direction:column;justify-content:center}
234 + #S .qm{font-size:260px;line-height:.5;color:__A__;font-weight:900;font-family:Georgia,serif;height:140px;opacity:0}
235 + .scene.on #S_ .qm{animation:fu .5s ease .1s forwards}
236 + #S .qt{font-size:__FS__px;font-weight:800;line-height:1.35;letter-spacing:-.01em;max-width:920px}
237 + #S .w{display:inline-block;opacity:0;transform:translateY(24px)}
238 + .scene.on #S_ .w{animation:fu .45s ease forwards}
239 + #S .hl{color:__A__;filter:drop-shadow(0 6px 20px __A__44)}
240 + """.replace("__FS__",str(fs))
241 + html='<div class="mid"><div class="qm">“</div><div class="qt">'+spans+'</div></div>'
242 + return html, css, round(dur,1), None
243 +
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 309 def build_html(ins):
248 g=ins.get('genre')
249 if g in ('list','grid') and ins.get('list'):
250 return build_list(ins)
251 if g=='spotlight' and (ins.get('product') or {}).get('image_data'):
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 = """
310 + p=pal(ins["site"])
311 + scenes=plan_scenes(ins,p)
312 + tot=round(sum(s["dur"] for s in scenes),2)
313 + shell="""
263 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 316 .stage{position:relative;width:1080px;height:__H__px;overflow:hidden;
266 background:radial-gradient(1300px 900px at 78% -5%, __A__55,transparent 55%),
267 radial-gradient(1100px 1000px at -12% 105%, __B__3a,transparent 55%),
268 linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 14s ease-in-out infinite alternate}
317 + background:radial-gradient(1300px 900px at 78% -5%, __A__50,transparent 55%),
318 + radial-gradient(1100px 1000px at -12% 105%, __B__36,transparent 55%),
319 + linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 18s ease-in-out infinite alternate}
269 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%);
271 filter:blur(30px);animation:float 16s ease-in-out infinite alternate;opacity:.7}
272 @keyframes float{0%{transform:translate(-6%, -4%) scale(1)}100%{transform:translate(8%, 6%) scale(1.15)}}
273 .pad{position:absolute;inset:0;padding:120px 90px 250px;display:flex;flex-direction:column}
274 .eyebrow{display:flex;align-items:center;gap:16px;font-weight:800;letter-spacing:.16em;font-size:27px;
275 text-transform:uppercase;color:__A__;opacity:0;animation:fadeUp .8s ease .15s forwards}
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;
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}
322 + .eyeb{position:absolute;top:104px;left:90px;right:250px;display:flex;align-items:center;gap:14px;font-weight:800;
323 + letter-spacing:.16em;font-size:25px;text-transform:uppercase;color:__A__;z-index:8;opacity:0;animation:fu .7s ease .2s forwards}
324 + .eyeb .dot{width:46px;height:6px;border-radius:3px;background:__A__;flex:0 0 46px}
325 + .kabadge{position:absolute;top:92px;right:90px;background:__A__;color:#0d0d0d;font-weight:900;font-size:48px;
326 + padding:10px 24px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;z-index:8;
279 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}
281 .tag{font-size:38px;font-weight:600;color:#ffffffb0;margin-top:12px;opacity:0;animation:fadeUp .8s ease .65s forwards}
282 .mid{flex:1;display:flex;flex-direction:column;justify-content:center;min-height:0}
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}
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__}
328 + .wm{position:absolute;left:90px;bottom:96px;font-size:27px;font-weight:800;color:#ffffff9a;z-index:8}
329 + .wm b{color:__A__}
330 + .prog{position:absolute;left:0;bottom:0;height:9px;background:linear-gradient(90deg,__A__,__B__);width:0;z-index:9;
331 + animation:prog __TOT__s linear .1s forwards;box-shadow:0 0 20px __A__}
303 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 334 @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}
306 @keyframes heroIn{to{opacity:1;transform:translateY(0) scale(1)}}
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%)}
308 .numwrap{animation:heroIn .95s cubic-bezier(.2,1,.3,1) .95s forwards, pulse 3s ease-in-out 2s infinite}
309 @keyframes pulse{0%,100%{filter:none}50%{filter:brightness(1.12)}}
335 + .scene{position:absolute;inset:0;padding:220px 90px 210px;display:flex;flex-direction:column;z-index:4;
336 + opacity:0;pointer-events:none;transform:translateY(46px) scale(.985);
337 + transition:opacity .5s ease,transform .62s cubic-bezier(.2,.8,.2,1)}
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"])
312 .replace("__B__",p["b"]).replace("__G1__",p["g1"]).replace("__G2__",p["g2"])
313 .replace("__NUM__",str(numsize)).replace("__DUR__",str(DUR)))
314 tag = ('<div class="tag">'+esc(ins.get("tagline",""))+'</div>') if ins.get("tagline") else ""
315 tiles_block = ('<div class="tiles">'+tiles_html+'</div>') if tiles else ""
316 body = ('<div class="stage"><div class="mesh"></div><div class="vign"></div><div class="pad">'
317 '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · '+today().upper()+'</div>'
318 '<div class="kabadge">KA</div>'
319 '<div class="site">'+esc(ins["label"])+'</div>'+tag+
320 '<div class="mid">'
321 '<div class="numwrap"><div class="num" id="num" data-target="'+esc(ins["headline"])+'">'+esc(ins["headline"])+'</div></div>'
322 '<div class="lab">'+esc(ins["headline_label"])+'</div>'+tiles_block+
323 '</div>'
324 '<div class="cta"><div class="go">👉 www.'+ins["site"]+'.com</div><div class="brand">GROUPE ·KA</div></div>'
325 '<div class="sig">✦ Rédigé et publié par l\'Agent KA</div>'
326 '<div class="prog"></div>'
327 '</div></div>')
328 script = """<script>
329 (function(){
330 // ajuste la taille des valeurs de tuiles pour tenir sur une ligne
331 document.querySelectorAll('.tv[data-fit]').forEach(function(el){
332 var s=58; el.style.fontSize=s+'px';
333 while(el.scrollWidth>el.clientWidth && s>26){ s-=3; el.style.fontSize=s+'px'; }
344 + shell=(shell.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__A__",p["a"])
345 + .replace("__B__",p["b"]).replace("__G1__",p["g1"]).replace("__G2__",p["g2"])
346 + .replace("__TOT__",str(tot)).replace("__FOT__",str(round(tot-0.7,2))))
347 +
348 + css_all=[shell]; body=[]; sc_list=[]; hooks={}
349 + for s in scenes:
350 + sid="sc"+str(s["i"])
351 + css=(s["css"].replace(".scene.on #S_","#"+sid+".on").replace("#S_","#"+sid).replace("#S","#"+sid)
352 + .replace("__A__",p["a"]).replace("__B__",p["b"]).replace("__G2__",p["g2"]))
353 + # les hooks référencent des ids préfixés S_ -> remplacer par l'id de scène
354 + html=s["html"].replace('id="S_','id="'+sid+'_')
355 + hook=(s["hook"] or "").replace("'S_","'"+sid+"_")
356 + css_all.append(css)
357 + body.append('<div class="scene" id="'+sid+'">'+html+'</div>')
358 + sc_list.append([sid, s["dur"]])
359 + if hook: hooks[sid]=hook
360 +
361 + hooks_js="{"+",".join('"'+k+'":function(){'+v+'}' for k,v in hooks.items())+"}"
362 + script="""<script>
363 + function fmtInt(n){return n.toLocaleString('fr-CA').replace(/[\\u00a0\\u202f,]/g,' ')}
364 + function cuInt(id,target,suffix,dur,begin){var el=document.getElementById(id);if(!el)return;var s=null;
365 + function st(ts){if(!s)s=ts;var t=ts-s;if(t<begin){requestAnimationFrame(st);return}
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>"""
351 return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+script+"</body></html>"
387 + </script>""".replace("__SC__",json.dumps(sc_list)).replace("__HOOKS__",hooks_js)
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 398 from playwright.sync_api import sync_playwright
355 399 with sync_playwright() as pw:
356 400 b=pw.chromium.launch(args=["--no-sandbox"])
357 ctx=b.new_context(viewport={"width":1080,"height":HREEL}, device_scale_factor=2,
358 record_video_dir=tmpdir, record_video_size={"width":1080,"height":HREEL})
401 + ctx=b.new_context(viewport={"width":1080,"height":H}, device_scale_factor=2,
402 + record_video_dir=tmpdir, record_video_size={"width":1080,"height":H})
359 403 pg=ctx.new_page()
360 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 406 vid=pg.video
363 407 ctx.close(); b.close()
364 408 path=vid.path()
365 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 411 def render(ins, out_dir):
430 412 os.makedirs(out_dir,exist_ok=True)
431 413 prod=ins.get('product')
@@ -436,21 +418,28 @@ def render(ins, out_dir):
436 418 if it.get('image') and not it.get('image_data'):
437 419 du=data_uri(it['image'])
438 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 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))
442 webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp)
443 mood=ins.get('variant')
444 if mood is None:
445 mood=int(hashlib.md5((ins['site']+str(ins.get('insight_id',''))).encode()).hexdigest(),16)
446 wav=os.path.join(tmp,"bed.wav"); make_music(DUR,wav,int(mood))
424 + html,dur=build_html(ins)
425 + html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(html)
426 + webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp,dur)
427 +
428 + # musique : style en rotation via variant, contenu par graine (unique)
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 436 out=os.path.join(out_dir,f"reel-{ins['site']}-{ins.get('insight_id','x')}.mp4")
448 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]",
450 "-map","[v]","-map","1:a","-c:v","libx264","-preset","slow","-crf","18",
451 "-c:a","aac","-b:a","160k","-pix_fmt","yuv420p","-movflags","+faststart","-shortest",out],
438 + "-filter_complex","[0:v]scale=1080:"+str(H)+":force_original_aspect_ratio=increase,crop=1080:"+str(H)+",fps=30,format=yuv420p[v];"
439 + "[1:a]acompressor=threshold=-16dB:ratio=3:attack=8:release=180,alimiter=limit=0.95[a]",
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 442 capture_output=True)
453 # nettoyage
454 443 for f in glob.glob(os.path.join(tmp,"*")):
455 444 try: os.remove(f)
456 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