SPB Git forge

spb/admin-ka

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

Social: generateur images+reels v3 — diversite et beaute

Images (render_card_html.py) :
- 3 nouveaux genres : donut (part en % en anneau SVG), grid (mosaique 2x2
  de vraies photos), quote (le saviez-vous typographique, chiffres accentues)
- 3 themes en rotation par variant/hash : nuit (actuel), creme (clair
  neo-brutaliste Ka — cartes blanches bord noir + ombre dure, accents
  assombris lisibles), cadre (sombre encadre) ; genres photo restent nuit
- ranking : max 5 barres quand sous-textes (debordait sur le footer)
- auto-fit JS des gros chiffres (plus de debordement hero/tuiles)

Reels (make_reel.py) :
- 2 nouveaux gabarits : donut anime (trace de l anneau + count-up %) et
  quote (apparition mot a mot, chiffres en accent)
- 4 ambiances musicales en rotation (uplift Am-F-C-G, chill, energique
  Em-C-G-D, chaleureux F-G-Am) : tempo kick/shaker/arpege parametres
- bandeau : retrait a droite (la date passait sous le badge KA)

social.js :
- roue de rotation des genres bien plus riche pour les posts auto
  (hero/ranking/donut/quote/spotlight/list/grid selon donnees dispo)
  et pour les reels auto/lots ; variant (postCount) transmis aux rendus
  (theme visuel + ambiance musicale tournent a chaque post)
- planificateur Studio : nouveaux modes grid/donut/quote
- fetchItems : deduplication par titre (fini les annonces en double)
- runReelCycle diversifie (decorateForPrompt manquait)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 3613b45

3 changed files +479 −187

modified server/social.js +59 −26
@@ -531,9 +531,12 @@ async function fetchItems(site, count) {
531 531 const arr = data[cfg.arr] || [];
532 532 const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');
533 533 const valid = [];
534 + const seen = new Set();
534 535 for (const it of arr) {
535 536 const row = mapItem(site, it, fr);
536 537 if (!row || !row.image || !row.title) continue;
538 + if (seen.has(row.title)) continue; // pas deux fois la même adresse/annonce
539 + seen.add(row.title);
537 540 if (row.title.length > 64) row.title = row.title.slice(0, 62) + '…';
538 541 valid.push(row);
539 542 }
@@ -549,10 +552,10 @@ async function fetchItems(site, count) {
549 552 // analyse le prompt -> {site, mode, count}
550 553 async function planFromPrompt(prompt) {
551 554 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';
552 − const sys = "Tu es le planificateur du Studio KA. À partir d'une demande, renvoie UNIQUEMENT un objet JSON (rien d'autre) : {\"site\":\"<id ou vide>\",\"mode\":\"stat|spotlight|list\",\"count\":<1-6>}.\n" +
555 + const sys = "Tu es le planificateur du Studio KA. À partir d'une demande, renvoie UNIQUEMENT un objet JSON (rien d'autre) : {\"site\":\"<id ou vide>\",\"mode\":\"stat|spotlight|list|grid|donut|quote\",\"count\":<1-6>}.\n" +
553 556 "Sites: lou-ka (logements à louer), immo-ka (propriétés à vendre), vrai-prix (évaluations), auto-ka (autos occasion), food-ka (épicerie/soldes), fabri-ka (produits québécois), resto-ka (restos), sorti-ka (événements), crea-ka (créateurs), job-ka (emplois), trouve-ka (recherche web).\n" +
554 − "Modes: 'stat' = un chiffre marquant ; 'spotlight' = UNE annonce/produit en vedette avec photo ; 'list' = une LISTE de plusieurs derniers items (logements, propriétés, produits, événements).\n" +
555 − "Règles: 'liste/derniers/plusieurs/top N/montre' => mode=list (count = N demandé ou 5). 'dernier/une annonce/en vedette' => spotlight. Sinon stat. list & spotlight ne valent que pour lou-ka, immo-ka, fabri-ka, sorti-ka ; sinon stat. Choisis le site le plus pertinent.";
557 + "Modes: 'stat' = un chiffre marquant ; 'spotlight' = UNE annonce/produit en vedette avec photo ; 'list' = une LISTE de plusieurs derniers items ; 'grid' = MOSAÏQUE de 4 photos d'items ; 'donut' = un POURCENTAGE mis en scène en anneau ; 'quote' = le fait raconté en grande typo (« le saviez-vous »).\n" +
558 + "Règles: 'liste/derniers/plusieurs/top N/montre' => list (count = N demandé ou 5). 'mosaïque/collage/en images' => grid. 'dernier/une annonce/en vedette' => spotlight. 'pourcentage/part/proportion' => donut. 'fait/anecdote/saviez-vous/storytelling' => quote. Sinon stat. list/grid/spotlight ne valent que pour lou-ka, immo-ka, fabri-ka, sorti-ka ; sinon stat/donut/quote. Choisis le site le plus pertinent.";
556 559 try {
557 560 const raw = await anthropic(sys, "Demande : " + prompt, model, 120);
558 561 const m = raw.match(/\{[\s\S]*\}/);
@@ -560,38 +563,67 @@ async function planFromPrompt(prompt) {
560 563 return { site: (plan.site || '').trim(), mode: plan.mode || 'stat', count: Math.min(6, Math.max(2, plan.count || 5)) };
561 564 } catch { return { site: '', mode: 'stat', count: 5 }; }
562 565 }
563 −// applique un plan à un insight (list / spotlight / stat) + repli
564 −async function decoratePlan(insight, plan) {
565 − if (plan.mode === 'list' && SPOT[insight.site]) {
566 − const items = await fetchItems(insight.site, plan.count);
567 − if (items && items.length >= 2) { insight.list = items; insight.genre = 'list'; insight.list_title = LIST_TITLES[insight.site] || insight.headline_label; return insight; }
568 − }
569 − if ((plan.mode === 'list' || plan.mode === 'spotlight') && SPOT[insight.site]) {
566 +// pose la variante (rotation thème visuel + ambiance musicale des rendus)
567 +function applyVariant(insight) { insight.variant = state.postCount || 0; return insight; }
568 +const isPct = (insight) => /%\s*$/.test(String(insight.headline || ''));
569 +
570 +// attache les données nécessaires à un genre ; renvoie le genre réellement possible
571 +async function attachGenre(insight, genre, count) {
572 + if (genre === 'spotlight') {
570 573 const feat = await fetchFeatured(insight.site);
571 − if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }
574 + if (feat) { insight.product = feat; return 'spotlight'; }
575 + return null;
572 576 }
573 − insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';
577 + if (genre === 'list' || genre === 'grid') {
578 + const items = await fetchItems(insight.site, genre === 'grid' ? 4 : (count || 5));
579 + if (items && items.length >= (genre === 'grid' ? 4 : 2)) {
580 + insight.list = items;
581 + insight.list_title = LIST_TITLES[insight.site] || insight.headline_label;
582 + return genre;
583 + }
584 + return null;
585 + }
586 + if (genre === 'donut') return isPct(insight) ? 'donut' : null;
587 + if (genre === 'quote') return String(insight.fact || '').length >= 40 ? 'quote' : null;
588 + if (genre === 'ranking') return (insight.bars && insight.bars.length >= 4) ? 'ranking' : null;
589 + return 'hero';
590 +}
591 +
592 +// applique un plan à un insight (mode explicite du Studio) + repli
593 +async function decoratePlan(insight, plan) {
594 + applyVariant(insight);
595 + const wanted = plan.mode === 'stat' ? (isPct(insight) ? 'donut' : 'hero') : plan.mode;
596 + let g = await attachGenre(insight, wanted, plan.count);
597 + if (!g && (wanted === 'list' || wanted === 'grid')) g = await attachGenre(insight, 'spotlight');
598 + if (!g) g = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';
599 + insight.genre = g;
574 600 return insight;
575 601 }
576 602
603 +// rotation des genres pour les REELS auto / lots (sans prompt)
577 604 async function decorateForPrompt(insight) {
578 − if (SPOT[insight.site]) {
579 − const feat = await fetchFeatured(insight.site);
580 − if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }
581 − }
582 − insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';
605 + const n = applyVariant(insight).variant;
606 + const wheel = [];
607 + if (SPOT[insight.site]) wheel.push('spotlight', 'list');
608 + if (isPct(insight)) wheel.push('donut');
609 + wheel.push('hero', 'quote');
610 + let g = await attachGenre(insight, wheel[n % wheel.length]);
611 + if (!g) g = 'hero';
612 + insight.genre = g;
583 613 return insight;
584 614 }
585 615
586 −// choisit le genre visuel (rotation) et attache le produit vedette si spotlight
616 +// rotation des genres pour les POSTS image auto (le plus de diversité possible)
587 617 async function decorateCreative(insight) {
588 − const n = state.postCount || 0;
589 − let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';
590 − if (SPOT[insight.site] && (n % 3 === 0)) {
591 − const feat = await fetchFeatured(insight.site);
592 − if (feat) { insight.product = feat; genre = 'spotlight'; }
593 − }
594 − insight.genre = genre;
618 + const n = applyVariant(insight).variant;
619 + const wheel = ['hero'];
620 + if (insight.bars && insight.bars.length >= 4) wheel.push('ranking');
621 + if (isPct(insight)) wheel.push('donut');
622 + wheel.push('quote');
623 + if (SPOT[insight.site]) wheel.push('spotlight', 'list', 'grid');
624 + let g = await attachGenre(insight, wheel[n % wheel.length]);
625 + if (!g) g = 'hero';
626 + insight.genre = g;
595 627 return insight;
596 628 }
597 629
@@ -621,7 +653,7 @@ export async function writeCaption(insight, extraPrompt = '') {
621 653 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';
622 654 const url = `https://www.${insight.site}.com`;
623 655 let user;
624 − if (insight.list && insight.genre === 'list') {
656 + if (insight.list && (insight.genre === 'list' || insight.genre === 'grid')) {
625 657 const lignes = insight.list.slice(0, 5).map(x => '• ' + x.title + (x.price ? ' — ' + x.price : '') + (x.subtitle ? ' (' + x.subtitle + ')' : '')).join('\n');
626 658 user = `Présente CETTE liste réelle de ${insight.label} (n'invente rien, appuie-toi sur ces items) :\n` +
627 659 `${insight.list_title || ''}\n${lignes}\n\n` +
@@ -755,6 +787,7 @@ export async function runReelCycle(trigger = 'reel') {
755 787 const login = await fbLoginState();
756 788 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }
757 789 const insight = await pickInsight({});
790 + await decorateForPrompt(insight);
758 791 const video = await makeReel(insight);
759 792 const caption = await writeCaption(insight);
760 793 await publishReelToPage(caption, video);
modified server/social/make_reel.py +141 −14
@@ -39,6 +39,11 @@ def data_uri(url):
39 39 except Exception: return None
40 40 def esc(s): return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
41 41 def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}"
42 +import re
43 +def parse_pct(h):
44 + m=re.match(r'^\+?(\d+(?:[.,]\d+)?)\s*%$', str(h or "").strip())
45 + return float(m.group(1).replace(",",".")) if m else None
46 +NUMRE = re.compile(r'\d[\d\u00a0\u202f ,\.]*%?\$?')
42 47
43 48 def build_spotlight(ins):
44 49 H=HREEL; p=pal(ins["site"]); prod=ins.get("product") or {}
@@ -137,11 +142,118 @@ def build_list(ins):
137 142 + '</div></div>')
138 143 return "<!doctype html><html><head><meta charset=\"utf-8\"><style>" + css + "</style></head><body>" + body + "</body></html>"
139 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
178 + 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}
192 + @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","")))
225 + words=fact.split()
226 + delay=min(0.13, 5.5/max(1,len(words)))
227 + spans=""
228 + for i,w in enumerate(words):
229 + 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>"
246 +
140 247 def build_html(ins):
141 − if ins.get('genre')=='list' and ins.get('list'):
248 + g=ins.get('genre')
249 + if g in ('list','grid') and ins.get('list'):
142 250 return build_list(ins)
143 − if ins.get('genre')=='spotlight' and (ins.get('product') or {}).get('image_data'):
251 + if g=='spotlight' and (ins.get('product') or {}).get('image_data'):
144 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)
145 257 H=HREEL; p=pal(ins["site"])
146 258 tiles=ins.get("tiles") or []
147 259 hl=str(ins["headline"]); L=len(hl)
@@ -252,12 +364,24 @@ def record_webm(html_path, out_webm, tmpdir):
252 364 path=vid.path()
253 365 os.replace(path, out_webm)
254 366
255 −def make_music(dur, out_wav):
256 − """Lit musical riche : progression Am-F-C-G (pad) + basse + kick 4-temps + shaker
257 − + arpège/pluck brillant, avec réverbe/compresseur/limiteur."""
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."""
258 382 tmpdir=os.path.dirname(out_wav)
259 − 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)]
260 − roots=[110.00,87.31,130.81,98.00]
383 + M=MOODS[int(mood)%len(MOODS)]
384 + chords=M["chords"]; roots=M["roots"]; beat=M["beat"]; arpf=M["arp"]
261 385 seg=round(dur/len(chords),3)
262 386 def concat(paths,outp):
263 387 lst=outp+".txt"; open(lst,"w").write("".join("file '"+x+"'\n" for x in paths))
@@ -280,16 +404,16 @@ def make_music(dur, out_wav):
280 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),
281 405 w],capture_output=True); bassparts.append(w)
282 406 bass=os.path.join(tmpdir,"bass.wav"); concat(bassparts,bass)
283 − # kick 4-temps
407 + # kick (tempo selon l'ambiance)
284 408 kick=os.path.join(tmpdir,"kick.wav")
285 − subprocess.run([FFMPEG,"-y","-f","lavfi","-i","aevalsrc=0.75*sin(2*PI*55*t)*exp(-9*mod(t\,0.5)):d=%s:s=44100"%dur,kick],capture_output=True)
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)
286 410 # shaker/hat contretemps
287 411 hat=os.path.join(tmpdir,"hat.wav")
288 − subprocess.run([FFMPEG,"-y","-f","lavfi","-i","aevalsrc=0.10*(random(0)*2-1)*exp(-45*mod(t+0.25\,0.5)):d=%s:s=44100"%dur,"-af","highpass=f=6500",hat],capture_output=True)
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)
289 413 # arpège/pluck brillant (croches, deux notes en alternance)
290 − arp=os.path.join(tmpdir,"arp.wav")
414 + arp=os.path.join(tmpdir,"arp.wav"); e=beat/2; e2=beat/4
291 415 subprocess.run([FFMPEG,"-y","-f","lavfi",
292 − "-i","aevalsrc=0.13*sin(2*PI*880*t)*exp(-13*mod(t\,0.25))+0.10*sin(2*PI*659.25*(t-0.125))*exp(-13*mod(t+0.125\,0.25)):d=%s:s=44100"%dur,
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),
293 417 "-af","highpass=f=350,lowpass=f=6000",arp],capture_output=True)
294 418 # mix final
295 419 subprocess.run([FFMPEG,"-y","-i",pad,"-i",bass,"-i",kick,"-i",hat,"-i",arp,
@@ -312,11 +436,14 @@ def render(ins, out_dir):
312 436 if it.get('image') and not it.get('image_data'):
313 437 du=data_uri(it['image'])
314 438 if du: it['image_data']=du
315 − key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8]
439 + key=hashlib.md5((ins['site']+str(ins.get('insight_id',''))+str(ins.get('genre',''))+'reel').encode()).hexdigest()[:8]
316 440 tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True)
317 441 html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins))
318 442 webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp)
319 − wav=os.path.join(tmp,"bed.wav"); make_music(DUR,wav)
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))
320 447 out=os.path.join(out_dir,f"reel-{ins['site']}-{ins.get('insight_id','x')}.mp4")
321 448 subprocess.run([FFMPEG,"-y","-i",webm,"-i",wav,
322 449 "-filter_complex","[0:v]scale=1080:"+str(HREEL)+":force_original_aspect_ratio=increase,crop=1080:"+str(HREEL)+",fps=30,format=yuv420p[v]",
modified server/social/render_card_html.py +279 −147
@@ -2,13 +2,15 @@
2 2 """Rendu d'images sociales KA haute qualité via Chrome headless (HTML/CSS).
3 3
4 4 Lit un JSON « creative » sur stdin et produit une image 1080x1350 spectaculaire.
5 −Plusieurs « genres » de mise en page, dégradés par site, photos produit réelles.
6 5
7 6 Genres : hero (nombre géant), ranking (palmarès), spotlight (produit vedette photo),
8 − deal (aubaine prix), pulse (activité live).
7 + list (sélection d'items photo), grid (mosaïque 2x2 de photos),
8 + donut (part en % en anneau SVG), quote (« le saviez-vous » typographique).
9 +Thèmes (rotation via `variant` ou hash) : nuit (sombre), creme (clair néo-brutaliste Ka),
10 + cadre (sombre encadré). Les genres photo (spotlight/list/grid) restent en nuit.
9 11 Sortie : chemin du PNG sur stdout.
10 12 """
11 −import json, sys, os, base64, datetime, urllib.request, ssl, hashlib, subprocess, glob
13 +import json, sys, os, re, base64, datetime, urllib.request, ssl, hashlib, subprocess, glob
12 14
13 15 CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
14 16
@@ -23,19 +25,20 @@ def find_chrome():
23 25 MOIS = ["janvier","février","mars","avril","mai","juin","juillet","août",
24 26 "septembre","octobre","novembre","décembre"]
25 27
26 −# palettes de dégradé par site (2 arrêts sombres tintés + couleur d'accent vive)
28 +# palettes par site : accent vif (a), accent secondaire (b), dégradé sombre (g1,g2),
29 +# accent assombri lisible sur fond clair (ad)
27 30 PALETTES = {
28 − "lou-ka": {"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09"},
29 − "immo-ka": {"a":"#e23744","b":"#ff6b76","g1":"#2a0c10","g2":"#0d0a0b"},
30 − "food-ka": {"a":"#1f9d55","b":"#5fd08a","g1":"#07230f","g2":"#0a0d0b"},
31 − "fabri-ka": {"a":"#c4532e","b":"#e8895f","g1":"#241009","g2":"#0d0a09"},
32 − "resto-ka": {"a":"#f08c00","b":"#ffb84d","g1":"#241804","g2":"#0d0b08"},
33 − "auto-ka": {"a":"#ff5a2a","b":"#ff8f6b","g1":"#26100a","g2":"#0d0a09"},
34 − "vrai-prix":{"a":"#d9f26b","b":"#b8e04a","g1":"#16230a","g2":"#0b0d08"},
35 − "trouve-ka":{"a":"#d9f26b","b":"#8fd3ff","g1":"#0a1a20","g2":"#080b0d"},
36 − "sorti-ka": {"a":"#d9f26b","b":"#ff8fd0","g1":"#210a1c","g2":"#0c090c"},
37 − "crea-ka": {"a":"#d9f26b","b":"#c08bff","g1":"#160a24","g2":"#0a090d"},
38 − "job-ka": {"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"},
31 + "lou-ka": {"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09","ad":"#c24f00"},
32 + "immo-ka": {"a":"#e23744","b":"#ff6b76","g1":"#2a0c10","g2":"#0d0a0b","ad":"#b02532"},
33 + "food-ka": {"a":"#1f9d55","b":"#5fd08a","g1":"#07230f","g2":"#0a0d0b","ad":"#157a41"},
34 + "fabri-ka": {"a":"#c4532e","b":"#e8895f","g1":"#241009","g2":"#0d0a09","ad":"#9c3f21"},
35 + "resto-ka": {"a":"#f08c00","b":"#ffb84d","g1":"#241804","g2":"#0d0b08","ad":"#b96c00"},
36 + "auto-ka": {"a":"#ff5a2a","b":"#ff8f6b","g1":"#26100a","g2":"#0d0a09","ad":"#cc3f14"},
37 + "vrai-prix":{"a":"#d9f26b","b":"#b8e04a","g1":"#16230a","g2":"#0b0d08","ad":"#6b7f1d"},
38 + "trouve-ka":{"a":"#d9f26b","b":"#8fd3ff","g1":"#0a1a20","g2":"#080b0d","ad":"#6b7f1d"},
39 + "sorti-ka": {"a":"#d9f26b","b":"#ff8fd0","g1":"#210a1c","g2":"#0c090c","ad":"#6b7f1d"},
40 + "crea-ka": {"a":"#d9f26b","b":"#c08bff","g1":"#160a24","g2":"#0a090d","ad":"#6b7f1d"},
41 + "job-ka": {"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d","ad":"#6b7f1d"},
39 42 }
40 43
41 44 def pal(site): return PALETTES.get(site, PALETTES["vrai-prix"])
@@ -58,155 +61,195 @@ def today_str():
58 61 t = datetime.date.today()
59 62 return f"{t.day} {MOIS[t.month-1]} {t.year}"
60 63
64 +def parse_pct(h):
65 + m = re.match(r'^\+?(\d+(?:[.,]\d+)?)\s*%$', str(h or "").strip())
66 + return float(m.group(1).replace(",", ".")) if m else None
67 +
61 68 FONT = "'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif"
69 +THEMES = ["nuit","creme","cadre"]
62 70
63 −def base_css(p):
64 − return f"""
65 − *{{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased;}}
66 − html,body{{width:1080px;height:1350px;overflow:hidden;font-family:{FONT};color:#fff;}}
67 − .stage{{position:relative;width:1080px;height:1350px;
68 − background:
69 − radial-gradient(1200px 700px at 82% -6%, {p['a']}44 0%, transparent 55%),
70 − radial-gradient(900px 900px at -12% 108%, {p['b']}33 0%, transparent 55%),
71 − linear-gradient(160deg, {p['g1']} 0%, {p['g2']} 100%);}}
72 − .grain{{position:absolute;inset:0;opacity:.045;mix-blend-mode:overlay;
73 − background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");}}
74 − .pad{{position:absolute;inset:0;padding:70px 72px 250px;display:flex;flex-direction:column;}}
75 − .eyebrow{{display:flex;align-items:center;gap:14px;font-weight:800;letter-spacing:.16em;
76 − font-size:22px;text-transform:uppercase;color:{p['a']};}}
77 − .eyebrow .dot{{width:40px;height:5px;border-radius:3px;background:{p['a']};}}
78 − .kabadge{{position:absolute;top:60px;right:72px;background:{p['a']};color:#0d0d0d;
79 − font-weight:900;font-size:40px;padding:8px 20px;border-radius:16px;transform:rotate(-4deg);
80 − box-shadow:0 10px 30px {p['a']}55;}}
81 − .site{{font-size:88px;font-weight:900;letter-spacing:-.02em;margin-top:26px;}}
82 − .tag{{font-size:31px;font-weight:600;color:#ffffffb0;margin-top:6px;}}
83 − .foot{{position:absolute;left:72px;right:72px;bottom:66px;display:flex;justify-content:space-between;
84 − align-items:center;font-weight:800;}}
85 − .foot .url{{font-size:34px;color:{p['a']};letter-spacing:.01em;}}
86 − .foot .brand{{font-size:26px;color:#ffffff88;letter-spacing:.12em;}}
87 − .sig{{position:absolute;left:72px;bottom:150px;font-size:20px;color:#ffffff66;font-weight:600;
88 − display:flex;align-items:center;gap:8px;}}
89 − .glass{{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);
90 − border-radius:22px;backdrop-filter:blur(8px);}}
91 − """
71 +def theme_of(ins):
72 + t = ins.get("theme")
73 + if t in THEMES: return t
74 + v = ins.get("variant")
75 + if v is None:
76 + v = int(hashlib.md5((ins["site"]+str(ins.get("insight_id",""))).encode()).hexdigest(), 16)
77 + return THEMES[int(v) % len(THEMES)]
92 78
93 −def hdr(ins, p):
94 − return f"""
95 − <div class="eyebrow"><span class="dot"></span>{esc(ins['label'].upper())} · STAT DU JOUR · {today_str().upper()}</div>
96 − <div class="kabadge">KA</div>
97 − <div class="site">{esc(ins['label'])}</div>
98 − {f'<div class="tag">{esc(ins.get("tagline",""))}</div>' if ins.get('tagline') else ''}
79 +def tokens(p, theme):
80 + if theme == "creme":
81 + return {
82 + "fg":"#141814","muted":"#141814b3","muted2":"#14181487","sig":"#14181466",
83 + "acc":p["ad"],
84 + "glass":"background:#ffffff;border:3px solid #141814;box-shadow:9px 9px 0 #141814;",
85 + "badge_extra":"border:3px solid #141814;box-shadow:7px 7px 0 #141814;",
86 + "numgrad":"background:linear-gradient(180deg,#141814 0%,"+p["ad"]+" 150%);",
87 + "numglow":"filter:drop-shadow(0 8px 22px "+p["a"]+"40);",
88 + "track":"#1418141f",
89 + "grain_op":".05",
90 + "stage":("background:radial-gradient(1100px 680px at 85% -8%, "+p["a"]+"30 0%, transparent 55%),"
91 + "radial-gradient(900px 900px at -10% 112%, "+p["b"]+"26 0%, transparent 55%),"
92 + "linear-gradient(168deg,#f7f6ef 0%,#edece4 100%);"),
93 + "frame":"",
94 + }
95 + t = {
96 + "fg":"#ffffff","muted":"#ffffffb0","muted2":"#ffffff8c","sig":"#ffffff66",
97 + "acc":p["a"],
98 + "glass":"background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);backdrop-filter:blur(8px);",
99 + "badge_extra":"box-shadow:0 10px 30px "+p["a"]+"55;",
100 + "numgrad":"background:linear-gradient(180deg,#fff 0%,"+p["a"]+" 130%);",
101 + "numglow":"filter:drop-shadow(0 12px 40px "+p["a"]+"44);",
102 + "track":"rgba(255,255,255,.09)",
103 + "grain_op":".045",
104 + "stage":("background:radial-gradient(1200px 700px at 82% -6%, "+p["a"]+"44 0%, transparent 55%),"
105 + "radial-gradient(900px 900px at -12% 108%, "+p["b"]+"33 0%, transparent 55%),"
106 + "linear-gradient(160deg, "+p["g1"]+" 0%, "+p["g2"]+" 100%);"),
107 + "frame":"",
108 + }
109 + if theme == "cadre":
110 + t["stage"] = ("background:radial-gradient(1100px 800px at 50% -12%, "+p["a"]+"3a 0%, transparent 60%),"
111 + "radial-gradient(800px 800px at 100% 110%, "+p["b"]+"2a 0%, transparent 55%),"
112 + "linear-gradient(200deg, "+p["g1"]+" 0%, "+p["g2"]+" 100%);")
113 + t["frame"] = (".stage::before{content:'';position:absolute;inset:26px;border:2px solid "+p["a"]+"66;"
114 + "border-radius:36px;pointer-events:none;z-index:5}"
115 + ".stage::after{content:'';position:absolute;inset:38px;border:1px solid #ffffff1c;"
116 + "border-radius:28px;pointer-events:none;z-index:5}")
117 + return t
118 +
119 +def base_css(p, T):
120 + css = """
121 + *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased;}
122 + html,body{width:1080px;height:1350px;overflow:hidden;font-family:__FONT__;color:__FG__;}
123 + .stage{position:relative;width:1080px;height:1350px;__STAGE__}
124 + __FRAME__
125 + .grain{position:absolute;inset:0;opacity:__GOP__;mix-blend-mode:overlay;
126 + background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");}
127 + .pad{position:absolute;inset:0;padding:70px 72px 250px;display:flex;flex-direction:column;z-index:2;}
128 + .eyebrow{display:flex;align-items:center;gap:14px;font-weight:800;letter-spacing:.16em;
129 + font-size:22px;text-transform:uppercase;color:__ACC__;}
130 + .eyebrow .dot{width:40px;height:5px;border-radius:3px;background:__ACC__;}
131 + .kabadge{position:absolute;top:60px;right:72px;background:__A__;color:#0d0d0d;
132 + font-weight:900;font-size:40px;padding:8px 20px;border-radius:16px;transform:rotate(-4deg);z-index:6;__BADGE__}
133 + .site{font-size:88px;font-weight:900;letter-spacing:-.02em;margin-top:26px;}
134 + .tag{font-size:31px;font-weight:600;color:__MUTED__;margin-top:6px;}
135 + .foot{position:absolute;left:72px;right:72px;bottom:66px;display:flex;justify-content:space-between;
136 + align-items:center;font-weight:800;z-index:6;}
137 + .foot .url{font-size:34px;color:__ACC__;letter-spacing:.01em;}
138 + .foot .brand{font-size:26px;color:__MUTED2__;letter-spacing:.12em;}
139 + .sig{position:absolute;left:72px;bottom:150px;font-size:20px;color:__SIG__;font-weight:600;
140 + display:flex;align-items:center;gap:8px;z-index:6;}
141 + .glass{border-radius:22px;__GLASS__}
99 142 """
143 + return (css.replace("__FONT__",FONT).replace("__FG__",T["fg"]).replace("__STAGE__",T["stage"])
144 + .replace("__FRAME__",T["frame"]).replace("__GOP__",T["grain_op"])
145 + .replace("__MUTED2__",T["muted2"]).replace("__MUTED__",T["muted"]).replace("__SIG__",T["sig"])
146 + .replace("__GLASS__",T["glass"]).replace("__BADGE__",T["badge_extra"])
147 + .replace("__ACC__",T["acc"]).replace("__A__",p["a"]))
148 +
149 +def hdr(ins, p, kicker="STAT DU JOUR"):
150 + return ('<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · '
151 + + esc(kicker)+' · '+today_str().upper()+'</div>'
152 + + '<div class="kabadge">KA</div>'
153 + + '<div class="site">'+esc(ins["label"])+'</div>'
154 + + (('<div class="tag">'+esc(ins.get("tagline",""))+'</div>') if ins.get("tagline") else ''))
100 155
101 156 def footer(ins, p):
102 157 url = "www." + ins["site"] + ".com"
103 − return f"""
104 − <div class="sig">✶ Rédigé et publié par l'Agent KA</div>
105 − <div class="foot"><div class="url">{url}</div><div class="brand">GROUPE ·KA</div></div>
106 − """
158 + return ('<div class="sig">✶ Rédigé et publié par l\'Agent KA</div>'
159 + + '<div class="foot"><div class="url">'+url+'</div><div class="brand">GROUPE ·KA</div></div>')
107 160
108 −def tiles_html(tiles, p):
161 +def tiles_html(tiles, p, T):
109 162 cells = ""
110 163 for v, l in tiles:
111 − cells += f"""<div class="glass tile"><div class="tv">{esc(v)}</div><div class="tl">{esc(l)}</div></div>"""
112 − return f"""<div class="tiles">{cells}</div>
113 − <style>
114 − .tiles{{display:grid;grid-template-columns:repeat({len(tiles)},1fr);gap:18px;margin-top:auto;margin-bottom:0;}}
115 − .tile{{padding:22px 24px;}}
116 − .tv{{font-size:46px;font-weight:900;color:{p['a']};letter-spacing:-.01em;}}
117 − .tl{{font-size:23px;font-weight:600;color:#ffffffa0;margin-top:6px;line-height:1.2;}}
118 − </style>"""
164 + cells += '<div class="glass tile"><div class="tv" data-fit>'+esc(v)+'</div><div class="tl">'+esc(l)+'</div></div>'
165 + css = """
166 + .tiles{display:grid;grid-template-columns:repeat(__N__,1fr);gap:18px;margin-top:auto;margin-bottom:0;}
167 + .tile{padding:22px 24px;}
168 + .tv{font-size:46px;font-weight:900;color:__ACC__;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;}
169 + .tl{font-size:23px;font-weight:600;color:__MUTED__;margin-top:6px;line-height:1.2;}
170 + """.replace("__N__",str(len(tiles))).replace("__ACC__",T["acc"]).replace("__MUTED__",T["muted"])
171 + return '<div class="tiles">'+cells+'</div><style>'+css+'</style>'
119 172
120 −def genre_hero(ins, p):
173 +def genre_hero(ins, p, T):
121 174 tiles = ins.get("tiles") or []
122 − return f"""
123 − <div class="pad">
124 − {hdr(ins,p)}
125 − <div class="hero">
126 − <div class="num">{esc(ins['headline'])}</div>
127 − <div class="lab">{esc(ins['headline_label'])}</div>
128 − </div>
129 − {tiles_html(tiles,p) if tiles else '<div style="margin-top:auto"></div>'}
130 − {footer(ins,p)}
131 − </div>
132 − <style>
133 − .hero{{margin-top:70px;}}
134 − .num{{font-size:230px;font-weight:900;line-height:.9;letter-spacing:-.03em;
135 − background:linear-gradient(180deg,#fff 0%,{p['a']} 130%);-webkit-background-clip:text;
136 − -webkit-text-fill-color:transparent;filter:drop-shadow(0 12px 40px {p['a']}44);}}
137 − .lab{{font-size:44px;font-weight:700;color:#fff;margin-top:20px;max-width:820px;line-height:1.12;}}
138 − </style>"""
175 + hl = str(ins["headline"]); L = len(hl)
176 + numsize = 150 if L >= 10 else (190 if L >= 8 else 230)
177 + css = """
178 + .hero{margin-top:70px;}
179 + .num{font-size:__NS__px;font-weight:900;line-height:.9;letter-spacing:-.03em;display:block;
180 + max-width:100%;white-space:nowrap;overflow:hidden;
181 + __NUMGRAD__-webkit-background-clip:text;-webkit-text-fill-color:transparent;__NUMGLOW__}
182 + .lab{font-size:44px;font-weight:700;margin-top:20px;max-width:820px;line-height:1.12;}
183 + """.replace("__NS__",str(numsize)).replace("__NUMGRAD__",T["numgrad"]).replace("__NUMGLOW__",T["numglow"])
184 + return ('<div class="pad">'+hdr(ins,p)
185 + + '<div class="hero"><div class="num" data-fit>'+esc(hl)+'</div>'
186 + + '<div class="lab">'+esc(ins["headline_label"])+'</div></div>'
187 + + (tiles_html(tiles,p,T) if tiles else '<div style="margin-top:auto"></div>')
188 + + footer(ins,p)+'</div><style>'+css+'</style>')
139 189
140 −def genre_ranking(ins, p):
190 +def genre_ranking(ins, p, T):
141 191 bars = ins.get("bars") or []
192 + # avec sous-textes, 6 rangées débordent sur le pied de page -> max 5
193 + has_sub = any(len(b) > 3 and b[3] for b in bars)
194 + bars = bars[:5] if has_sub else bars[:6]
142 195 maxv = max([b[1] for b in bars] or [1])
143 196 rows = ""
144 197 for b in bars:
145 198 label, val, vtxt = b[0], b[1], b[2]
146 199 sub = b[3] if len(b) > 3 else ""
147 200 w = max(8, int(100*val/maxv))
148 − rows += f"""
149 − <div class="row">
150 − <div class="rl"><span class="rlab">{esc(label)}</span><span class="rval">{esc(vtxt)}</span></div>
151 − <div class="track"><div class="fill" style="width:{w}%"></div></div>
152 − {f'<div class="rsub">{esc(sub)}</div>' if sub else ''}
153 − </div>"""
154 − return f"""
155 − <div class="pad">
156 − {hdr(ins,p)}
157 − <div class="rank-title">{esc(ins['headline_label'])}</div>
158 − <div class="rows">{rows}</div>
159 − {footer(ins,p)}
160 − </div>
161 − <style>
162 − .rank-title{{font-size:40px;font-weight:800;color:#fff;margin-top:50px;margin-bottom:8px;}}
163 − .rows{{margin-top:20px;display:flex;flex-direction:column;gap:30px;}}
164 − .rl{{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;}}
165 − .rlab{{font-size:34px;font-weight:700;}}
166 − .rval{{font-size:36px;font-weight:900;color:{p['a']};}}
167 − .track{{height:20px;border-radius:12px;background:rgba(255,255,255,.09);overflow:hidden;}}
168 − .fill{{height:100%;border-radius:12px;background:linear-gradient(90deg,{p['a']},{p['b']});
169 − box-shadow:0 4px 18px {p['a']}55;}}
170 − .rsub{{font-size:22px;color:#ffffff8c;margin-top:8px;font-weight:600;}}
171 − </style>"""
201 + rows += ('<div class="row"><div class="rl"><span class="rlab">'+esc(label)+'</span>'
202 + + '<span class="rval">'+esc(vtxt)+'</span></div>'
203 + + '<div class="track"><div class="fill" style="width:'+str(w)+'%"></div></div>'
204 + + (('<div class="rsub">'+esc(sub)+'</div>') if sub else '')+'</div>')
205 + css = """
206 + .rank-title{font-size:40px;font-weight:800;margin-top:50px;margin-bottom:8px;}
207 + .rows{margin-top:20px;display:flex;flex-direction:column;gap:30px;}
208 + .rl{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;}
209 + .rlab{font-size:34px;font-weight:700;}
210 + .rval{font-size:36px;font-weight:900;color:__ACC__;}
211 + .track{height:20px;border-radius:12px;background:__TRACK__;overflow:hidden;}
212 + .fill{height:100%;border-radius:12px;background:linear-gradient(90deg,__ACC__,__B__);
213 + box-shadow:0 4px 18px __A__55;}
214 + .rsub{font-size:22px;color:__MUTED2__;margin-top:8px;font-weight:600;}
215 + """.replace("__ACC__",T["acc"]).replace("__TRACK__",T["track"]).replace("__B__",p["b"]) \
216 + .replace("__A__",p["a"]).replace("__MUTED2__",T["muted2"])
217 + return ('<div class="pad">'+hdr(ins,p)
218 + + '<div class="rank-title">'+esc(ins["headline_label"])+'</div>'
219 + + '<div class="rows">'+rows+'</div>'+footer(ins,p)+'</div><style>'+css+'</style>')
172 220
173 −def genre_spotlight(ins, p):
221 +def genre_spotlight(ins, p, T):
174 222 prod = ins.get("product") or {}
175 223 img = prod.get("image_data") or ""
176 − chips = "".join(f'<span class="chip">{esc(c)}</span>' for c in (prod.get("meta") or []))
224 + chips = "".join('<span class="chip">'+esc(c)+'</span>' for c in (prod.get("meta") or []))
177 225 price = prod.get("price","")
178 − return f"""
179 − <div class="stage-photo" style="background-image:url('{img}')"></div>
180 − <div class="scrim"></div>
181 − <div class="pad">
182 − <div class="eyebrow"><span class="dot"></span>{esc(ins['label'].upper())} · À DÉCOUVRIR · {today_str().upper()}</div>
183 − <div class="kabadge">KA</div>
184 − <div class="spot">
185 − <div class="ptitle">{esc(prod.get('title',''))}</div>
186 − <div class="psub">{esc(prod.get('subtitle',''))}</div>
187 − <div class="chips">{chips}</div>
188 − {f'<div class="price">{esc(price)}</div>' if price else ''}
189 − <div class="tagline2">{esc(ins.get('tagline',''))}</div>
190 − </div>
191 − {footer(ins,p)}
192 − </div>
193 − <style>
194 − .stage-photo{{position:absolute;top:0;left:0;right:0;height:760px;background-size:cover;background-position:center;}}
195 − .scrim{{position:absolute;inset:0;background:
196 − linear-gradient(180deg, rgba(0,0,0,.15) 0%, rgba(0,0,0,0) 34%, {p['g2']}cc 62%, {p['g2']} 82%);}}
197 − .spot{{margin-top:auto;}}
198 − .ptitle{{font-size:62px;font-weight:900;line-height:1.02;letter-spacing:-.02em;max-width:900px;
199 − text-shadow:0 4px 24px #000a;}}
200 − .psub{{font-size:30px;font-weight:600;color:#ffffffc0;margin-top:12px;}}
201 − .chips{{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}}
202 − .chip{{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);border-radius:999px;
203 − padding:10px 20px;font-size:24px;font-weight:700;}}
204 − .price{{font-size:80px;font-weight:900;color:{p['a']};margin-top:24px;letter-spacing:-.02em;
205 − filter:drop-shadow(0 8px 24px {p['a']}55);}}
206 − .tagline2{{font-size:28px;font-weight:600;color:#ffffffa8;margin-top:18px;}}
207 − </style>"""
226 + css = """
227 + .stage-photo{position:absolute;top:0;left:0;right:0;height:760px;background-size:cover;background-position:center;}
228 + .scrim{position:absolute;inset:0;background:
229 + linear-gradient(180deg, rgba(0,0,0,.15) 0%, rgba(0,0,0,0) 34%, __G2__cc 62%, __G2__ 82%);}
230 + .spot{margin-top:auto;}
231 + .ptitle{font-size:62px;font-weight:900;line-height:1.02;letter-spacing:-.02em;max-width:900px;
232 + text-shadow:0 4px 24px #000a;}
233 + .psub{font-size:30px;font-weight:600;color:#ffffffc0;margin-top:12px;}
234 + .chips{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}
235 + .chip{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);border-radius:999px;
236 + padding:10px 20px;font-size:24px;font-weight:700;}
237 + .price{font-size:80px;font-weight:900;color:__A__;margin-top:24px;letter-spacing:-.02em;
238 + filter:drop-shadow(0 8px 24px __A__55);}
239 + .tagline2{font-size:28px;font-weight:600;color:#ffffffa8;margin-top:18px;}
240 + """.replace("__G2__",p["g2"]).replace("__A__",p["a"])
241 + return ('<div class="stage-photo" style="background-image:url(\''+img+'\')"></div><div class="scrim"></div>'
242 + + '<div class="pad">'
243 + + '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · À DÉCOUVRIR · '+today_str().upper()+'</div>'
244 + + '<div class="kabadge">KA</div>'
245 + + '<div class="spot"><div class="ptitle">'+esc(prod.get("title",""))+'</div>'
246 + + '<div class="psub">'+esc(prod.get("subtitle",""))+'</div>'
247 + + '<div class="chips">'+chips+'</div>'
248 + + (('<div class="price">'+esc(price)+'</div>') if price else '')
249 + + '<div class="tagline2">'+esc(ins.get("tagline",""))+'</div></div>'
250 + + footer(ins,p)+'</div><style>'+css+'</style>')
208 251
209 −def genre_list(ins, p):
252 +def genre_list(ins, p, T):
210 253 items = ins.get("list") or []
211 254 rows = ""
212 255 for it in items[:5]:
@@ -238,22 +281,111 @@ def genre_list(ins, p):
238 281 + footer(ins, p)
239 282 + '</div><style>' + css + '</style>')
240 283
284 +def genre_grid(ins, p, T):
285 + items = [it for it in (ins.get("list") or []) if it.get("image_data")][:4]
286 + cells = ""
287 + for it in items:
288 + cells += ('<div class="cell"><div class="cph" style="background-image:url(\''+it["image_data"]+'\')"></div>'
289 + + '<div class="cscrim"></div><div class="cbot"><div class="ct">'+esc(it.get("title",""))+'</div>'
290 + + (('<div class="cp">'+esc(it["price"])+'</div>') if it.get("price") else '')
291 + + '</div></div>')
292 + heading = esc(ins.get("list_title","") or ins.get("headline_label","") or "À la une")
293 + css = """
294 + .gtitle{font-size:42px;font-weight:800;color:#fff;margin-top:10px;margin-bottom:22px;max-width:920px;line-height:1.1}
295 + .gwrap{display:grid;grid-template-columns:1fr 1fr;gap:18px}
296 + .cell{position:relative;height:350px;border-radius:22px;overflow:hidden;border:1px solid rgba(255,255,255,.16)}
297 + .cph{position:absolute;inset:0;background-size:cover;background-position:center}
298 + .cscrim{position:absolute;inset:0;background:linear-gradient(180deg,transparent 42%,#000d 100%)}
299 + .cbot{position:absolute;left:18px;right:18px;bottom:14px}
300 + .ct{font-size:26px;font-weight:800;line-height:1.15;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;text-shadow:0 2px 10px #000}
301 + .cp{font-size:28px;font-weight:900;color:__A__;margin-top:4px;text-shadow:0 2px 10px #000}
302 + """.replace("__A__", p["a"])
303 + return ('<div class="pad">'
304 + + '<div class="eyebrow"><span class="dot"></span>' + esc(ins["label"].upper()) + ' · EN IMAGES · ' + today_str().upper() + '</div>'
305 + + '<div class="kabadge">KA</div>'
306 + + '<div class="site" style="font-size:68px;margin-top:14px">' + esc(ins["label"]) + '</div>'
307 + + '<div class="gtitle">' + heading + '</div>'
308 + + '<div class="gwrap">' + cells + '</div>'
309 + + footer(ins, p)
310 + + '</div><style>' + css + '</style>')
311 +
312 +NUMRE = re.compile(r'(\d[\d\u00a0\u202f ,\.]*\d|\d)(\s?(%|\$|G\$|M\$|milliards?|millions?))?')
313 +
314 +def genre_quote(ins, p, T):
315 + fact = str(ins.get("fact") or (str(ins["headline"])+" "+ins.get("headline_label","")))
316 + txt = NUMRE.sub(lambda m: '<em>'+m.group(0)+'</em>', esc(fact))
317 + L = len(fact)
318 + fs = 64 if L < 170 else (56 if L < 240 else 48)
319 + tiles = (ins.get("tiles") or [])[:3]
320 + css = """
321 + .qmark{font-size:230px;line-height:.55;color:__ACC__;font-weight:900;margin-top:70px;height:118px;
322 + font-family:Georgia,serif;opacity:.9}
323 + .qtext{font-size:__FS__px;font-weight:800;line-height:1.3;letter-spacing:-.01em;max-width:930px;}
324 + .qtext em{font-style:normal;color:__ACC__;}
325 + """.replace("__ACC__",T["acc"]).replace("__FS__",str(fs))
326 + return ('<div class="pad">'+hdr(ins,p,"LE SAVIEZ-VOUS ?")
327 + + '<div class="qmark">“</div>'
328 + + '<div class="qtext">'+txt+'</div>'
329 + + (tiles_html(tiles,p,T) if tiles else '<div style="margin-top:auto"></div>')
330 + + footer(ins,p)+'</div><style>'+css+'</style>')
331 +
332 +def genre_donut(ins, p, T):
333 + pct = parse_pct(ins["headline"]) or 0.0
334 + C = 2*3.14159*210
335 + filled = C*min(100.0, pct)/100.0
336 + svg = ('<svg width="540" height="540" viewBox="0 0 540 540">'
337 + + '<circle cx="270" cy="270" r="210" fill="none" stroke="'+T["track"]+'" stroke-width="60"/>'
338 + + '<circle cx="270" cy="270" r="210" fill="none" stroke="url(#gd)" stroke-width="60" stroke-linecap="round" '
339 + + 'stroke-dasharray="'+str(round(filled,1))+' '+str(round(C,1))+'" transform="rotate(-90 270 270)"/>'
340 + + '<defs><linearGradient id="gd" x1="0" y1="0" x2="1" y2="1">'
341 + + '<stop offset="0" stop-color="'+T["acc"]+'"/><stop offset="1" stop-color="'+p["b"]+'"/></linearGradient></defs></svg>')
342 + tiles = (ins.get("tiles") or [])[:3]
343 + css = """
344 + .drow{display:flex;align-items:center;gap:44px;margin-top:56px}
345 + .dwrap{position:relative;width:540px;height:540px;flex:0 0 540px}
346 + .dperc{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
347 + font-size:104px;font-weight:900;color:__ACC__;letter-spacing:-.02em}
348 + .dlab{flex:1;font-size:41px;font-weight:700;line-height:1.18}
349 + """.replace("__ACC__",T["acc"])
350 + return ('<div class="pad">'+hdr(ins,p)
351 + + '<div class="drow"><div class="dwrap">'+svg+'<div class="dperc">'+esc(ins["headline"])+'</div></div>'
352 + + '<div class="dlab">'+esc(ins["headline_label"])+'</div></div>'
353 + + (tiles_html(tiles,p,T) if tiles else '<div style="margin-top:auto"></div>')
354 + + footer(ins,p)+'</div><style>'+css+'</style>')
355 +
241 356 GENRES = {"hero":genre_hero, "ranking":genre_ranking, "spotlight":genre_spotlight,
242 − "list":genre_list, "deal":genre_hero, "pulse":genre_hero}
357 + "list":genre_list, "grid":genre_grid, "quote":genre_quote, "donut":genre_donut,
358 + "deal":genre_hero, "pulse":genre_hero}
359 +
360 +FIT_JS = """<script>
361 +document.querySelectorAll('[data-fit]').forEach(function(el){
362 + var s=parseFloat(getComputedStyle(el).fontSize);
363 + while(el.scrollWidth>el.clientWidth && s>24){ s-=4; el.style.fontSize=s+'px'; }
364 +});
365 +</script>"""
243 366
244 367 def build_html(ins):
245 368 p = pal(ins["site"])
246 369 genre = ins.get("genre","hero")
247 370 if genre == "ranking" and not ins.get("bars"): genre = "hero"
248 371 if genre == "spotlight" and not (ins.get("product") or {}).get("image_data"): genre = "hero"
372 + if genre == "grid":
373 + n_img = len([i for i in (ins.get("list") or []) if i.get("image_data")])
374 + if n_img < 4: genre = "list" if n_img >= 2 else "hero"
249 375 if genre == "list" and not ins.get("list"): genre = "hero"
250 − body = GENRES.get(genre, genre_hero)(ins, p)
251 − return f"""<!doctype html><html><head><meta charset="utf-8"><style>{base_css(p)}</style></head>
252 − <body><div class="stage"><div class="grain"></div>{body}</div></body></html>""", genre
376 + if genre == "donut" and parse_pct(ins.get("headline")) is None: genre = "hero"
377 + if genre == "quote" and len(str(ins.get("fact") or "")) < 40: genre = "hero"
378 + # les genres photo restent sur le thème sombre (les photos y ressortent mieux)
379 + theme = "nuit" if genre in ("spotlight","list","grid") else theme_of(ins)
380 + T = tokens(p, theme)
381 + body = GENRES.get(genre, genre_hero)(ins, p, T)
382 + html = ("<!doctype html><html><head><meta charset=\"utf-8\"><style>"+base_css(p,T)+"</style></head>"
383 + + "<body><div class=\"stage\"><div class=\"grain\"></div>"+body+"</div>"+FIT_JS+"</body></html>")
384 + return html, genre, theme
253 385
254 386 def render(ins, out_dir):
255 387 os.makedirs(out_dir, exist_ok=True)
256 − # télécharger la photo produit en data-uri (robuste, pas de hotlink)
388 + # télécharger les photos en data-uri (robuste, pas de hotlink)
257 389 prod = ins.get("product")
258 390 if prod and prod.get("image") and not prod.get("image_data"):
259 391 du = data_uri(prod["image"])
@@ -262,11 +394,11 @@ def render(ins, out_dir):
262 394 if it.get("image") and not it.get("image_data"):
263 395 du = data_uri(it["image"])
264 396 if du: it["image_data"] = du
265 − html, genre = build_html(ins)
266 − key = hashlib.md5((ins["site"]+ins.get("insight_id","")+genre).encode()).hexdigest()[:8]
397 + html, genre, theme = build_html(ins)
398 + key = hashlib.md5((ins["site"]+str(ins.get("insight_id",""))+genre+theme).encode()).hexdigest()[:8]
267 399 html_path = os.path.join(out_dir, f"card-{ins['site']}-{key}.html")
268 400 big = os.path.join(out_dir, f"_big-{key}.png")
269 − out = os.path.join(out_dir, f"card-{ins['site']}-{ins.get('insight_id','x')}-{genre}.png")
401 + out = os.path.join(out_dir, f"card-{ins['site']}-{ins.get('insight_id','x')}-{genre}-{theme}.png")
270 402 open(html_path,"w",encoding="utf-8").write(html)
271 403 chrome = find_chrome()
272 404 subprocess.run([chrome,"--headless","--disable-gpu","--hide-scrollbars",
273 405