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%

Studio: indicateur de chargement (spinner+shimmer) ; musique v3 (basse+arpege) ; vignette+pulse

SPB committed 1 mo ago (Aug 23, 2026) parent fbbdcd8

3 changed files +95 −65

modified public/app.js +40 −33
@@ -1435,10 +1435,23 @@
1435 1435 })();
1436 1436
1437 1437
1438 // ===== STUDIO_SOCIAL v2 (générateurs + galerie, download + copy) =====
1438 +
1439 +
1440 +
1441 +// ===== STUDIO_SOCIAL v3 (générateurs + galerie + loading) =====
1439 1442 (function(){
1440 1443 const $ = (id) => document.getElementById(id);
1441 1444 const SITE_LABEL = {"lou-ka":"Lou·Ka","immo-ka":"Immo·Ka","vrai-prix":"Vrai-Prix","auto-ka":"Auto·Ka","food-ka":"Food·Ka","fabri-ka":"Fabri·Ka","resto-ka":"Resto·Ka","sorti-ka":"Sorti·Ka","crea-ka":"Créa·Ka","job-ka":"Job·Ka","trouve-ka":"Trouve·Ka"};
1445 + // overlay de chargement
1446 + let ov;
1447 + function showLoading(msg){
1448 + if(!ov){ ov=document.createElement('div'); ov.id='stLoad';
1449 + ov.innerHTML='<div class="stload-box"><div class="stload-spin"></div><div class="stload-msg"></div><div class="stload-sub">Ça peut prendre un moment…</div></div>';
1450 + document.body.appendChild(ov); }
1451 + ov.querySelector('.stload-msg').textContent=msg||'Génération…';
1452 + ov.classList.add('on');
1453 + }
1454 + function hideLoading(){ if(ov) ov.classList.remove('on'); }
1442 1455 async function copyText(txt, btn){
1443 1456 try { await navigator.clipboard.writeText(txt); }
1444 1457 catch(e){ const t=document.createElement('textarea'); t.value=txt; document.body.appendChild(t); t.select(); try{document.execCommand('copy');}catch(_){}
@@ -1446,55 +1459,49 @@
1446 1459 if(btn){ const o=btn.innerHTML; btn.innerHTML='✓ Copié'; setTimeout(()=>btn.innerHTML=o,1400); }
1447 1460 }
1448 1461 function esc(s){ return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
1449 function renderGallery(items){
1462 + function renderGallery(items, generating){
1450 1463 const el=$('stGallery'); if(!el) return;
1451 1464 if($('stCount')) $('stCount').textContent = items.length ? (items.length+' visuel'+(items.length>1?'s':'')) : '';
1452 if(!items.length){ el.innerHTML='<div class="soc-sub">Aucun visuel encore — génère ton premier post ou reel ci-dessus.</div>'; return; }
1453 el.innerHTML = items.map(function(it,idx){
1465 + let html = '';
1466 + if(generating){ html += '<div class="st-item st-skel"><div class="st-media"><div class="st-shimmer"></div><span class="st-badge">⏳ en création…</span></div><div class="st-cap">L\'Agent KA génère un visuel…</div></div>'; }
1467 + if(!items.length && !generating){ el.innerHTML='<div class="soc-sub">Aucun visuel encore — génère ton premier post ou reel ci-dessus.</div>'; return; }
1468 + html += items.map(function(it,idx){
1454 1469 const media = it.kind==='reel'
1455 1470 ? '<video src="'+it.url+'" muted loop playsinline preload="metadata" onmouseover="this.play()" onmouseout="this.pause()"></video>'
1456 1471 : '<img src="'+it.url+'" loading="lazy">';
1457 1472 const badge = (SITE_LABEL[it.site]||it.site)+(it.kind==='reel'?' · Reel':' · Post');
1458 return '<div class="st-item">'
1459 + '<div class="st-media">'+media+'<span class="st-badge">'+esc(badge)+'</span></div>'
1473 + return '<div class="st-item"><div class="st-media">'+media+'<span class="st-badge">'+esc(badge)+'</span></div>'
1460 1474 + '<div class="st-cap" id="stcap'+idx+'">'+esc(it.caption||'')+'</div>'
1461 + '<div class="st-item-actions">'
1462 + '<a class="btn-cta small" href="'+it.url+'" download="'+it.site+'-'+it.kind+(it.kind==='reel'?'.mp4':'.png')+'">⬇︎</a>'
1463 + '<button class="btn-ghost small" data-copy="'+idx+'">📋 Copier</button>'
1464 + '</div></div>';
1475 + + '<div class="st-item-actions"><a class="btn-cta small" href="'+it.url+'" download="'+it.site+'-'+it.kind+(it.kind==='reel'?'.mp4':'.png')+'">⬇︎</a>'
1476 + + '<button class="btn-ghost small" data-copy="'+idx+'">📋 Copier</button></div></div>';
1465 1477 }).join('');
1478 + el.innerHTML = html;
1466 1479 el.querySelectorAll('[data-copy]').forEach(function(b){ b.onclick=function(){ copyText(items[+b.dataset.copy].caption||'', b); }; });
1467 1480 }
1481 + let polling=false;
1468 1482 async function loadGallery(){
1469 1483 try{ const d=await (await fetch('/api/social/gallery')).json();
1470 renderGallery(d.items||[]);
1471 if(d.gen && d.gen.running){ if($('stGenStatus')) $('stGenStatus').textContent='⏳ génération des reels en cours…'; setTimeout(loadGallery, 12000); }
1472 else if($('stGenStatus')) $('stGenStatus').textContent='';
1484 + const gen = d.gen && d.gen.running;
1485 + renderGallery(d.items||[], gen);
1486 + if($('stGenStatus')) $('stGenStatus').textContent = gen ? '⏳ génération des reels en cours…' : '';
1487 + if(gen && !polling){ polling=true; setTimeout(function(){ polling=false; loadGallery(); }, 10000); }
1473 1488 }catch(e){}
1474 1489 }
1475 async function genPost(){
1476 const g=$('stPostGen'); if(!g) return; const prev=g.textContent; g.disabled=true; g.textContent='Génération…';
1477 try{ const r=await fetch('/api/social/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({site:$('stPostSite').value,prompt:$('stPostPrompt').value})});
1478 const d=await r.json(); if(!d.draft) alert('Échec : '+(d.error||'?')); else loadGallery();
1479 }catch(e){ alert('Échec : '+e.message); } g.disabled=false; g.textContent=prev;
1480 }
1481 async function genReel(){
1482 const g=$('stReelGen'); if(!g) return; const prev=g.textContent; g.disabled=true; g.textContent='Génération vidéo…';
1483 try{ const r=await fetch('/api/social/reel/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({site:$('stReelSite').value,prompt:$('stReelPrompt').value})});
1484 const d=await r.json(); if(!d.draft) alert('Échec : '+(d.error||'?')); else loadGallery();
1485 }catch(e){ alert('Échec : '+e.message); } g.disabled=false; g.textContent=prev;
1486 }
1487 async function gen10(){
1488 const b=$('stReel10'); if(!b) return;
1489 try{ const r=await fetch('/api/social/reel/genbatch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});
1490 const d=await r.json(); if($('stGenStatus')) $('stGenStatus').textContent = d.started? '⏳ 10 reels en génération…' : (d.reason||'déjà en cours');
1491 setTimeout(loadGallery, 8000);
1490 + async function genOne(url, body, msg){
1491 + showLoading(msg);
1492 + try{ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
1493 + const d=await r.json(); if(!d.draft){ alert('Échec : '+(d.error||'?')); } else { await loadGallery(); }
1492 1494 }catch(e){ alert('Échec : '+e.message); }
1495 + hideLoading();
1493 1496 }
1494 1497 function bind(){
1495 if($('stPostGen')) $('stPostGen').onclick=genPost;
1496 if($('stReelGen')) $('stReelGen').onclick=genReel;
1497 if($('stReel10')) $('stReel10').onclick=gen10;
1498 + if($('stPostGen')) $('stPostGen').onclick=()=>genOne('/api/social/generate',{site:$('stPostSite').value,prompt:$('stPostPrompt').value},'🖼️ Génération de l\'image…');
1499 + if($('stReelGen')) $('stReelGen').onclick=()=>genOne('/api/social/reel/generate',{site:$('stReelSite').value,prompt:$('stReelPrompt').value},'🎬 Génération du reel (vidéo + musique)…');
1500 + if($('stReel10')) $('stReel10').onclick=async ()=>{
1501 + try{ const r=await fetch('/api/social/reel/genbatch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});
1502 + const d=await r.json(); if($('stGenStatus')) $('stGenStatus').textContent=d.started?'⏳ 10 reels en génération…':(d.reason||'déjà en cours'); loadGallery();
1503 + }catch(e){ alert('Échec : '+e.message); }
1504 + };
1498 1505 if($('stRefresh')) $('stRefresh').onclick=loadGallery;
1499 1506 }
1500 1507 bind();
modified public/style.css +12 −0
@@ -677,3 +677,15 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
677 677 .st-item-actions{display:flex;gap:6px;padding:0 10px 10px;margin-top:auto}
678 678 .st-item-actions .btn-cta.small{flex:0 0 auto;text-decoration:none;text-align:center}
679 679 .st-item-actions .btn-ghost.small{flex:1}
680 +
681 +/* ---- Studio loading ---- */
682 +#stLoad{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;background:rgba(8,8,10,.72);backdrop-filter:blur(4px)}
683 +#stLoad.on{display:flex}
684 +.stload-box{background:var(--card,#141414);border:2px solid var(--ink,#1a1a1a);border-radius:18px;padding:26px 30px;text-align:center;box-shadow:4px 4px 0 var(--ink,#1a1a1a);max-width:80vw}
685 +.stload-spin{width:44px;height:44px;margin:0 auto 14px;border:5px solid rgba(127,127,127,.25);border-top-color:var(--accent,#f97316);border-radius:50%;animation:stspin .8s linear infinite}
686 +@keyframes stspin{to{transform:rotate(360deg)}}
687 +.stload-msg{font-weight:800;font-size:15px}
688 +.stload-sub{font-size:12px;color:var(--muted,#888);margin-top:6px}
689 +.st-skel .st-media{position:relative}
690 +.st-shimmer{position:absolute;inset:0;background:linear-gradient(100deg,#1a1a1a 30%,#2a2a2a 50%,#1a1a1a 70%);background-size:200% 100%;animation:stshim 1.3s linear infinite}
691 +@keyframes stshim{to{background-position:-200% 0}}
modified server/social/make_reel.py +43 −32
@@ -136,13 +136,16 @@ def build_html(ins):
136 136 @keyframes fadeUp{to{opacity:1;transform:translateY(0)}}
137 137 @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}
138 138 @keyframes heroIn{to{opacity:1;transform:translateY(0) scale(1)}}
139 + .vign{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% 42%, transparent 55%, rgba(0,0,0,.55) 100%)}
140 + .numwrap{animation:heroIn .95s cubic-bezier(.2,1,.3,1) .95s forwards, pulse 3s ease-in-out 2s infinite}
141 + @keyframes pulse{0%,100%{filter:none}50%{filter:brightness(1.12)}}
139 142 """
140 143 css=(css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__A__",p["a"])
141 144 .replace("__B__",p["b"]).replace("__G1__",p["g1"]).replace("__G2__",p["g2"])
142 145 .replace("__NUM__",str(numsize)).replace("__DUR__",str(DUR)))
143 146 tag = ('<div class="tag">'+esc(ins.get("tagline",""))+'</div>') if ins.get("tagline") else ""
144 147 tiles_block = ('<div class="tiles">'+tiles_html+'</div>') if tiles else ""
145 body = ('<div class="stage"><div class="mesh"></div><div class="pad">'
148 + body = ('<div class="stage"><div class="mesh"></div><div class="vign"></div><div class="pad">'
146 149 '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · '+today().upper()+'</div>'
147 150 '<div class="kabadge">KA</div>'
148 151 '<div class="site">'+esc(ins["label"])+'</div>'+tag+
@@ -194,45 +197,53 @@ def record_webm(html_path, out_webm, tmpdir):
194 197 os.replace(path, out_webm)
195 198
196 199 def make_music(dur, out_wav):
197 """Lit musical pro : progression Am-F-C-G (pad) + kick 4-temps + shaker + réverbe."""
200 + """Lit musical riche : progression Am-F-C-G (pad) + basse + kick 4-temps + shaker
201 + + arpège/pluck brillant, avec réverbe/compresseur/limiteur."""
198 202 tmpdir=os.path.dirname(out_wav)
199 203 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)]
204 + roots=[110.00,87.31,130.81,98.00]
200 205 seg=round(dur/len(chords),3)
201 parts=[]
206 + def concat(paths,outp):
207 + lst=outp+".txt"; open(lst,"w").write("".join("file '"+x+"'\n" for x in paths))
208 + subprocess.run([FFMPEG,"-y","-f","concat","-safe","0","-i",lst,"-c","copy",outp],capture_output=True)
209 + # pad (accords)
210 + padparts=[]
202 211 for i,(f1,f2,f3) in enumerate(chords):
203 w=os.path.join(tmpdir,f"ch{i}.wav")
204 subprocess.run([FFMPEG,"-y",
205 "-f","lavfi","-i",f"sine=frequency={f1}:duration={seg}",
206 "-f","lavfi","-i",f"sine=frequency={f2}:duration={seg}",
207 "-f","lavfi","-i",f"sine=frequency={f3}:duration={seg}",
208 "-filter_complex",
209 f"[0][1][2]amix=inputs=3:normalize=0,volume=0.22,lowpass=f=1300,"
210 f"afade=t=in:st=0:d=0.25,afade=t=out:st={seg-0.35}:d=0.35[a]",
211 "-map","[a]",w],capture_output=True)
212 parts.append(w)
213 # concat des accords -> pad
214 lst=os.path.join(tmpdir,"chords.txt")
215 open(lst,"w").write("".join(f"file '{w}'\n" for w in parts))
216 pad=os.path.join(tmpdir,"pad.wav")
217 subprocess.run([FFMPEG,"-y","-f","concat","-safe","0","-i",lst,"-c","copy",pad],capture_output=True)
218 # kick 4-temps (120 BPM) + shaker offbeat, synthétisés
212 + w=os.path.join(tmpdir,"ch%d.wav"%i)
213 + subprocess.run([FFMPEG,"-y","-f","lavfi","-i","sine=frequency=%s:duration=%s"%(f1,seg),
214 + "-f","lavfi","-i","sine=frequency=%s:duration=%s"%(f2,seg),
215 + "-f","lavfi","-i","sine=frequency=%s:duration=%s"%(f3,seg),
216 + "-filter_complex","[0][1][2]amix=inputs=3:normalize=0,volume=0.20,lowpass=f=1200,afade=t=in:st=0:d=0.3,afade=t=out:st=%s:d=0.4[a]"%(seg-0.4),
217 + "-map","[a]",w],capture_output=True); padparts.append(w)
218 + pad=os.path.join(tmpdir,"pad.wav"); concat(padparts,pad)
219 + # basse (fondamentale par accord)
220 + bassparts=[]
221 + for i,r in enumerate(roots):
222 + w=os.path.join(tmpdir,"b%d.wav"%i)
223 + subprocess.run([FFMPEG,"-y","-f","lavfi","-i","sine=frequency=%s:duration=%s"%(r,seg),
224 + "-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),
225 + w],capture_output=True); bassparts.append(w)
226 + bass=os.path.join(tmpdir,"bass.wav"); concat(bassparts,bass)
227 + # kick 4-temps
219 228 kick=os.path.join(tmpdir,"kick.wav")
220 subprocess.run([FFMPEG,"-y","-f","lavfi",
221 "-i",f"aevalsrc=0.7*sin(2*PI*55*t)*exp(-9*mod(t\,0.5)):d={dur}:s=44100",
222 kick],capture_output=True)
229 + 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)
230 + # shaker/hat contretemps
223 231 hat=os.path.join(tmpdir,"hat.wav")
232 + 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)
233 + # arpège/pluck brillant (croches, deux notes en alternance)
234 + arp=os.path.join(tmpdir,"arp.wav")
224 235 subprocess.run([FFMPEG,"-y","-f","lavfi",
225 "-i",f"aevalsrc=0.12*(random(0)*2-1)*exp(-45*mod(t+0.25\,0.5)):d={dur}:s=44100",
226 "-af","highpass=f=6000",hat],capture_output=True)
227 # mix final : pad + kick + hat, réverbe douce, master fades
228 subprocess.run([FFMPEG,"-y","-i",pad,"-i",kick,"-i",hat,
236 + "-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,
237 + "-af","highpass=f=350,lowpass=f=6000",arp],capture_output=True)
238 + # mix final
239 + subprocess.run([FFMPEG,"-y","-i",pad,"-i",bass,"-i",kick,"-i",hat,"-i",arp,
229 240 "-filter_complex",
230 f"[0]volume=1.0[p];[1]volume=0.9[k];[2]volume=0.5[h];"
231 f"[p][k][h]amix=inputs=3:normalize=0,"
232 f"aecho=0.8:0.85:60:0.25,"
233 f"acompressor=threshold=-16dB:ratio=3:attack=8:release=180,"
234 f"alimiter=limit=0.95,"
235 f"afade=t=in:st=0:d=0.5,afade=t=out:st={dur-1.6}:d=1.6[a]",
241 + "[0]volume=1.0[p];[1]volume=1.0[b];[2]volume=0.9[k];[3]volume=0.5[h];[4]volume=0.55[ar];"
242 + "[p][b][k][h][ar]amix=inputs=5:normalize=0,"
243 + "aecho=0.8:0.88:70:0.22,"
244 + "acompressor=threshold=-18dB:ratio=3.5:attack=6:release=160,"
245 + "highpass=f=32,alimiter=limit=0.94,"
246 + "afade=t=in:st=0:d=0.5,afade=t=out:st=%s:d=1.6[a]"%(dur-1.6),
236 247 "-map","[a]","-ar","44100",out_wav],capture_output=True)
237 248
238 249 def render(ins, out_dir):
239 250