From 05a655558fd09b9ba09a6857abc5d505ec29ec25 Mon Sep 17 00:00:00 2001 From: drjones Date: Thu, 1 Oct 2026 18:36:53 +0000 Subject: [PATCH] v2: PWA install, golden hour calc, hands-free voice commands, HYPE button, thirds overlay, frame strip, kokoro keep-warm, llms.txt --- app.py | 209 +++++++++++++++++++++++++++++++++++- static/apple-touch-icon.png | Bin 0 -> 1042 bytes static/icon-192.png | Bin 0 -> 1096 bytes static/icon-512.png | Bin 0 -> 3260 bytes static/manifest.json | 13 +++ static/sw.js | 8 ++ 6 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 static/apple-touch-icon.png create mode 100644 static/icon-192.png create mode 100644 static/icon-512.png create mode 100644 static/manifest.json create mode 100644 static/sw.js 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 0000000000000000000000000000000000000000..b5fb1adba1fa7f0a4861c5131ef1ec555228341a GIT binary patch literal 1042 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbof%&GVi(^Q|oVRx!^KLr`xCB~x zSHG7263g`D2xHOoo!$Z#0z%%t!E+QXW552tq|N+!S%!Dh-%sCu?Gvzlb^Gvop~mKG zOzcY=n=c)3%zUuGq#!`bLW5Uk3LBpnGhX43@o%4hU%kJz6Quj`=NtpM?I06=+}_`O zu%dcx9M*_wsC11 z&k6DN&nxrKh#&tpOIx`hV3sD|ZNmz!V43sRbPO#YeXw~cYx(F#_-AESKCjJ(6D=5a zZ$5l6*tq#=>*2R0)}IeK`m^>u&+@yS+;aF;OnXX8a>!Y>ncRHyg=J@O^W74(_;$sn z>;zN$$wHTaE~hPi`EIff-z$EZl(xrDR^~7A7xR@dQE`-+e&_Kr3%MqXj1c|&_3yjAjAQQq z_?R8&$#|ScO=aqpV}|P7l1i$DXY!q#Jq=|`iYm!h8c5Cuv!=Zc#>CD;Q^-^CpH}C^7b#Ap8xW1a!fhb-*~p^v6xp9lknt61!_XZ z8)t~OPn|U5ybG_)A+Mw);iCl_f}LDCuO4cwRe3dIMekyJlj&WRa#U z^fhwD!yPf6D`xCCdS6jh9T-3lCd}pq29}nujQah@0dsgGfdN-ALB8+uGCP;~nx`yQ z*r&=ID*rWSO5vOUkKfGfJ0DqmNo)U-^WyP>d0z@o+<*W0((%H8#DsH9?S~!ZCbITD zY;89yxbUd&@RN1@XIYOQICN~LAm4mv*{Onkimj6kfayk@Z*}$k`Qm-&4xO1M%eS5R z-AO+7Tz;8o?)@hVk0e%{`CW8mmk_cs}i&v%#rOoGhEAAjU{#v-#t2$Wy= z=PBC++kvt|@=p_qGskobCS0!1S`@gEh@6Bq-%VR{pJn6c)$2H>e*@-q22WQ%mvv4F FO#qD+%g6u# literal 0 HcmV?d00001 diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..d3ac526c6fbfea0bd76a267cf252126c6094fa39 GIT binary patch literal 1096 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfra1G#WAE}&fB|zS<;3gt%=L~ zPiB`Fd=Glj#eKK?ThOVN2{-&6%~b9!dH?hO_X7=f@{>9b`0Jmy*LQLMUHC)2lffm6 zK}nNgqDX@WmxBtc!XzdEPsWZV3_#lm3oppes;@g+*RqrusOiP;pNTE|IDm#M@UOPF z>A8>&GN;5|?fF55FBubdNrP;#KGr6%3RLg@@xG!rJ ztl|c#xGpg@nejzJ!)>Mu|1T6+FvzYy$mgKKrqJfXv=PoHrc*^H-xdDEGGFSac=`3-hGYRD)72e4@L+r{$x z|Ho;C@m|QvJLSEKIiuD}RWg)eO1Gb(TPNew(qPXZFNdiwQqrgJ1cX{uN+;&#QAm;MJYkA2<{*y}BOhute);vV}hUQfyO%6cpr+-{vO z)^NPgRndKyR_YLK5H-_}$>G_gq(t+R3_A_GcuGDo7A1&OzDQvW2?|On4rUDCb(=WN zPtd_r)wRf+l`%C@B=p4T)uRQ85}H@3rIw!FK;6?1ooHfuvYcZX&!@9E1LZ1}5PmNo1WU#G^Hy3>?UfctMcEmi&&A+m z-+%1S9bSj;&8M3|dE(0j4nvRm;Ito}d-(958o>sSRX@s?U3w8gTK+>*ebM!$eB)!D VBA(7KdcYEb!PC{xWt~$(69C>opql^y literal 0 HcmV?d00001 diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..5a62ab5bbd85a8bec45b6d525e177adbc36878c4 GIT binary patch literal 3260 zcmdT{`$H2~7CtkXLJ~s>0R$pKK+=Fxjfe`O5P1kfgcM&`DFg&dYg@3kfC^!PJgiVa zK^__*RExB-6~tn#MMe<{6_qYlu?56O#neD?jRcJ$JDKcuf7ySa`6W5`aqcm?8Qma4Auj85=FDCCQXCU~pq|e6baI~eh{{V=V z+`ke>m9yDeu*};H`VR%SSRv9mZMMI1AVu6bm`?>UHQH402!mhC7qs!<1EMz25`}m6 z&wgSJo8DbpZ%rNEUvzF(ku_9q52|-Y@Di-}Faw^PsqH^Zd3b!)uOOKLMQ4uo9|ciH zV)qdYx7{MP7f}_s>alyK>Qz*WALU87KNL<`!L3Aof<1!oeE9j7bePprDm+Aavj1s8 z;iq&s)m$vB1|lW;M^_Bz4+h&uQJ%!3U7bt}f6?S3b^&3bdhsz3*38Y@LM16&KmEda z=I*;IOvjXmf;9sM2K}oesJ**<40Tz|?N(HaJ=={5^Fb(Y-)BoVHk#CX%y1!Vb~jab zI5kO^3K6OM9P=k^>LPru8y39?E;dzq^-!Dn3ki$5dZ>J0McAVReYVAH)9i%8U^kt@ z;!%A&`*$l*Dk_TK#Hyqk881;7mcPJ1F&S~Or`JsvYfO&F{_oFsUda8|VpT8|@A&zc zqtDi=R+C1y?x}uE-63D$_;vfLndioaB5PzZsDE04>o9z)*bwVQ%tZ;?^qi&P#A_(w z+mMs&MR=fuOM1?taKZ{D?8JtPN%##0ID6@r_JFe$jGWPJ^gvSS&`~pkc@gCx)dqPW zDEpdb9<3MSRj9_6I`p`~7PWuf`doLk?6PK;f|bX{@2mYY+*f~yN-9++Z56jko*@qF<@=HIZG@cFEcx{9&4o& zScCJ78|?vukTf{^3uyBg1U=pPdUP&=DHjb`X7q?Qqy72Ag>4l|b0not6BOXpUkPUu zvabBmwXAWSS)4T`dw!z>3|O9h-dHVD{+2M1_OfpR!Mk9@&0+6_Oc^ z_GrR}^#Wu%#Ee&{%5i~I4`f5K_AVRO_QHn5mv{LPFF^;Zh7Af51!>K8137u1;L)1> z2XYh;TaPt!FXYG}RzPb;2Xbn_TZsv4hdZL+$U(%uQ*+K0#yt>?RD0AKk4u8L7weDF zMH%66dr&`uN!bqg1F^v#6&~Rbq3RhaDlB9XI~sDBNVS*|wp}~M!TCM#p4h;|q%K|} zbfDvPB<3JTQaT)822BxWjh8fwB4C*kQ};}qj)FE9n)Js$)qBD3x#`XK?F zJ%P2~$>wrMK$bg+1Fq?ZXX-GiIV#%w(Jzm(qW4gcB(ht0s~}6f!USieVtCwTY4;bJ z;3cuA|A#Xdhnky71`2sk&~BnSzbf@>{#=>~`7o`gXBbF*_w?FRRA+x>X`+XDdDGpVA+-tD8 zVB*sQLsx}3$xnNX{1oD_?(sTMF5++7Ld73b^_#=@9C#eo8B+tz-ZZA34w^6GJWUav z+7%ikAZhWjkrtOp#r5V0K4nw%CY?mGulf}fcct`g%YTOszX_X2v=K$j?^=W5$Er7{ zgi$$qKagLJ!|-eNTT((mX_1`hhY~XJo8D9!z@2nY2t|oX)FA2F08$}LZO^^uh2TYJ3x^r-+mb73pHoqMce!;RN-B-M#YUxZ zWJIzLBzbQYv{D5jd~3NCE^ss9$UPebxAraw$M9&DHwk#gTE72s6}obf`x+`<$NR5N zIw4BlHVabtkQAwGXe)&%X1GbC#;47!KMaz}dj%w?7-!GuO5p~bNflbOGb1u{EWo6) zj2iZ4#(0%uhv7d$%vG^!?3lrgddHCJYhbv*3E`8;0xJAm$K6<_dznc)y?XRYZ~)4> zu5_Ao;{7ZQebR?#N|dADt@bl8DB3m3XZ9b@Q~hNI7t_*@+Z|`F=OmKBHKqn!G${1T pNxHW)hWd9G)&J-J!RTqRfp+|x_NQJ!Ta`e5)`mm|e-kLp{WmK~TAu&_ literal 0 HcmV?d00001 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))))); +});