v2: PWA install, golden hour calc, hands-free voice commands, HYPE button, thirds overlay, frame strip, kokoro keep-warm, llms.txt
This commit is contained in:
209
app.py
209
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"""<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="Lyra — an AI muse & on-set director for model + photographer duos.">
|
||||
<meta name="theme-color" content="#0b0a14">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Lyra">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||
<link rel="icon" href="/static/icon-192.png">
|
||||
<title>Lyra ✦ Muse & Director</title>
|
||||
<style>
|
||||
:root{--bg:#0b0a14;--panel:#151327;--panel2:#1d1a35;--gold:#e8c15a;--gold2:#f5df9a;--txt:#eae7f5;--dim:#9a94b8;--ok:#6ee7a0;--warn:#ff9d6b;--accent:#8f7bff}
|
||||
@@ -218,6 +299,18 @@ input[type=range]{width:150px}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--gold);padding:10px 22px;border-radius:20px;font-size:14px;opacity:0;transition:.3s;z-index:200}
|
||||
.toast.show{opacity:1}
|
||||
.autoBadge{font-size:11px;color:var(--dim);border:1px solid #3a3466;padding:2px 10px;border-radius:10px}
|
||||
.thirds{position:absolute;inset:0;pointer-events:none;display:none;background:
|
||||
linear-gradient(to right,transparent calc(33.33% - .5px),#e8c15a66 calc(33.33% - .5px),#e8c15a66 calc(33.33% + .5px),transparent calc(33.33% + .5px)),
|
||||
linear-gradient(to right,transparent calc(66.66% - .5px),#e8c15a66 calc(66.66% - .5px),#e8c15a66 calc(66.66% + .5px),transparent calc(66.66% + .5px)),
|
||||
linear-gradient(to bottom,transparent calc(33.33% - .5px),#e8c15a66 calc(33.33% - .5px),#e8c15a66 calc(33.33% + .5px),transparent calc(33.33% + .5px)),
|
||||
linear-gradient(to bottom,transparent calc(66.66% - .5px),#e8c15a66 calc(66.66% - .5px),#e8c15a66 calc(66.66% + .5px),transparent calc(66.66% + .5px))}
|
||||
.frame{width:110px;border-radius:10px;border:2px solid transparent;position:relative;cursor:pointer}
|
||||
.frame img{width:100%;border-radius:8px;display:block}
|
||||
.frame.pass{border-color:var(--ok)}.frame.fail{border-color:var(--warn)}
|
||||
.frame .sc{position:absolute;top:4px;right:4px;background:#000a;border-radius:8px;padding:0 6px;font-size:11px;color:var(--gold2)}
|
||||
.frame .say{font-size:10px;color:var(--dim);padding:3px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
#hypeBtn{position:fixed;bottom:18px;right:18px;width:64px;height:64px;border-radius:50%;font-size:26px;background:radial-gradient(circle at 30% 30%,#8f7bff,#5b3fd6);border:none;cursor:pointer;z-index:90;box-shadow:0 4px 24px #8f7bff66}
|
||||
#hypeBtn:active{transform:scale(.92)}
|
||||
</style></head><body>
|
||||
<header>
|
||||
<div style="font-size:30px">✦</div>
|
||||
@@ -231,6 +324,8 @@ input[type=range]{width:150px}
|
||||
<button data-t="analyze">Analyzer</button>
|
||||
<button data-t="shots">Shot List</button>
|
||||
<button data-t="timer">Timer</button>
|
||||
<button data-t="sun">Golden Hour</button>
|
||||
<button data-t="strip">Frames</button>
|
||||
<button data-t="settings">Settings</button>
|
||||
</nav>
|
||||
<main>
|
||||
@@ -292,7 +387,10 @@ input[type=range]{width:150px}
|
||||
<button class="btn ghost" onclick="analyzeLastShot()">Analyze last capture</button>
|
||||
<label class="btn ghost" style="display:inline-block">Upload file<input type="file" id="upFile" accept="image/*" style="display:none" onchange="analyzeFile(this)"></label>
|
||||
</div>
|
||||
<video id="camPreview" autoplay playsinline muted></video>
|
||||
<div style="position:relative">
|
||||
<video id="camPreview" autoplay playsinline muted style="width:100%;display:block"></video>
|
||||
<div class="thirds" id="thirdsOv"></div>
|
||||
</div>
|
||||
<canvas id="snap" style="display:none"></canvas>
|
||||
<div id="verdict" style="display:none"></div>
|
||||
</div>
|
||||
@@ -324,6 +422,30 @@ input[type=range]{width:150px}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab" id="t-sun">
|
||||
<div class="card">
|
||||
<h2>Golden hour ✦ today</h2>
|
||||
<div class="row">
|
||||
<button class="btn" onclick="loadSun()">Use my location</button>
|
||||
<span id="sunOut" style="color:var(--dim)">Lynnwood by default.</span>
|
||||
</div>
|
||||
<div id="sunBoxes" style="display:flex;gap:14px;flex-wrap:wrap;margin-top:14px"></div>
|
||||
<p style="color:var(--dim);font-size:13px;margin-top:10px">The hour after sunrise and before sunset — soft, directional, flattering. Shoot then.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab" id="t-strip">
|
||||
<div class="card">
|
||||
<h2>Frame strip ✦ this session</h2>
|
||||
<div class="row">
|
||||
<button class="btn" onclick="analyzeCapture()">📸 Capture + judge</button>
|
||||
<button class="btn ghost" onclick="clearStrip()">Clear</button>
|
||||
<span class="autoBadge" id="stripCount"></span>
|
||||
</div>
|
||||
<div id="stripOut" style="display:flex;flex-wrap:wrap;gap:10px;margin-top:14px"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab" id="t-settings">
|
||||
<div class="card">
|
||||
<h2>Settings ✦ saved on this device</h2>
|
||||
@@ -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=>
|
||||
`<div class="frame ${f.verdict==='PASS'?'pass':'fail'}" onclick="speak(${JSON.stringify(f.say||'').replace(/"/g,'"')})">
|
||||
<img src="${f.thumb}"><span class="sc">${f.score||''}</span><div class="say">${(f.say||'').slice(0,40)}</div></div>`).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?`<div style="background:var(--panel2);border:1px solid #3a3466;border-radius:12px;padding:12px 18px">
|
||||
<div style="font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:1px">${name}</div>
|
||||
<div style="font-size:26px;color:${color}">${w[0]}–${w[1]}</div></div>`:'';
|
||||
$('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(()=>{});
|
||||
</script></body></html>"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user