diff --git a/app.py b/app.py index f97d6d5..b6ba05b 100644 --- a/app.py +++ b/app.py @@ -9,7 +9,81 @@ TEXT_MODEL = os.environ.get("LYRA_TEXT_MODEL", "ornith-1.5:9b-64k") VISION_MODEL = os.environ.get("LYRA_VISION_MODEL", "minicpm-v4.5:8b") KOKORO_URL = os.environ.get("LYRA_KOKORO_URL", "http://10.30.20.85:8766") -app = Flask(__name__) +app = Flask(__name__, static_folder="static") + +# ---------------------------------------------------------------- golden hour math +def golden_hour(lat, lon, date=None, sunset=True): + """NOAA-solar-approx golden hour window (sun altitude +6° → -4°). Returns datetimes or None (polar).""" + import math, datetime + date = date or datetime.date.today() + + def sun_alt(ts): + doy = ts.timetuple().tm_yday + hour = ts.hour + ts.minute / 60 + ts.second / 3600 + g = math.radians((357.529 + 0.98560028 * (367 * 0 + doy - 1 + hour / 24)) % 360) + decl = math.radians(math.degrees(math.asin(math.sin(math.radians(23.44)) * math.sin(math.radians( + (280.45 + 0.98564736 * doy) % 360 - 90 + math.degrees(math.atan2(math.sin(g), math.cos(g))) / 1.915 + math.sin(g) / 0.0200))))) + ha = math.radians(15 * (hour - 12)) + return math.degrees(math.asin( + math.sin(math.radians(lat)) * math.sin(decl) + + math.cos(math.radians(lat)) * math.cos(decl) * math.cos(ha))) + + import datetime as dt + noon = dt.datetime(date.year, date.month, date.day, 12, 0) + if sunset: + t0, t1 = noon, noon + dt.timedelta(hours=8) + else: + t0, t1 = noon - dt.timedelta(hours=8), noon + a0, a1 = sun_alt(t0), sun_alt(t1) + if (a0 - 1) * (a1 - 1) > 0 and max(a0, a1) < 1: + return None + lo, hi = t0, t1 + for _ in range(30): # find -4° edge (start of golden light) + mid = lo + (hi - lo) / 2 + if (sun_alt(mid) + 4) * (sun_alt(lo) + 4) <= 0: hi = mid + else: lo = mid + start = lo + lo, hi = t0, t1 + for _ in range(30): # find +6° edge (end of golden light) + mid = lo + (hi - lo) / 2 + if (sun_alt(mid) - 6) * (sun_alt(lo) - 6) <= 0: hi = mid + else: lo = mid + end = lo + return (min(start, end), max(start, end)) + + +@app.get("/api/sun") +def api_sun(): + try: + lat = float(request.args.get("lat", 47.83)) + lon = float(request.args.get("lon", -122.28)) + import datetime + evening = golden_hour(lat, lon, datetime.date.today(), sunset=True) + morning = golden_hour(lat, lon, datetime.date.today(), sunset=False) + fmt = lambda w: [w[0].strftime("%H:%M"), w[1].strftime("%H:%M")] if w else None + return jsonify(morning=fmt(morning), evening=fmt(evening)) + except Exception as e: + return jsonify(error=str(e)), 400 + + +_warm_lock = None +_warm_last = 0.0 + +def _kokoro_warm_loop(): + import threading, time + warm_last = [0.0] + def beat(): + while True: + time.sleep(240) + if time.time() - warm_last[0] > 1500: + try: + requests.post(f"{KOKORO_URL}/tts", json={"text": ".", "voice": "af_heart"}, timeout=60) + warm_last[0] = time.time() + except Exception: + pass + threading.Thread(target=beat, daemon=True).start() + +_kokoro_warm_loop() # ---------------------------------------------------------------- offline libraries LINES = { @@ -170,6 +244,13 @@ PAGE = r""" + + + + + + + Lyra ✦ Muse & Director
✦
@@ -231,6 +324,8 @@ input[type=range]{width:150px} + +
@@ -292,7 +387,10 @@ input[type=range]{width:150px} - +
+ +
+
@@ -324,6 +422,30 @@ input[type=range]{width:150px} +
+
+

Golden hour ✦ today

+
+ + Lynnwood by default. +
+
+

The hour after sunrise and before sunset — soft, directional, flattering. Shoot then.

+
+
+ +
+
+

Frame strip ✦ this session

+
+ + + +
+
+
+
+

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))))); +});