766 lines
44 KiB
Python
766 lines
44 KiB
Python
#!/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 & 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 & 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,'"')})">
|
||
<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)
|