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%

Social: pipeline Reels (video animee HTML + musique)

- make_reel.py: HTML anime (count-up, degrade, pops) -> Playwright/Chromium -> ffmpeg mp4 h264 + lit musical synthetise ; hauteur parametrable (KA_REEL_H)
- social.js: makeReel/generateReelDraft/publishReelDraft/runReelCycle ; publication video (injection File mp4), flux reel multi-Suivant, rotation reelEvery
- endpoints /api/social/reel/{generate,publish,run,:name} + section Reels dans admin (apercu video + telechargement + publier assiste)
- signature Agent KA sur legendes
- NOTE: images 100% auto ; auto-publish video/reel limite par clic de confiance FB sur noeud headless (ecran virtuel) -> publication assistee/manuelle

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 173c601

5 changed files +417 −19

modified public/app.js +34 −0
@@ -140,6 +140,40 @@
140 140 } catch (e) { el.textContent = 'Erreur : ' + e.message; }
141 141 };
142 142 }
143 + let socReelDraft = null;
144 + (function bindReel(){
145 + const gen = document.getElementById('socReelGen');
146 + if (gen) gen.onclick = async () => {
147 + const prev = gen.textContent; gen.disabled = true; gen.textContent = 'Génération vidéo…';
148 + try {
149 + const r = await fetch('/api/social/reel/generate', { method:'POST', headers:{'Content-Type':'application/json'},
150 + body: JSON.stringify({ site: document.getElementById('socReelSite').value, prompt: document.getElementById('socReelPrompt').value }) });
151 + const d = await r.json();
152 + if (d.draft) { socReelDraft = d.draft;
153 + document.getElementById('socReelVid').src = d.draft.videoUrl + '?t=' + Date.now();
154 + document.getElementById('socReelCaption').value = d.draft.caption;
155 + document.getElementById('socReelDl').href = d.draft.videoUrl;
156 + document.getElementById('socReelDraft').classList.remove('hidden');
157 + } else banner('bad', 'Échec : ' + (d.error||'?'));
158 + } catch(e){ banner('bad','Échec : '+e.message); }
159 + gen.disabled = false; gen.textContent = prev;
160 + };
161 + const pub = document.getElementById('socReelPublish');
162 + if (pub) pub.onclick = async () => {
163 + if (!socReelDraft) return;
164 + pub.disabled = true; pub.textContent = 'Publication…';
165 + banner('muted', '🎬 Publication du reel — termine le « Suivant » sur l\'écran distant du nœud si demandé', null, 9000);
166 + try {
167 + const r = await fetch('/api/social/reel/publish', { method:'POST', headers:{'Content-Type':'application/json'},
168 + body: JSON.stringify({ caption: document.getElementById('socReelCaption').value, video: socReelDraft.video.split('/').pop() }) });
169 + const d = await r.json();
170 + banner(d.ok?'good':'bad', d.ok?'📣 Reel publié':'Échec : '+(d.error||'?'));
171 + if (d.ok) { document.getElementById('socReelDraft').classList.add('hidden'); socReelDraft=null; loadSocial(); }
172 + } catch(e){ banner('bad','Échec : '+e.message); }
173 + pub.disabled = false; pub.textContent = '📣 Publier (assisté)';
174 + };
175 + })();
176 +
143 177 async function doGenerate() {
144 178 const gen = $('socGenerate'); const prev = gen.textContent;
145 179 gen.disabled = true; gen.textContent = 'Génération…';
modified public/index.html +26 −0
@@ -183,6 +183,32 @@
183 183 </div>
184 184 </div>
185 185
186 + <div class="soc-card">
187 + <h3>Reel vidéo (animation + musique)</h3>
188 + <p class="soc-sub">L'Agent KA génère un reel animé avec musique. La publication vidéo demande un clic « Suivant » manuel (limite du nœud sans écran) — utilise « Publier (assisté) » puis termine sur l'écran distant, ou télécharge pour publier depuis ton téléphone.</p>
189 + <textarea id="socReelPrompt" rows="2" placeholder="Sujet du reel (optionnel)"></textarea>
190 + <div class="soc-actions">
191 + <select id="socReelSite" class="soc-select">
192 + <option value="">Choix auto (fait marquant)</option>
193 + <option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option>
194 + <option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option>
195 + <option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option>
196 + <option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option>
197 + <option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option>
198 + <option value="trouve-ka">Trouve·Ka</option>
199 + </select>
200 + <button id="socReelGen" class="btn-cta small">🎬 Générer un reel</button>
201 + </div>
202 + <div id="socReelDraft" class="soc-draft hidden">
203 + <video id="socReelVid" controls playsinline style="width:100%;max-width:300px;border-radius:12px;border:2px solid var(--ink,#1a1a1a);align-self:center"></video>
204 + <textarea id="socReelCaption" rows="6"></textarea>
205 + <div class="soc-actions">
206 + <button id="socReelPublish" class="btn-cta small">📣 Publier (assisté)</button>
207 + <a id="socReelDl" class="btn-ghost small" download>⬇︎ Télécharger</a>
208 + </div>
209 + </div>
210 + </div>
211 +
186 212 <div class="soc-card">
187 213 <h3>Journal</h3>
188 214 <div id="socLog" class="soc-log"></div>
modified server/server.js +30 −2
@@ -10,7 +10,7 @@ import { spawn } from 'node:child_process';
10 10 import { fileURLToPath } from 'node:url';
11 11 import { WebSocketServer } from 'ws';
12 12 import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js';
13 import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState } from './social.js';
13 +import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState, generateReelDraft, publishReelDraft, runReelCycle, reelPath } from './social.js';
14 14 import { execFile } from 'node:child_process';
15 15
16 16 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -704,6 +704,34 @@ const server = http.createServer(async (req, res) => {
704 704 res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' });
705 705 return res.end(fs.readFileSync(fp));
706 706 }
707 + if (p === '/api/social/reel/generate' && req.method === 'POST') {
708 + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
709 + try {
710 + const draft = await generateReelDraft({ site: (body.site || '').trim(), prompt: (body.prompt || '').trim() });
711 + audit({ kind: 'social_reel_generate', site: draft.insight.site });
712 + return json(res, 200, { draft });
713 + } catch (e) { return json(res, 502, { error: e.message }); }
714 + }
715 + if (p === '/api/social/reel/publish' && req.method === 'POST') {
716 + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
717 + try {
718 + await publishReelDraft({ caption: String(body.caption || ''), video: reelPath(body.video || '') });
719 + audit({ kind: 'social_reel_publish', ip: clientIp(req) });
720 + return json(res, 200, { ok: true });
721 + } catch (e) { return json(res, 502, { error: e.message }); }
722 + }
723 + if (p === '/api/social/reel/run' && req.method === 'POST') {
724 + audit({ kind: 'social_reel_run', ip: clientIp(req) });
725 + const r = await runReelCycle('reel-manuel');
726 + return json(res, r.ok ? 200 : 502, r);
727 + }
728 + if (p.startsWith('/api/social/reel/') && req.method === 'GET') {
729 + const fp = reelPath(p.split('/')[4] || '');
730 + if (!fp) { res.writeHead(404); return res.end(); }
731 + const stat = fs.statSync(fp);
732 + res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': stat.size, 'Cache-Control': 'no-cache' });
733 + return res.end(fs.readFileSync(fp));
734 + }
707 735
708 736 return json(res, 404, { error: 'not found' });
709 737 }
@@ -894,7 +922,7 @@ startMonitor({
894 922 // module Social : publication automatique horaire + génération sur demande
895 923 initSocial(
896 924 { anthropicKey: UPGRADER_KEY, upgraderModel: UPGRADER_MODEL,
897 socialModel: config.socialModel, socialIntervalMin: config.socialIntervalMin },
925 + socialModel: config.socialModel, socialIntervalMin: config.socialIntervalMin, reelEvery: config.reelEvery },
898 926 (line) => broadcastAll({ type: 'social_log', line }),
899 927 );
900 928
modified server/social.js +163 −17
@@ -20,9 +20,11 @@ const APP_DIR = path.join(__dirname, '..');
20 20 const DATA_DIR = path.join(APP_DIR, 'data');
21 21 const SOCIAL_DIR = path.join(DATA_DIR, 'social');
22 22 const CARDS_DIR = path.join(SOCIAL_DIR, 'cards');
23 +const REELS_DIR = path.join(SOCIAL_DIR, 'reels');
23 24 const STATE_PATH = path.join(SOCIAL_DIR, 'state.json');
24 25 const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl');
25 26 fs.mkdirSync(CARDS_DIR, { recursive: true });
27 +fs.mkdirSync(REELS_DIR, { recursive: true });
26 28
27 29 const PAGE_ID = '61593422723708'; // Page « Groupe-KA » du compte perso
28 30 const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`;
@@ -30,6 +32,7 @@ const PY = '/opt/homebrew/bin/python3';
30 32 const INSIGHTS = path.join(__dirname, 'social', 'insights.py');
31 33 const RENDER = path.join(__dirname, 'social', 'render_card.py');
32 34 const RENDER_HTML = path.join(__dirname, 'social', 'render_card_html.py');
35 +const MAKE_REEL = path.join(__dirname, 'social', 'make_reel.py');
33 36
34 37 let CFG = {}; // injecté par init()
35 38 let LOGGER = () => {};
@@ -136,6 +139,8 @@ end tell`;
136 139
137 140 async function gotoPage() {
138 141 await resolveFbWindow();
142 + try { await run('/usr/bin/osascript', ['-e', `tell application "Safari" to set bounds of window id ${fbWinId} to {0, 0, 1512, 964}`]); } catch {}
143 + try { await osaJS("window.onbeforeunload=null; 'ok'"); } catch {}
139 144 await run('/usr/bin/osascript', ['-e',
140 145 `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "${PAGE_URL}"\nend timeout\nend tell`]);
141 146 }
@@ -182,6 +187,7 @@ async function dismissModals(times) {
182 187 export function fbLoginState() { return withSafari(_fbLoginState); }
183 188 async function _fbLoginState() {
184 189 await resolveFbWindow();
190 + try { await osaJS("window.onbeforeunload=null; 'ok'"); } catch {}
185 191 await run('/usr/bin/osascript', ['-e',
186 192 `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "https://www.facebook.com/"\nend timeout\nend tell`]);
187 193 await sleep(6000);
@@ -194,8 +200,9 @@ async function _fbLoginState() {
194 200 }
195 201
196 202 // ---------- publication (texte + 1 image) sur la Page ----------
197 export function publishToPage(caption, imagePath) { return withSafari(() => _publishToPage(caption, imagePath)); }
198 async function _publishToPage(caption, imagePath) {
203 +export function publishToPage(caption, imagePath) { return withSafari(() => _publishMedia(caption, imagePath, false)); }
204 +export function publishReelToPage(caption, videoPath) { return withSafari(() => _publishMedia(caption, videoPath, true)); }
205 +async function _publishMedia(caption, mediaPath, isVideo) {
199 206 await gotoPage();
200 207 await sleep(9000);
201 208
@@ -256,17 +263,19 @@ async function _publishToPage(caption, imagePath) {
256 263 }
257 264 if (!(len > 40)) throw new Error('collage légende échoué');
258 265
259 // 3) injecter l'image dans l'input file du composeur (File construit en JS)
260 const imgB64 = fs.readFileSync(imagePath).toString('base64');
266 + // 3) injecter le média (image OU vidéo) dans l'input file du composeur
267 + const mediaB64 = fs.readFileSync(mediaPath).toString('base64');
268 + const fname = isVideo ? 'ka-reel.mp4' : 'ka-stat.png';
269 + const mtype = isVideo ? 'video/mp4' : 'image/png';
261 270 const injectJS = `(function(){
262 271 var inp=document.querySelector('div[role=dialog] input[type=file]') ||
263 document.querySelector('input[type=file][accept*="image"]') ||
272 + document.querySelector('input[type=file][accept*="${isVideo ? 'video' : 'image'}"]') ||
264 273 document.querySelector('input[type=file]');
265 274 if(!inp) return 'NO_INPUT';
266 275 try{
267 var bin=atob('${imgB64}'); var len=bin.length; var arr=new Uint8Array(len);
276 + var bin=atob('${mediaB64}'); var len=bin.length; var arr=new Uint8Array(len);
268 277 for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);
269 var file=new File([arr], 'ka-stat.png', {type:'image/png'});
278 + var file=new File([arr], '${fname}', {type:'${mtype}'});
270 279 var dt=new DataTransfer(); dt.items.add(file);
271 280 inp.files=dt.files;
272 281 inp.dispatchEvent(new Event('change',{bubbles:true}));
@@ -284,16 +293,18 @@ async function _publishToPage(caption, imagePath) {
284 293 return 'clicked';})();`;
285 294 await osaJS(clickPhoto); await sleep(2500);
286 295 const inj2 = await osaJS(injectJS);
287 if (inj2 !== 'injected') throw new Error('injection image échouée (' + inj + ' / ' + inj2 + ')');
296 + if (inj2 !== 'injected') throw new Error('injection média échouée (' + inj + ' / ' + inj2 + ')');
288 297 }
289 298
290 299 // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)
291 300 const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';
292 301 var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});
302 + dlg.querySelectorAll('video').forEach(function(){n++;});
293 303 return ''+n;})();`;
294 const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, 12, 4000);
295 if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu image absent');
296 await sleep(2000);
304 + const prevTries = isVideo ? 30 : 12; // upload/traitement vidéo plus long
305 + const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, prevTries, 5000);
306 + if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu média absent');
307 + await sleep(isVideo ? 4000 : 2000);
297 308
298 309 // 5) Suivant (si présent) puis Publier
299 310 const clickBtn = (label) => `(function(){
@@ -304,10 +315,79 @@ async function _publishToPage(caption, imagePath) {
304 315 if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';
305 316 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});
306 317 return 'clicked';})();`;
307 const nx = await osaJS(clickBtn('Suivant'));
308 if (nx === 'clicked') await sleep(7000);
309 const pub = await pollJS(clickBtn('Publier'), v => v === 'clicked', 6, 4000);
310 if (pub !== 'clicked') throw new Error('bouton Publier indisponible (' + pub + ')');
318 + // pour la vidéo, FB exige un clic « de confiance » : on FOCUS le bouton en JS
319 + // puis on envoie une vraie touche Entrée via System Events (indépendant des coords écran)
320 + const focusBtn = (labels) => `(function(){
321 + var want=${JSON.stringify(labels)}; var btn=null;
322 + document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){
323 + if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();
324 + if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;
325 + });
326 + if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center'});}catch(e){} btn.focus();
327 + return (document.activeElement===btn)?'focused':'focus_fail';
328 + })();`;
329 + async function clickBtnOS(labels) {
330 + const coordJS = `(function(){
331 + var want=${JSON.stringify(labels)}; var btn=null;
332 + document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){
333 + if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();
334 + if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;
335 + });
336 + if(!btn) return 'NO_BTN';
337 + try{btn.scrollIntoView({block:'center',inline:'center'});}catch(e){}
338 + var r=btn.getBoundingClientRect();
339 + var gx=Math.round(window.screenX + r.left + r.width/2);
340 + var gy=Math.round(window.screenY + r.top + r.height/2);
341 + return gx+' '+gy;
342 + })();`;
343 + const co = await osaJS(coordJS);
344 + if (co === 'NO_BTN' || co.indexOf(' ') < 0) return 'NO_BTN';
345 + const [gx, gy] = co.split(' ').map(n => parseInt(n, 10));
346 + if (!(gx > 0 && gy > 0)) return 'OFFSCREEN:' + co;
347 + const r = await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.3',
348 + '-e',`tell application "System Events" to click at {${gx}, ${gy}}`]);
349 + return r.error ? ('ERR:' + r.error.slice(0,60)) : 'clicked';
350 + }
351 + async function advanceByKey(labels) {
352 + const f = await osaJS(focusBtn(labels));
353 + if (f !== 'focused') return f;
354 + await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.35','-e','tell application "System Events" to key code 36']);
355 + return 'pressed';
356 + }
357 + const clickAny = (labels) => `(function(){
358 + var want=${JSON.stringify(labels)}; var btn=null;
359 + document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){
360 + if(btn) return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();
361 + if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;
362 + });
363 + if(!btn) return 'NO_BTN';
364 + ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});
365 + return 'clicked';
366 + })();`;
367 + const canPublishJS = `(function(){var ok=false;document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){var al=(b.getAttribute('aria-label')||b.innerText||'').trim();if((al==='Publier'||al==='Partager')&&b.getAttribute('aria-disabled')!=='true')ok=true;});return ok?'YES':'NO';})();`;
368 + if (isVideo) {
369 + // Reel/vidéo : avancer les « Suivant » par focus + touche Entrée réelle
370 + for (let i = 0; i < 6; i++) {
371 + if ((await osaJS(canPublishJS)) === 'YES') break;
372 + const r = await clickBtnOS(['Suivant']);
373 + if (r === 'clicked') { await sleep(6000); await dismissModals(1); } else { await sleep(4000); }
374 + }
375 + // Publier / Partager par focus + Entrée (poll pendant le téléversement)
376 + let done = false;
377 + for (let i = 0; i < 14; i++) {
378 + if ((await osaJS(canPublishJS)) === 'YES') {
379 + const r = await clickBtnOS(['Publier', 'Partager']);
380 + if (r === 'clicked') { done = true; break; }
381 + }
382 + await sleep(5000); await dismissModals(1);
383 + }
384 + if (!done) throw new Error('bouton Publier/Partager indisponible (vidéo)');
385 + } else {
386 + const nx = await osaJS(clickAny(['Suivant']));
387 + if (nx === 'clicked') await sleep(7000);
388 + const pub = await pollJS(clickAny(['Publier', 'Partager']), v => v === 'clicked', 6, 4000);
389 + if (pub !== 'clicked') throw new Error('bouton Publier/Partager indisponible (' + pub + ')');
390 + }
311 391
312 392 // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte
313 393 // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique
@@ -320,7 +400,8 @@ async function _publishToPage(caption, imagePath) {
320 400 return open ? 'OPEN' : 'GONE';
321 401 })();`;
322 402 let gone = 'OPEN';
323 for (let i = 0; i < 18; i++) {
403 + const goneTries = isVideo ? 40 : 18;
404 + for (let i = 0; i < goneTries; i++) {
324 405 await sleep(8000);
325 406 await dismissModals(2); // ferme toute boîte non bloquante en route
326 407 try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }
@@ -511,6 +592,69 @@ export async function publishDraft({ caption, image }) {
511 592 return true;
512 593 }
513 594
595 +// ---------- Reels (vidéo animée + musique) ----------
596 +export async function makeReel(insight) {
597 + const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));
598 + const p = (r.stdout || '').trim().split('\n').pop();
599 + if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));
600 + return p;
601 +}
602 +
603 +export function reelPath(name) {
604 + const safe = path.basename(name);
605 + const fp = path.join(REELS_DIR, safe);
606 + return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;
607 +}
608 +
609 +// brouillon reel : insight -> vidéo + légende (sans publier)
610 +export async function generateReelDraft({ site = '', prompt = '' } = {}) {
611 + let insight;
612 + if (prompt && !site) {
613 + const r = await run(PY, [INSIGHTS]);
614 + const cands = (JSON.parse(r.stdout || '{}').candidates) || [];
615 + const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');
616 + const chosen = await anthropic(
617 + "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.",
618 + `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30);
619 + insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];
620 + } else {
621 + insight = await pickInsight({ site });
622 + }
623 + const video = await makeReel(insight);
624 + const caption = await writeCaption(insight, prompt);
625 + return { insight, video, caption, videoUrl: '/api/social/reel/' + path.basename(video) };
626 +}
627 +
628 +export async function publishReelDraft({ caption, video }) {
629 + if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');
630 + const login = await fbLoginState();
631 + if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');
632 + await publishReelToPage(caption, video);
633 + state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();
634 + logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });
635 + return true;
636 +}
637 +
638 +// cycle reel complet automatique
639 +export async function runReelCycle(trigger = 'reel') {
640 + logEvent({ kind: 'cycle_start', trigger, format: 'reel' });
641 + try {
642 + const login = await fbLoginState();
643 + if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }
644 + const insight = await pickInsight({});
645 + const video = await makeReel(insight);
646 + const caption = await writeCaption(insight);
647 + await publishReelToPage(caption, video);
648 + state.lastSites = [...state.lastSites, insight.site].slice(-6);
649 + state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();
650 + logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });
651 + return { ok: true, insight, caption, video };
652 + } catch (e) {
653 + logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });
654 + return { ok: false, error: e.message };
655 + }
656 +}
657 +
514 658 // ---------- planificateur horaire ----------
515 659 function scheduleNext() {
516 660 clearTimeout(timer);
@@ -520,7 +664,9 @@ function scheduleNext() {
520 664 const wait = Math.max(60 * 1000, period - since);
521 665 state.nextAt = Date.now() + wait; saveState();
522 666 timer = setTimeout(async () => {
523 await runAutoCycle('auto');
667 + const every = CFG.reelEvery || 0; // 0 = jamais de reel auto ; N = 1 reel tous les N posts
668 + if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');
669 + else await runAutoCycle('auto');
524 670 scheduleNext();
525 671 }, wait);
526 672 }
added server/social/make_reel.py +164 −0
@@ -0,0 +1,164 @@
1 +#!/usr/bin/env python3
2 +"""Génère un Reel vertical animé (1080x1920) + musique à partir d'un insight KA.
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.
6 +"""
7 +import json, sys, os, base64, datetime, urllib.request, ssl, subprocess, glob, hashlib
8 +
9 +CTX = ssl.create_default_context(); CTX.check_hostname=False; CTX.verify_mode=ssl.CERT_NONE
10 +FFMPEG = "/opt/homebrew/bin/ffmpeg"
11 +MOIS=["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"]
12 +FONT="'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif"
13 +DUR=9.0 # secondes
14 +HREEL=int(os.environ.get("KA_REEL_H","1350"))
15 +
16 +PALETTES={
17 + "lou-ka":{"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09"},
18 + "immo-ka":{"a":"#e23744","b":"#ff6b76","g1":"#2a0c10","g2":"#0d0a0b"},
19 + "food-ka":{"a":"#1f9d55","b":"#5fd08a","g1":"#07230f","g2":"#0a0d0b"},
20 + "fabri-ka":{"a":"#c4532e","b":"#e8895f","g1":"#241009","g2":"#0d0a09"},
21 + "resto-ka":{"a":"#f08c00","b":"#ffb84d","g1":"#241804","g2":"#0d0b08"},
22 + "auto-ka":{"a":"#ff5a2a","b":"#ff8f6b","g1":"#26100a","g2":"#0d0a09"},
23 + "vrai-prix":{"a":"#d9f26b","b":"#b8e04a","g1":"#16230a","g2":"#0b0d08"},
24 + "trouve-ka":{"a":"#d9f26b","b":"#8fd3ff","g1":"#0a1a20","g2":"#080b0d"},
25 + "sorti-ka":{"a":"#d9f26b","b":"#ff8fd0","g1":"#210a1c","g2":"#0c090c"},
26 + "crea-ka":{"a":"#d9f26b","b":"#c08bff","g1":"#160a24","g2":"#0a090d"},
27 + "job-ka":{"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"},
28 +}
29 +def pal(s): return PALETTES.get(s, PALETTES["vrai-prix"])
30 +def esc(s): return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
31 +def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}"
32 +
33 +def build_html(ins):
34 + H=HREEL
35 + pt=130 if H>=1600 else 80
36 + pb=400 if H>=1600 else 220
37 + p=pal(ins["site"])
38 + tiles=ins.get("tiles") or []
39 + hl=str(ins["headline"]); L=len(hl)
40 + numsize = 150 if L>=9 else (180 if L>=7 else (230 if L>=5 else 300))
41 + tiles_html="".join(f'<div class="tile t{i}"><div class="tv">{esc(v)}</div><div class="tl">{esc(l)}</div></div>' for i,(v,l) in enumerate(tiles))
42 + return f"""<!doctype html><html><head><meta charset="utf-8"><style>
43 + *{{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}}
44 + html,body{{width:1080px;height:{H}px;overflow:hidden;font-family:{FONT};color:#fff}}
45 + .stage{{position:relative;width:1080px;height:{H}px;
46 + background:radial-gradient(1200px 900px at 80% 0%, {p['a']}44,transparent 55%),
47 + radial-gradient(1000px 1000px at -10% 100%, {p['b']}33,transparent 55%),
48 + linear-gradient(160deg,{p['g1']},{p['g2']});
49 + background-size:140% 140%;animation:drift 12s ease-in-out infinite alternate}}
50 + @keyframes drift{{0%{{background-position:0% 0%}}100%{{background-position:100% 100%}}}}
51 + .pad{{position:absolute;inset:0;padding:{pt}px 90px {pb}px;display:flex;flex-direction:column}}
52 + .eyebrow{{display:flex;align-items:center;gap:16px;font-weight:800;letter-spacing:.16em;font-size:28px;
53 + text-transform:uppercase;color:{p['a']};opacity:0;animation:fadeUp .8s ease .2s forwards}}
54 + .eyebrow .dot{{width:52px;height:6px;border-radius:3px;background:{p['a']}}}
55 + .kabadge{{position:absolute;top:120px;right:90px;background:{p['a']};color:#0d0d0d;font-weight:900;
56 + font-size:56px;padding:12px 26px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;
57 + animation:pop .7s cubic-bezier(.2,1.4,.4,1) .4s forwards;box-shadow:0 16px 40px {p['a']}66}}
58 + .site{{font-size:120px;font-weight:900;letter-spacing:-.02em;margin-top:40px;opacity:0;
59 + animation:fadeUp .8s ease .5s forwards}}
60 + .tag{{font-size:40px;font-weight:600;color:#ffffffb0;margin-top:12px;opacity:0;animation:fadeUp .8s ease .7s forwards}}
61 + .num{{font-size:{numsize}px;font-weight:900;line-height:.9;letter-spacing:-.03em;margin-top:80px;white-space:nowrap;
62 + background:linear-gradient(180deg,#fff,{p['a']} 130%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;
63 + filter:drop-shadow(0 16px 50px {p['a']}55);opacity:0;transform:translateY(40px) scale(.9);
64 + animation:heroIn 1s cubic-bezier(.2,1,.3,1) 1.0s forwards}}
65 + .lab{{font-size:56px;font-weight:700;margin-top:30px;max-width:900px;line-height:1.12;opacity:0;
66 + animation:fadeUp .8s ease 1.4s forwards}}
67 + .tiles{{display:flex;gap:22px;margin-top:auto;margin-bottom:0}}
68 + .tile{{flex:1;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.14);border-radius:26px;
69 + padding:30px 26px;opacity:0;transform:translateY(30px);animation:fadeUp .7s ease forwards}}
70 + .t0{{animation-delay:1.8s}}.t1{{animation-delay:2.0s}}.t2{{animation-delay:2.2s}}.t3{{animation-delay:2.4s}}
71 + .tv{{font-size:60px;font-weight:900;color:{p['a']}}}
72 + .tl{{font-size:29px;font-weight:600;color:#ffffffa0;margin-top:8px;line-height:1.2}}
73 + .cta{{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fadeUp .8s ease 2.8s forwards}}
74 + .cta .go{{font-size:46px;font-weight:900;color:{p['a']}}}
75 + .cta .brand{{font-size:30px;font-weight:700;color:#ffffff88;letter-spacing:.12em;margin-top:6px}}
76 + .sig{{position:absolute;left:90px;bottom:90px;font-size:26px;color:#ffffff66;font-weight:600;opacity:0;
77 + animation:fadeUp .8s ease 3.1s forwards}}
78 + @keyframes fadeUp{{to{{opacity:1;transform:translateY(0)}}}}
79 + @keyframes pop{{to{{opacity:1;transform:rotate(-4deg) scale(1)}}}}
80 + @keyframes heroIn{{to{{opacity:1;transform:translateY(0) scale(1)}}}}
81 + </style></head><body>
82 + <div class="stage"><div class="pad">
83 + <div class="eyebrow"><span class="dot"></span>{esc(ins['label'].upper())} · {today().upper()}</div>
84 + <div class="kabadge">KA</div>
85 + <div class="site">{esc(ins['label'])}</div>
86 + {f'<div class="tag">{esc(ins.get("tagline",""))}</div>' if ins.get('tagline') else ''}
87 + <div class="num" id="num" data-target="{esc(ins['headline'])}">{esc(ins['headline'])}</div>
88 + <div class="lab">{esc(ins['headline_label'])}</div>
89 + {f'<div class="tiles">{tiles_html}</div>' if tiles else ''}
90 + <div class="cta"><div class="go">👉 www.{ins['site']}.com</div><div class="brand">GROUPE ·KA</div></div>
91 + <div class="sig">✶ Rédigé et publié par l'Agent KA</div>
92 + </div></div>
93 + <script>
94 + // count-up si le nombre-héros est numérique
95 + (function(){{
96 + var el=document.getElementById('num'); var raw=el.getAttribute('data-target');
97 + var m=raw.match(/^[\\d .,]+/); if(!m) return;
98 + var digits=m[0].replace(/[^\\d]/g,''); if(digits.length<2) return;
99 + var target=parseInt(digits,10); var suffix=raw.slice(m[0].length);
100 + var prefix=''; var start=null, dur=1400, begin=1000;
101 + function fmt(n){{return n.toLocaleString('fr-CA').replace(/,/g,' ')}}
102 + function step(ts){{ if(!start)start=ts; var t=ts-start;
103 + if(t<begin){{requestAnimationFrame(step);return;}}
104 + var k=Math.min(1,(t-begin)/dur); var e=1-Math.pow(1-k,3);
105 + el.textContent=fmt(Math.round(target*e))+suffix;
106 + if(k<1)requestAnimationFrame(step);
107 + }}
108 + el.textContent=fmt(0)+suffix; requestAnimationFrame(step);
109 + }})();
110 + </script></body></html>"""
111 +
112 +def record_webm(html_path, out_webm, tmpdir):
113 + from playwright.sync_api import sync_playwright
114 + with sync_playwright() as pw:
115 + b=pw.chromium.launch(args=["--no-sandbox","--force-device-scale-factor=1"])
116 + ctx=b.new_context(viewport={"width":1080,"height":HREEL},
117 + record_video_dir=tmpdir, record_video_size={"width":1080,"height":HREEL})
118 + pg=ctx.new_page()
119 + pg.goto("file://"+html_path)
120 + pg.wait_for_timeout(int(DUR*1000))
121 + vid=pg.video
122 + ctx.close(); b.close()
123 + path=vid.path()
124 + os.replace(path, out_webm)
125 +
126 +def make_music(dur, out_wav):
127 + # nappe d'accord douce (Am : A3+C4+E4) + tremolo + passe-bas
128 + subprocess.run([FFMPEG,"-y",
129 + "-f","lavfi","-i",f"sine=frequency=220:duration={dur}",
130 + "-f","lavfi","-i",f"sine=frequency=261.63:duration={dur}",
131 + "-f","lavfi","-i",f"sine=frequency=329.63:duration={dur}",
132 + "-f","lavfi","-i",f"sine=frequency=110:duration={dur}",
133 + "-filter_complex",
134 + "[0][1][2]amix=inputs=3:normalize=0,volume=0.18,tremolo=f=4.5:d=0.4,lowpass=f=1400[pad];"
135 + "[3]volume=0.10,tremolo=f=2:d=0.6[sub];"
136 + "[pad][sub]amix=inputs=2:normalize=0,afade=t=in:st=0:d=1.2,afade=t=out:st="+str(dur-1.5)+":d=1.5[a]",
137 + "-map","[a]",out_wav],capture_output=True)
138 +
139 +def render(ins, out_dir):
140 + os.makedirs(out_dir,exist_ok=True)
141 + key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8]
142 + tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True)
143 + html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins))
144 + webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp)
145 + wav=os.path.join(tmp,"bed.wav"); make_music(DUR,wav)
146 + out=os.path.join(out_dir,f"reel-{ins['site']}-{ins.get('insight_id','x')}.mp4")
147 + subprocess.run([FFMPEG,"-y","-i",webm,"-i",wav,
148 + "-filter_complex","[0:v]scale=1080:"+str(HREEL)+":force_original_aspect_ratio=increase,crop=1080:"+str(HREEL)+",fps=30,format=yuv420p[v]",
149 + "-map","[v]","-map","1:a","-c:v","libx264","-preset","medium","-crf","20",
150 + "-c:a","aac","-b:a","160k","-pix_fmt","yuv420p","-movflags","+faststart","-shortest",out],
151 + capture_output=True)
152 + # nettoyage
153 + for f in glob.glob(os.path.join(tmp,"*")):
154 + try: os.remove(f)
155 + except: pass
156 + try: os.rmdir(tmp)
157 + except: pass
158 + if not os.path.exists(out): raise SystemExit("mp4 non produit")
159 + return out
160 +
161 +if __name__=="__main__":
162 + ins=json.load(sys.stdin)
163 + out_dir=sys.argv[1] if len(sys.argv)>1 else "/tmp/ka-reels"
164 + print(render(ins,out_dir))
165