Fix noVNC (import module statique window.KA_RFB) + reels polyvalents
- noVNC: import ESM statique en tete -> window.KA_RFB (fin de undefined is not a constructor) - Reels genre spotlight: vraie annonce/produit avec photo (Ken Burns), legende decrivant l item - decorateForPrompt: une demande (ex. dernier logement Lou-Ka) sort le vrai contenu, pas la stat globale - fix count-up decimales/% (48,2 % ne devient plus 482 %) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 changed files +89 −6
modified
public/app.js
+1 −1
@@ -80,7 +80,7 @@ | ||
| 80 | 80 | statusEl.textContent = 'connexion…'; |
| 81 | 81 | try { |
| 82 | 82 | const pw = await (await fetch('/api/social/vncpw')).json(); |
| 83 | − const { default: RFB } = await import('/vendor/novnc/core/rfb.js'); | |
| 83 | + const RFB = window.KA_RFB; if (!RFB) { statusEl.textContent = 'noVNC pas encore chargé — rafraîchis la page'; return; } | |
| 84 | 84 | const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/vncws'; |
| 85 | 85 | panel.style.display = 'block'; |
| 86 | 86 | _rfb = new RFB(panel, url, { credentials: { password: pw.password } }); |
modified
public/index.html
+1 −0
@@ -267,5 +267,6 @@ | ||
| 267 | 267 | <script src="/vendor/marked.min.js"></script> |
| 268 | 268 | <script src="/vendor/highlight.min.js"></script> |
| 269 | 269 | <script src="/app.js"></script> |
| 270 | +<script type="module">import RFB from "/vendor/novnc/core/rfb.js?v=3"; window.KA_RFB=RFB;</script> | |
| 270 | 271 | </body> |
| 271 | 272 | </html> |
modified
server/social.js
+28 −5
@@ -485,6 +485,15 @@ async function fetchFeatured(site) { | ||
| 485 | 485 | return null; |
| 486 | 486 | } |
| 487 | 487 | |
| 488 | +async function decorateForPrompt(insight) { | |
| 489 | + if (SPOT[insight.site]) { | |
| 490 | + const feat = await fetchFeatured(insight.site); | |
| 491 | + if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; } | |
| 492 | + } | |
| 493 | + insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero'; | |
| 494 | + return insight; | |
| 495 | +} | |
| 496 | + | |
| 488 | 497 | // choisit le genre visuel (rotation) et attache le produit vedette si spotlight |
| 489 | 498 | async function decorateCreative(insight) { |
| 490 | 499 | const n = state.postCount || 0; |
@@ -522,10 +531,21 @@ Règles STRICTES : | ||
| 522 | 531 | export async function writeCaption(insight, extraPrompt = '') { |
| 523 | 532 | const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8'; |
| 524 | 533 | const url = `https://www.${insight.site}.com`; |
| 525 | − const user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` + | |
| 526 | − `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` + | |
| 527 | − (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') + | |
| 528 | − `\nRédige la publication Facebook.`; | |
| 534 | + let user; | |
| 535 | + if (insight.product && insight.genre === 'spotlight') { | |
| 536 | + const pr = insight.product; | |
| 537 | + user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` + | |
| 538 | + `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') + | |
| 539 | + (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') + | |
| 540 | + `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` + | |
| 541 | + (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') + | |
| 542 | + `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`; | |
| 543 | + } else { | |
| 544 | + user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` + | |
| 545 | + `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` + | |
| 546 | + (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') + | |
| 547 | + `\nRédige la publication Facebook.`; | |
| 548 | + } | |
| 529 | 549 | const txt = await anthropic(CAPTION_SYSTEM, user, model, 500); |
| 530 | 550 | return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com"; |
| 531 | 551 | } |
@@ -545,10 +565,11 @@ export async function generateDraft({ site = '', prompt = '' } = {}) { | ||
| 545 | 565 | CFG.socialModel || 'claude-opus-4-8', 30); |
| 546 | 566 | const pickSite = (chosen || '').trim().split(/\s/)[0]; |
| 547 | 567 | insight = cands.find(c => c.site === pickSite) || cands[0]; |
| 568 | + await decorateForPrompt(insight); | |
| 548 | 569 | } else { |
| 549 | 570 | insight = await pickInsight({ site }); |
| 571 | + await decorateCreative(insight); | |
| 550 | 572 | } |
| 551 | − await decorateCreative(insight); | |
| 552 | 573 | const image = await renderCard(insight); |
| 553 | 574 | const caption = await writeCaption(insight, prompt); |
| 554 | 575 | return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) }; |
@@ -617,8 +638,10 @@ export async function generateReelDraft({ site = '', prompt = '' } = {}) { | ||
| 617 | 638 | "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.", |
| 618 | 639 | `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30); |
| 619 | 640 | insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0]; |
| 641 | + await decorateForPrompt(insight); | |
| 620 | 642 | } else { |
| 621 | 643 | insight = await pickInsight({ site }); |
| 644 | + await decorateForPrompt(insight); | |
| 622 | 645 | } |
| 623 | 646 | const video = await makeReel(insight); |
| 624 | 647 | const caption = await writeCaption(insight, prompt); |
modified
server/social/make_reel.py
+59 −0
@@ -27,10 +27,65 @@ PALETTES={ | ||
| 27 | 27 | "job-ka":{"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"}, |
| 28 | 28 | } |
| 29 | 29 | def pal(s): return PALETTES.get(s, PALETTES["vrai-prix"]) |
| 30 | +def data_uri(url): | |
| 31 | + import base64 | |
| 32 | + try: | |
| 33 | + req=urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0 (Macintosh)"}) | |
| 34 | + with urllib.request.urlopen(req, timeout=15, context=CTX) as r: raw=r.read() | |
| 35 | + mime="image/jpeg" | |
| 36 | + if url.lower().endswith(".png"): mime="image/png" | |
| 37 | + elif url.lower().endswith(".webp"): mime="image/webp" | |
| 38 | + return "data:"+mime+";base64,"+base64.b64encode(raw).decode() | |
| 39 | + except Exception: return None | |
| 30 | 40 | def esc(s): return str(s).replace("&","&").replace("<","<").replace(">",">") |
| 31 | 41 | def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}" |
| 32 | 42 | |
| 43 | +def build_spotlight(ins): | |
| 44 | + H=HREEL; p=pal(ins["site"]); prod=ins.get("product") or {} | |
| 45 | + img=prod.get("image_data",""); chips="".join('<span class="chip">'+esc(c)+'</span>' for c in (prod.get("meta") or [])) | |
| 46 | + price=esc(prod.get("price","")); title=esc(prod.get("title","")); sub=esc(prod.get("subtitle","")) | |
| 47 | + css = """ | |
| 48 | + *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased} | |
| 49 | + html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff;background:__G2__} | |
| 50 | + .photo{position:absolute;top:0;left:0;right:0;height:__PH__px;background:url('__IMG__') center/cover;animation:kb 10s ease-out forwards} | |
| 51 | + @keyframes kb{from{transform:scale(1.0)}to{transform:scale(1.12)}} | |
| 52 | + .scrim{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,.15) 0%,rgba(0,0,0,0) 30%,__G2__cc 55%,__G2__ 72%)} | |
| 53 | + .eyebrow{position:absolute;top:120px;left:90px;display:flex;align-items:center;gap:14px;font-weight:800;letter-spacing:.16em;font-size:26px;text-transform:uppercase;color:__A__;opacity:0;animation:fu .8s ease .2s forwards;text-shadow:0 2px 12px #000} | |
| 54 | + .eyebrow .dot{width:46px;height:6px;border-radius:3px;background:__A__} | |
| 55 | + .kabadge{position:absolute;top:110px;right:90px;background:__A__;color:#0d0d0d;font-weight:900;font-size:52px;padding:10px 24px;border-radius:20px;transform:rotate(-4deg) scale(.5);opacity:0;animation:pop .7s cubic-bezier(.2,1.4,.4,1) .5s forwards;box-shadow:0 16px 40px __A__66} | |
| 56 | + .info{position:absolute;left:90px;right:90px;bottom:270px} | |
| 57 | + .ptitle{font-size:74px;font-weight:900;line-height:1.02;letter-spacing:-.02em;text-shadow:0 4px 24px #000a;opacity:0;transform:translateY(30px);animation:fu .9s ease 1.0s forwards} | |
| 58 | + .psub{font-size:36px;font-weight:600;color:#ffffffc8;margin-top:14px;opacity:0;animation:fu .9s ease 1.25s forwards} | |
| 59 | + .chips{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px} | |
| 60 | + .chip{background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.22);border-radius:999px;padding:12px 24px;font-size:28px;font-weight:700;opacity:0;animation:fu .8s ease 1.45s forwards} | |
| 61 | + .price{font-size:92px;font-weight:900;color:__A__;margin-top:26px;letter-spacing:-.02em;filter:drop-shadow(0 10px 30px __A__66);opacity:0;transform:scale(.9);animation:popc .8s cubic-bezier(.2,1.3,.4,1) 1.7s forwards} | |
| 62 | + .cta{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fu .8s ease 2.2s forwards} | |
| 63 | + .cta .go{font-size:46px;font-weight:900;color:__A__} | |
| 64 | + .cta .brand{font-size:30px;font-weight:700;color:#ffffff88;letter-spacing:.12em;margin-top:6px} | |
| 65 | + .sig{position:absolute;left:90px;bottom:88px;font-size:26px;color:#ffffff66;font-weight:600;opacity:0;animation:fu .8s ease 2.5s forwards} | |
| 66 | + @keyframes fu{to{opacity:1;transform:translateY(0)}} | |
| 67 | + @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}} | |
| 68 | + @keyframes popc{to{opacity:1;transform:scale(1)}} | |
| 69 | + """ | |
| 70 | + css = (css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__G2__",p["g2"]) | |
| 71 | + .replace("__PH__",str(int(H*0.62))).replace("__IMG__",img).replace("__A__",p["a"])) | |
| 72 | + body = ( | |
| 73 | + '<div class="photo"></div><div class="scrim"></div>' | |
| 74 | + '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · À DÉCOUVRIR</div>' | |
| 75 | + '<div class="kabadge">KA</div>' | |
| 76 | + '<div class="info"><div class="ptitle">'+title+'</div>' | |
| 77 | + + (('<div class="psub">'+sub+'</div>') if sub else '') | |
| 78 | + + '<div class="chips">'+chips+'</div>' | |
| 79 | + + (('<div class="price">'+price+'</div>') if price else '') | |
| 80 | + + '</div>' | |
| 81 | + '<div class="cta"><div class="go">\U0001f449 www.'+ins["site"]+'.com</div><div class="brand">GROUPE ·KA</div></div>' | |
| 82 | + '<div class="sig">✦ Publié par l\'Agent KA</div>' | |
| 83 | + ) | |
| 84 | + return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+"</body></html>" | |
| 85 | + | |
| 33 | 86 | def build_html(ins): |
| 87 | + if ins.get('genre')=='spotlight' and (ins.get('product') or {}).get('image_data'): | |
| 88 | + return build_spotlight(ins) | |
| 34 | 89 | H=HREEL |
| 35 | 90 | pt=130 if H>=1600 else 80 |
| 36 | 91 | pb=400 if H>=1600 else 220 |
@@ -168,6 +223,10 @@ def make_music(dur, out_wav): | ||
| 168 | 223 | |
| 169 | 224 | def render(ins, out_dir): |
| 170 | 225 | os.makedirs(out_dir,exist_ok=True) |
| 226 | + prod=ins.get('product') | |
| 227 | + if prod and prod.get('image') and not prod.get('image_data'): | |
| 228 | + du=data_uri(prod['image']) | |
| 229 | + if du: prod['image_data']=du | |
| 171 | 230 | key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8] |
| 172 | 231 | tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True) |
| 173 | 232 | html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins)) |
| 174 | 233 | |