Lyra v1: muse & director
This commit is contained in:
552
app.py
Normal file
552
app.py
Normal 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 & 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 & 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)
|
||||
Reference in New Issue
Block a user