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>
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("&","&").replace("<","<").replace(">",">") | |
| 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 | ||