Settings ✦ saved on this device
@@ -445,7 +567,8 @@ function paintLight(){
if(lightOn)pulseT=requestAnimationFrame(frame)}
cancelAnimationFrame(pulseT);pulseT=requestAnimationFrame(frame);}
let stream=null;
-async function camPreviewToggle(){await camStream(true)}
+function thirdsToggle(){const t=$('thirdsOv');t.style.display=t.style.display==='block'?'none':'block'}
+async function camPreviewToggle(){await camStream(true);thirdsToggle()}
async function camStream(on){
if(on&&!stream){try{stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'environment'},audio:false});
const v=$('camPreview');v.srcObject=stream;v.style.display='block';
@@ -457,7 +580,16 @@ function stopCam(){if(stream){stream.getTracks().forEach(t=>t.stop());stream=nul
async function torchToggle(){if(!stream)return;const track=stream.getVideoTracks()[0];
const on=!(track._torch);track._torch=on;
try{await track.applyConstraints({advanced:[{torch:on}]})}catch(e){toast('Torch unsupported')}}
-let lastShot=null;
+let lastShot=null,strip=[];
+function stripAdd(thumb,verdict,score,say){
+ strip.unshift({thumb,verdict,score,say});if(strip.length>60)strip.pop();renderStrip()}
+function renderStrip(){
+ const p=strip.filter(f=>f.verdict==='PASS').length;
+ $('stripCount').textContent=strip.length+' frames'+(strip.length?` · ${p}✓ ${strip.length-p}✗`:'');
+ $('stripOut').innerHTML=strip.map(f=>
+ `
+
${f.score||''}${(f.say||'').slice(0,40)}
`).join('');}
+function clearStrip(){strip=[];renderStrip()}
async function analyzeCapture(){
if(!stream)await camStream(true);
if(!stream)return toast('Enable camera preview first');
@@ -476,7 +608,11 @@ function analyzeB64(dataUrl){
.then(r=>r.json()).then(d=>{
if(d.error){$('verdict').textContent='⚠ '+d.error;return}
$('verdict').textContent=d.analysis;
- const m=d.analysis.match(/SAY THIS:\s*(.+)/);if(m){setTimeout(()=>speak(m[1]),600)}
+ const m=d.analysis.match(/SAY THIS:\s*(.+)/);
+ const v=(d.analysis.match(/VERDICT:\s*(\w+)/)||[])[1]||'';
+ const s=(d.analysis.match(/SCORE:\s*(\d+)/)||[])[1]||'';
+ if(lastShot)stripAdd(lastShot,v,s,m?m[1]:'');
+ if(m){setTimeout(()=>speak(m[1]),600)}
}).catch(()=>{$('verdict').textContent='⚠ analyzer unreachable'})}
function analyzeLastShot(){lastShot?analyzeB64(lastShot):toast('No capture yet')}
$('camInLight').onchange=e=>{if(e.target.checked)camStream(true);else stopCam()};
@@ -534,6 +670,55 @@ loadSettings();
function applyEngineTag(){$('engineTag').textContent=$('setEngine').value==='kokoro'?'kokoro · '+$('setKVoice').value:'browser voice'}
applyEngineTag();
setTimeout(autoDirector,800);
+
+/* ---------- HYPE button ---------- */
+const hype=document.createElement('button');hype.id='hypeBtn';hype.textContent='⚡';hype.title='HYPE — say something electric NOW';
+hype.onclick=()=>{getLines();document.querySelector('[data-t="lines"]').click();
+ setTimeout(()=>{const ls=[...document.querySelectorAll('.line')];if(ls.length)speak(ls[0].firstChild.textContent)},1400)};
+document.body.appendChild(hype);
+
+/* ---------- golden hour ---------- */
+function loadSun(){
+ $('sunOut').textContent='✦ reading the sky…';
+ const done=(lat,lon,label)=>fetch('/api/sun?lat='+lat+'&lon='+lon).then(r=>r.json()).then(d=>{
+ const box=(name,w,color)=>w?`
+
${name}
+
${w[0]}–${w[1]}
`:'';
+ $('sunBoxes').innerHTML=box('Morning',d.morning,'var(--gold2)')+box('Evening',d.evening,'#ff9d6b');
+ $('sunOut').textContent=label;});
+ if(navigator.geolocation)navigator.geolocation.getCurrentPosition(
+ p=>done(p.coords.latitude,p.coords.longitude,'Your sky.'),
+ ()=>done(47.83,-122.28,'Lynnwood, WA.'),{timeout:5000});
+ else done(47.83,-122.28,'Lynnwood, WA.');}
+document.querySelector('[data-t="sun"]').addEventListener('click',loadSun);
+
+/* ---------- voice commands (hands-free) ---------- */
+let recg=null,vcOn=false;
+function vcToggle(){
+ if(vcOn){recg&&recg.stop();return}
+ const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
+ if(!SR)return toast('Voice commands not supported here');
+ recg=new SR();recg.continuous=true;recg.interimResults=false;recg.lang='en-US';
+ recg.onresult=e=>{const t=e.results[e.results.length-1][0].transcript||'';
+ handleCommand(t.toLowerCase().trim())};
+ recg.onend=()=>{if(vcOn)try{recg.start()}catch(_){}};
+ recg.onerror=()=>{};
+ recg.start();vcOn=true;$('vcBtn').textContent='🎤 listening — tap to stop';toast('Say: "new lines" · "pose" · "analyze" · "hype"');}
+function handleCommand(t){
+ if(/new lines|more lines|another line/.test(t)){getLines();toast('✦ fresh lines')}
+ else if(/random pose|new pose|^pose$/.test(t)){randomPose()}
+ else if(/analyz|check the frame|judge/.test(t)){document.querySelector('[data-t="analyze"]').click();analyzeCapture()}
+ else if(/hype|let.s go|electric/.test(t)){hype.onclick()}
+ else if(/light mode|light rig/.test(t)){document.querySelector('[data-t="light"]').click()}
+ else if(/stop|quiet|shut up/.test(t)){speechSynthesis.cancel();toast('quieted')}}
+function vcBtnInit(){
+ const b=document.createElement('button');b.id='vcBtn';b.className='btn ghost';b.style.margin='10px 0';
+ b.textContent='🎤 Hands-free mode';b.onclick=vcToggle;
+ document.querySelector('#t-lines .card').appendChild(b);}
+vcBtnInit();
+
+/* ---------- PWA ---------- */
+if('serviceWorker' in navigator)navigator.serviceWorker.register('/static/sw.js').catch(()=>{});
"""
@@ -562,5 +747,19 @@ def api_tts():
return jsonify(error="kokoro unavailable"), 404
+@app.get("/llms.txt")
+def llms_txt():
+ return app.response_class(
+ "# Lyra — Muse & Director\n\n"
+ "On-set copilot for model + photographer shoots. Self-hosted, agent-accessible.\n\n"
+ "## API\n"
+ "- GET /health — liveness\n"
+ "- GET /api/lines?category={sweet,witty,confident,playful,hype}&spice=1-3&count=N — spoken lines for the model\n"
+ "- POST /api/analyze {\"image\": \"data:image/jpeg;base64,...\"} — frame verdict: PASS/NEEDS WORK, score, lighting, pose fix, line to say\n"
+ "- GET /api/sun?lat=&lon= — golden hour windows for today\n"
+ "- POST /api/tts {\"text\":..., \"voice\": \"af_heart|af_bella|af_nicole\"} — neural TTS, returns WAV\n\n",
+ mimetype="text/plain")
+
+
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, threaded=True)
diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png
new file mode 100644
index 0000000..b5fb1ad
Binary files /dev/null and b/static/apple-touch-icon.png differ
diff --git a/static/icon-192.png b/static/icon-192.png
new file mode 100644
index 0000000..d3ac526
Binary files /dev/null and b/static/icon-192.png differ
diff --git a/static/icon-512.png b/static/icon-512.png
new file mode 100644
index 0000000..5a62ab5
Binary files /dev/null and b/static/icon-512.png differ
diff --git a/static/manifest.json b/static/manifest.json
new file mode 100644
index 0000000..b84136f
--- /dev/null
+++ b/static/manifest.json
@@ -0,0 +1,13 @@
+{
+ "name": "Lyra — Muse & Director",
+ "short_name": "Lyra",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0b0a14",
+ "theme_color": "#0b0a14",
+ "description": "On-set copilot: lines, poses, light, frame judging, voice.",
+ "icons": [
+ {"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"},
+ {"src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png"}
+ ]
+}
diff --git a/static/sw.js b/static/sw.js
new file mode 100644
index 0000000..b8cea96
--- /dev/null
+++ b/static/sw.js
@@ -0,0 +1,8 @@
+const C='lyra-v2';
+self.addEventListener('install',e=>self.skipWaiting());
+self.addEventListener('activate',e=>e.waitUntil(clients.claim()));
+self.addEventListener('fetch',e=>{
+ if(e.request.method!=='GET'||e.request.url.includes('/api/'))return;
+ e.respondWith(caches.open(C).then(c=>c.match(e.request).then(r=>
+ r||(fetch(e.request).then(res=>{if(res.ok)c.put(e.request,res.clone());return res}).catch(()=>r)))));
+});