Studio: indicateur de chargement (spinner+shimmer) ; musique v3 (basse+arpege) ; vignette+pulse
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,'&').replace(/</g,'<').replace(/>/g,'>'); } |
| 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 | |