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")
|
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")
|
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
|
# ---------------------------------------------------------------- offline libraries
|
||||||
LINES = {
|
LINES = {
|
||||||
@@ -170,6 +244,13 @@ PAGE = r"""<!doctype html>
|
|||||||
<html lang="en"><head>
|
<html lang="en"><head>
|
||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<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="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>
|
<title>Lyra ✦ Muse & Director</title>
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#0b0a14;--panel:#151327;--panel2:#1d1a35;--gold:#e8c15a;--gold2:#f5df9a;--txt:#eae7f5;--dim:#9a94b8;--ok:#6ee7a0;--warn:#ff9d6b;--accent:#8f7bff}
|
: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{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}
|
.toast.show{opacity:1}
|
||||||
.autoBadge{font-size:11px;color:var(--dim);border:1px solid #3a3466;padding:2px 10px;border-radius:10px}
|
.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>
|
</style></head><body>
|
||||||
<header>
|
<header>
|
||||||
<div style="font-size:30px">✦</div>
|
<div style="font-size:30px">✦</div>
|
||||||
@@ -231,6 +324,8 @@ input[type=range]{width:150px}
|
|||||||
<button data-t="analyze">Analyzer</button>
|
<button data-t="analyze">Analyzer</button>
|
||||||
<button data-t="shots">Shot List</button>
|
<button data-t="shots">Shot List</button>
|
||||||
<button data-t="timer">Timer</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>
|
<button data-t="settings">Settings</button>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
@@ -292,7 +387,10 @@ input[type=range]{width:150px}
|
|||||||
<button class="btn ghost" onclick="analyzeLastShot()">Analyze last capture</button>
|
<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>
|
<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>
|
</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>
|
<canvas id="snap" style="display:none"></canvas>
|
||||||
<div id="verdict" style="display:none"></div>
|
<div id="verdict" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,6 +422,30 @@ input[type=range]{width:150px}
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="tab" id="t-settings">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Settings ✦ saved on this device</h2>
|
<h2>Settings ✦ saved on this device</h2>
|
||||||
@@ -445,7 +567,8 @@ function paintLight(){
|
|||||||
if(lightOn)pulseT=requestAnimationFrame(frame)}
|
if(lightOn)pulseT=requestAnimationFrame(frame)}
|
||||||
cancelAnimationFrame(pulseT);pulseT=requestAnimationFrame(frame);}
|
cancelAnimationFrame(pulseT);pulseT=requestAnimationFrame(frame);}
|
||||||
let stream=null;
|
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){
|
async function camStream(on){
|
||||||
if(on&&!stream){try{stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'environment'},audio:false});
|
if(on&&!stream){try{stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'environment'},audio:false});
|
||||||
const v=$('camPreview');v.srcObject=stream;v.style.display='block';
|
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];
|
async function torchToggle(){if(!stream)return;const track=stream.getVideoTracks()[0];
|
||||||
const on=!(track._torch);track._torch=on;
|
const on=!(track._torch);track._torch=on;
|
||||||
try{await track.applyConstraints({advanced:[{torch:on}]})}catch(e){toast('Torch unsupported')}}
|
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(){
|
async function analyzeCapture(){
|
||||||
if(!stream)await camStream(true);
|
if(!stream)await camStream(true);
|
||||||
if(!stream)return toast('Enable camera preview first');
|
if(!stream)return toast('Enable camera preview first');
|
||||||
@@ -476,7 +608,11 @@ function analyzeB64(dataUrl){
|
|||||||
.then(r=>r.json()).then(d=>{
|
.then(r=>r.json()).then(d=>{
|
||||||
if(d.error){$('verdict').textContent='⚠ '+d.error;return}
|
if(d.error){$('verdict').textContent='⚠ '+d.error;return}
|
||||||
$('verdict').textContent=d.analysis;
|
$('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'})}
|
}).catch(()=>{$('verdict').textContent='⚠ analyzer unreachable'})}
|
||||||
function analyzeLastShot(){lastShot?analyzeB64(lastShot):toast('No capture yet')}
|
function analyzeLastShot(){lastShot?analyzeB64(lastShot):toast('No capture yet')}
|
||||||
$('camInLight').onchange=e=>{if(e.target.checked)camStream(true);else stopCam()};
|
$('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'}
|
function applyEngineTag(){$('engineTag').textContent=$('setEngine').value==='kokoro'?'kokoro · '+$('setKVoice').value:'browser voice'}
|
||||||
applyEngineTag();
|
applyEngineTag();
|
||||||
setTimeout(autoDirector,800);
|
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>"""
|
</script></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
@@ -562,5 +747,19 @@ def api_tts():
|
|||||||
return jsonify(error="kokoro unavailable"), 404
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=5000, threaded=True)
|
app.run(host="0.0.0.0", port=5000, threaded=True)
|
||||||
|
|||||||
BIN
static/apple-touch-icon.png
Normal file
BIN
static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
13
static/manifest.json
Normal file
13
static/manifest.json
Normal file
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
static/sw.js
Normal file
8
static/sw.js
Normal file
@@ -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)))));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user