Lyra v1: muse & director

This commit is contained in:
drjones
2026-09-30 23:38:00 +00:00
commit a7e0185ca9

552
app.py Normal file
View File

@@ -0,0 +1,552 @@
#!/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.69:11434")
TEXT_MODEL = os.environ.get("LYRA_TEXT_MODEL", "qwen3.5:9b")
VISION_MODEL = os.environ.get("LYRA_VISION_MODEL", "minicpm-v4.6:1b")
app = Flask(__name__)
# ---------------------------------------------------------------- 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, "think": False,
"options": {"temperature": 0.9, "num_predict": 400}}
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()
@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=45)
lines = [l.strip().strip('"-•') for l in out.splitlines() if l.strip()]
lines = [l for l in lines if 3 < len(l) < 140][:count]
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.">
<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}
</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="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>
<video id="camPreview" autoplay playsinline muted></video>
<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-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="browser">Browser TTS</option><option value="piper">Piper (.69, falls back)</option></select></span></div>
<div class="stg"><span>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==='piper'){
fetch('/api/tts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})})
.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;
async function camPreviewToggle(){await camStream(true)}
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;
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*(.+)/);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||'browser';$('setVoice').value=S.voice||$('setVoice').value;
$('setRate').value=S.rate||1;$('setPitch').value=S.pitch||1;
$('setTModel').value=S.tmodel||'qwen3.5:9b';$('setVModel').value=S.vmodel||'minicpm-v4.6:latest';
$('setHost').value=S.host||'http://10.30.20.69: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.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();$('engineTag').textContent=$('setEngine').value==='piper'?'piper → browser fallback':'browser voice';
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();
$('engineTag').textContent=S.engine==='piper'?'piper → browser fallback':'browser voice';
setTimeout(autoDirector,800);
</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():
"""Try Piper on the configured host (OpenAI-compat /v1/audio/speech); 404 -> client falls back."""
text = request.json.get("text", "")[:500]
host = request.json.get("host", "")
base = host or OLLAMA_URL
try:
r = requests.post(f"{base.rsplit(':', 1)[0]}:8080/v1/audio/speech",
json={"input": text, "voice": "piper"}, timeout=20)
if r.status_code == 200:
return app.response_class(r.content, mimetype="audio/wav")
except Exception:
pass
return jsonify(error="piper unavailable"), 404
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, threaded=True)