Files
lyra/app.py

766 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""LYRA — Muse & Director for model + photographer shoots.
Single-file Flask app. Deployed on Proxmox CT 172."""
import base64, json, os, random, re, requests
from flask import Flask, jsonify, request, render_template_string
OLLAMA_URL = os.environ.get("LYRA_OLLAMA_URL", "http://10.30.20.222:11434")
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__, 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 = {
"sweet": [
"You have no idea what you look like through this lens right now.",
"The light found you first today — I'm just keeping up.",
"Every frame of you could hang in a gallery and you know it.",
"You make my job suspiciously easy.",
"That little smile just paid for this whole shoot.",
],
"witty": [
"Careful — if you get any prettier the camera files a complaint.",
"I'd tell you to look natural, but 'devastatingly gorgeous' is your natural.",
"The camera asked for your autograph. I said no, it's my turn.",
"Warning: frame is now 40% more beautiful than when we started.",
"You're upstaging the lighting. The lighting is upset.",
],
"confident": [
"Chin down, eyes to me. Own the frame — it's yours.",
"Slow everything down. You're not posing, you're arriving.",
"Give me that look like you already know the shot is perfect.",
"Shoulders back, weight on the back hip. Power, not effort.",
"Don't smile for me — smirk for the world.",
],
"playful": [
"Laugh like I just told you the worst joke in the world. Go.",
"Pretend the tripod said something rude about your outfit.",
"Move like the music in your head is fantastic.",
"Give me 'caught mid-secret.' Now 'committed to the secret.'",
"Whisper your favorite curse word and we'll call it candid.",
],
"hype": [
"THAT is the shot. Frame of the day, locked.",
"You just made a 50mm lens feel inadequate.",
"If this doesn't break the internet I'll fix the internet.",
"Series complete — and you didn't even break a sweat.",
"We're not deleting a single one of those.",
],
}
POSES = [
# standing
("Standing — Contrapposto", "Weight on back leg, front knee soft, hips angled 30° off camera, shoulders square to lens.", "standing"),
("Standing — Over-the-Shoulder Look", "Body turned away, look back over shoulder toward lens, hand near collarbone.", "standing"),
("Standing — Wall Lean", "Lean shoulder blades to wall, one knee bent, foot flat on wall.", "standing"),
("Standing — Hands in Hair", "Fingers threaded through hair at temples, elbows lifted, chin slightly up.", "standing"),
("Standing — Crossed Arms Confidence", "Arms crossed loosely, weight back, chin level, direct gaze.", "standing"),
("Standing — Skirt/Dress Swish", "Mid-motion fabric swish, elbows out, eyes closed or down.", "standing"),
("Standing — Back to Camera", "Face away, look up and over shoulder, arms relaxed at sides.", "standing"),
("Standing — Arms Framing Face", "Both hands framing jaw, fingers relaxed, eyes soft-focus to lens.", "standing"),
# seated
("Seated — Chair Forward Lean", "Sit astride or sideways, forearms on chair back, lean toward lens.", "seated"),
("Seated — Floor Knees", "Kneel sitting on heels, hands on thighs, spine long, chin up.", "seated"),
("Seated — Legs to the Side", "Legs folded to one side, one hand propped, other in lap.", "seated"),
("Seated — Stool Elbow on Knee", "Elbow to knee, chin resting on knuckles, dead-eye gaze.", "seated"),
("Seated — Reclined Support", "Recline back on one straight arm, legs crossed at ankle.", "seated"),
# wall / leaning
("Wall — Forearm Brace", "Forearm flat on wall, body angled, far hip toward lens.", "wall"),
("Wall — Full Back Press", "Back to wall, chin slightly down, eyes to lens, hands in pockets.", "wall"),
("Wall — Peeking Around Edge", "Head around corner of wall, playful suspicion.", "wall"),
("Doorway — Frame in Frame", "Stand in doorway, shoulders relaxed, feet crossed at ankle.", "wall"),
# floor
("Floor — Lying on Stomach", "Prop on forearms, ankles crossed in air, chin on hands.", "floor"),
("Floor — Side Recline", "Lying on side, head on hand, top leg bent over.", "floor"),
("Floor — Back with Hair Fan", "On back, hair fanned, camera directly overhead.", "floor"),
("Floor — Sitting Hug Knees", "Hug knees, chin resting on knee, eyes up to lens.", "floor"),
# movement
("Movement — Hair Flip", "Mid hair-flip, eyes closed 70% of the flips, burst mode.", "movement"),
("Movement — Walk Toward Lens", "Walk naturally, gaze at lens, arms loose, slight smile.", "movement"),
("Movement — Twirl", "Slow twirl with skirt or dress, shutter 1/500+, burst.", "movement"),
("Movement — Jump Capture", "Small jump, arms free — take 6+ frames, pick the apex.", "movement"),
("Movement — Turn Back Mid-Step", "Walking away, snap head-turn back at the peak.", "movement"),
# closeup
("Closeup — Hand Near Face", "Fingertips on cheekbone or lips, eyes to lens.", "closeup"),
("Closeup — Eyes Closed Serenity", "Face to light, eyes closed, lips relaxed.", "closeup"),
("Closeup — Profile Edge", "Hard profile against clean background, chin lifted 5°.", "closeup"),
("Closeup — Over-Hand Gaze", "One hand over forehead shading eyes, gaze under it.", "closeup"),
("Closeup — Laugh Macro", "Real laugh, slight head tilt back — catch the in-between.", "closeup"),
]
SYSTEM_LINE = (
"You are Lyra, a witty photographer's assistant on set. Generate exactly %d short lines "
"the photographer can say ALOUD to the model between shots. Category: %s. "
"Spice level 1-3: %d (1=sweet, 3=flirty-bold but tasteful). One line each, max 14 words, "
"no numbering, no quotes, no explanation. One per line."
)
SYSTEM_VISION = (
"You are a professional photography director reviewing a RAW frame from a live shoot. "
"Reply in EXACTLY this format:\nVERDICT: PASS or NEEDS WORK\nSCORE: n/10\n"
"LIGHTING: one sentence\nPOSE: one sentence of concrete adjustment for the model "
"(body, hands, chin, eyes)\nSAY THIS: one short line the photographer can say out loud "
"right now. Be specific, be brief."
)
def ollama_generate(prompt, model, images=None, timeout=60):
payload = {"model": model, "prompt": prompt, "stream": False,
"options": {"temperature": 0.9, "num_predict": 700}}
if "qwen3" in model:
payload["think"] = False
if images:
payload = {"model": model, "messages": [
{"role": "user", "content": prompt, "images": images}],
"stream": False, "keep_alive": "30m",
"options": {"temperature": 0.4, "num_predict": 500}}
r = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=timeout)
r.raise_for_status()
return (r.json().get("message") or {}).get("content", "").strip()
r = requests.post(f"{OLLAMA_URL}/api/generate", json=payload, timeout=timeout)
r.raise_for_status()
return r.json().get("response", "").strip()
@app.get("/health")
def health():
return jsonify(ok=True, app="lyra")
@app.get("/api/lines")
def api_lines():
cat = request.args.get("category", "witty")
spice = int(request.args.get("spice", 2))
count = min(int(request.args.get("count", 5)), 10)
fallback = random.sample(LINES.get(cat, LINES["witty"]), min(count, len(LINES.get(cat, []))))
try:
out = ollama_generate(SYSTEM_LINE % (count, cat, spice), TEXT_MODEL, timeout=90)
out = re.sub(r"<think>.*?</think>", "", out, flags=re.S) # strip CoT blocks
lines = [l.strip().strip('"-•') for l in out.splitlines() if l.strip()]
lines = [l for l in lines if 3 < len(l) < 140 and not re.match(
r"^(okay|let me|i (will|'ll|'m)|sure|here|the user|we need|first|next|okay so)", l, re.I)]
lines = lines[-count:] if len(lines) > count else lines # CoT models put answers last
if not lines:
raise ValueError("empty")
return jsonify(source="ollama", lines=lines)
except Exception:
return jsonify(source="offline", lines=fallback)
@app.post("/api/analyze")
def api_analyze():
data = request.get_json(force=True)
img = data.get("image", "")
if "," in img:
img = img.split(",", 1)[1]
try:
raw = base64.b64decode(img)
if len(raw) > 8_000_000:
return jsonify(error="image too large (max ~8MB)"), 413
b64 = base64.b64encode(raw).decode()
out = ollama_generate(SYSTEM_VISION, VISION_MODEL, images=[b64], timeout=120)
return jsonify(analysis=out, source="ollama")
except Exception as e:
return jsonify(error=f"analysis unavailable: {e}"), 502
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}
*{box-sizing:border-box;margin:0;padding:0}
body{background:radial-gradient(1200px 800px at 70% -10%,#231d4d 0%,var(--bg) 55%);color:var(--txt);font:16px/1.55 -apple-system,'Segoe UI',Roboto,sans-serif;min-height:100vh}
header{display:flex;align-items:center;gap:14px;padding:18px 26px;border-bottom:1px solid #2a2547}
header h1{font-size:22px;letter-spacing:2px;color:var(--gold);font-weight:600}
header .sub{color:var(--dim);font-size:13px}
nav{display:flex;gap:6px;padding:12px 26px 0;flex-wrap:wrap}
nav button{background:none;border:1px solid #2e2950;color:var(--dim);padding:8px 16px;border-radius:20px;cursor:pointer;font-size:14px;transition:.2s}
nav button.on{color:var(--gold);border-color:var(--gold);background:#e8c15a14}
main{max-width:1080px;margin:0 auto;padding:24px 26px 80px}
.tab{display:none}.tab.on{display:block}
.card{background:var(--panel);border:1px solid #2a2547;border-radius:14px;padding:20px;margin-bottom:18px}
.card h2{font-size:15px;color:var(--gold);letter-spacing:1px;text-transform:uppercase;margin-bottom:12px;font-weight:600}
.btn{background:linear-gradient(135deg,var(--gold),#d9a83e);color:#1a1405;border:none;padding:10px 20px;border-radius:10px;font-weight:600;cursor:pointer;font-size:15px;transition:.15s}
.btn:hover{filter:brightness(1.1)}.btn:active{transform:scale(.97)}
.btn.ghost{background:var(--panel2);color:var(--txt);border:1px solid #3a3466}
.btn.sm{padding:6px 14px;font-size:13px}
select,input[type=range],input[type=number]{accent-color:var(--gold)}
select{background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:8px;font-size:14px}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.lbl{color:var(--dim);font-size:13px;min-width:120px}
.line{background:var(--panel2);border-left:3px solid var(--gold);border-radius:0 10px 10px 0;padding:12px 16px;margin:8px 0;font-size:18px;cursor:pointer;transition:.15s}
.line:hover{background:#262147}
.line .src{display:block;font-size:11px;color:var(--dim);margin-top:4px}
.pose{background:var(--panel2);border:1px solid #2e2950;border-radius:12px;padding:14px 16px;margin:8px 0}
.pose b{color:var(--gold2)} .pose .cat{float:right;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
#light{display:none;position:fixed;inset:0;z-index:100}
#lightCanvas{width:100%;height:100%}
#lightUI{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);display:flex;gap:10px;background:#00000088;padding:10px 16px;border-radius:14px;backdrop-filter:blur(8px)}
#cam{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;display:none}
video#camPreview{position:fixed;top:12px;right:12px;width:180px;border-radius:10px;border:1px solid #fff3;display:none;z-index:101}
#verdict{white-space:pre-wrap;font-size:15px;background:var(--panel2);border-radius:12px;padding:16px;margin-top:12px;border:1px solid #3a3466}
.shot{display:flex;gap:10px;align-items:center;padding:8px 12px;background:var(--panel2);border-radius:10px;margin:6px 0}
.shot.done{opacity:.45;text-decoration:line-through}
.shot button{background:none;border:none;color:var(--dim);cursor:pointer;font-size:16px}
.progress{height:8px;background:#2a2547;border-radius:4px;overflow:hidden;margin:10px 0}
.progress>div{height:100%;background:linear-gradient(90deg,var(--gold),var(--accent));width:0;transition:.4s}
#timerBig{font-size:64px;text-align:center;font-family:'SF Mono',monospace;color:var(--gold);margin:14px 0}
.stg{display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-bottom:1px solid #241f40;font-size:14px}
.stg .c{display:flex;gap:10px;align-items:center}
input[type=range]{width:150px}
#voiceDot{width:10px;height:10px;border-radius:50%;background:#3a3466;display:inline-block;margin-right:6px}
#voiceDot.live{background:var(--ok);box-shadow:0 0 8px var(--ok)}
.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>
<div><h1>LYRA</h1><div class="sub">muse &amp; director — for the photographer and his star</div></div>
<div style="margin-left:auto"><span id="voiceDot"></span><span class="sub" id="engineTag">browser voice</span></div>
</header>
<nav id="nav">
<button data-t="lines" class="on">Lines</button>
<button data-t="poses">Poses</button>
<button data-t="light">Light</button>
<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>
<section class="tab on" id="t-lines">
<div class="card">
<h2>Say something ✦ tap any line to speak it</h2>
<div class="row" style="margin-bottom:14px">
<span class="lbl">Mood</span>
<select id="lineCat"><option value="sweet">Sweet</option><option value="witty" selected>Witty</option><option value="confident">Confident direction</option><option value="playful">Playful</option><option value="hype">Hype</option></select>
<span class="lbl">Spice</span><input type="range" id="spice" min="1" max="3" value="2">
<span class="lbl">Count</span><input type="number" id="lineCount" value="5" min="3" max="10" style="width:60px;background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:6px">
<button class="btn" onclick="getLines()">Generate ✦</button>
<button class="btn ghost sm" onclick="speakRandom()">🎲 Speak one</button>
</div>
<div id="linesOut"><div class="sub" style="color:var(--dim)">Tap Generate — Lyra writes fresh lines via the local model, or falls back to her set book.</div></div>
<div class="row" style="margin-top:12px">
<label style="color:var(--dim);font-size:13px"><input type="checkbox" id="autoSpeak"> Auto-read new lines aloud</label>
<span class="autoBadge" id="autoHint" style="display:none">auto-director every <span id="autoEvery">45</span>s</span>
<label style="color:var(--dim);font-size:13px;display:none" id="autoRow"><input type="checkbox" id="autoMode"> Auto-director mode (new line every N s)</label>
</div>
</div>
</section>
<section class="tab" id="t-poses">
<div class="card">
<h2>Pose book ✦ <span id="poseCount"></span></h2>
<div class="row" style="margin-bottom:12px">
<select id="poseCat"><option value="all">All</option><option value="standing">Standing</option><option value="seated">Seated</option><option value="wall">Wall / leaning</option><option value="floor">Floor</option><option value="movement">Movement</option><option value="closeup">Close-up</option></select>
<input id="poseQ" placeholder="search…" style="flex:1;background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:8px">
<button class="btn" onclick="randomPose()">🎲 Random pose</button>
</div>
<div id="posesOut"></div>
</div>
</section>
<section class="tab" id="t-light">
<div class="card">
<h2>Light rig</h2>
<div class="row">
<button class="btn" id="lightBtn" onclick="lightToggle()">☀️ Light mode on</button>
<button class="btn ghost" onclick="camPreviewToggle()">📷 Camera preview</button>
<button class="btn ghost" id="torchBtn" onclick="torchToggle()" style="display:none">🔦 Torch</button>
</div>
<div class="row" style="margin-top:14px">
<span class="lbl">Color</span><input type="range" id="lightHue" min="0" max="45" value="28" oninput="paintLight()">
<span class="lbl">Brightness</span><input type="range" id="lightBri" min="20" max="100" value="95" oninput="paintLight()">
<span class="lbl">Pulse</span><input type="range" id="lightPulse" min="0" max="100" value="0" oninput="paintLight()">
</div>
<p style="color:var(--dim);font-size:13px;margin-top:10px">Turn screen brightness to max and face it at her as a soft fill. Pulse adds a slow breathing warmth.</p>
</div>
</section>
<section class="tab" id="t-analyze">
<div class="card">
<h2>Frame analyzer ✦ capture or drop a shot</h2>
<div class="row">
<button class="btn" onclick="analyzeCapture()">📸 Capture &amp; analyze</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>
</div>
<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>
</section>
<section class="tab" id="t-shots">
<div class="card">
<h2>Shot list</h2>
<div class="row" style="margin-bottom:10px">
<input id="shotIn" placeholder="add a shot…" style="flex:1;background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:8px" onkeydown="if(event.key=='Enter')addShot()">
<button class="btn sm" onclick="addShot()">Add</button>
<button class="btn ghost sm" onclick="addFromPoseBook()">+ from pose book</button>
</div>
<div class="progress"><div id="shotProg"></div></div>
<div id="shotsOut"></div>
</div>
</section>
<section class="tab" id="t-timer">
<div class="card">
<h2>Shoot timer</h2>
<div id="timerBig">00:00</div>
<div class="row" style="justify-content:center">
<span class="lbl">Burst every</span><input type="number" id="burstSec" value="30" min="5" max="600" style="width:70px;background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:6px">s
<button class="btn" id="timerBtn" onclick="timerToggle()">Start</button>
<button class="btn ghost" onclick="timerReset()">Reset</button>
</div>
<p style="color:var(--dim);font-size:13px;text-align:center;margin-top:8px">Chimes each interval — keep the energy moving.</p>
</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>
<div class="stg"><span>Voice engine</span><span class="c">
<select id="setEngine"><option value="kokoro">Kokoro neural (sultry, self-hosted)</option><option value="browser">Browser TTS</option></select></span></div>
<div class="stg"><span>Kokoro voice</span><span class="c">
<select id="setKVoice"><option value="af_heart">Heart (warm, breathy)</option><option value="af_bella">Bella (bright, expressive)</option><option value="af_nicole">Nicole (soft, intimate)</option></select></span></div>
<div class="stg"><span>Browser voice</span><span class="c"><select id="setVoice" style="max-width:280px"></select></span></div>
<div class="stg"><span>Speech rate <b id="rateV">1.0</b></span><span class="c"><input type="range" id="setRate" min="0.5" max="2" step="0.05" value="1"></span></div>
<div class="stg"><span>Pitch <b id="pitchV">1.0</b></span><span class="c"><input type="range" id="setPitch" min="0.5" max="1.6" step="0.05" value="1"></span></div>
<div class="stg"><span>Lyra text model</span><span class="c"><input id="setTModel" style="background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:6px;width:200px"></span></div>
<div class="stg"><span>Vision model</span><span class="c"><input id="setVModel" style="background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:6px;width:200px"></span></div>
<div class="stg"><span>Ollama host</span><span class="c"><input id="setHost" style="background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:6px;width:240px"></span></div>
<div class="stg"><span>Auto-director interval <b id="aeV">45</b>s</span><span class="c"><input type="range" id="setAutoEvery" min="15" max="180" step="5" value="45"></span></div>
<div class="stg"><span>Theme warmth</span><span class="c"><input type="range" id="setWarm" min="0" max="100" value="50"></span></div>
<div class="row" style="margin-top:14px">
<button class="btn" onclick="saveSettings()">Save</button>
<button class="btn ghost" onclick="testVoice()">🔊 Test voice</button>
</div>
</div>
</section>
</main>
<div id="light"><canvas id="lightCanvas"></canvas></div>
<div id="lightUI" style="display:none">
<button class="btn sm" onclick="lightToggle()">Exit light</button>
<label style="color:#fff;font-size:13px"><input type="checkbox" id="camInLight"> camera preview</label>
</div>
<div class="toast" id="toast"></div>
<footer style="text-align:center;padding:18px;color:var(--dim);font-size:13px">✦ Lyra · <a href="https://buymeacoffee.com/r26xrthzttg" style="color:var(--gold)">Buy me a coffee</a></footer>
<script>
const POSES = __POSES__;
let S = JSON.parse(localStorage.getItem('lyra')||'{}');
function saveS(){localStorage.setItem('lyra',JSON.stringify(S));}
function $(id){return document.getElementById(id)}
function toast(m){const t=$('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2200)}
/* nav */
document.querySelectorAll('#nav button').forEach(b=>b.onclick=()=>{
document.querySelectorAll('#nav button').forEach(x=>x.classList.remove('on'));b.classList.add('on');
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('on'));$('t-'+b.dataset.t).classList.add('on');});
/* ---------- voice ---------- */
let VOICES=[];
function loadVoices(){VOICES=speechSynthesis.getVoices();const sel=$('setVoice');sel.innerHTML='';
const fem=VOICES.filter(v=>/female|samantha|victoria|karen|serena|moira|tessa|zira|aria|jenny/i.test(v.name));
(fem.length?fem:VOICES).forEach(v=>{const o=document.createElement('option');o.value=v.name;o.textContent=v.name;sel.appendChild(o)});
if(S.voice){sel.value=S.voice}else if(fem[0]){sel.value=fem[0].name}}
speechSynthesis.onvoiceschanged=loadVoices;loadVoices();
function speak(text,cb){
if(!$('voiceDot'))return;const dot=$('voiceDot');dot.classList.add('live');
const done=()=>{dot.classList.remove('live');if(cb)cb()};
if($('setEngine').value==='kokoro'){
fetch('/api/tts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,voice:$('setKVoice').value})})
.then(r=>r.ok?r.blob():Promise.reject()).then(b=>{const a=new Audio(URL.createObjectURL(b));a.onended=done;a.play()})
.catch(()=>{browserSpeak(text,done)});
}else browserSpeak(text,done);
}
function browserSpeak(text,cb){const u=new SpeechSynthesisUtterance(text);
const v=VOICES.find(x=>x.name===$('setVoice').value);if(v)u.voice=v;
u.rate=+$('setRate').value;u.pitch=+$('setPitch').value;
u.onend=()=>{if(cb)cb()};u.onerror=u.onend;speechSynthesis.speak(u);}
function speakRandom(){const ls=[...document.querySelectorAll('.line')];if(!ls.length)return toast('Generate lines first');speak(ls[Math.floor(Math.random()*ls.length)].firstChild.textContent)}
/* ---------- lines ---------- */
let autoTimer=null;
function getLines(){
const cat=$('lineCat').value,spice=$('spice').value,count=$('lineCount').value;
$('linesOut').innerHTML='<div style="color:var(--dim)">✦ thinking…</div>';
fetch(`/api/lines?category=${cat}&spice=${spice}&count=${count}`).then(r=>r.json()).then(d=>{
$('linesOut').innerHTML='';
d.lines.forEach(l=>{const div=document.createElement('div');div.className='line';
div.innerHTML=l+`<span class="src">tap to speak · ${d.source}</span>`;
div.onclick=()=>speak(l);$('linesOut').appendChild(div)});
if($('autoSpeak').checked){const l=d.lines[0];speak(l);}
}).catch(()=>toast('Line engine unreachable'));
}
function autoDirector(){
if(autoTimer)clearInterval(autoTimer);
if($('autoMode').checked){autoTimer=setInterval(getLines,+$('setAutoEvery').value*1000);toast('Auto-director on')}
}
$('autoMode').onchange=autoDirector;
$('autoMode').closest('.row').style.display='flex';
$('autoEvery').textContent=S.autoEvery||45;
if(S.autoMode){$('autoMode').checked=true;setTimeout(autoDirector,500)}
/* ---------- poses ---------- */
$('poseCount').textContent=POSES.length+' positions';
function renderPoses(){
const c=$('poseCat').value,q=$('poseQ').value.toLowerCase();
$('posesOut').innerHTML='';
POSES.filter(p=>(c==='all'||p[2]===c)&&(p[0]+p[1]).toLowerCase().includes(q))
.forEach(p=>{const d=document.createElement('div');d.className='pose';
d.innerHTML=`<span class="cat">${p[2]}</span><b>${p[0]}</b><br>${p[1]} <button class="btn ghost sm" style="float:right;margin-top:4px" onclick="speak('${p[1].replace(/'/g,"\\'")}')">🔊 say it</button>`;
$('posesOut').appendChild(d)});}
$('poseCat').onchange=renderPoses;$('poseQ').oninput=renderPoses;renderPoses();
function randomPose(){const p=POSES[Math.floor(Math.random()*POSES.length)];
toast(p[0]);speak(p[0]+'. '+p[1]);
$('poseCat').value='all';$('poseQ').value='';renderPoses();
const el=[...document.querySelectorAll('.pose')].find(e=>e.textContent.includes(p[0]));
if(el){el.style.borderColor='var(--gold)';el.scrollIntoView({behavior:'smooth',block:'center'})}}
/* ---------- light ---------- */
let lightOn=false,pulseT=null;
function lightToggle(){lightOn=!lightOn;
$('light').style.display=lightOn?'block':'none';
$('lightUI').style.display=lightOn?'flex':'none';
$('lightBtn').textContent=lightOn?'🌙 Light mode off':'☀️ Light mode on';
if(lightOn){paintLight()}else{cancelAnimationFrame(pulseT);stopCam()}}
function paintLight(){
const hue=+$('lightHue').value,bri=+$('lightBri').value,pulse=+$('lightPulse').value;
const c=$('lightCanvas'),x=c.getContext('2d');
function frame(t){c.width=innerWidth;c.height=innerHeight;
const p=pulse?Math.sin(t/900)*pulse/200:0;const b=Math.min(1,Math.max(0,bri/100+p));
const g=x.createRadialGradient(c.width/2,c.height*.35,50,c.width/2,c.height*.35,c.width*.8);
g.addColorStop(0,`hsla(${hue},80%,${85*b}%,1)`);g.addColorStop(1,`hsla(${hue},70%,${55*b}%,1)`);
x.fillStyle=g;x.fillRect(0,0,c.width,c.height);
if(lightOn)pulseT=requestAnimationFrame(frame)}
cancelAnimationFrame(pulseT);pulseT=requestAnimationFrame(frame);}
let stream=null;
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';
const track=stream.getVideoTracks()[0];const caps=track.getCapabilities?track.getCapabilities():{};
if(caps.torch){$('torchBtn').style.display='inline-block'}}
catch(e){toast('Camera denied')}}
if(!on&&stream){stopCam()}}
function stopCam(){if(stream){stream.getTracks().forEach(t=>t.stop());stream=null;$('camPreview').style.display='none';$('torchBtn').style.display='none'}}
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,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,'&quot;')})">
<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');
const v=$('camPreview'),c=$('snap');
c.width=v.videoWidth;c.height=v.videoHeight;c.getContext('2d').drawImage(v,0,0);
analyzeB64(c.toDataURL('image/jpeg',0.85));}
function analyzeFile(inp){const f=inp.files[0];if(!f)return;const r=new FileReader();
r.onload=()=>analyzeB64(r.result);r.readAsDataURL(f)}
window.addEventListener('paste',e=>{const it=[...(e.clipboardData.items)].find(i=>i.type.startsWith('image'));
if(it&&document.querySelector('#t-analyze.on')){const f=it.getAsFile();const r=new FileReader();r.onload=()=>analyzeB64(r.result);r.readAsDataURL(f)}});
function analyzeB64(dataUrl){
lastShot=dataUrl;
$('verdict').style.display='block';$('verdict').textContent='✦ reviewing the frame…';
fetch('/api/analyze',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({image:dataUrl,tmodel:$('setTModel').value,vmodel:$('setVModel').value})})
.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*(.+)/);
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()};
/* ---------- shot list ---------- */
function shots(){return S.shots||[]}
function renderShots(){
const ss=shots(),done=ss.filter(s=>s.done).length;
$('shotProg').style.width=ss.length?(done/ss.length*100)+'%':'0';
$('shotsOut').innerHTML='';
ss.forEach((s,i)=>{const d=document.createElement('div');d.className='shot'+(s.done?' done':'');
d.innerHTML=`<button onclick="toggleShot(${i})">${s.done?'✓':'○'}</button><span style="flex:1">${s.text}</span>
<button onclick="delShot(${i})">✕</button>`;$('shotsOut').appendChild(d)});}
function addShot(){const t=$('shotIn').value.trim();if(!t)return;const ss=shots();ss.push({text:t,done:false});S.shots=ss;saveS();$('shotIn').value='';renderShots()}
function addFromPoseBook(){const ss=shots();POSES.forEach(p=>ss.push({text:p[0],done:false}));S.shots=ss;saveS();renderShots();toast('Pose book added')}
function toggleShot(i){const ss=shots();ss[i].done=!ss[i].done;S.shots=ss;saveS();renderShots()}
function delShot(i){const ss=shots();ss.splice(i,1);S.shots=ss;saveS();renderShots()}
renderShots();
/* ---------- timer ---------- */
let tStart=null,tInt=null,bInt=null;
function timerToggle(){
if(tInt){clearInterval(tInt);clearInterval(bInt);tInt=null;$('timerBtn').textContent='Start';return}
tStart=Date.now();$('timerBtn').textContent='Pause';
tInt=setInterval(()=>{const s=Math.floor((Date.now()-tStart)/1000);
$('timerBig').textContent=String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0')},500);
bInt=setInterval(chime,+$('burstSec').value*1000);}
function chime(){const ctx=new(window.AudioContext||window.webkitAudioContext)();
[880,1320].forEach((f,i)=>{const o=ctx.createOscillator(),g=ctx.createGain();o.frequency.value=f;
g.gain.value=.15;o.connect(g);g.connect(ctx.destination);o.start(ctx.currentTime+i*.15);o.stop(ctx.currentTime+i*.15+.12)})}
function timerReset(){clearInterval(tInt);clearInterval(bInt);tInt=null;$('timerBig').textContent='00:00';$('timerBtn').textContent='Start'}
/* ---------- settings ---------- */
function loadSettings(){
$('setEngine').value=S.engine||'kokoro';$('setKVoice').value=S.kvoice||'af_heart';$('setVoice').value=S.voice||$('setVoice').value;
$('setRate').value=S.rate||1;$('setPitch').value=S.pitch||1;
$('setTModel').value=S.tmodel||'qwen3.5:4b';$('setVModel').value=S.vmodel||'minicpm-v4.5:8b';
$('setHost').value=S.host||'http://10.30.20.222:11434';$('setAutoEvery').value=S.autoEvery||45;
$('setWarm').value=S.warm||50;$('autoMode').checked=!!S.autoMode;$('autoSpeak').checked=!!S.autoSpeak;
$('rateV').textContent=$('setRate').value;$('pitchV').textContent=$('setPitch').value;$('aeV').textContent=$('setAutoEvery').value;
applyWarm();}
function saveSettings(){
S.engine=$('setEngine').value;S.kvoice=$('setKVoice').value;S.voice=$('setVoice').value;S.rate=$('setRate').value;S.pitch=$('setPitch').value;
S.tmodel=$('setTModel').value;S.vmodel=$('setVModel').value;S.host=$('setHost').value;S.autoEvery=+$('setAutoEvery').value;
S.warm=$('setWarm').value;S.autoMode=$('autoMode').checked;S.autoSpeak=$('autoSpeak').checked;
saveS();applyWarm();applyEngineTag();
toast('Saved');autoDirector();}
function testVoice(){speak('Ready when you are. She looks incredible.')}
$('setRate').oninput=()=>$('rateV').textContent=$('setRate').value;
$('setPitch').oninput=()=>$('pitchV').textContent=$('setPitch').value;
$('setAutoEvery').oninput=()=>$('aeV').textContent=$('setAutoEvery').value;
function applyWarm(){const w=+$('setWarm').value;document.body.style.filter=`sepia(${w/400})`}
$('setWarm').oninput=applyWarm;
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>"""
@app.get("/")
def index():
poses_js = json.dumps([[n, d, c] for n, d, c in POSES])
html = PAGE.replace("__POSES__", poses_js)
return html
@app.post("/api/tts")
def api_tts():
"""Kokoro neural TTS on light_reaper (via reverse tunnel on PVE). 404 -> client falls back to browser voice."""
body = request.json or {}
text = (body.get("text") or "").strip()[:600]
voice = body.get("voice") or "af_heart"
if not text:
return jsonify(error="empty"), 400
try:
r = requests.post(f"{KOKORO_URL}/tts",
json={"text": text, "voice": voice, "speed": 1.0}, timeout=90)
if r.status_code == 200 and r.content[:4] == b"RIFF":
return app.response_class(r.content, mimetype="audio/wav")
except Exception:
pass
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)