diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 0000000..b4af7ba --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,5 @@ +{ + "name": "OpenLive", + "install": "bash .cursor/install.sh", + "start": "bash .cursor/start-gateway.sh" +} diff --git a/.cursor/install.sh b/.cursor/install.sh new file mode 100755 index 0000000..f354784 --- /dev/null +++ b/.cursor/install.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Cloud Agent install: pin Rust 1.98 (lockfile needs edition 2024 / rustc >= 1.85), +# then fetch and compile the workspace including test binaries. +set -euo pipefail + +rustup toolchain install 1.98.0 --component rustfmt --component clippy --no-self-update +rustup default 1.98.0 +cargo fetch --locked +cargo build --workspace --locked --all-targets diff --git a/.cursor/start-gateway.sh b/.cursor/start-gateway.sh new file mode 100755 index 0000000..8362054 --- /dev/null +++ b/.cursor/start-gateway.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Per-boot: mock OpenLive gateway on :8787. Idempotent; returns after /health is OK. +set -euo pipefail + +mkdir -p /tmp/openlive +if curl -sf --max-time 2 http://127.0.0.1:8787/health >/dev/null; then + echo openlive-gateway already healthy + exit 0 +fi +if [ ! -x ./target/debug/openlive-gateway ]; then + echo missing ./target/debug/openlive-gateway >&2 + exit 1 +fi +setsid ./target/debug/openlive-gateway \ + --listen 0.0.0.0:8787 \ + --provider mock \ + --web-dir apps/openlive-gateway/web \ + /tmp/openlive/gateway.log 2>&1 & +echo $! >/tmp/openlive/gateway.pid +for _ in $(seq 1 60); do + if curl -sf --max-time 2 http://127.0.0.1:8787/health >/dev/null; then + echo openlive-gateway ready + exit 0 + fi + sleep 0.25 +done +echo gateway failed to become healthy >&2 +cat /tmp/openlive/gateway.log >&2 || true +exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c110fb..37fe77b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,10 +66,14 @@ jobs: key: cargo-tauri-2.11.4-${{ runner.os }} - name: Install Tauri CLI - run: cargo install tauri-cli --version 2.11.4 + # --force keeps cached runners healthy when cargo-tauri already exists. + run: cargo install tauri-cli --version 2.11.4 --force - name: Lint desktop app working-directory: apps/openlive-desktop + env: + # Clippy/check must not require a prebuilt gateway binary. + OPENLIVE_SKIP_GATEWAY_BUILD: "1" run: | cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings diff --git a/.gitignore b/.gitignore index b62ddf6..afc8f46 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /target +apps/openlive-desktop/target/ /data +apps/openlive-gateway/data/ *.zip .DS_Store diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 93fb97d..548fd71 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -68,6 +68,20 @@ binaries and update this notice. --- +## Call surface UI + +Vendored under `apps/openlive-gateway/web/vendor/`. Keep the license files beside the bundles when redistributing. + +| Project | License | Use in OpenLive | +|---------|---------|-----------------| +| **[Bloub](https://github.com/jeremy-prt/bloub)** (Jérémy Perret) | MIT | Framework-free face/orb engine (`engine.sample(t)`). Design imitates the x.ai avatar. **OpenLive is not affiliated with xAI, x.ai, or OpenAI.** | +| **[Morphicons](https://github.com/guillermolg00/morphicons)** | MIT | Spring morphs for Mute / End / Settings (`createMorph` + lucide path data) | +| **[Lucide](https://lucide.dev/)** | ISC | Icon path data consumed by Morphicons (not `lucide-react`) | + +Morphicons are **not** used as the speaking face. + +--- + ## Fonts (web UI) | Family | Source | License | diff --git a/apps/openlive-desktop/README.md b/apps/openlive-desktop/README.md index c6b5730..baae174 100644 --- a/apps/openlive-desktop/README.md +++ b/apps/openlive-desktop/README.md @@ -49,7 +49,12 @@ cargo tauri build ## Notes -- The desktop shell loads the same web UI as the browser version. -- The gateway server must be running locally for the voice surface to work. +- The desktop shell loads a local listening-orb splash immediately, then + navigates to the gateway UI once `http://127.0.0.1:12345/health` is up. + Spawning the gateway must not block first paint. +- `OPENLIVE_SKIP_GATEWAY_BUILD=1` skips copying the real gateway binary + (used by clippy). A placeholder file is written so Tauri's resource + check still passes. - Replace `icons/icon.ico` and `icons/icon.icns` with branded assets before - publishing. + publishing. Regenerate placeholders with + `python3 scripts/generate-icons.py`. diff --git a/apps/openlive-desktop/build.rs b/apps/openlive-desktop/build.rs index d9908de..4b46ddf 100644 --- a/apps/openlive-desktop/build.rs +++ b/apps/openlive-desktop/build.rs @@ -6,21 +6,28 @@ // // The gateway must already be built (e.g. by beforeBuildCommand or manually // with `cargo build -p openlive-gateway --release`). Set -// OPENLIVE_SKIP_GATEWAY_BUILD=1 to skip staging entirely. +// OPENLIVE_SKIP_GATEWAY_BUILD=1 to skip staging entirely (clippy/check). +// tauri_build still requires the resource path to exist, so the skip path +// writes a tiny placeholder file. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; fn main() { - if std::env::var("OPENLIVE_SKIP_GATEWAY_BUILD").is_ok() { - eprintln!("[openlive-desktop/build] OPENLIVE_SKIP_GATEWAY_BUILD is set; skipping gateway staging."); - tauri_build::build(); - return; - } + println!("cargo:rerun-if-env-changed=OPENLIVE_SKIP_GATEWAY_BUILD"); let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") .map(PathBuf::from) .expect("CARGO_MANIFEST_DIR not set"); + if std::env::var("OPENLIVE_SKIP_GATEWAY_BUILD").is_ok() { + eprintln!( + "[openlive-desktop/build] OPENLIVE_SKIP_GATEWAY_BUILD is set; skipping gateway staging." + ); + write_placeholder_gateway(&manifest_dir); + tauri_build::build(); + return; + } + // apps/openlive-desktop -> project root let project_root = manifest_dir .parent() @@ -50,13 +57,36 @@ fn main() { // Use a single cross-platform bundled name. Windows requires the .exe // extension for std::process::Command to find it; on macOS/Linux the // extension is harmless and keeps the config simple. - let dst_dir = manifest_dir.join("target/release"); - let dst = dst_dir.join("openlive-gateway-bundled.exe"); - - std::fs::create_dir_all(&dst_dir).expect("failed to create target/release"); + let dst = gateway_resource_path(&manifest_dir); + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).expect("failed to create target/release"); + } std::fs::copy(&src, &dst).expect("failed to stage gateway binary for bundling"); - eprintln!("[openlive-desktop/build] Staged gateway for bundling: {}", dst.display()); + eprintln!( + "[openlive-desktop/build] Staged gateway for bundling: {}", + dst.display() + ); tauri_build::build(); } + +fn gateway_resource_path(manifest_dir: &Path) -> PathBuf { + manifest_dir + .join("target/release") + .join("openlive-gateway-bundled.exe") +} + +/// `tauri_build` validates `bundle.resources` even during clippy. A +/// placeholder keeps lint from requiring a real gateway binary. +fn write_placeholder_gateway(manifest_dir: &Path) { + let dst = gateway_resource_path(manifest_dir); + if dst.exists() { + return; + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).expect("failed to create target/release"); + } + std::fs::write(&dst, b"OPENLIVE_SKIP_GATEWAY_BUILD placeholder\n") + .expect("failed to write placeholder gateway resource"); +} diff --git a/apps/openlive-desktop/icons/128x128.png b/apps/openlive-desktop/icons/128x128.png new file mode 100644 index 0000000..1fc15ab Binary files /dev/null and b/apps/openlive-desktop/icons/128x128.png differ diff --git a/apps/openlive-desktop/icons/128x128@2x.png b/apps/openlive-desktop/icons/128x128@2x.png new file mode 100644 index 0000000..867b117 Binary files /dev/null and b/apps/openlive-desktop/icons/128x128@2x.png differ diff --git a/apps/openlive-desktop/icons/32x32.png b/apps/openlive-desktop/icons/32x32.png new file mode 100644 index 0000000..9b80cad Binary files /dev/null and b/apps/openlive-desktop/icons/32x32.png differ diff --git a/apps/openlive-desktop/icons/icon.icns b/apps/openlive-desktop/icons/icon.icns index 6ef8d3b..1102ae5 100644 Binary files a/apps/openlive-desktop/icons/icon.icns and b/apps/openlive-desktop/icons/icon.icns differ diff --git a/apps/openlive-desktop/icons/icon.ico b/apps/openlive-desktop/icons/icon.ico index f299375..b494ed9 100644 Binary files a/apps/openlive-desktop/icons/icon.ico and b/apps/openlive-desktop/icons/icon.ico differ diff --git a/apps/openlive-desktop/scripts/generate-icons.py b/apps/openlive-desktop/scripts/generate-icons.py new file mode 100644 index 0000000..945e01c --- /dev/null +++ b/apps/openlive-desktop/scripts/generate-icons.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Generate placeholder OpenLive desktop icons (PNG, ICO, ICNS).""" + +from __future__ import annotations + +import math +import struct +import zlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "icons" + + +def chunk(tag: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + +def write_png(width: int, height: int, rgba_at) -> bytes: + raw = bytearray() + for y in range(height): + raw.append(0) + for x in range(width): + raw.extend(rgba_at(x, y, width, height)) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + chunk(b"IEND", b"") + ) + + +def orb_pixel(x: int, y: int, w: int, h: int) -> bytes: + cx, cy = (w - 1) / 2, (h - 1) / 2 + nx = (x - cx) / max(cx, 1) + ny = (y - cy) / max(cy, 1) + r = math.hypot(nx, ny) + # Transparent outside the orb so the dock icon is circular-ish. + if r > 1.02: + return b"\x00\x00\x00\x00" + # Soft edge + edge = max(0.0, min(1.0, (1.02 - r) / 0.08)) + # Light paper orb with two dark elliptical eyes (Bloub-ish). + paper = (244, 244, 242) + ink = (10, 10, 12) + highlight = 1.0 - min(1.0, math.hypot(nx + 0.22, ny + 0.28) * 0.85) + shade = min(1.0, r * 0.35) + pr = int(paper[0] * (0.82 + 0.18 * highlight) * (1 - shade * 0.12)) + pg = int(paper[1] * (0.82 + 0.18 * highlight) * (1 - shade * 0.12)) + pb = int(paper[2] * (0.84 + 0.16 * highlight) * (1 - shade * 0.08)) + + def in_eye(ex: float, rot: float) -> bool: + dx, dy = nx - ex, ny + 0.04 + cr, sr = math.cos(rot), math.sin(rot) + rx = dx * cr + dy * sr + ry = -dx * sr + dy * cr + return (rx / 0.16) ** 2 + (ry / 0.30) ** 2 <= 1.0 + + if in_eye(-0.22, math.radians(-18)) or in_eye(0.22, math.radians(18)): + pr, pg, pb = ink + alpha = int(255 * edge) + return bytes((max(0, min(255, pr)), max(0, min(255, pg)), max(0, min(255, pb)), alpha)) + + +def write_ico(path: Path, sizes: list[int]) -> None: + images = [(size, write_png(size, size, orb_pixel)) for size in sizes] + count = len(images) + offset = 6 + 16 * count + directory = struct.pack(" None: + body = b"" + for ostype, data in entries.items(): + body += ostype + struct.pack(">I", len(data) + 8) + data + path.write_bytes(b"icns" + struct.pack(">I", len(body) + 8) + body) + + +def main() -> None: + ROOT.mkdir(parents=True, exist_ok=True) + pngs = {} + for size in (16, 32, 64, 128, 256, 512, 1024): + pngs[size] = write_png(size, size, orb_pixel) + if size in {32, 128, 256}: + name = "128x128@2x.png" if size == 256 else f"{size}x{size}.png" + (ROOT / name).write_bytes(pngs[size]) + write_ico(ROOT / "icon.ico", [16, 32, 48, 256]) + write_icns( + ROOT / "icon.icns", + { + b"icp4": pngs[16], + b"icp5": pngs[32], + b"icp6": pngs[64], + b"ic07": pngs[128], + b"ic08": pngs[256], + b"ic09": pngs[512], + b"ic10": pngs[1024], + b"ic11": pngs[32], + b"ic12": pngs[64], + b"ic13": pngs[256], + b"ic14": pngs[512], + }, + ) + print(f"wrote icons in {ROOT}") + + +if __name__ == "__main__": + main() diff --git a/apps/openlive-desktop/splash/index.html b/apps/openlive-desktop/splash/index.html new file mode 100644 index 0000000..da0ce81 --- /dev/null +++ b/apps/openlive-desktop/splash/index.html @@ -0,0 +1,82 @@ + + + + + + OpenLive + + + +
+ + + + + +
+

Listening

+

Speak freely.

+
+
+ + + diff --git a/apps/openlive-desktop/src/main.rs b/apps/openlive-desktop/src/main.rs index 5fafcc1..63725df 100644 --- a/apps/openlive-desktop/src/main.rs +++ b/apps/openlive-desktop/src/main.rs @@ -3,6 +3,9 @@ // Wraps the openlive-gateway web surface in a Tauri webview. The gateway // server is spawned as a child process on startup, kept alive for the // lifetime of the app, and killed on exit. +// +// The listening orb is shown immediately from a local splash page. Gateway +// spawn must not block first paint. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] @@ -12,7 +15,7 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use tauri::Manager; -/// Port the gateway listens on (kept in sync with tauri.conf.json devUrl). +/// Port the gateway listens on (kept in sync with splash + web UI). const GATEWAY_PORT: u16 = 12345; static GATEWAY_CHILD: Mutex> = Mutex::new(None); @@ -20,20 +23,15 @@ static GATEWAY_CHILD: Mutex> = Mutex::new(None); fn main() { tauri::Builder::default() .setup(|app| { - // Resolve the gateway binary and web assets, then spawn the gateway. - // We do this inside setup() so we can use app.path().resource_dir() - // for bundled resources and fall back to the source tree in dev mode. let (gateway_exe, web_dir) = resolve_gateway_and_web(app.handle()); spawn_gateway(&gateway_exe, web_dir.as_deref()); - wait_for_gateway_ready(); - // Create the main window only after the gateway is ready so the - // webview never sees an ERR_CONNECTION_REFUSED page. - let url = format!("http://127.0.0.1:{GATEWAY_PORT}"); - tauri::WebviewWindowBuilder::new( + // Show the listening orb immediately. Splash polls /health, and a + // background thread also navigates once TCP is up. + let window = tauri::WebviewWindowBuilder::new( app, "main", - tauri::WebviewUrl::External(url.parse().unwrap()), + tauri::WebviewUrl::App("index.html".into()), ) .title("OpenLive") .inner_size(1280.0, 820.0) @@ -41,6 +39,13 @@ fn main() { .center() .build()?; + let window_for_poll = window.clone(); + std::thread::spawn(move || { + wait_for_gateway_ready(); + let js = format!("window.location.replace('http://127.0.0.1:{GATEWAY_PORT}/')"); + let _ = window_for_poll.eval(&js); + }); + Ok(()) }) .on_window_event(|_window, event| { @@ -83,7 +88,7 @@ fn resolve_gateway_and_web(handle: &tauri::AppHandle) -> (Option, Optio // 2. Dev mode: find the project root by walking up from the executable. let exe_dir = std::env::current_exe() .ok() - .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .and_then(|p| p.parent().map(Path::to_path_buf)) .unwrap_or_else(|| PathBuf::from(".")); if let Some(project_root) = find_project_root(&exe_dir) { @@ -137,7 +142,9 @@ fn spawn_gateway(gateway_exe: &Option, web_dir: Option<&Path>) { match cmd.spawn() { Ok(child) => { eprintln!("[openlive-desktop] Gateway spawned (pid {})", child.id()); - *GATEWAY_CHILD.lock().unwrap() = Some(child); + *GATEWAY_CHILD + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(child); } Err(e) => { eprintln!("[openlive-desktop] Failed to spawn gateway: {e}"); diff --git a/apps/openlive-desktop/tauri.conf.json b/apps/openlive-desktop/tauri.conf.json index 1879b00..4af58a2 100644 --- a/apps/openlive-desktop/tauri.conf.json +++ b/apps/openlive-desktop/tauri.conf.json @@ -6,8 +6,7 @@ "build": { "beforeDevCommand": "", "beforeBuildCommand": "cargo build -p openlive-gateway --release", - "frontendDist": "http://127.0.0.1:12345", - "devUrl": "http://127.0.0.1:12345" + "frontendDist": "splash" }, "app": { "windows": [], @@ -28,6 +27,9 @@ "active": true, "targets": ["msi", "dmg", "app"], "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns" ], @@ -42,8 +44,7 @@ }, "macOS": { "frameworks": [], - "minimumSystemVersion": "10.13", - "entitlements": "" + "minimumSystemVersion": "10.13" } } } diff --git a/apps/openlive-gateway/src/config.rs b/apps/openlive-gateway/src/config.rs index 982b77b..4013113 100644 --- a/apps/openlive-gateway/src/config.rs +++ b/apps/openlive-gateway/src/config.rs @@ -183,7 +183,7 @@ pub fn provider_catalog() -> serde_json::Value { { "id": "mock", "class": "mock", - "summary": "Offline formant duplex for demos (no external services)", + "summary": "Offline duplex for local UI (no external services; no formant on the demo path)", "cli": "--provider mock" }, { diff --git a/apps/openlive-gateway/src/main.rs b/apps/openlive-gateway/src/main.rs index de4a6f4..3581c63 100644 --- a/apps/openlive-gateway/src/main.rs +++ b/apps/openlive-gateway/src/main.rs @@ -299,9 +299,9 @@ async fn tts_status() -> Json { let st = piper_status(DEFAULT_PIPER_VOICE); Json(serde_json::json!({ "piper": st, - "fallback": "formant", + "fallback": "silence until Piper (formant only if engine=formant)", "browser_tts": "optional client-side", - "preferred": if st.available { "piper" } else { "formant" }, + "preferred": if st.available { "piper" } else { "silence" }, })) } @@ -334,7 +334,7 @@ async fn tts_speak( .and_then(serde_json::Value::as_str) .unwrap_or("auto"); - // Prefer Piper when installed; otherwise formant. + // Prefer Piper. Auto never falls through to formant (demo path = zero fake voice). if prefer != "formant" { match piper_synthesize(text, voice_id) { Ok((pcm, rate)) => { @@ -353,19 +353,16 @@ async fn tts_speak( .into_response(); } Err(e) => { - if prefer == "piper" { - let st = piper_status(voice_id); - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": e, - "piper": st, - "hint": "Copy install_command_windows or install_command_unix from /v1/tts/status", - })), - ) - .into_response(); - } - // fall through to formant + let st = piper_status(voice_id); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "error": e, + "piper": st, + "hint": "Copy install_command_windows or install_command_unix from /v1/tts/status. Formant is only used when engine=formant.", + })), + ) + .into_response(); } } } @@ -1288,7 +1285,7 @@ async fn sandbox_test_run(State(state): State, headers: HeaderMap) -> let read = sandbox_read_file(path); tests.push(serde_json::json!({ "name": "sandbox_write_read", - "ok": write.is_ok() && read.as_ref().map(|t| t.contains("openlive-ok")).unwrap_or(false), + "ok": write.is_ok() && read.as_ref().is_ok_and(|t| t.contains("openlive-ok")), "detail": format!("{:?} / {:?}", write, read.as_ref().map(|s| s.chars().take(40).collect::())), })); // 2. Calculator / identity via agent @@ -1345,7 +1342,7 @@ async fn sandbox_test_run(State(state): State, headers: HeaderMap) -> let after = sandbox_read_file(cpath); tests.push(serde_json::json!({ "name": "pending_confirm_write", - "ok": approved.is_ok() && after.as_ref().map(|t| t.contains("v2-approved")).unwrap_or(false), + "ok": approved.is_ok() && after.as_ref().is_ok_and(|t| t.contains("v2-approved")), "detail": format!("{:?} / {:?}", approved, after), })); // 5. Lab note + browse wiki summary @@ -1361,7 +1358,7 @@ async fn sandbox_test_run(State(state): State, headers: HeaderMap) -> .await; tests.push(serde_json::json!({ "name": "browse_wikipedia", - "ok": browse.as_ref().map(|(t, _)| t.to_ascii_lowercase().contains("agent")).unwrap_or(false), + "ok": browse.as_ref().is_ok_and(|(t, _)| t.to_ascii_lowercase().contains("agent")), "detail": browse.as_ref().map_or_else(std::clone::Clone::clone, |(t, c)| format!("{} @ {}", t.chars().take(80).collect::(), c.url)), })); // 6. Durable profile @@ -1739,7 +1736,7 @@ async fn list_voices(State(state): State) -> Json { "active": active, "engine": if piper.available { "piper+formant" } else { "openlive-formant" }, "piper": piper, - "note": "Prefer open-source Piper when installed; formant is always available offline." + "note": "Prefer Piper when installed. Formant is available only when engine=formant." })) } @@ -2000,20 +1997,19 @@ async fn session_transcript( })); } } - "user_transcript_delta" => { + "user_transcript_delta" if payload .get("is_final") .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - if let Some(text) = payload.get("text").and_then(|t| t.as_str()) { - turns.push(serde_json::json!({ - "role": "user", - "text": text, - "sequence": row.sequence, - "event_id": row.event_id, - })); - } + .unwrap_or(false) => + { + if let Some(text) = payload.get("text").and_then(|t| t.as_str()) { + turns.push(serde_json::json!({ + "role": "user", + "text": text, + "sequence": row.sequence, + "event_id": row.event_id, + })); } } _ => {} @@ -2390,6 +2386,5 @@ fn feature_flags(state: &AppState) -> serde_json::Value { fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) - .unwrap_or(0) + .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) } diff --git a/apps/openlive-gateway/src/session.rs b/apps/openlive-gateway/src/session.rs index 4221f42..221c8da 100644 --- a/apps/openlive-gateway/src/session.rs +++ b/apps/openlive-gateway/src/session.rs @@ -707,8 +707,7 @@ impl SessionCoordinator { let conversation_version = self .leases .active() - .map(|lease| lease.conversation_version) - .unwrap_or_default(); + .map_or_else(Default::default, |lease| lease.conversation_version); self.engine.mark_response_started(generation_id); // Bind every pending (unbound) task to this generation. Tasks // admitted between turns now get attached to the upcoming diff --git a/apps/openlive-gateway/src/session_registry.rs b/apps/openlive-gateway/src/session_registry.rs index 4b5a44f..8a64921 100644 --- a/apps/openlive-gateway/src/session_registry.rs +++ b/apps/openlive-gateway/src/session_registry.rs @@ -63,12 +63,11 @@ impl SessionRegistry { pub fn list(&self) -> Vec { self.inner .lock() - .map(|guard| guard.values().cloned().collect()) - .unwrap_or_default() + .map_or_else(|_| Vec::new(), |guard| guard.values().cloned().collect()) } pub fn active_count(&self) -> usize { - self.inner.lock().map(|g| g.len()).unwrap_or(0) + self.inner.lock().map_or(0, |g| g.len()) } pub fn opened_total(&self) -> u64 { @@ -79,8 +78,7 @@ impl SessionRegistry { fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) - .unwrap_or(0) + .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) } #[cfg(test)] diff --git a/apps/openlive-gateway/src/session_state.rs b/apps/openlive-gateway/src/session_state.rs index c5374bb..bd07a88 100644 --- a/apps/openlive-gateway/src/session_state.rs +++ b/apps/openlive-gateway/src/session_state.rs @@ -781,7 +781,7 @@ mod tests { let first = orchestrator .admit(sample_request(task_id, "Remind me"), None, 0) .expect("first admit"); - assert!(first.task_id == task_id); + assert_eq!(first.task_id, task_id); assert!(orchestrator .admit(sample_request(task_id, "Remind me again"), None, 0) .is_none()); diff --git a/apps/openlive-gateway/src/webrtc_media.rs b/apps/openlive-gateway/src/webrtc_media.rs index ef8f291..08cc30f 100644 --- a/apps/openlive-gateway/src/webrtc_media.rs +++ b/apps/openlive-gateway/src/webrtc_media.rs @@ -99,7 +99,7 @@ impl WebRtcHub { #[must_use] pub fn peer_count(&self) -> usize { - self.peers.lock().map(|g| g.len()).unwrap_or(0) + self.peers.lock().map_or(0, |g| g.len()) } /// Answer a browser SDP offer. Returns (`answer_sdp`, session). diff --git a/apps/openlive-gateway/tests/task_lifecycle.rs b/apps/openlive-gateway/tests/task_lifecycle.rs index bd520aa..5ea6193 100644 --- a/apps/openlive-gateway/tests/task_lifecycle.rs +++ b/apps/openlive-gateway/tests/task_lifecycle.rs @@ -2,10 +2,10 @@ //! //! This test spawns the `openlive-gateway` binary with the mock provider, //! opens a WebSocket connection, and exercises the complete task lifecycle: -//! 1. capability_offer → capability_selected (resume_supported = true) -//! 2. task_requested → task_acknowledged (latency < 500ms) -//! 3. task_cancel → task_outcome (result = cancelled) -//! 4. session_resume → replay of buffered task_acknowledged +//! 1. `capability_offer` → `capability_selected` (`resume_supported` = true) +//! 2. `task_requested` → `task_acknowledged` (latency < 500ms) +//! 3. `task_cancel` → `task_outcome` (result = cancelled) +//! 4. `session_resume` → replay of buffered `task_acknowledged` //! //! The test uses `tokio-tungstenite` as the WebSocket client and reads //! binary envelopes via the same framing the browser uses. It is the @@ -54,9 +54,10 @@ async fn spawn_gateway() -> (String, tokio::process::Child) { // Wait for the gateway to start listening. let deadline = Instant::now() + Duration::from_secs(5); loop { - if Instant::now() > deadline { - panic!("gateway did not start listening on {listen} within 5s"); - } + assert!( + Instant::now() <= deadline, + "gateway did not start listening on {listen} within 5s" + ); if tokio::net::TcpStream::connect(&listen).await.is_ok() { break; } @@ -112,9 +113,7 @@ async fn wait_for_event( ) -> EventEnvelope { let deadline = Instant::now() + Duration::from_secs(3); loop { - if Instant::now() > deadline { - panic!("timed out waiting for event"); - } + assert!(Instant::now() <= deadline, "timed out waiting for event"); let envelope = recv_envelope(socket).await; if predicate(&envelope.event) { return envelope; @@ -123,6 +122,7 @@ async fn wait_for_event( } #[tokio::test] +#[allow(clippy::too_many_lines)] async fn task_lifecycle_request_acknowledge_cancel_resume() { let (url, mut child) = spawn_gateway().await; @@ -269,7 +269,7 @@ async fn task_lifecycle_request_acknowledge_cancel_resume() { return envelope; } RealtimeEvent::Pong => return envelope, - _ => continue, + _ => {} } } }) @@ -488,7 +488,9 @@ async fn duplicate_task_id_is_rejected() { /// If these thresholds regress, the orchestrator's `admit()` path has grown /// non-trivial work and needs profiling. #[tokio::test] +#[allow(clippy::doc_markdown)] async fn task_acknowledgement_latency_benchmark() { + const SAMPLES: usize = 50; let (url, mut child) = spawn_gateway().await; let (mut socket, _response) = tokio_tungstenite::connect_async(&url) @@ -530,9 +532,8 @@ async fn task_acknowledgement_latency_benchmark() { }) .await; - // Send 50 task_requested events, then collect 50 task_acknowledged + // Send 50 `task_requested` events, then collect 50 `task_acknowledged` // events, measuring the round-trip for each. - const SAMPLES: usize = 50; let mut task_ids = Vec::with_capacity(SAMPLES); let mut send_times = Vec::with_capacity(SAMPLES); @@ -566,15 +567,16 @@ async fn task_acknowledgement_latency_benchmark() { .expect("timed out waiting for ack") .expect("stream closed") .expect("ws error"); - let text = match envelope { - Message::Text(t) => t, - _ => continue, + let Message::Text(text) = envelope else { + continue; }; let envelope: EventEnvelope = serde_json::from_str(&text).expect("deserialize"); if let RealtimeEvent::TaskAcknowledged(ack) = &envelope.event { if let Some(idx) = task_ids.iter().position(|id| *id == ack.task_id) { if acked_ids.insert(ack.task_id) { - latencies.push(send_times[idx].elapsed().as_millis() as u64); + latencies.push( + u64::try_from(send_times[idx].elapsed().as_millis()).unwrap_or(u64::MAX), + ); } } } diff --git a/apps/openlive-gateway/web/app.js b/apps/openlive-gateway/web/app.js index d38a09a..f64de77 100644 --- a/apps/openlive-gateway/web/app.js +++ b/apps/openlive-gateway/web/app.js @@ -8,7 +8,7 @@ * Architecture: * - `socket` is the binary WebSocket to /v1/realtime on the gateway. * - `audio` is the AudioSession (mic capture + playback worklets). - * - `visualizer` is the canvas orb renderer. + * - `visualizer` is the bloub orb (x.ai-style face; not affiliated with xAI). * - `transcript` is the in-memory TranscriptLog. * - `telemetry` is the ConnectionTelemetry rolling window. * - `settings` is the persisted UI preferences. @@ -87,6 +87,13 @@ import { } from "./speech-tts.js"; import { exportMemory, saveMemoryItem } from "./memory-client.js"; import { fetchTtsStatus, piperInstallUi, speakOpenLive } from "./tts-client.js"; +import { + allowInboundProviderPcm, + demoAllowsFormant, + isRealTtsReady, +} from "./demo-voice.js"; +import { installCallMorphs } from "./call-controls.js"; +import { isGestureGatedMicError, shouldAutoJoinCall } from "./call-entry.js"; import { BACKCHANNEL_TOKENS, identityReply, @@ -176,6 +183,10 @@ let transcriptVisible = false; let lastServerSequence = 0; let reconnectAttempt = 0; let conversationActive = false; +let joining = false; +/** Bumped to cancel an in-flight auto-join when the user hits End. */ +let joinEpoch = 0; +let bootSplashDismissed = false; let microphoneActive = false; let userEnded = true; let pttHeld = false; @@ -186,6 +197,11 @@ let mode = VoiceMode.IDLE; /** Bumped on every barge-in / cancel so in-flight agent + TTS bail out. */ let assistantTurnId = 0; let lastBargeInAt = 0; +let lastSpokenFinal = ""; +let lastSpokenAt = 0; +/** Tracks whether we've already received+played streaming PCM for the current + * generation. When true, speakAssistant is skipped to avoid double audio. */ +let receivedMediaForGeneration = false; let settings = loadSettings(); /** @type {ReturnType} */ @@ -243,9 +259,17 @@ const BUILTIN_PROVIDER_DETAILS = { custom: { id: "custom", name: "Custom", base_url: "http://127.0.0.1:8000/v1", default_model: "default", models: [], free_tier: true, description: "Any OpenAI-compatible base URL. Enter base URL, then pick or type a model id." }, }; -const visualizer = new VoiceVisualizer(controls.voiceOrb); +const visualizer = new VoiceVisualizer(controls.bloubOrb || controls.voiceOrb); visualizer.setMotionScale(settings.motionScale); +/** Morphicons on Mute / End / Settings. Installed after DOM listeners. */ +let callMorphs = { + setMuted() {}, + setSettingsOpen() {}, +}; +/** Piper (or other neural TTS) is ready — demo path stays silent until then. */ +let realTtsReady = false; + const mediaCapture = new MediaCaptureSession({ onState: handleCaptureState, onError: ({ message }) => showNotice(message), @@ -411,7 +435,7 @@ controls.settings?.addEventListener("click", () => { if (!controls.settingsVoice?.options?.length) { fillVoiceSelect(voices.length ? voices : OFFLINE_VOICES); } - toggleSettings(); + setCallSettingsOpen(); // Scroll settings body to top on open so the user always sees the first section. const settingsBody = document.querySelector(".settings-body"); if (settingsBody) { @@ -424,7 +448,7 @@ controls.settings?.addEventListener("click", () => { }); controls.closeSettings?.addEventListener("click", () => { playClick("soft"); - toggleSettings(false); + setCallSettingsOpen(false); }); controls.backchannels?.addEventListener("change", (event) => persistField("backchannels", event.target.value, /* reconfigure */ true), @@ -519,10 +543,6 @@ controls.debug?.addEventListener("click", () => { playClick("soft"); toggleDiagnostics(); }); -controls.brand?.addEventListener("click", () => { - playClick("soft"); - toggleDiagnostics(); -}); controls.closeDebug?.addEventListener("click", () => { playClick("soft"); toggleDiagnostics(false); @@ -592,13 +612,34 @@ controls.composerInput?.addEventListener("keydown", (event) => { playClick("confirm"); void submitComposerText(); }); -// Prime Web Audio on first pointer anywhere in the surface. +// Prime Web Audio on first pointer. If auto-join was gesture-gated, +// the same tap starts the call — GPT-Live is a call, not a Start button. +// Prime Web Audio on first pointer. Empty-stage taps start the call; +// chrome (Mute / End / Settings) keeps its own handlers. document.addEventListener( "pointerdown", - () => { + (event) => { unlockUiAudio(); + const target = event.target; + if (!(target instanceof Element)) return; + if ( + target.closest( + "button, a, input, select, textarea, .sheet, .setup-wizard, .diagnostics, .onboarding", + ) + ) { + return; + } + if ( + shouldAutoJoinCall({ + conversationActive, + joining, + setupOpen: isInteractionBlocked(), + }) + ) { + void beginConversation(); + } }, - { once: true, passive: true }, + { passive: true }, ); wireUiSoundToggle(); @@ -625,7 +666,7 @@ installShortcuts({ toggleMute: handleMuteToggle, toggleTranscript: () => toggleTranscript(), toggleDiagnostics: () => toggleDiagnostics(), - toggleSettings: () => toggleSettings(), + toggleSettings: () => setCallSettingsOpen(), toggleInstructions: () => { const open = toggleInstructions(); if (open) renderInstructionsPanel(customInstructions, onInstructionAxisChange); @@ -649,6 +690,8 @@ installShortcuts({ endConversation: endConversation, closeOverlays: () => { closeOverlays(); + callMorphs.setSettingsOpen(false); + controls.settings?.setAttribute("aria-expanded", "false"); }, showOnboarding: () => setOnboardingOpen(true), }); @@ -678,8 +721,9 @@ applySettingsForm(); applySetupToSettingsForm(); applySessionCapFromSettings(); refreshFullscreenToggle(); -visualizer.setMode(VoiceMode.IDLE); -resetExperience(); +mode = VoiceMode.LISTENING; +visualizer.setMode(VoiceMode.LISTENING); +setVoiceMode(VoiceMode.LISTENING); renderVoiceList(voices, selectedVoice.id, onVoiceSelected); renderModeList(MODES, selectedModeId, onModeSelected); setVoiceBadge(selectedVoice.glyph); @@ -691,34 +735,64 @@ refreshInstructionsBadge(); // shows pending tasks without waiting for a new acknowledgement. initializeTaskRail(); -// Load LLM provider catalog + sync gateway config (non-blocking). -void bootstrapLlmUi().then(() => { - // Dismiss the boot splash once the provider catalog is loaded. - dismissBootSplash(); -}); -// Paint Settings → Runtime as soon as the page loads (don't wait for Start). +// First paint is the call. Do not wait on catalog fetch or a brand animation. +dismissBootSplash(); +void bootstrapLlmUi(); void refreshRuntimeStatus(); -if (!isSetupComplete()) { - openSetupWizard({ force: true }); -} else { - setSetupOpen(false); - if (!settings.onboardingDismissed) { - setOnboardingOpen(true); - } - void pushLlmConfig(setup).catch(() => {}); -} - -// Failsafe: dismiss splash after 3s even if bootstrapLlmUi hangs. -setTimeout(dismissBootSplash, 3000); +setSetupOpen(false); +void pushLlmConfig(setup).catch(() => {}); +void refreshTtsWarmup(); +window.setInterval(() => { + void refreshTtsWarmup(); +}, 4000); // Install ripple click feedback on all interactive elements. installRippleFeedback(); +callMorphs = installCallMorphs(); + +if ( + shouldAutoJoinCall({ + conversationActive, + joining, + setupOpen: false, + }) +) { + void beginConversation({ auto: true }); +} /* --------------------------------------------------------------------------- Primary action / conversation lifecycle --------------------------------------------------------------------------- */ +function setCallSettingsOpen(force) { + const next = toggleSettings(force); + callMorphs.setSettingsOpen(next); + controls.settings?.setAttribute("aria-expanded", String(next)); + return next; +} + +document.addEventListener( + "click", + (event) => { + const target = event.target; + if (!(target instanceof Element) || !target.closest("#brand")) return; + event.preventDefault(); + playClick("soft"); + setCallSettingsOpen(); + }, + true, +); + +async function refreshTtsWarmup() { + try { + const status = await fetchTtsStatus(); + realTtsReady = isRealTtsReady(status); + } catch { + realTtsReady = false; + } +} + function isInteractionBlocked() { return document.body.classList.contains("setup-open"); } @@ -750,6 +824,7 @@ function handlePttEnd() { } async function handlePrimaryAction() { + if (joining) return; if (isInteractionBlocked()) { showSetupRequiredNotice(); return; @@ -771,6 +846,7 @@ function handleMuteToggle() { audio.stopMicrophone(); microphoneActive = false; setConversationActive(true, false, settings.entryMode === "ptt"); + callMorphs.setMuted(true); transition(VoiceMode.MUTED); stopSpeechRecognition(); return; @@ -778,13 +854,21 @@ function handleMuteToggle() { audio.startMicrophone().then(() => { microphoneActive = true; setConversationActive(true, true, settings.entryMode === "ptt"); + callMorphs.setMuted(false); hideNotice(); transition(VoiceMode.LISTENING); startSpeechRecognition(); }).catch((error) => showNotice(microphoneErrorMessage(error))); } -async function beginConversation() { +async function beginConversation({ auto = false } = {}) { + if (conversationActive || joining) return; + if (isInteractionBlocked()) { + if (!auto) showSetupRequiredNotice(); + return; + } + joining = true; + const epoch = ++joinEpoch; userEnded = false; reconnectAttempt = 0; mediaTimeUs = 0; @@ -800,8 +884,10 @@ async function beginConversation() { setAssistantText(""); closeOverlays(); hideNotice(); - setStarting(true); - transition(VoiceMode.STARTING); + if (!auto) { + setStarting(true); + transition(VoiceMode.STARTING); + } try { setup = loadSetup(); await pushLlmConfig(setup).catch(() => {}); @@ -838,8 +924,10 @@ async function beginConversation() { addTimeline("microphone", `Capture started at ${sampleRate} Hz`); } + if (epoch !== joinEpoch) return; conversationActive = true; setConversationActive(true, true, settings.entryMode === "ptt"); + callMorphs.setMuted(false); transition(VoiceMode.LISTENING); startSpeechRecognition(); transcript.append("system", "Conversation started."); @@ -849,6 +937,7 @@ async function beginConversation() { setQuotaPill(quota.remainingSeconds(), "ok"); } } catch (error) { + if (epoch !== joinEpoch) return; userEnded = true; fallbackInProgress = false; closeWebRtcConnection(); @@ -858,16 +947,29 @@ async function beginConversation() { microphoneActive = false; conversationActive = false; setConversationActive(false); - showNotice(microphoneErrorMessage(error)); - transition(VoiceMode.ERROR); - addTimeline("start_error", error.message); + if (auto && isGestureGatedMicError(error)) { + // Browser needs a tap. Stay on the call surface; first pointer starts it. + transition(VoiceMode.IDLE); + } else { + showNotice(microphoneErrorMessage(error)); + transition(VoiceMode.ERROR); + addTimeline("start_error", error.message); + } } finally { - setStarting(false); + if (epoch === joinEpoch) { + joining = false; + setStarting(false); + } } } function endConversation() { - if (!conversationActive && mode === VoiceMode.IDLE) return; + joinEpoch += 1; + joining = false; + if (!conversationActive && mode === VoiceMode.IDLE) { + setStarting(false); + return; + } userEnded = true; conversationActive = false; microphoneActive = false; @@ -894,6 +996,7 @@ function endConversation() { renderTranscript(transcript.entries); resetExperience(); visualizer.setMode(VoiceMode.IDLE); + callMorphs.setMuted(false); telemetry.reset(); addTimeline("session", "Conversation ended"); } @@ -1927,12 +2030,6 @@ function hardInterruptAssistant(source = "barge-in") { }, 320); } -let lastSpokenFinal = ""; -let lastSpokenAt = 0; -/** Tracks whether we've already received+played streaming PCM for the current - * generation. When true, speakAssistant is skipped to avoid double audio. */ -let receivedMediaForGeneration = false; - /** Control message types that should be buffered + retried during a transport * fallback transition (when neither WebRTC DC nor WebSocket is ready yet). * Hoisted to module level so the Set isn't recreated on every sendControl call. */ @@ -1949,11 +2046,20 @@ const CRITICAL_CONTROL_TYPES = new Set([ function handleMedia(packet) { observeServerSequence(packet.sequence); - // Prefer the gateway's native TTS pipeline (Piper/formant) when it is - // actively streaming PCM. This is more reliable than browser TTS alone - // and avoids the intermittent silence/hang issues seen on Windows - // Chrome/Edge with speechSynthesis. + // Prefer the gateway's native TTS pipeline (Piper) when it is + // actively streaming PCM. Mock formant frames are dropped on the + // demo path unless the user explicitly selected engine=formant. if (packet?.pcm?.length > 0 && audio) { + if (!allowInboundProviderPcm(loadSetup(), activeProvider)) { + if (!handleMedia._droppedMock) { + handleMedia._droppedMock = true; + addTimeline( + "tts", + "Ignored mock formant PCM — demo path uses Piper or silence", + ); + } + return; + } receivedMediaForGeneration = true; audio.enqueue(packet).catch((error) => { console.warn("Failed to enqueue server PCM:", error); @@ -2146,7 +2252,7 @@ function handleControl(envelope) { assistantText = ""; transcript.finalizeByGeneration(envelope.generation_id, finalText); renderTranscript(transcript.entries); - // Robust TTS: prefer gateway Piper/formant PCM, fall back to browser. + // Robust TTS: Piper (or configured engine). Auto never pads with formant. setup = loadSetup(); const isSoftAck = /^(mm-?hmm|mhmm|mhm)\.?$/i.test(finalText); // Dedupe: gateway can emit the same final twice under race conditions. @@ -2775,25 +2881,11 @@ function wireSetupWizard() { } function wireUiSoundToggle() { - const host = document.querySelector("#settingsPanel .sheet-body"); - if (!host || document.querySelector("#uiSoundToggle")) return; - const fieldset = document.createElement("fieldset"); - fieldset.className = "sheet-group"; - fieldset.innerHTML = ` - Feel - -

Subtle Web Audio feedback — never interrupts voice.

- `; - // Insert before Runtime if present, else append. - const runtime = [...host.querySelectorAll("fieldset")].find((f) => - f.querySelector("legend")?.textContent?.includes("Runtime"), - ); - if (runtime) host.insertBefore(fieldset, runtime); - else host.appendChild(fieldset); - document.querySelector("#uiSoundToggle")?.addEventListener("change", (event) => { + const toggle = document.querySelector("#uiSoundToggle"); + if (!toggle || toggle.dataset.bound === "1") return; + toggle.dataset.bound = "1"; + toggle.checked = !isUiSoundMuted(); + toggle.addEventListener("change", (event) => { setUiSoundMuted(!event.target.checked); if (event.target.checked) { unlockUiAudio(); @@ -2929,21 +3021,42 @@ function wireSetupSettingsBindings() { void withLoading(controls.settingsProbeAgent, probeAgentFromForm("settings")), ); controls.reopenSetup?.addEventListener("click", () => { - toggleSettings(false); + setCallSettingsOpen(false); openSetupWizard({ force: true }); }); + controls.settingsCamera?.addEventListener("click", () => { + setCallSettingsOpen(false); + void toggleCamera(); + }); + controls.settingsScreen?.addEventListener("click", () => { + setCallSettingsOpen(false); + void toggleScreenShare(); + }); + controls.settingsTranscript?.addEventListener("click", () => { + setCallSettingsOpen(false); + toggleTranscript(true); + }); + controls.settingsModes?.addEventListener("click", () => { + setCallSettingsOpen(false); + toggleModePicker(true); + }); + controls.settingsTasks?.addEventListener("click", () => { + setCallSettingsOpen(false); + setTaskRailVisible(true); + }); + controls.settingsDiagnostics?.addEventListener("click", () => { + setCallSettingsOpen(false); + toggleDiagnostics(true); + }); } async function bootstrapLlmUi() { - setBootStatus("Connecting to gateway…"); try { const data = await fetchLlmProviders(); llmProviders = data.providers || []; } catch { llmProviders = []; - setBootStatus("Gateway offline — using defaults…"); } - setBootStatus(llmProviders.length ? "Loading voices…" : "Gateway offline — using defaults…"); fillProviderSelects(); // Profile roster (always fill, even if gateway voices fail). fillVoiceSelect(OFFLINE_VOICES); @@ -3765,9 +3878,9 @@ function speechOpts(extra = {}) { } /** - * Speak assistant text with the best available engine. - * Tries gateway TTS (Piper/formant) first, then browser TTS as fallback. - * Handles failures gracefully and transitions back to listening when done. + * Speak assistant text with the configured engine. + * Auto/Piper: neural TTS only — silence + on-screen text if Piper is not ready. + * Formant and browser run only when the user explicitly selected them. */ async function speakAssistant(text, speakTurn, isSoftAck = false) { const localSetup = loadSetup(); @@ -3799,8 +3912,8 @@ async function speakAssistant(text, speakTurn, isSoftAck = false) { } } - // Fallback to browser TTS if gateway TTS is disabled or failed. - if (!gatewayOk && localSetup.browserTts !== false && browserTtsAvailable()) { + // Browser TTS only when the user picked that engine — never as an auto pad. + if (!gatewayOk && ttsEngine === "browser" && browserTtsAvailable()) { try { const fullySpoken = await speakBrowser( text, @@ -3820,9 +3933,8 @@ async function speakAssistant(text, speakTurn, isSoftAck = false) { } } - // If neither engine could speak, at least keep the conversation alive. - if (!gatewayOk && (localSetup.browserTts === false || !browserTtsAvailable())) { - addTimeline("tts", "No TTS engine available; text shown only"); + if (!gatewayOk && ttsEngine !== "browser") { + addTimeline("tts", "No neural TTS yet; showing text only"); } // Transition back to listening when appropriate. @@ -3886,8 +3998,8 @@ function showPiperInstallModal(ui) { } /** - * Always try to speak assistant text. - * Prefers open-source Piper → formant → browser. + * Speak assistant text for previews / explicit out-loud requests. + * Auto never pads with formant or browser TTS. * @param {string} text * @param {object} [extra] * @returns {Promise} @@ -3922,7 +4034,6 @@ async function speakAssistantOutLoud(text, extra = {}) { } } - // Prefer open-source Piper → formant → browser (browser quality is last resort). const spoken = await speakOpenLive(line, { voiceId: setup.voiceId || selectedVoice?.id, voiceURI: setup.browserVoiceURI || null, @@ -3934,14 +4045,18 @@ async function speakAssistantOutLoud(text, extra = {}) { const ok = !!spoken.ok; if (ok) { hideNotice(); - } else if (spoken.piper && !spoken.piper.available) { - const ui = piperInstallUi({ piper: spoken.piper }); - showPiperInstallModal(ui); + } else if ( + setup.ttsEngine === "piper" && + spoken.piper && + !spoken.piper.available && + !speakAssistantOutLoud._shownInstall + ) { + speakAssistantOutLoud._shownInstall = true; + showPiperInstallModal(piperInstallUi({ piper: spoken.piper })); + } else if ((setup.ttsEngine || "auto") === "auto") { + hideNotice(); } else { - showNotice( - spoken.error || - "Speech failed. Settings → TTS: try Formant or install Piper (open-source).", - ); + showNotice(spoken.error || "Speech failed."); } if (conversationActive && microphoneActive && !userEnded) { @@ -4037,7 +4152,8 @@ async function previewSelectedVoice() { setup = saveSetup({ browserVoiceURI: uri }); if (controls.settingsSystemVoice) controls.settingsSystemVoice.value = uri; } - if (setup.browserTts !== false && browserTtsAvailable() && catalog.length) { + const engine = setup.ttsEngine || "auto"; + if (engine === "browser" && setup.browserTts !== false && browserTtsAvailable() && catalog.length) { showNotice(`Playing: ${catalog.find((c) => c.id === uri)?.name || "system voice"}…`); stopBrowserSpeech(); const ok = await speakBrowser( @@ -4049,15 +4165,26 @@ async function previewSelectedVoice() { return; } } - // Always offer formant backup on preview failure. - showNotice("Browser voice failed — playing backup formant voice…"); - const data = await previewVoice(id, line); - await playPcmBase64(data.pcm_base64, data.sample_rate || 24000); + const spoken = await speakOpenLive(line, { + voiceId: id, + voiceURI: uri || null, + ttsEngine: engine, + }); + if (spoken.ok) { + hideNotice(); + return; + } + if (demoAllowsFormant(setup)) { + showNotice("Playing formant preview…"); + const data = await previewVoice(id, line); + await playPcmBase64(data.pcm_base64, data.sample_rate || 24000); + hideNotice(); + return; + } hideNotice(); showNotice( - catalog.length - ? "Backup voice works. For natural speech, pick a System voice and try Preview again (Edge works best)." - : "No system voices found. Install Windows Speech voices, or keep using backup formant voice.", + spoken.error || + "Preview silent until Piper is installed. Settings → TTS can switch to Formant.", ); } catch (e) { showNotice(e?.message || "Preview failed"); @@ -5008,55 +5135,14 @@ function microphoneErrorMessage(error) { Boot splash lifecycle + ripple click feedback (v26.7.16 UI revamp) --------------------------------------------------------------------------- */ -let bootSplashDismissed = false; -window.__openliveBootStart = performance.now(); - /** - * Fade out the boot/splash overlay and mark the app as ready. - * Called after bootstrapLlmUi completes or a 3s failsafe timeout. - */ -/** - * Fade out the boot/splash overlay and mark the app shell as ready. - * Boot sequence: white sphere scales in, then after 1s "Openlive" slides - * out and fades before the splash is removed. The splash stays for at - * least 2.4s so the brand animation can complete. + * The call surface is first paint. Keep the boot flag in sync if a host + * still sets data-boot=loading before this module runs. */ function dismissBootSplash() { if (bootSplashDismissed) return; bootSplashDismissed = true; - - // Keep the splash visible long enough for the brand animation to play: - // 1s delay + 1.2s slide/fade = ~2.2s minimum. Add a small buffer. - const splashStart = window.__openliveBootStart || performance.now(); - const elapsed = performance.now() - splashStart; - const minDuration = 2400; - const remaining = Math.max(0, minDuration - elapsed); - - const doDismiss = () => { - const splash = document.getElementById("bootSplash"); - if (splash) { - splash.classList.add("is-hidden"); - setTimeout(() => { - if (splash.parentNode) splash.parentNode.removeChild(splash); - }, 900); - } - document.body.dataset.boot = "ready"; - }; - - if (remaining <= 0) { - doDismiss(); - } else { - setTimeout(doDismiss, remaining); - } -} - -/** - * Update the boot status text shown during splash. - * @param {string} text - */ -function setBootStatus(text) { - const el = document.getElementById("bootStatus"); - if (el) el.textContent = text; + document.body.dataset.boot = "ready"; } /** diff --git a/apps/openlive-gateway/web/audio-session.js b/apps/openlive-gateway/web/audio-session.js index 71d9b9f..c34a88b 100644 --- a/apps/openlive-gateway/web/audio-session.js +++ b/apps/openlive-gateway/web/audio-session.js @@ -8,13 +8,23 @@ import { } from "./audio-utils.js"; import { EmotionDetector } from "./emotion-detector.js"; +/** Local-first barge-in order. VAD ducks before any server round trip. */ +export const BARGE_IN_CHAIN = Object.freeze([ + "local_duck", + "soft_duck", + "hard_yield", + "cancel", +]); + /** * Openlive 26.7.16 — AudioSession * * Owns the AudioContext, the microphone capture worklet, the playback * worklet, and the output gain node. Bridges binary PCM frames between * the WebSocket and the worklets. Local-first interruption (the reversible - * duck before any server round trip) lives here. + * duck before any server round trip) lives here: + * local_duck → soft_duck → hard_yield → cancel generation + * VAD ducks playback before waiting on server RTT. * * Phase 1 client-side intelligence chain: * mic → RNNoise worklet → Silero VAD worklet → capture worklet diff --git a/apps/openlive-gateway/web/bloub-orb.js b/apps/openlive-gateway/web/bloub-orb.js new file mode 100644 index 0000000..3121078 --- /dev/null +++ b/apps/openlive-gateway/web/bloub-orb.js @@ -0,0 +1,271 @@ +/** + * SVG host for the vendored bloub engine (MIT, Jérémy Perret). + * Eyes are mask holes in the body, matching the x.ai-style face. + * Not affiliated with xAI. + */ + +import { + BotEngine, + DEMI_VIEWBOX, + NOTIF_BLUE, + RAYON, +} from "./vendor/bloub/engine.js"; +import { bloubShouldDim, bloubStateFor } from "./call-state.js"; + +const VB = DEMI_VIEWBOX; +// On OpenLive's black call surface the face reads as a light orb with dark +// eye holes (paper shows through the mask). Swapping these makes the body +// disappear into the page. +const PAPER = "#0a0a0c"; +const INK = "#f4f4f2"; + +export class BloubOrb { + /** + * @param {SVGSVGElement} svg + */ + constructor(svg) { + this.svg = svg; + this.engine = new BotEngine(RAYON, "idle"); + this.clock = 0; + this.last = 0; + this.mode = "idle"; + this.uid = `bloub-${Math.random().toString(36).slice(2, 8)}`; + this.reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; + this.motionScale = 1; + this.frame = null; + this.mount(); + this.tick = (now) => this.draw(now); + this.frame = requestAnimationFrame(this.tick); + } + + mount() { + const svg = this.svg; + svg.setAttribute("viewBox", `${-VB} ${-VB} ${VB * 2} ${VB * 2}`); + svg.setAttribute("role", "img"); + svg.setAttribute( + "aria-label", + "OpenLive face — inspired by the x.ai avatar; not affiliated with xAI", + ); + const ns = "http://www.w3.org/2000/svg"; + svg.replaceChildren(); + const defs = document.createElementNS(ns, "defs"); + const mask = document.createElementNS(ns, "mask"); + mask.id = `${this.uid}-mask`; + mask.setAttribute("maskUnits", "userSpaceOnUse"); + mask.setAttribute("x", String(-VB)); + mask.setAttribute("y", String(-VB)); + mask.setAttribute("width", String(VB * 2)); + mask.setAttribute("height", String(VB * 2)); + this.bodyMask = document.createElementNS(ns, "path"); + this.bodyMask.setAttribute("fill", "#fff"); + this.eyeMasks = [0, 1].map(() => { + const p = document.createElementNS(ns, "path"); + p.setAttribute("fill", "#000"); + return p; + }); + this.notch = document.createElementNS(ns, "circle"); + this.notch.setAttribute("fill", "#000"); + this.notch.setAttribute("visibility", "hidden"); + mask.append(this.bodyMask, ...this.eyeMasks, this.notch); + defs.append(mask); + this.gradRoot = document.createElementNS(ns, "g"); + defs.append(this.gradRoot); + + this.arcBack = document.createElementNS(ns, "g"); + this.arcBack.setAttribute("fill", "none"); + this.arcBack.setAttribute("stroke-linecap", "round"); + this.dotsBehind = document.createElementNS(ns, "g"); + this.bodyGroup = document.createElementNS(ns, "g"); + this.paperPath = document.createElementNS(ns, "path"); + this.paperPath.setAttribute("fill", PAPER); + const inkGroup = document.createElementNS(ns, "g"); + inkGroup.setAttribute("mask", `url(#${mask.id})`); + const ink = document.createElementNS(ns, "rect"); + ink.setAttribute("x", String(-VB)); + ink.setAttribute("y", String(-VB)); + ink.setAttribute("width", String(VB * 2)); + ink.setAttribute("height", String(VB * 2)); + ink.setAttribute("fill", INK); + inkGroup.append(ink); + this.bodyGroup.append(this.paperPath, inkGroup); + this.dotsFront = document.createElementNS(ns, "g"); + this.notif = document.createElementNS(ns, "circle"); + this.notif.setAttribute("fill", NOTIF_BLUE); + this.notif.setAttribute("visibility", "hidden"); + this.arcFront = document.createElementNS(ns, "g"); + this.arcFront.setAttribute("fill", "none"); + this.arcFront.setAttribute("stroke-linecap", "round"); + + svg.append( + defs, + this.arcBack, + this.dotsBehind, + this.bodyGroup, + this.dotsFront, + this.notif, + this.arcFront, + ); + } + + /** + * @param {string} mode + */ + setMode(mode) { + this.mode = mode; + const state = bloubStateFor(mode); + this.engine.setState(state, this.clock); + this.svg.classList.toggle("is-dimmed", bloubShouldDim(mode)); + } + + /** + * @param {number} input + * @param {number} output + */ + setSignals(input, output) { + const speaking = output > 0.04; + const listening = input > 0.12 && !speaking; + this.engine.setLook( + { + yaw: (input - 0.5) * 18, + pitch: speaking ? -8 : listening ? 6 : 0, + mix: speaking || listening ? 0.35 : 0, + spin: 0, + wander: bloubShouldDim(this.mode) ? 0.15 : 1, + }, + this.clock, + ); + } + + fireBargeIn() { + this.engine.setState("burst", this.clock); + } + + /** + * @param {number} scale + */ + setMotionScale(scale) { + this.motionScale = Math.max(0, Math.min(1, scale)); + } + + destroy() { + if (this.frame) cancelAnimationFrame(this.frame); + this.frame = null; + } + + draw(now) { + if (!this.frame) return; + this.frame = requestAnimationFrame(this.tick); + const dt = this.last ? (now - this.last) / 1000 : 0; + this.last = now; + const advance = this.reducedMotion ? 0 : dt * this.motionScale; + this.clock += advance; + this.paint(this.engine.sample(this.clock)); + } + + /** + * @param {import("./vendor/bloub/engine.js").BotFrame} frame + */ + paint(frame) { + this.bodyMask.setAttribute("d", frame.bodyPath); + this.paperPath.setAttribute("d", frame.bodyPath); + this.bodyGroup.setAttribute("opacity", String(frame.bodyAlpha)); + for (let i = 0; i < 2; i++) { + const eye = frame.eyes[i]; + const node = this.eyeMasks[i]; + if (!eye) { + node.setAttribute("d", ""); + continue; + } + node.setAttribute("d", eye.d); + node.setAttribute("transform", eye.matrix); + node.setAttribute("opacity", String(eye.alpha)); + } + if (frame.notch) { + this.notch.setAttribute("visibility", "visible"); + this.notch.setAttribute("cx", String(frame.notch.x)); + this.notch.setAttribute("cy", String(frame.notch.y)); + this.notch.setAttribute("r", String(frame.notch.r)); + } else { + this.notch.setAttribute("visibility", "hidden"); + } + if (frame.notif) { + this.notif.setAttribute("visibility", "visible"); + this.notif.setAttribute("cx", String(frame.notif.x)); + this.notif.setAttribute("cy", String(frame.notif.y)); + this.notif.setAttribute("r", String(frame.notif.r)); + } else { + this.notif.setAttribute("visibility", "hidden"); + } + this.paintDots(frame); + this.paintArcs(frame); + } + + paintDots(frame) { + const host = frame.dotsBehind ? this.dotsBehind : this.dotsFront; + const other = frame.dotsBehind ? this.dotsFront : this.dotsBehind; + other.replaceChildren(); + const ns = "http://www.w3.org/2000/svg"; + while (host.childNodes.length > frame.dots.length) { + host.lastChild.remove(); + } + frame.dots.forEach((dot, i) => { + let node = host.childNodes[i]; + const isPath = Boolean(dot.d); + if (!node || node.tagName !== (isPath ? "path" : "circle")) { + node = document.createElementNS(ns, isPath ? "path" : "circle"); + if (host.childNodes[i]) host.replaceChild(node, host.childNodes[i]); + else host.append(node); + } + node.setAttribute("fill", INK); + node.setAttribute("opacity", String(dot.opacity)); + if (isPath) { + node.setAttribute("d", dot.d); + node.setAttribute( + "transform", + `translate(${dot.x} ${dot.y}) rotate(${dot.rot ?? 0}) scale(${RAYON})`, + ); + } else { + node.setAttribute("cx", String(dot.x)); + node.setAttribute("cy", String(dot.y)); + node.setAttribute("r", String(dot.r)); + node.removeAttribute("transform"); + } + }); + } + + paintArcs(frame) { + const ns = "http://www.w3.org/2000/svg"; + this.gradRoot.replaceChildren(); + this.arcBack.replaceChildren(); + this.arcFront.replaceChildren(); + for (const arc of frame.arcs) { + const gid = `${this.uid}-${arc.id}`; + const grad = document.createElementNS(ns, "linearGradient"); + grad.id = gid; + grad.setAttribute("gradientUnits", "userSpaceOnUse"); + grad.setAttribute("x1", String(arc.grad.x1)); + grad.setAttribute("y1", String(arc.grad.y1)); + grad.setAttribute("x2", String(arc.grad.x2)); + grad.setAttribute("y2", String(arc.grad.y2)); + const stops = arc.grad.stops || []; + stops.forEach((c, i) => { + const stop = document.createElementNS(ns, "stop"); + stop.setAttribute("offset", String(stops.length > 1 ? i / (stops.length - 1) : 0)); + stop.setAttribute("stop-color", c); + grad.append(stop); + }); + this.gradRoot.append(grad); + for (const [host, d] of [ + [this.arcBack, arc.back], + [this.arcFront, arc.front], + ]) { + const path = document.createElementNS(ns, "path"); + path.setAttribute("d", d); + path.setAttribute("stroke", `url(#${gid})`); + path.setAttribute("stroke-width", String(arc.width)); + path.setAttribute("opacity", String(arc.opacity)); + host.append(path); + } + } + } +} diff --git a/apps/openlive-gateway/web/call-controls.js b/apps/openlive-gateway/web/call-controls.js new file mode 100644 index 0000000..1046947 --- /dev/null +++ b/apps/openlive-gateway/web/call-controls.js @@ -0,0 +1,59 @@ +/** + * Morphicons (MIT) on the default call chrome: Mute, End, Settings. + * Uses lucide data exports, not lucide-react. + */ + +import { createMorph, Mic, MicOff, PhoneOff, Settings, X } from "./vendor/morphicons.js"; + +function svgPathHost() { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "1.8"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + const path = document.createElementNS(ns, "path"); + svg.append(path); + return { svg, path }; +} + +/** + * @param {HTMLElement | null} host + * @param {unknown} icon + */ +function mountMorph(host, icon) { + if (!host) return null; + host.replaceChildren(); + const { svg, path } = svgPathHost(); + host.append(svg); + return createMorph(path, icon, { reducedMotion: "user" }); +} + +/** + * Wire Mute / End / Settings morphs. Safe to call once after DOM is ready. + * + * @returns {{ + * setMuted: (muted: boolean) => void, + * setSettingsOpen: (open: boolean) => void, + * }} + */ +export function installCallMorphs() { + const mute = mountMorph(document.querySelector('[data-morph="mute"]'), Mic); + mountMorph(document.querySelector('[data-morph="end"]'), PhoneOff); + const settings = mountMorph( + document.querySelector('[data-morph="settings"]'), + Settings, + ); + + return { + setMuted(muted) { + mute?.morphTo(muted ? MicOff : Mic, "snappy"); + }, + setSettingsOpen(open) { + settings?.morphTo(open ? X : Settings, "snappy"); + }, + }; +} diff --git a/apps/openlive-gateway/web/call-entry.js b/apps/openlive-gateway/web/call-entry.js new file mode 100644 index 0000000..000961a --- /dev/null +++ b/apps/openlive-gateway/web/call-entry.js @@ -0,0 +1,25 @@ +/** + * GPT-Live entry policy: the default surface is an active call. + * + * Browsers often refuse getUserMedia until a user gesture. Auto-join + * should then fail quietly and wait for a tap — not show a setup wizard + * or a lab error dump. + */ + +/** + * @param {{ conversationActive?: boolean, joining?: boolean, setupOpen?: boolean }} state + * @returns {boolean} + */ +export function shouldAutoJoinCall(state = {}) { + if (state.conversationActive || state.joining || state.setupOpen) return false; + return true; +} + +/** + * @param {unknown} error + * @returns {boolean} + */ +export function isGestureGatedMicError(error) { + const name = error && typeof error === "object" ? error.name : ""; + return name === "NotAllowedError" || name === "SecurityError" || name === "NotFoundError"; +} diff --git a/apps/openlive-gateway/web/call-state.js b/apps/openlive-gateway/web/call-state.js new file mode 100644 index 0000000..06e441a --- /dev/null +++ b/apps/openlive-gateway/web/call-state.js @@ -0,0 +1,40 @@ +/** + * Map OpenLive voice modes onto bloub engine states. + * + * Warming / listening → idle (gaze drift + blink) + * Speaking → orbit + * Interrupted / barge-in → burst, then back to listening + * Muted → idle (renderer dims the face) + */ + +import { VoiceMode } from "./visual-state.js"; + +const MAP = Object.freeze({ + [VoiceMode.IDLE]: "idle", + [VoiceMode.STARTING]: "idle", + [VoiceMode.LISTENING]: "idle", + [VoiceMode.THINKING]: "thinking", + [VoiceMode.SPEAKING]: "orbit", + [VoiceMode.YIELDING]: "wink", + [VoiceMode.INTERRUPTED]: "burst", + [VoiceMode.MUTED]: "idle", + [VoiceMode.RECONNECTING]: "swirl", + [VoiceMode.CONNECTION_ERROR]: "idle", + [VoiceMode.ERROR]: "idle", +}); + +/** + * @param {string} mode + * @returns {string} + */ +export function bloubStateFor(mode) { + return MAP[mode] || "idle"; +} + +/** + * @param {string} mode + * @returns {boolean} + */ +export function bloubShouldDim(mode) { + return mode === VoiceMode.MUTED; +} diff --git a/apps/openlive-gateway/web/demo-voice.js b/apps/openlive-gateway/web/demo-voice.js new file mode 100644 index 0000000..d85da20 --- /dev/null +++ b/apps/openlive-gateway/web/demo-voice.js @@ -0,0 +1,56 @@ +/** + * OpenLive demo-path voice policy. + * + * Happy path (ttsEngine === "auto"): speak only with real neural TTS (Piper). + * Never pad with formant/mock — the fake→real switch is an audible tell. + * Formant is allowed only when the user explicitly selects that engine + * (first-run / last-resort fallback). + */ + +export const DEMO_TTS_POLICY = "neural-or-silence"; + +/** + * @param {{ ttsEngine?: string } | null | undefined} setup + * @returns {boolean} + */ +export function demoAllowsFormant(setup) { + return (setup?.ttsEngine || "auto") === "formant"; +} + +/** + * Engine string sent to `/v1/tts/speak`. Auto never requests formant. + * + * @param {{ ttsEngine?: string } | null | undefined} setup + * @returns {"auto" | "piper" | "formant" | "browser"} + */ +export function neuralSpeakEngine(setup) { + const engine = setup?.ttsEngine || "auto"; + if (engine === "formant" || engine === "browser" || engine === "piper") { + return engine; + } + return "piper"; +} + +/** + * @param {{ piper?: { available?: boolean }, preferred?: string } | null | undefined} status + * @returns {boolean} + */ +export function isRealTtsReady(status) { + return Boolean(status?.piper?.available); +} + +/** + * Whether inbound provider PCM may play on the demo path. + * Mock formant frames are dropped unless the user opted into formant TTS. + * + * @param {{ ttsEngine?: string } | null | undefined} setup + * @param {{ id?: string, provider_class?: string } | null | undefined} provider + * @returns {boolean} + */ +export function allowInboundProviderPcm(setup, provider) { + if (demoAllowsFormant(setup)) return true; + const id = String(provider?.id || ""); + const klass = String(provider?.provider_class || ""); + const isMock = klass === "mock" || id.includes("mock"); + return !isMock; +} diff --git a/apps/openlive-gateway/web/index.html b/apps/openlive-gateway/web/index.html index c74667c..57f8214 100644 --- a/apps/openlive-gateway/web/index.html +++ b/apps/openlive-gateway/web/index.html @@ -6,25 +6,10 @@ OpenLive - - - - - -
-
- - Openlive - Initializing… -
-
- + - + +

Soft clicks for controls — never interrupts voice.

-
-

- - Runtime -

-
-

Loading gateway status…

-
- +
+ + + + Advanced + + Agents, camera, sandbox, diagnostics + + +
+

+ + Background agent +

+

Built-in tools: web search, time, calculator. Uses the language model above — no external coding agent.

+
+ + +

Classes limit tools and tag memory. Say “yes” / “确认” to approve a pending file change by voice.

+
+ + +
-
-
+ + +
+

+ + Sandbox workspace +

+

Agent file tools run inside a constrained sandbox under your app data (not your whole disk). Lab/test folders are created automatically.

+
+

Loading sandbox…

+
    +

    Recent screenshots & PDFs

    + +
    + + + + +
    +

    +
    +
    + +
    +

    + + Capture & workspace +

    +

    Camera, screen share, multi-agent, and diagnostics stay here — not on the call chrome.

    +
    +
    + + + +
    +
    + + + +
    +

    Face: Bloub (MIT). Inspired by the x.ai avatar; OpenLive is not affiliated with xAI or OpenAI. Controls: Morphicons + Lucide.

    +
    +
    + +
    +

    + + Runtime +

    +
    +

    Loading gateway status…

    +
    + +
    +
    +
    + diff --git a/apps/openlive-gateway/web/package-lock.json b/apps/openlive-gateway/web/package-lock.json index 29993b6..85bd0bf 100644 --- a/apps/openlive-gateway/web/package-lock.json +++ b/apps/openlive-gateway/web/package-lock.json @@ -2,5 +2,506 @@ "name": "web", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "dependencies": { + "esbuild": "^0.28.2", + "lucide": "^1.34.0", + "morphicons": "^1.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/lucide": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.34.0.tgz", + "integrity": "sha512-OOPdNzu3Ocs9lLPOu1sWVIp4sue3HXYjZThAmSXu/NhBJ9ZnfLQhCGfbQRYvsVJvSKwi/KtLE5pNbS4fYl5zrA==", + "license": "ISC" + }, + "node_modules/morphicons": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/morphicons/-/morphicons-1.7.0.tgz", + "integrity": "sha512-MOqSK+O5RdxynER5016vUvvqQaHkqqWYmNpocsk9TEszUZ9PB/K52Yqq8AGTRK1NUO5dj8znGSEVf9slqEIQaw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18", + "react-native": ">=0.71", + "react-native-svg": ">=14", + "svelte": ">=5", + "vue": ">=3.3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + }, + "react-native-svg": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + } + } } diff --git a/apps/openlive-gateway/web/package.json b/apps/openlive-gateway/web/package.json index 2c20219..dd4e1d7 100644 --- a/apps/openlive-gateway/web/package.json +++ b/apps/openlive-gateway/web/package.json @@ -2,6 +2,12 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/*.test.js" + "test": "node --test tests/*.test.js", + "vendor": "node scripts/vendor-ui.mjs" + }, + "devDependencies": { + "esbuild": "^0.28.2", + "lucide": "^1.34.0", + "morphicons": "^1.7.0" } } diff --git a/apps/openlive-gateway/web/scripts/vendor-ui.mjs b/apps/openlive-gateway/web/scripts/vendor-ui.mjs new file mode 100644 index 0000000..d9b075f --- /dev/null +++ b/apps/openlive-gateway/web/scripts/vendor-ui.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/** + * Rebuild vendored bloub + morphicons ESM bundles. + * Run from apps/openlive-gateway/web: `npm run vendor` + */ +import * as esbuild from "esbuild"; + +await esbuild.build({ + entryPoints: ["vendor/bloub/entry.js"], + bundle: true, + format: "esm", + outfile: "vendor/bloub/engine.js", + target: ["es2022"], + legalComments: "inline", +}); + +await esbuild.build({ + entryPoints: ["vendor/morphicons-entry.js"], + bundle: true, + format: "esm", + outfile: "vendor/morphicons.js", + target: ["es2022"], + legalComments: "inline", +}); + +console.log("vendored bloub + morphicons"); diff --git a/apps/openlive-gateway/web/settings-store.js b/apps/openlive-gateway/web/settings-store.js index d13865d..a685dd1 100644 --- a/apps/openlive-gateway/web/settings-store.js +++ b/apps/openlive-gateway/web/settings-store.js @@ -15,7 +15,7 @@ export const DEFAULT_SETTINGS = Object.freeze({ // Minimal black is the default live surface (v26.7.16). theme: "minimal", motionScale: 1, - showLatency: true, + showLatency: false, entryMode: "auto", backchannels: "natural", speedOverride: "auto", diff --git a/apps/openlive-gateway/web/setup-store.js b/apps/openlive-gateway/web/setup-store.js index cd4c839..c033b02 100644 --- a/apps/openlive-gateway/web/setup-store.js +++ b/apps/openlive-gateway/web/setup-store.js @@ -34,7 +34,7 @@ export const DEFAULT_SETUP = Object.freeze({ /** Always empty in persisted JSON; use session memory via loadSetup(). */ modelApiKey: "", llmModel: "meta/llama-3.1-8b-instruct", - ttsModel: "formant", + ttsModel: "piper", asrModel: "browser", voiceId: "en_US-lessac-medium", /** @@ -53,9 +53,9 @@ export const DEFAULT_SETUP = Object.freeze({ browserTts: true, /** * TTS engine preference: - * - auto: Piper if installed, else formant, else browser + * - auto: Piper neural only (silence until ready — no formant pad) * - piper: open-source neural (requires install) - * - formant: built-in gateway synth + * - formant: built-in backup (explicit last resort) * - browser: Web Speech API */ ttsEngine: "auto", diff --git a/apps/openlive-gateway/web/styles.css b/apps/openlive-gateway/web/styles.css index c6e1971..cb744f7 100644 --- a/apps/openlive-gateway/web/styles.css +++ b/apps/openlive-gateway/web/styles.css @@ -26,8 +26,9 @@ color-scheme: dark; /* Typography */ - --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-display: var(--font-sans); --font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace; /* Spacing scale (4px base) */ @@ -154,7 +155,9 @@ html[data-theme="signal"] { transition: opacity 0.8s var(--ease-out), visibility 0.8s var(--ease-out); } -.boot-splash.is-hidden { +.boot-splash.is-hidden, +.boot-splash[hidden] { + display: none !important; opacity: 0; visibility: hidden; pointer-events: none; @@ -510,13 +513,14 @@ svg { display: block; } -/* Fallback for inline paths; symbols carry their own stroke attrs. */ -svg path, -svg rect, -svg circle, -svg line, -svg polyline, -svg polygon { +/* Fallback for inline icon paths; symbols carry their own stroke attrs. + Bloub's face uses filled mask geometry — exclude it from the icon stroke reset. */ +svg:not(.bloub-orb) path, +svg:not(.bloub-orb) rect, +svg:not(.bloub-orb) circle, +svg:not(.bloub-orb) line, +svg:not(.bloub-orb) polyline, +svg:not(.bloub-orb) polygon { fill: none; stroke: currentColor; stroke-width: 1.8; @@ -524,6 +528,12 @@ svg polygon { stroke-linejoin: round; } +.bloub-orb path, +.bloub-orb rect, +.bloub-orb circle { + stroke: none; +} + .icon-button svg, .composer-icon svg, .composer-mic svg, @@ -3527,12 +3537,14 @@ html[data-theme="minimal"] { --ambient-1: transparent; --ambient-2: transparent; --ambient-3: transparent; - --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-sans: ui-sans-serif, system-ui, -apple-system, sans-serif; } html[data-theme="minimal"] body, body[data-ui="minimal"] { background: #000; + overflow: hidden; + height: 100dvh; } html[data-theme="minimal"] .ambient { @@ -3732,11 +3744,19 @@ body[data-ui="minimal"] .minimal-topbar { background: transparent; border: 0; padding: 16px 20px; + z-index: 50; + animation: none !important; + transform: none !important; + pointer-events: auto; } body[data-ui="minimal"] .minimal-brand { background: transparent; - padding: 6px 10px; + padding: 8px 12px; + min-height: 44px; + cursor: pointer; + position: relative; + z-index: 1; } body[data-ui="minimal"] .minimal-brand .brand-name { @@ -3744,15 +3764,45 @@ body[data-ui="minimal"] .minimal-brand .brand-name { letter-spacing: -0.02em; } +body[data-ui="minimal"] .brand-version { + display: none; +} + body[data-ui="minimal"] .minimal-shell { display: block; + position: relative; + inset: auto; + overflow: visible; min-height: 100svh; + /* Boot fade-in leave-behind transforms make position:fixed dock clip. */ + transform: none !important; + filter: none !important; + animation: none !important; + opacity: 1 !important; + z-index: 1; + pointer-events: none; +} + +body[data-ui="minimal"] .orb-shell, +body[data-ui="minimal"] .minimal-copy, +body[data-ui="minimal"] .notice, +body[data-ui="minimal"] .assistant-ephemeral, +body[data-ui="minimal"] .agent-toast, +body[data-ui="minimal"] .agent-confirm-modal { + pointer-events: auto; } body[data-ui="minimal"] .minimal-desk { - min-height: 100svh; + min-height: 100dvh; + height: 100dvh; display: flex; flex-direction: column; + overflow: visible; +} + +body[data-ui="minimal"] .live-desk, +body[data-ui="minimal"] .voice-stage { + overflow: visible; } body[data-ui="minimal"] .minimal-stage { @@ -3822,18 +3872,28 @@ body[data-ui="minimal"] .assistant-ephemeral { min-height: 1.5em; } -/* Bottom composer */ +/* Bottom call chrome */ body[data-ui="minimal"] .minimal-dock { position: fixed; left: 0; right: 0; bottom: 0; - padding: 0 16px calc(20px + env(safe-area-inset-bottom)); + padding: 0 16px max(20px, env(safe-area-inset-bottom)); background: transparent; border: 0; display: flex; justify-content: center; + align-items: flex-end; z-index: 30; + transform: none !important; + animation: none !important; + opacity: 1 !important; + min-height: 104px; + pointer-events: none; +} +body[data-ui="minimal"] .minimal-dock .call-chrome, +body[data-ui="minimal"] .minimal-dock .call-btn { + pointer-events: auto; } .composer-bar { @@ -4272,6 +4332,8 @@ body[data-ui="minimal"] .transcript-drawer { body[data-ui="minimal"] .backchannel-badge { background: rgba(107, 140, 255, 0.2); color: #c8d4ff; + opacity: 0; + animation: none; transition: opacity 180ms var(--ease-out), transform 180ms var(--ease-out); } @@ -4976,7 +5038,9 @@ body[data-ui="minimal"] .settings-sheet.sheet { transition: opacity 600ms var(--ease-out), visibility 600ms; } -.boot-splash.is-hidden { +.boot-splash.is-hidden, +.boot-splash[hidden] { + display: none !important; opacity: 0; visibility: hidden; pointer-events: none; @@ -5620,7 +5684,8 @@ body[data-boot="loading"] .control-dock { body[data-boot="ready"] .topbar, body[data-boot="ready"] .signal-shell, body[data-boot="ready"] .control-dock { - animation: boot-fade-in 600ms var(--ease-out) both; + animation: none; + opacity: 1; } /* ========================================================================= @@ -6098,3 +6163,168 @@ body:has(.settings-sheet[data-open="true"])::before { transition: none !important; } } + +/* --------------------------------------------------------------------------- + Default call surface (bloub + Mute / End / Settings) + --------------------------------------------------------------------------- */ + +body[data-ui="minimal"] .minimal-topbar { + pointer-events: none; +} +body[data-ui="minimal"] .minimal-topbar .brand { + pointer-events: auto; + opacity: 0.55; +} +body[data-ui="minimal"] .brand-version, +body[data-ui="minimal"] .top-actions { + display: none !important; +} +body[data-ui="minimal"] .context-rail { + display: none !important; +} +body.show-rail[data-ui="minimal"] .context-rail { + display: grid !important; +} +body[data-ui="minimal"] .minimal-shell { + grid-template-columns: 1fr; + inset: auto; + overflow: visible; +} + +.bloub-orb { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} +#bloubOrb.is-dimmed { + opacity: 0.42; + filter: grayscale(0.25); + transition: opacity 280ms ease, filter 280ms ease; +} +body[data-ui="minimal"] .orb-shell { + filter: none; + background: transparent; +} +body[data-ui="minimal"] .orb-shell::after { + display: none; +} +body[data-ui="minimal"] .orb-aura, +body[data-ui="minimal"] .orb-glow, +body[data-ui="minimal"] .orb-ring { + display: none; +} +body[data-ui="minimal"] .orb-ring.barge-in { + display: block; +} + +.call-chrome { + display: flex; + align-items: flex-end; + justify-content: center; + gap: 28px; + width: min(420px, 100%); + padding: 8px 12px 4px; +} +.call-btn { + appearance: none; + border: 0; + background: transparent; + color: #f4f4f2; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + cursor: pointer; + min-width: 72px; +} +.call-btn .morph-host, +.call-btn .morph-host svg { + width: 28px; + height: 28px; + display: block; +} +.call-mute, +.call-settings, +.call-end { + justify-content: center; + gap: 0; + padding: 0; + position: relative; +} +.call-mute, +.call-settings { + width: 64px; + height: 64px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + color: #f4f4f2; +} +.call-end { + width: 72px; + height: 72px; + border-radius: 50%; + background: #e11d48; + color: #fff; + box-shadow: 0 10px 28px rgba(225, 29, 72, 0.35); +} +.call-btn-label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} +.call-btn:active { + transform: scale(0.94); +} +.call-mute.muted, +.call-settings[aria-expanded="true"] { + background: rgba(255, 255, 255, 0.18); +} +body[data-boot="loading"] .call-chrome { + opacity: 0; +} +body[data-boot="ready"] .call-chrome { + opacity: 1; + transition: opacity 400ms ease; +} + +details.settings-lab { + margin: 8px 0 24px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + /* Settings body is a grid. Default min-height:auto is required so the + closed summary is not crushed to the border width (~2px). */ + min-height: min-content; + align-self: start; + width: 100%; + overflow: visible; +} +details.settings-lab > summary { + list-style: none; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px 18px; +} +details.settings-lab > summary::-webkit-details-marker { + display: none; +} +details.settings-lab .settings-lab-summary .settings-section-title { + margin: 0; +} +details.settings-lab .settings-lab-hint { + font-size: 0.8rem; + color: var(--fg-subtle, rgba(244, 244, 242, 0.55)); +} +details.settings-lab[open] > summary { + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +details.settings-lab .settings-section { + padding-inline: 8px; +} + + diff --git a/apps/openlive-gateway/web/tests/call-entry.test.js b/apps/openlive-gateway/web/tests/call-entry.test.js new file mode 100644 index 0000000..7cf64ae --- /dev/null +++ b/apps/openlive-gateway/web/tests/call-entry.test.js @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isGestureGatedMicError, shouldAutoJoinCall } from "../call-entry.js"; + +test("auto-join is the default when idle", () => { + assert.equal(shouldAutoJoinCall({}), true); + assert.equal(shouldAutoJoinCall({ conversationActive: false, joining: false }), true); +}); + +test("auto-join does not stack on an active or joining call", () => { + assert.equal(shouldAutoJoinCall({ conversationActive: true }), false); + assert.equal(shouldAutoJoinCall({ joining: true }), false); + assert.equal(shouldAutoJoinCall({ setupOpen: true }), false); +}); + +test("mic permission and missing-device errors wait for a tap", () => { + assert.equal(isGestureGatedMicError({ name: "NotAllowedError" }), true); + assert.equal(isGestureGatedMicError({ name: "SecurityError" }), true); + assert.equal(isGestureGatedMicError({ name: "NotFoundError" }), true); + assert.equal(isGestureGatedMicError({ name: "NotReadableError" }), false); + assert.equal(isGestureGatedMicError(null), false); +}); diff --git a/apps/openlive-gateway/web/tests/call-state.test.js b/apps/openlive-gateway/web/tests/call-state.test.js new file mode 100644 index 0000000..a96a180 --- /dev/null +++ b/apps/openlive-gateway/web/tests/call-state.test.js @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { bloubShouldDim, bloubStateFor } from "../call-state.js"; +import { VoiceMode } from "../visual-state.js"; +import { BARGE_IN_CHAIN } from "../audio-session.js"; + +test("listening and warming map to idle bloub gaze", () => { + assert.equal(bloubStateFor(VoiceMode.IDLE), "idle"); + assert.equal(bloubStateFor(VoiceMode.STARTING), "idle"); + assert.equal(bloubStateFor(VoiceMode.LISTENING), "idle"); +}); + +test("speaking and barge-in have distinct expressions", () => { + assert.equal(bloubStateFor(VoiceMode.SPEAKING), "orbit"); + assert.equal(bloubStateFor(VoiceMode.INTERRUPTED), "burst"); + assert.equal(bloubStateFor(VoiceMode.YIELDING), "wink"); + assert.equal(bloubStateFor(VoiceMode.THINKING), "thinking"); + assert.equal(bloubStateFor(VoiceMode.RECONNECTING), "swirl"); +}); + +test("muted dims the face without leaving idle", () => { + assert.equal(bloubStateFor(VoiceMode.MUTED), "idle"); + assert.equal(bloubShouldDim(VoiceMode.MUTED), true); + assert.equal(bloubShouldDim(VoiceMode.LISTENING), false); +}); + +test("barge-in ducks locally before server yield", () => { + assert.deepEqual(BARGE_IN_CHAIN, [ + "local_duck", + "soft_duck", + "hard_yield", + "cancel", + ]); +}); diff --git a/apps/openlive-gateway/web/tests/demo-voice.test.js b/apps/openlive-gateway/web/tests/demo-voice.test.js new file mode 100644 index 0000000..08d5924 --- /dev/null +++ b/apps/openlive-gateway/web/tests/demo-voice.test.js @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + allowInboundProviderPcm, + demoAllowsFormant, + DEMO_TTS_POLICY, + isRealTtsReady, + neuralSpeakEngine, +} from "../demo-voice.js"; + +test("demo path policy is neural-or-silence", () => { + assert.equal(DEMO_TTS_POLICY, "neural-or-silence"); +}); + +test("auto never requests formant", () => { + assert.equal(neuralSpeakEngine({}), "piper"); + assert.equal(neuralSpeakEngine({ ttsEngine: "auto" }), "piper"); + assert.equal(demoAllowsFormant({ ttsEngine: "auto" }), false); + assert.equal(demoAllowsFormant({ ttsEngine: "formant" }), true); +}); + +test("explicit engines are preserved", () => { + assert.equal(neuralSpeakEngine({ ttsEngine: "piper" }), "piper"); + assert.equal(neuralSpeakEngine({ ttsEngine: "formant" }), "formant"); + assert.equal(neuralSpeakEngine({ ttsEngine: "browser" }), "browser"); +}); + +test("real TTS ready only when Piper is available", () => { + assert.equal(isRealTtsReady({ piper: { available: true } }), true); + assert.equal(isRealTtsReady({ piper: { available: false } }), false); + assert.equal(isRealTtsReady(null), false); +}); + +test("mock provider PCM is dropped unless formant is opted in", () => { + const mock = { id: "mock-local", provider_class: "mock" }; + const grok = { id: "grok", provider_class: "native_duplex" }; + assert.equal(allowInboundProviderPcm({ ttsEngine: "auto" }, mock), false); + assert.equal(allowInboundProviderPcm({ ttsEngine: "formant" }, mock), true); + assert.equal(allowInboundProviderPcm({ ttsEngine: "auto" }, grok), true); +}); diff --git a/apps/openlive-gateway/web/tests/protocol.test.js b/apps/openlive-gateway/web/tests/protocol.test.js index 3af92b3..63e675f 100644 --- a/apps/openlive-gateway/web/tests/protocol.test.js +++ b/apps/openlive-gateway/web/tests/protocol.test.js @@ -253,6 +253,8 @@ test("jitter target expands on loss and recovers after stable playout", () => { --------------------------------------------------------------------------- */ test("voice presentation keeps live states concise and distinct", () => { + assert.equal(voicePresentation(VoiceMode.IDLE).label, "Tap to talk"); + assert.equal(voicePresentation(VoiceMode.IDLE).detail, "I'll listen as soon as you start."); assert.equal(voicePresentation(VoiceMode.LISTENING).label, "Listening"); assert.equal(voicePresentation(VoiceMode.SPEAKING).label, "Speaking"); assert.notEqual( @@ -272,7 +274,7 @@ test("voice presentation keeps live states concise and distinct", () => { test("v1.2 YIELDING mode is distinct from INTERRUPTED and LISTENING", () => { assert.notEqual(VoiceMode.YIELDING, VoiceMode.INTERRUPTED); assert.notEqual(VoiceMode.YIELDING, VoiceMode.LISTENING); - assert.equal(voicePresentation(VoiceMode.YIELDING).label, "Yielding"); + assert.equal(voicePresentation(VoiceMode.YIELDING).label, "Listening"); // YIELDING gets a slightly higher input weight than LISTENING so the orb // visibly reacts to the user's voice even while output is being ducked. assert.ok( @@ -541,6 +543,7 @@ test("clearSettings is safe to call even without localStorage", () => { test("default theme is minimal black for v26.7.16", () => { assert.equal(DEFAULT_SETTINGS.theme, "minimal"); assert.equal(DEFAULT_SETTINGS.backchannels, "natural"); + assert.equal(DEFAULT_SETTINGS.showLatency, false); }); test("saveSettings accepts minimal theme", () => { diff --git a/apps/openlive-gateway/web/tts-client.js b/apps/openlive-gateway/web/tts-client.js index c3917f7..933026b 100644 --- a/apps/openlive-gateway/web/tts-client.js +++ b/apps/openlive-gateway/web/tts-client.js @@ -1,16 +1,17 @@ /** - * OpenLive 26.7.16 — TTS client (Piper via gateway, formant, then browser). + * OpenLive 26.7.16 — TTS client (Piper via gateway; formant only if requested). */ import { playPcmBase64 } from "./agent-client.js"; import { listBrowserVoices, speakBrowser, waitForVoices } from "./speech-tts.js"; +import { neuralSpeakEngine } from "./demo-voice.js"; /** * @returns {Promise} */ export async function fetchTtsStatus() { const r = await fetch("/v1/tts/status"); - return r.json().catch(() => ({ preferred: "formant", piper: { available: false } })); + return r.json().catch(() => ({ preferred: "piper", piper: { available: false } })); } /** @@ -29,11 +30,12 @@ export async function speakOpenLive(text, opts = {}) { const line = String(text || "").trim(); if (!line) return { ok: false, engine: "none", error: "empty" }; - const engine = opts.ttsEngine || "auto"; + const requested = opts.ttsEngine || "auto"; + const engine = neuralSpeakEngine({ ttsEngine: requested }); const onStatus = opts.onStatus || (() => {}); - // 1) Piper / formant via gateway (most reliable path for this product). - if (engine === "auto" || engine === "piper" || engine === "formant") { + // Piper (or explicit formant). Auto never pads with formant. + if (engine === "piper" || engine === "formant") { try { onStatus(engine === "formant" ? "Speaking (formant)…" : "Speaking (open-source TTS)…"); const r = await fetch("/v1/tts/speak", { @@ -42,13 +44,13 @@ export async function speakOpenLive(text, opts = {}) { body: JSON.stringify({ text: line.slice(0, 800), voice_id: opts.voiceId || "en_US-lessac-medium", - engine: engine === "formant" ? "formant" : "auto", + engine: engine === "formant" ? "formant" : "piper", }), }); const data = await r.json().catch(() => ({})); if (r.ok && data.pcm_base64) { await playPcmBase64(data.pcm_base64, data.sample_rate || 24000); - return { ok: true, engine: data.engine || "formant", piper: data.piper }; + return { ok: true, engine: data.engine || engine, piper: data.piper }; } if (engine === "piper") { return { @@ -65,8 +67,8 @@ export async function speakOpenLive(text, opts = {}) { } } - // 2) Browser Web Speech (last resort — quality varies a lot). - if (engine === "auto" || engine === "browser") { + // Browser Web Speech only when explicitly selected. + if (engine === "browser") { try { onStatus("Speaking (browser)…"); await waitForVoices(4000); diff --git a/apps/openlive-gateway/web/ui.js b/apps/openlive-gateway/web/ui.js index 6505bbb..05c75d3 100644 --- a/apps/openlive-gateway/web/ui.js +++ b/apps/openlive-gateway/web/ui.js @@ -169,6 +169,13 @@ const elements = { settingsMediaGallery: query("#settingsMediaGallery"), settingsSandboxTestStatus: query("#settingsSandboxTestStatus"), reopenSetup: query("#reopenSetup"), + bloubOrb: query("#bloubOrb"), + settingsCamera: query("#settingsCamera"), + settingsScreen: query("#settingsScreen"), + settingsTranscript: query("#settingsTranscript"), + settingsModes: query("#settingsModes"), + settingsTasks: query("#settingsTasks"), + settingsDiagnostics: query("#settingsDiagnostics"), }; export const controls = { @@ -205,6 +212,7 @@ export const controls = { closeVoice: elements.closeVoice, closeMode: elements.closeMode, voiceOrb: elements.voiceOrb, + orbShell: elements.orbShell, onboardingDismiss: elements.onboardingDismiss, onboardingStart: elements.onboardingStart, setupWizard: elements.setupWizard, @@ -275,6 +283,13 @@ export const controls = { settingsMediaGallery: elements.settingsMediaGallery, settingsSandboxTestStatus: elements.settingsSandboxTestStatus, reopenSetup: elements.reopenSetup, + bloubOrb: elements.bloubOrb, + settingsCamera: elements.settingsCamera, + settingsScreen: elements.settingsScreen, + settingsTranscript: elements.settingsTranscript, + settingsModes: elements.settingsModes, + settingsTasks: elements.settingsTasks, + settingsDiagnostics: elements.settingsDiagnostics, }; /* --------------------------------------------------------------------------- @@ -326,7 +341,7 @@ export function setVoiceMode(mode, detail) { listening: "You have the floor", thinking: "Reasoning", speaking: "OpenLive has the floor", - yielding: "Yielding", + yielding: "Listening", interrupted: "Floor returned", muted: "Microphone paused", reconnecting: "Restoring floor", @@ -342,11 +357,8 @@ export function setVoiceMode(mode, detail) { /** * Update the primary dock button to reflect conversation + microphone state. - * Three shapes: - * - Inactive: "Start", full-width pill with mic icon. - * - Active + mic on (auto VAD): collapsed circle, mic icon only. - * - Active + mic off: "Resume", full-width pill. - * - PTT mode + active: shows PTT icon and label "Hold". + * Default chrome is Mute / End / Settings. Idle Mute still starts the call + * (user-gesture mic unlock) without looking like a lab "Start" control. * * @param {boolean} active - Whether a conversation is in progress. * @param {boolean} [microphoneActive] - Whether the mic is currently captured. @@ -361,8 +373,8 @@ export function setConversationActive(active, microphoneActive = active, pttMode // Composer mic mirrors dock state for minimal UI. elements.primary.classList.toggle("composer-live", active); if (!active) { - setText(elements.primaryLabel, "Start"); - elements.primary.setAttribute("aria-label", "Start conversation"); + setText(elements.primaryLabel, "Mute"); + elements.primary.setAttribute("aria-label", "Start call"); } else if (pttMode) { setText(elements.primaryLabel, microphoneActive ? "Release" : "Hold"); elements.primary.setAttribute( @@ -371,17 +383,13 @@ export function setConversationActive(active, microphoneActive = active, pttMode ); } else if (microphoneActive) { setText(elements.primaryLabel, "Mute"); - elements.primary.setAttribute("aria-label", "Pause microphone"); + elements.primary.setAttribute("aria-label", "Mute"); } else { - setText(elements.primaryLabel, "Resume"); - elements.primary.setAttribute("aria-label", "Resume microphone"); + setText(elements.primaryLabel, "Unmute"); + elements.primary.setAttribute("aria-label", "Unmute"); } if (elements.end) { - elements.end.hidden = !active; - // Force reflow so end-button entrance animation restarts cleanly. - if (active) { - void elements.end.offsetWidth; - } + elements.end.hidden = false; } const composer = document.querySelector(".composer-bar"); composer?.classList.toggle("is-live", active); @@ -406,7 +414,7 @@ export function setConversationActive(active, microphoneActive = active, pttMode export function setStarting(starting) { if (!elements.primary) return; elements.primary.disabled = starting; - setText(elements.primaryLabel, starting ? "Starting…" : "Start"); + setText(elements.primaryLabel, starting ? "Listening…" : "Mute"); } /** diff --git a/apps/openlive-gateway/web/vendor/bloub/LICENSE b/apps/openlive-gateway/web/vendor/bloub/LICENSE new file mode 100644 index 0000000..d63275e --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jérémy Perret + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/openlive-gateway/web/vendor/bloub/NOTICE b/apps/openlive-gateway/web/vendor/bloub/NOTICE new file mode 100644 index 0000000..19b95ba --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/NOTICE @@ -0,0 +1,6 @@ +Bloub (MIT) — https://github.com/jeremy-prt/bloub +Copyright (c) 2026 Jérémy Perret + +Vendored framework-free engine under src/bot (engine.sample(t)). +The visual design imitates the x.ai / Grok avatar. OpenLive is not affiliated +with xAI, x.ai, or OpenAI. diff --git a/apps/openlive-gateway/web/vendor/bloub/engine.js b/apps/openlive-gateway/web/vendor/bloub/engine.js new file mode 100644 index 0000000..8737689 --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/engine.js @@ -0,0 +1,1628 @@ +// vendor/bloub/src/math.ts +var TAU = Math.PI * 2; +var clamp = (v, lo = 0, hi = 1) => v < lo ? lo : v > hi ? hi : v; +var lerp = (a, b, t) => a + (b - a) * t; +var easings = { + easeOutCubic: (t) => 1 - (1 - t) ** 3, + easeInOutCubic: (t) => t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2, + easeOutQuint: (t) => 1 - (1 - t) ** 5 +}; +function loopNoise(t, period, seed = 0) { + const p = t / period * TAU; + return 0.55 * Math.sin(p + seed) + 0.3 * Math.sin(2 * p + seed * 1.7 + 1.1) + 0.15 * Math.sin(3 * p + seed * 2.3 + 2.4); +} +function createRng(seed) { + let a = seed >>> 0; + return () => { + a = a + 1831565813 >>> 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} +var r2 = (v) => Math.round(v * 100) / 100; + +// vendor/bloub/src/decor.ts +function wheel(hue, s = 0.55, l = 0.62) { + const h = (hue % 360 + 360) % 360; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(h / 60 % 2 - 1)); + const m = l - c / 2; + const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]; + const hex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; +} +function arcRender(seed, t, scale, id, opacity = 1) { + const spin2 = seed.phase + t * seed.speed * TAU; + const cu = Math.cos(seed.tilt); + const su = Math.sin(seed.tilt); + const kz = Math.sqrt(Math.max(0, 1 - seed.k * seed.k)); + const N = 64; + const span = seed.sweep * TAU; + let front = ""; + let back = ""; + let prev = null; + for (let i = 0; i <= N; i++) { + const th = spin2 + i / N * span; + const ct = Math.cos(th); + const st = Math.sin(th); + const x = seed.a * (ct * cu + st * -su * seed.k) + seed.cx; + const y = seed.a * (ct * su + st * cu * seed.k) + seed.cy; + const z = seed.a * st * kz; + const behind = z < 0; + const sx = r2(x * scale); + const sy = r2(y * scale); + const cmd = behind !== prev ? "M" : "L"; + if (behind) back += `${cmd}${sx} ${sy}`; + else front += `${cmd}${sx} ${sy}`; + prev = behind; + } + const gx = Math.cos(seed.tilt) * seed.a * scale; + const gy = Math.sin(seed.tilt) * seed.a * scale; + return { + id, + front, + back, + width: seed.width * scale, + opacity, + grad: { + x1: r2(seed.cx * scale - gx), + y1: r2(seed.cy * scale - gy), + x2: r2(seed.cx * scale + gx), + y2: r2(seed.cy * scale + gy), + stops: [wheel(seed.hue), wheel(seed.hue + seed.hueSpan * 0.5), wheel(seed.hue + seed.hueSpan)] + } + }; +} +var RING_RNG = createRng(659918); +var RINGS = Array.from({ length: 6 }, (_, i) => ({ + a: 1.3 + RING_RNG() * 0.1, + k: 0.05 + RING_RNG() * 0.4, + tilt: i / 6 * Math.PI + RING_RNG() * 0.5, + speed: 3 + RING_RNG() * 0.7, + phase: RING_RNG() * TAU, + sweep: 0.6 + RING_RNG() * 0.25, + hue: i * 360 / 6 + RING_RNG() * 30, + hueSpan: 60 + RING_RNG() * 60, + width: 0.05 + RING_RNG() * 0.012, + cx: 0, + cy: 0.1 +})); +var SWOOSH = Array.from({ length: 4 }, (_, i) => ({ + a: 0.78 + i * 0.2, + k: 0.05 + i * 0.02, + tilt: -0.62 + i * 0.05, + speed: 0.3, + phase: 0.06 * i, + sweep: 0.4, + hue: 95 + i * 62, + hueSpan: 100, + width: 0.05, + cx: 0, + cy: -0.12 +})); +var DOT_X = [-0.557, -0.013, 0.532]; +var DOT_R = 0.165; +var DOT_PEAK = 1.25; +var P_RNG = createRng(48879); +var PARTICLES = Array.from({ length: 5 }, (_, i) => ({ + birth: i * 0.2, + angle: P_RNG() * TAU, + rho: 0.58 + P_RNG() * 0.18 +})); +function particles(t, scale) { + const out = []; + for (const p of PARTICLES) { + const u = t - p.birth; + if (u < 0 || u > 0.62) continue; + const rho = p.rho * Math.pow(0.75, u * 10); + const a = p.angle + u * 100 * Math.PI / 180; + out.push({ + x: Math.cos(a) * rho * scale, + y: Math.sin(a) * rho * scale, + r: (0.04 + 0.028 * clamp(u / 0.55)) * scale, + depth: clamp(1 - rho / 0.8), + opacity: clamp(u / 0.06) * clamp((0.62 - u) / 0.08) + }); + } + return out; +} +var COMET_RNG = createRng(49383); +var COMET_RIBBONS = Array.from({ length: 4 }, (_, i) => { + const d = i - 1.5; + return { + a: 0.85 * (1 + d * 0.03), + // meme aplatissement a +-5 % pres : les rubans forment un faisceau serre + k: 0.15 / 0.85 * (1 + d * 0.16), + tilt: 34 * Math.PI / 180 + d * 0.035, + speed: 210 / 360, + // dephasage mesure : 10 a 20 degres entre rubans, pas davantage + phase: -i * 0.045 + COMET_RNG() * 0.012, + sweep: 0.34, + hue: i * 85 + COMET_RNG() * 20, + hueSpan: 80, + width: 0.095, + cx: 0, + cy: 0 + }; +}); +var COMET_DOT = 0.129; +var NOTIF_BLUE = "#2496e8"; +var NOTIF_ANGLE = -42; +var NOTIF_DIST = 1.003; +var NOTIF_R = 0.15; +var NOTIF_POP = 1.14; +var NOTIF_MARGIN = 0.054; + +// vendor/bloub/src/face.ts +var EYE_SPLIT = 15.46; +var EYE_W = 0.186; +var EYE_H = 0.412; +var REST_GAZE = { yaw: 28.49, pitch: 28.62, roll: -13 }; +var deg = (d) => d * Math.PI / 180; +function spin(u, v, angle) { + const c = Math.cos(angle); + const s = Math.sin(angle); + return [ + [u[0] * c + v[0] * s, u[1] * c + v[1] * s, u[2] * c + v[2] * s], + [v[0] * c - u[0] * s, v[1] * c - u[1] * s, v[2] * c - u[2] * s] + ]; +} +function eyePoses(gaze, scale, split = EYE_SPLIT) { + let f = [0, 0, 1]; + let right = [1, 0, 0]; + let down = [0, 1, 0]; + [f, right] = spin(f, right, deg(gaze.yaw)); + [down, f] = spin(down, f, deg(gaze.pitch)); + [right, down] = spin(right, down, deg(gaze.roll)); + const build = (side) => { + const [ef, er] = spin(f, right, deg(split * side)); + return { + x: ef[0] * scale, + y: ef[1] * scale, + a: er[0], + b: er[1], + c: down[0], + d: down[1], + depth: ef[2] + }; + }; + return [build(-1), build(1)]; +} +var BLINK_RNG = createRng(24301); +var BLINKS = (() => { + const out = []; + let t = 1.4; + while (t < 900) { + out.push(t); + t += 1.9 + BLINK_RNG() * 2.7; + if (BLINK_RNG() < 0.18) { + out.push(t); + t += 0.24; + } + } + return out; +})(); +var BLINK_DUR = 0.18; +function blinkLid(t) { + for (let i = 0; i < BLINKS.length; i++) { + const start = BLINKS[i]; + if (t < start) break; + const k = (t - start) / BLINK_DUR; + if (k >= 0 && k <= 1) { + return k < 0.45 ? 1 - k / 0.45 : (k - 0.45) / 0.55; + } + } + return 1; +} +function liveliness(t, opt = {}) { + const { wander = 1, blink = true, float = true } = opt; + return { + dYaw: (loopNoise(t, 11.3, 0.4) * 5.5 + loopNoise(t, 3.7, 2.1) * 1.6) * wander, + dPitch: (loopNoise(t, 9.1, 1.3) * 4.2 + loopNoise(t, 4.3, 0.7) * 1.3) * wander, + dRoll: loopNoise(t, 13.7, 3.2) * 2.2 * wander, + lid: blink ? blinkLid(t) : 1, + // Au repos la video est quasiment immobile (centre stable a +-0.003, rayon + // constant) : toute la vie passe par le regard et les clignements. On garde + // juste de quoi ne pas figer completement l'image. + driftX: float ? loopNoise(t, 7.9, 1.9) * 6e-3 : 0, + driftY: float ? loopNoise(t, 5.3, 0.3) * 7e-3 : 0, + // La largeur est constante, seule la hauteur respire tres legerement. + breath: float ? 1 + Math.sin(t / 3.4 * Math.PI * 2) * 5e-3 : 1 + }; +} +function blinkScale(lid) { + return 0.06 + 0.94 * clamp(lid); +} + +// vendor/bloub/src/expressions.ts +var eye = (w, h, tilt = 0, open = 1) => ({ w, h, tilt, open }); +var pair = (w, h, tilt = 0, open = 1) => [ + eye(w, h, tilt, open), + eye(w, h, -tilt, open) +]; +var EXPRESSIONS = [ + { + // la pose relevée image par image sur la vidéo de référence + id: "neutre", + gaze: { ...REST_GAZE }, + split: EYE_SPLIT, + eyes: [eye(EYE_W, EYE_H), eye(EYE_W, EYE_H)] + }, + { + id: "attentif", + gaze: { yaw: 4, pitch: 5, roll: -4 }, + split: 16, + eyes: pair(0.21, 0.44) + }, + { + id: "surpris", + gaze: { yaw: 3, pitch: -3, roll: 0 }, + split: 19, + eyes: pair(0.45, 0.47) + }, + { + id: "excite", + gaze: { yaw: 6, pitch: -14, roll: 0 }, + split: 19.5, + eyes: pair(0.4, 0.56, -10) + }, + { + // yeux plissés en arc : les hauts convergent légèrement + id: "heureux", + gaze: { yaw: 5, pitch: 9, roll: 0 }, + split: 17, + eyes: pair(0.27, 0.17, 14) + }, + { + id: "hilare", + gaze: { yaw: 4, pitch: 14, roll: 0 }, + split: 18, + eyes: pair(0.34, 0.13, 20) + }, + { + // hauts des yeux qui convergent fort vers le centre + yeux étrécis + id: "colere", + gaze: { yaw: 3, pitch: 7, roll: 0 }, + split: 17, + eyes: pair(0.34, 0.15, 30) + }, + { + // l'inverse : les hauts divergent, et le regard tombe + id: "triste", + gaze: { yaw: 3, pitch: -13, roll: 0 }, + split: 16, + eyes: pair(0.22, 0.4, -28) + }, + { + id: "effraye", + gaze: { yaw: 2, pitch: -20, roll: 0 }, + split: 20.5, + eyes: pair(0.4, 0.6) + }, + { + // un œil franchement plus fermé que l'autre + id: "mefiant", + gaze: { yaw: 12, pitch: 6, roll: -6 }, + split: 16, + eyes: [eye(0.21, 0.4), eye(0.22, 0.15)] + }, + { + // asymétrique sur les deux axes : tailles ET inclinaisons dépareillées. + // L'œil plissé est volontairement plat (rapport 1,6) : à un rapport proche + // de 1 il serait rond, et son inclinaison ne se verrait pas. + id: "confus", + gaze: { yaw: -14, pitch: 3, roll: 8 }, + split: 16.5, + eyes: [eye(0.2, 0.44, -18), eye(0.28, 0.17, 14)] + }, + { + // la tête penche : c'est le roulis qui porte la curiosité + id: "curieux", + gaze: { yaw: 16, pitch: -9, roll: -15 }, + split: 16.5, + eyes: [eye(0.24, 0.46, -8), eye(0.2, 0.38, -8)] + }, + { + id: "fier", + gaze: { yaw: 5, pitch: 17, roll: 0 }, + split: 17, + eyes: pair(0.3, 0.15, 18) + }, + { + id: "timide", + gaze: { yaw: -19, pitch: -14, roll: -7 }, + split: 14, + eyes: pair(0.17, 0.3) + }, + { + // fentes horizontales et regard qui part sur le côté + id: "blase", + gaze: { yaw: -22, pitch: 2, roll: 0 }, + split: 16, + eyes: pair(0.3, 0.12) + }, + { + // paupières à moitié tombées : on passe par `open`, donc l'écrasement + // vertical à l'écran, le même mécanisme que le clignement + id: "somnolent", + gaze: { yaw: 6, pitch: -9, roll: -3 }, + split: 16, + eyes: pair(0.2, 0.42, 0, 0.42) + } +]; +var EXPRESSION_BY_ID = new Map(EXPRESSIONS.map((e) => [e.id, e])); +var lerpEyeCfg = (a, b, t) => ({ + w: lerp(a.w, b.w, t), + h: lerp(a.h, b.h, t), + tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t), + open: lerp(a.open, b.open, t) +}); +function blendExpression(a, b, t) { + return { + id: b.id, + gaze: { + yaw: lerp(a.gaze.yaw, b.gaze.yaw, t), + pitch: lerp(a.gaze.pitch, b.gaze.pitch, t), + roll: lerp(a.gaze.roll, b.gaze.roll, t) + }, + split: lerp(a.split, b.split, t), + eyes: [lerpEyeCfg(a.eyes[0], b.eyes[0], t), lerpEyeCfg(a.eyes[1], b.eyes[1], t)] + }; +} + +// vendor/bloub/src/profiles.ts +var PROFILE_SAMPLES = 64; +var PROFILES = { + // oeuf : meme hauteur que la boule, retreci en largeur + // image 164, empreinte mesuree 1.647 x 2.000 + egg: [0.8369, 0.8424, 0.8497, 0.8585, 0.8674, 0.8775, 0.8878, 0.8983, 0.9089, 0.9185, 0.9288, 0.9374, 0.9445, 0.9504, 0.9543, 0.9559, 0.9555, 0.9519, 0.9466, 0.9389, 0.9302, 0.9193, 0.9085, 0.8969, 0.8852, 0.8734, 0.8625, 0.8513, 0.8411, 0.8325, 0.8243, 0.8179, 0.8137, 0.8112, 0.8102, 0.8128, 0.8178, 0.8262, 0.8374, 0.8518, 0.8702, 0.8922, 0.9169, 0.9446, 0.9741, 1.0023, 1.0267, 1.0433, 1.0481, 1.0393, 1.0216, 0.997, 0.9697, 0.9418, 0.9169, 0.8949, 0.876, 0.8604, 0.849, 0.8394, 0.8337, 0.8314, 0.8305, 0.8326], + // hexagone pointe en haut, coins tres arrondis + // image 174, empreinte mesuree 1.826 x 2.011 + hexagon: [0.921, 0.9282, 0.9441, 0.9706, 0.9984, 1.0059, 0.9896, 0.9562, 0.929, 0.9124, 0.9047, 0.9058, 0.9157, 0.9349, 0.9642, 0.9873, 0.9882, 0.9665, 0.9336, 0.9105, 0.8968, 0.8918, 0.8955, 0.908, 0.9293, 0.9611, 0.982, 0.9812, 0.959, 0.9282, 0.9089, 0.8978, 0.8964, 0.9026, 0.9189, 0.9439, 0.9778, 0.999, 0.9964, 0.9713, 0.9439, 0.9274, 0.9196, 0.9206, 0.9308, 0.9502, 0.9799, 1.0121, 1.0226, 1.0071, 0.9752, 0.951, 0.9366, 0.9316, 0.9351, 0.9485, 0.9711, 1.0026, 1.0213, 1.0155, 0.9863, 0.9547, 0.9347, 0.9232], + // triangle pointe en haut, coins tres arrondis + // image 190, empreinte mesuree 1.995 x 1.884 + triangle: [0.7819, 0.8211, 0.8747, 0.944, 1.0223, 1.096, 1.1401, 1.134, 1.0808, 1.0047, 0.9265, 0.8603, 0.8104, 0.773, 0.745, 0.7273, 0.7151, 0.7118, 0.7148, 0.7245, 0.7427, 0.768, 0.8037, 0.8518, 0.9148, 0.9876, 1.0583, 1.1073, 1.1109, 1.0667, 0.994, 0.9164, 0.8482, 0.7948, 0.7555, 0.7261, 0.7056, 0.6925, 0.6859, 0.6869, 0.6938, 0.7084, 0.7305, 0.7615, 0.804, 0.8595, 0.9311, 1.0092, 1.0791, 1.1171, 1.1054, 1.0501, 0.9779, 0.905, 0.845, 0.799, 0.7656, 0.7413, 0.7258, 0.716, 0.7146, 0.7204, 0.733, 0.7528] +}; + +// vendor/bloub/src/shape.ts +var ANGLES = Array.from({ length: PROFILE_SAMPLES }, (_, i) => i / PROFILE_SAMPLES * TAU); +var COS = ANGLES.map(Math.cos); +var SIN = ANGLES.map(Math.sin); +function silhouette(name, pose = {}) { + return { + radii: [...PROFILES[name]], + rot: 0, + cx: 0, + cy: 0, + sx: 1, + sy: 1, + ...pose + }; +} +function circle(radius, pose = {}) { + return { + radii: new Array(PROFILE_SAMPLES).fill(radius), + rot: 0, + cx: 0, + cy: 0, + sx: 1, + sy: 1, + ...pose + }; +} +function blend(a, b, t, out) { + const dst = out ?? { radii: new Array(PROFILE_SAMPLES), rot: 0, cx: 0, cy: 0, sx: 1, sy: 1 }; + for (let i = 0; i < PROFILE_SAMPLES; i++) { + dst.radii[i] = lerp(a.radii[i] ?? 1, b.radii[i] ?? 1, t); + } + let dRot = b.rot - a.rot; + while (dRot > Math.PI) dRot -= TAU; + while (dRot < -Math.PI) dRot += TAU; + dst.rot = a.rot + dRot * t; + dst.cx = lerp(a.cx, b.cx, t); + dst.cy = lerp(a.cy, b.cy, t); + dst.sx = lerp(a.sx, b.sx, t); + dst.sy = lerp(a.sy, b.sy, t); + return dst; +} +function toPoints(s, scale, out = []) { + const cr = Math.cos(s.rot); + const sr = Math.sin(s.rot); + for (let i = 0; i < PROFILE_SAMPLES; i++) { + const r = s.radii[i] ?? 1; + const x = r * (COS[i] ?? 0); + const y = r * (SIN[i] ?? 0); + const rx = x * cr - y * sr; + const ry = x * sr + y * cr; + const p = out[i] ?? { x: 0, y: 0 }; + p.x = (rx * s.sx + s.cx) * scale; + p.y = (ry * s.sy + s.cy) * scale; + out[i] = p; + } + out.length = PROFILE_SAMPLES; + return out; +} +function closedPath(pts, tension = 1 / 6) { + const n = pts.length; + if (n < 3) return ""; + const first = pts[0]; + let d = `M${r2(first.x)} ${r2(first.y)}`; + for (let i = 0; i < n; i++) { + const p0 = pts[(i - 1 + n) % n]; + const p1 = pts[i]; + const p2 = pts[(i + 1) % n]; + const p3 = pts[(i + 2) % n]; + const c1x = p1.x + (p2.x - p0.x) * tension; + const c1y = p1.y + (p2.y - p0.y) * tension; + const c2x = p2.x - (p3.x - p1.x) * tension; + const c2y = p2.y - (p3.y - p1.y) * tension; + d += `C${r2(c1x)} ${r2(c1y)} ${r2(c2x)} ${r2(c2y)} ${r2(p2.x)} ${r2(p2.y)}`; + } + return `${d}Z`; +} +function profileFromPolygon(poly, cx, cy) { + const radii = new Array(PROFILE_SAMPLES).fill(0); + const n = poly.length; + for (let k = 0; k < PROFILE_SAMPLES; k++) { + const dx = COS[k] ?? 0; + const dy = SIN[k] ?? 0; + let best = 0; + for (let i = 0; i < n; i++) { + const a = poly[i]; + const b = poly[(i + 1) % n]; + const ex = b.x - a.x; + const ey = b.y - a.y; + const den = dx * ey - dy * ex; + if (Math.abs(den) < 1e-9) continue; + const px = a.x - cx; + const py = a.y - cy; + const t = (px * ey - py * ex) / den; + const u = (px * dy - py * dx) / den; + if (t > best && u >= 0 && u <= 1) best = t; + } + radii[k] = best; + } + return radii; +} +function hullOfCircles(x1, y1, r1, x2, y2, r2v, steps = 96) { + const dx = x2 - x1; + const dy = y2 - y1; + const dist = Math.hypot(dx, dy) || 1e-6; + const base2 = Math.atan2(dy, dx); + const spread = Math.acos(Math.max(-1, Math.min(1, (r1 - r2v) / dist))); + const pts = []; + for (let i = 0; i <= steps / 2; i++) { + const a = base2 + spread + (TAU - 2 * spread) * i / (steps / 2); + pts.push({ x: x1 + Math.cos(a) * r1, y: y1 + Math.sin(a) * r1 }); + } + for (let i = 0; i <= steps / 2; i++) { + const a = base2 - spread + 2 * spread * i / (steps / 2); + pts.push({ x: x2 + Math.cos(a) * r2v, y: y2 + Math.sin(a) * r2v }); + } + return pts; +} +function radiusAtAngle(radii, angle) { + const n = radii.length; + const t = (angle / TAU % 1 + 1) % 1 * n; + const i = Math.floor(t); + return lerp(radii[i % n] ?? 1, radii[(i + 1) % n] ?? 1, t - i); +} +function superellipseProfile(n, sx = 1, sy = 1) { + return ANGLES.map((_, i) => { + const c = Math.abs((COS[i] ?? 0) / sx) ** n; + const s = Math.abs((SIN[i] ?? 0) / sy) ** n; + return (c + s) ** (-1 / n); + }); +} +function unionOfCirclesProfile(circles) { + const out = new Array(PROFILE_SAMPLES).fill(0); + for (let i = 0; i < PROFILE_SAMPLES; i++) { + const dx = COS[i] ?? 0; + const dy = SIN[i] ?? 0; + let best = 0; + for (const c of circles) { + const b = dx * c.x + dy * c.y; + const disc = b * b - (c.x * c.x + c.y * c.y - c.r * c.r); + if (disc < 0) continue; + const t = b + Math.sqrt(disc); + if (t > best) best = t; + } + out[i] = best; + } + return out; +} +function roundedPolygon(verts, rc, arcSteps = 10) { + const n = verts.length; + const out = []; + const normal = (a, b) => { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy) || 1; + return Math.atan2(-dx / len, dy / len); + }; + for (let i = 0; i < n; i++) { + const prev = verts[(i - 1 + n) % n]; + const cur = verts[i]; + const next = verts[(i + 1) % n]; + const a0 = normal(prev, cur); + const a1 = normal(cur, next); + let d = a1 - a0; + while (d > Math.PI) d -= TAU; + while (d < -Math.PI) d += TAU; + for (let k = 0; k <= arcSteps; k++) { + const a = a0 + d * k / arcSteps; + out.push({ x: cur.x + Math.cos(a) * rc, y: cur.y + Math.sin(a) * rc }); + } + } + return out; +} +function regularPolygonProfile(sides, radius, rc, rotationDeg = 0) { + const rot = rotationDeg * Math.PI / 180; + const verts = Array.from({ length: sides }, (_, i) => { + const a = rot + i / sides * TAU; + return { x: Math.cos(a) * (radius - rc), y: Math.sin(a) * (radius - rc) }; + }); + return profileFromPolygon(roundedPolygon(verts, rc), 0, 0); +} +function polyPath(pts, scale = 1) { + if (pts.length < 3) return ""; + let d = ""; + for (let i = 0; i < pts.length; i++) { + const p = pts[i]; + d += `${i === 0 ? "M" : "L"}${r2(p.x * scale)} ${r2(p.y * scale)}`; + } + return `${d}Z`; +} +function capsulePath(w, h) { + const hw = Math.max(w, 0.01) / 2; + const hh = Math.max(h, 0.01) / 2; + const r = Math.min(hw, hh); + return `M${r2(-hw)} ${r2(-hh + r)}A${r2(r)} ${r2(r)} 0 0 1 ${r2(-hw + r)} ${r2(-hh)}L${r2(hw - r)} ${r2(-hh)}A${r2(r)} ${r2(r)} 0 0 1 ${r2(hw)} ${r2(-hh + r)}L${r2(hw)} ${r2(hh - r)}A${r2(r)} ${r2(r)} 0 0 1 ${r2(hw - r)} ${r2(hh)}L${r2(-hw + r)} ${r2(hh)}A${r2(r)} ${r2(r)} 0 0 1 ${r2(-hw)} ${r2(hh - r)}Z`; +} + +// vendor/bloub/src/skins.ts +function normalize(radii, max = 1) { + const peak = Math.max(...radii); + if (peak <= 0) return radii; + const k = max / peak; + return radii.map((r) => r * k); +} +var ANGLES2 = Array.from({ length: PROFILE_SAMPLES }, (_, i) => i / PROFILE_SAMPLES * Math.PI * 2); +var pebble = normalize( + ANGLES2.map((a) => 1 + 0.075 * Math.cos(2 * a + 0.5) + 0.035 * Math.cos(3 * a + 2.1)), + 1.02 +); +var cloud = normalize( + unionOfCirclesProfile([ + { x: -0.44, y: 0.2, r: 0.54 }, + { x: 0.46, y: 0.2, r: 0.5 }, + { x: 0.02, y: 0.3, r: 0.6 }, + { x: -0.24, y: -0.3, r: 0.48 }, + { x: 0.3, y: -0.24, r: 0.44 } + ]), + 1.02 +); +var droplet = normalize( + profileFromPolygon(hullOfCircles(0, 0.28, 0.66, 0, -0.96, 0.05), 0, 0), + 1.04 +); +var capsule = profileFromPolygon(hullOfCircles(-0.42, 0, 0.62, 0.42, 0, 0.62), 0, 0); +var SHAPES = [ + { id: "cercle", radii: new Array(PROFILE_SAMPLES).fill(1) }, + { id: "galet", radii: pebble }, + // 1.15 et pas 1.02 : sur une superellipse le rayon maximal est la diagonale, + // donc normaliser dessus donne une forme qui parait plus petite que le cercle. + { id: "squircle", radii: normalize(superellipseProfile(4.2), 1.15) }, + { id: "capsule", radii: capsule }, + // -90deg : un sommet vers le haut de l'ecran (y est oriente vers le bas) + { id: "triangle", radii: regularPolygonProfile(3, 1.12, 0.34, -90) }, + // 0deg : sommets a gauche et a droite, donc aretes du haut et du bas plates + { id: "hexagone", radii: regularPolygonProfile(6, 1.04, 0.26, 0) }, + { id: "nuage", radii: cloud }, + { id: "goutte", radii: droplet } +]; +var SHAPE_BY_ID = new Map(SHAPES.map((s) => [s.id, s])); +var COLORS = [ + { id: "encre", hex: "#0a0a0c" }, + { id: "brun", hex: "#8b5e3c" }, + { id: "rouge", hex: "#e8483f" }, + { id: "orange", hex: "#f08a24" }, + { id: "ambre", hex: "#f0b429" }, + { id: "vert", hex: "#3ecf8e" }, + { id: "turquoise", hex: "#2fbfa0" }, + { id: "bleu", hex: "#3b93f0" }, + { id: "violet", hex: "#8b5cf6" }, + { id: "rose", hex: "#e152b0" }, + { id: "gris", hex: "#a3a3a3" }, + { id: "creme", hex: "#f1efe9" } +]; +var COLOR_BY_ID = new Map(COLORS.map((c) => [c.id, c])); + +// vendor/bloub/src/states.ts +var pair2 = (w, h) => [ + { w, h, open: 1 }, + { w, h, open: 1 } +]; +function base(over = {}) { + return { + sil: circle(1), + offX: 0, + offY: 0, + gaze: { ...REST_GAZE }, + split: EYE_SPLIT, + eyes: pair2(EYE_W, EYE_H), + eyeAlpha: 1, + bodyAlpha: 1, + dots: [], + arcs: [], + notif: null, + dotsBehind: false, + ...over + }; +} +var BAR_UPRIGHT_CY = -0.1875; +var BAR_UPRIGHT = profileFromPolygon( + hullOfCircles(0, -0.505, 0.132, 0, 0.13, 0.075), + 0, + BAR_UPRIGHT_CY +); +var BAR_ITALIC = profileFromPolygon(hullOfCircles(0, -0.2535, 0.1345, 0, 0.2535, 0.1345), 0, 0); +var barUpright = (pose = {}) => ({ + radii: [...BAR_UPRIGHT], + rot: 0, + cx: 0, + cy: BAR_UPRIGHT_CY, + sx: 1, + sy: 1, + ...pose +}); +var barItalic = (pose = {}) => ({ + radii: [...BAR_ITALIC], + rot: 0, + cx: 0, + cy: 0, + sx: 1, + sy: 1, + ...pose +}); +var TEAR = polyPath(hullOfCircles(0, 0, 0.118, 0, 0.172, 0.012)); +var TRI_ORBIT = 0.213; +function spinningTriangle(rot) { + return silhouette("triangle", { + rot, + cx: -TRI_ORBIT * Math.sin(rot), + cy: TRI_ORBIT * Math.cos(rot) + }); +} +function dotPulse(t, index) { + const p = ((t - index * 0.5) / 1.5 % 1 + 1) % 1; + const k = p < 0.5 ? 0.5 - 0.5 * Math.cos(p * TAU) : 0; + return clamp(k * 2); +} +var STATES = [ + { + id: "idle", + duration: 2.4, + morph: 0.45, + blinkIn: false, + baseFace: true, + baseBody: true, + pose: () => base() + }, + { + id: "thinking", + duration: 2.6, + morph: 0.4, + baseFace: false, + baseBody: false, + blinkIn: true, + pose: (t) => { + const mid = dotPulse(t, 1); + const emerge = 0.3 + 0.7 * easings.easeOutCubic(clamp(t / 0.3)); + return base({ + // la boule DEVIENT le point du milieu : le morph reste continu + sil: circle(DOT_R * (1 + (DOT_PEAK - 1) * mid), { cx: DOT_X[1] }), + eyeAlpha: 0, + dots: [0, 2].map((i) => { + const k = dotPulse(t, i); + return { + x: DOT_X[i] * emerge, + y: 0, + r: DOT_R * (1 + (DOT_PEAK - 1) * k), + opacity: 0.55 + 0.45 * k + }; + }) + }); + } + }, + { + id: "wink", + duration: 1.6, + morph: 0.3, + blinkIn: true, + baseFace: false, + baseBody: true, + pose: () => base({ + gaze: { yaw: -5.37, pitch: 4.55, roll: 6.7 }, + split: 16.25, + // L'oeil ferme n'est pas l'oeil ouvert ecrase : c'est un tiret + // horizontal PLUS LARGE que l'oeil ouvert (0.447 contre 0.236). + eyes: [ + { w: 0.236, h: 0.464, open: 1 }, + { w: 0.447, h: 0.089, open: 1 } + ] + }) + }, + { + id: "wide", + duration: 1.8, + morph: 0.55, + blinkIn: true, + baseFace: false, + baseBody: true, + pose: () => base({ + gaze: { yaw: 6.92, pitch: -21.96, roll: 11.6 }, + split: 18.43, + eyes: pair2(0.356, 0.875) + }) + }, + { + id: "alert", + duration: 2.4, + // le "!" revient en place a 1.6 + 0.4 + minDuration: 2, + morph: 0.45, + baseFace: false, + baseBody: false, + blinkIn: false, + pose: (t) => { + const p = clamp(t / 1.5); + const travel = easings.easeInOutCubic(p) * 0.82 - 0.087; + const back = t > 1.6 ? clamp((t - 1.6) / 0.4) : 0; + const x = travel * (1 - back) + 0.1 * back; + const buzz = Math.sin(t * 2.5 * TAU) * 5e-3; + const tilt = 17.7 * Math.PI / 180; + return base({ + sil: barItalic({ rot: tilt, cx: x, cy: -0.325 - buzz }), + eyeAlpha: 0, + dots: [ + { + // le point suit l'axe du glyphe, a 0.580 du centre de la barre + x: x - Math.sin(tilt) * 0.58, + y: -0.325 + Math.cos(tilt) * 0.58 + buzz * 2.8, + r: 0.118, + d: TEAR, + rot: tilt * 180 / Math.PI, + opacity: 1 + } + ] + }); + } + }, + { + id: "notify", + duration: 2.2, + morph: 0.5, + blinkIn: true, + baseFace: false, + baseBody: true, + pose: (t) => { + const p = clamp(t / 0.45); + const pop = 1 + (NOTIF_POP - 1) * Math.sin(p * Math.PI) * (1 - p * 0.35); + const r = NOTIF_R * (p < 1 ? pop : 1); + const a = NOTIF_ANGLE * Math.PI / 180; + return base({ + // le regard part a l'oppose de la pastille + gaze: { yaw: -21.94, pitch: -5.82, roll: -12.2 }, + split: 18.89, + eyes: pair2(0.505, 0.498), + notif: { + x: Math.cos(a) * NOTIF_DIST, + y: Math.sin(a) * NOTIF_DIST, + r, + notch: r + NOTIF_MARGIN + } + }); + } + }, + { + id: "exclaim", + duration: 2, + morph: 0.45, + baseFace: false, + baseBody: false, + blinkIn: false, + pose: () => base({ + sil: barUpright(), + eyeAlpha: 0, + dots: [{ x: -0.012, y: 0.526, r: 0.113, opacity: 1 }] + }) + }, + { + id: "sleep", + duration: 2.4, + morph: 0.5, + baseFace: false, + baseBody: false, + blinkIn: false, + pose: (t) => base({ + // Rebond vertical mesure : +-0.19 autour de +0.11, periode 0.6 s. + sil: circle(0.1585, { cy: 0.11 + Math.sin(t * (TAU / 0.6)) * 0.19 }), + eyeAlpha: 0 + }) + }, + { + id: "egg", + duration: 1.8, + morph: 0.4, + baseFace: false, + baseBody: false, + blinkIn: true, + pose: () => base({ + sil: silhouette("egg"), + gaze: { yaw: 19.97, pitch: 26.01, roll: -17.1 }, + // les yeux se resserrent comme le corps + split: 11.07, + eyes: pair2(0.164, 0.385) + }) + }, + { + id: "hexagon", + duration: 1.6, + morph: 0.4, + baseFace: false, + baseBody: false, + blinkIn: true, + pose: () => base({ + sil: silhouette("hexagon"), + gaze: { yaw: 23.11, pitch: 24.42, roll: -13.3 }, + split: 13.37, + eyes: pair2(0.177, 0.411) + }) + }, + { + id: "play", + duration: 2, + morph: 0.5, + baseFace: false, + baseBody: false, + blinkIn: true, + pose: (t) => { + const fade = clamp(t / 0.35) * clamp((2.2 - t) / 0.5); + return base({ + sil: spinningTriangle(0), + gaze: { yaw: 12, pitch: -8, roll: -6 }, + split: 15, + eyes: pair2(0.18, 0.34), + // le bouquet balaie de la droite vers la gauche par-dessus le triangle + arcs: SWOOSH.map((s, i) => ({ + id: `sw${i}`, + seed: { ...s, cx: 0.45 - t * 0.42 }, + t, + opacity: fade + })) + }); + } + }, + { + id: "orbit", + duration: 3.4, + // le corps a fini de se relacher du triangle vers la boule a 1.6 + 0.9 + minDuration: 2.5, + morph: 0.6, + baseFace: false, + baseBody: false, + blinkIn: false, + pose: (t) => { + const ramp = easings.easeInOutCubic(clamp(t / 0.35)); + const rot = -TAU * 1.25 * t * ramp; + const back = easings.easeInOutCubic(clamp((t - 1.6) / 0.9)); + const tri = spinningTriangle(rot); + const ball = circle(1, { rot }); + const sil = { + radii: tri.radii.map((r, i) => r + (ball.radii[i] - r) * back), + rot, + cx: tri.cx * (1 - back), + cy: tri.cy * (1 - back), + sx: 1, + sy: 1 + }; + const fade = clamp(t / 0.8) * clamp((3.6 - t) / 0.9); + return base({ + sil, + // les yeux filent autour de la sphere ~3x plus vite que la silhouette + gaze: { + yaw: REST_GAZE.yaw + Math.sin(t * 6.5) * 65 * (1 - back), + pitch: -4 + back * 32, + roll: -13 + }, + eyes: pair2(0.18, 0.34 + back * 0.07), + // les anneaux entrent un par un sur 0.8 s + arcs: RINGS.map((s, i) => ({ + id: `rg${i}`, + seed: s, + t, + opacity: fade * clamp((t - i * 0.13) / 0.3) + })) + }); + } + }, + { + /** + * Entree dans la vue des reglages. + * + * SEUL etat qui n'est pas releve sur la video : il est CHOISI, comme la + * couleur `--ink`. Il emprunte le vocabulaire d'`orbit` — les memes anneaux, + * avec leurs parametres mesures — mais coupe court : 1 s au lieu de 3,4, la + * moitie des anneaux, et aucun triangle. + * + * Les deux drapeaux a `true` sont tout l'interet de cet etat : + * + * - `baseBody` laisse la forme choisie remplacer le corps, donc la vue peut + * imposer le cercle et le galet ou la goutte y MORPHENT au lieu de sauter ; + * - `baseFace` fait porter le visage de repos, donc le suivi du curseur + * s'applique des cette entree. Un etat qui aurait sa propre pose de regard + * (comme `orbit`) rendrait la main a l'etat suivant en pleine course, et + * les yeux sauteraient d'un coup a la reprise. + * + * Il n'est volontairement PAS dans `SEQUENCE` : ce n'est pas une animation du + * catalogue, c'est une transition d'interface. + */ + id: "swirl", + // un peu plus que le tour du regard (`TURN_TIME`, 1,1 s) : les yeux doivent + // etre poses a gauche avant que les anneaux ne s'effacent + duration: 1.3, + minDuration: 1.3, + morph: 0.3, + baseFace: true, + baseBody: true, + // le morph de forme est masque par un clignement, comme partout ailleurs + blinkIn: true, + pose: (t) => base({ + // trois anneaux sur les six d'`orbit` : la moitie du bouquet suffit a le + // reconnaitre, et c'est autant d'arcs en moins a rasteriser par image + arcs: RINGS.slice(0, 3).map((s, i) => ({ + id: `sw${i}`, + seed: s, + t, + // ils entrent l'un apres l'autre puis s'effacent avant la fin du bloc, + // pour que la reprise au repos se fasse sur une image deja propre + opacity: clamp((t - i * 0.06) / 0.14) * clamp((1.22 - t) / 0.34) + })) + }) + }, + { + id: "burst", + duration: 2.6, + // le corps est recompose a 1.7 + 0.7 + minDuration: 2.4, + morph: 0.4, + baseFace: false, + baseBody: false, + blinkIn: false, + pose: (t) => { + const collapse = 1 - 0.834 * easings.easeOutQuint(clamp(t / 0.7)); + const regrow = easings.easeOutQuint(clamp((t - 1.7) / 0.7)); + return base({ + sil: circle(collapse + (1 - collapse) * regrow), + eyeAlpha: clamp((t - 1.85) / 0.4), + dots: particles(t, 1), + dotsBehind: true + }); + } + }, + { + id: "comet", + duration: 2.4, + // le point se recompose a 1.85 + 0.6 = 2.45, soit 0.05 s apres la coupe de + // la video : ce reliquat se termine pendant le fondu suivant, comme dans la + // reference. On ne descend donc pas sous la duree mesuree. + minDuration: 2.4, + morph: 0.45, + baseFace: false, + baseBody: false, + blinkIn: false, + pose: (t) => { + const collapse = 1 - (1 - COMET_DOT) * easings.easeOutQuint(clamp(t / 0.55)); + const regrow = easings.easeOutQuint(clamp((t - 1.85) / 0.6)); + const fade = clamp((t - 0.15) / 0.25) * clamp((1.95 - t) / 0.3); + return base({ + // Le point derive de 0.035 vers le bas puis remonte (wobble mesure). + sil: circle(collapse + (1 - collapse) * regrow, { + cy: Math.sin(clamp(t / 1.7) * Math.PI) * 0.035 + }), + eyeAlpha: clamp((t - 2) / 0.35), + arcs: COMET_RIBBONS.map((s, i) => ({ id: `cm${i}`, seed: s, t, opacity: fade })) + }); + } + } +]; +var STATE_BY_ID = new Map(STATES.map((s) => [s.id, s])); + +// vendor/bloub/src/eyefit.ts +var R = 100; +var DERIVE_YAW = 5.5 + 1.6; +var DERIVE_PITCH = 4.2 + 1.3; +var DERIVE_X = 6e-3; +var DERIVE_Y = 7e-3; +function empreintes(visage, sil, radii) { + const out = []; + const poses = eyePoses(visage.gaze, R, visage.split); + for (let i = 0; i < 2; i++) { + const e = poses[i]; + if (e.depth <= 0.02) continue; + const cfg = visage.eyes[i]; + const phi = (cfg.tilt ?? 0) * Math.PI / 180; + const cp = Math.cos(phi); + const sp = Math.sin(phi); + const ax = e.a * cp + e.c * sp; + const ay = e.b * cp + e.d * sp; + const cx = -e.a * sp + e.c * cp; + const cy = -e.b * sp + e.d * cp; + const hw = Math.max(cfg.w * R, 0.01) / 2; + const hh = Math.max(cfg.h * R, 0.01) / 2; + const r = Math.min(hw, hh); + const long = hh > hw; + const demi = long ? hh - r : hw - r; + const fit = radiusAtAngle(radii, Math.atan2(e.y, e.x) - sil.rot); + out.push({ + x: e.x * fit, + y: e.y * fit, + ax: (long ? cx : ax) * demi, + ay: (long ? cy : ay) * demi, + r, + m: [ax, ay, cx, cy] + }); + } + return out; +} +function approche(pts, x0, y0, x1, y1) { + const sx = x1 - x0; + const sy = y1 - y0; + const len2 = sx * sx + sy * sy; + let best = Infinity; + let vx = 0; + let vy = 0; + for (let i = 0; i < pts.length; i++) { + const p = pts[i]; + let t = len2 > 0 ? ((p.x - x0) * sx + (p.y - y0) * sy) / len2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const ex = x0 + t * sx - p.x; + const ey = y0 + t * sy - p.y; + const d2 = ex * ex + ey * ey; + if (d2 < best) { + best = d2; + vx = ex; + vy = ey; + } + } + const d = Math.sqrt(best); + return { d, ux: d > 1e-9 ? vx / d : 0, uy: d > 1e-9 ? vy / d : 0 }; +} +var FLOTTEMENT = Math.hypot(DERIVE_X, DERIVE_Y) * R; +function pire(pts, emps, tx, ty) { + let marge = Infinity; + let ux = 0; + let uy = 0; + for (const e of emps) { + const x = e.x + tx; + const y = e.y + ty; + const a = approche(pts, x - e.ax, y - e.ay, x + e.ax, y + e.ay); + const [m0, m1, m2, m3] = e.m; + const rayon = e.r * Math.hypot(m0 * a.ux + m1 * a.uy, m2 * a.ux + m3 * a.uy) + FLOTTEMENT; + if (a.d - rayon < marge) { + marge = a.d - rayon; + ux = a.ux; + uy = a.uy; + } + } + return { marge, ux, uy }; +} +var DIRECTIONS = 12; +var DICHOTOMIE = 8; +function resous(epreuves) { + if (!epreuves.length) return { x: 0, y: 0 }; + const marge = (tx, ty) => { + let m = Infinity; + for (const ep of epreuves) m = Math.min(m, pire(ep.contour, ep.empreintes, tx, ty).marge); + return m; + }; + let requis = Infinity; + for (const ep of epreuves) { + requis = Math.min(requis, pire(ep.calContour, ep.reference, 0, 0).marge); + } + let mx = 0; + let my = 0; + const emps = epreuves[0].empreintes; + for (const e of emps) { + mx -= e.x / emps.length; + my -= e.y / emps.length; + } + const course = Math.max(0.35 * R, Math.hypot(mx, my) * 1.25); + requis = Math.min(requis, marge(mx, my)); + const depart = marge(0, 0); + if (depart >= requis && depart >= 0) return { x: 0, y: 0 }; + const cible = Math.max(requis, 0); + let meilleurX = 0; + let meilleurY = 0; + let meilleureNorme = Infinity; + let secoursX = 0; + let secoursY = 0; + let secours = depart; + for (let d = 0; d < DIRECTIONS; d++) { + const a = d / DIRECTIONS * Math.PI * 2; + const ux = Math.cos(a); + const uy = Math.sin(a); + if (marge(ux * course, uy * course) < cible) { + for (const k of [0.3, 0.6, 1]) { + const m = marge(ux * course * k, uy * course * k); + if (m > secours) { + secours = m; + secoursX = ux * course * k; + secoursY = uy * course * k; + } + } + continue; + } + let bas = 0; + let haut = course; + for (let i = 0; i < DICHOTOMIE; i++) { + const mid = (bas + haut) / 2; + if (marge(ux * mid, uy * mid) >= cible) haut = mid; + else bas = mid; + } + if (haut < meilleureNorme) { + meilleureNorme = haut; + meilleurX = ux * haut; + meilleurY = uy * haut; + } + } + const x = meilleureNorme === Infinity ? secoursX : meilleurX; + const y = meilleureNorme === Infinity ? secoursY : meilleurY; + return { x: +(x / R).toFixed(6), y: +(y / R).toFixed(6) }; +} +function visageDe(def, pose, expr) { + if (def.baseFace && expr) return { gaze: expr.gaze, split: expr.split, eyes: expr.eyes }; + return { gaze: pose.gaze, split: pose.split, eyes: pose.eyes }; +} +function dates(def) { + const signature = (p) => JSON.stringify([p.gaze, p.split, p.eyes, p.sil.rot, p.sil.cx, p.sil.cy, p.sil.sx, p.sil.sy]); + if (signature(def.pose(0)) === signature(def.pose(def.duration))) return [0]; + const n = 3; + return Array.from({ length: n }, (_, i) => i / (n - 1) * def.duration); +} +function decalagePour(def, radii, expr) { + const epreuves = []; + for (const t of dates(def)) { + const pose = def.pose(t); + const contour = toPoints({ ...pose.sil, radii }, R); + const calContour = toPoints(pose.sil, R); + const v = visageDe(def, pose, expr); + const coins = []; + for (const dy of [-DERIVE_YAW, DERIVE_YAW]) { + for (const dp of [-DERIVE_PITCH, DERIVE_PITCH]) { + coins.push({ + ...v, + gaze: { yaw: v.gaze.yaw + dy, pitch: v.gaze.pitch + dp, roll: v.gaze.roll } + }); + } + } + for (const c of coins) { + epreuves.push({ + empreintes: empreintes(c, pose.sil, radii), + reference: empreintes(c, pose.sil, pose.sil.radii), + contour, + calContour + }); + } + } + return resous(epreuves); +} +var NUL = { x: 0, y: 0 }; +var clef = (state, expr) => `${state}|${expr ?? ""}`; +function batir() { + return new Map( + SHAPES.map((forme) => { + const par = /* @__PURE__ */ new Map(); + for (const def of STATES) { + if (!def.baseBody) continue; + const expressions = def.baseFace ? [null, ...EXPRESSIONS] : [null]; + for (const expr of expressions) { + par.set(clef(def.id, expr?.id ?? null), decalagePour(def, forme.radii, expr)); + } + } + return [forme.radii, par]; + }) + ); +} +var DECALAGES = batir(); +function decalageDesYeux(radii, state, expr) { + if (!radii) return NUL; + const par = DECALAGES.get(radii); + if (!par) return NUL; + return par.get(clef(state, expr)) ?? par.get(clef(state, null)) ?? NUL; +} + +// vendor/bloub/src/engine.ts +var NO_LOOK = { yaw: 0, pitch: 0, mix: 0, spin: 0, wander: 1 }; +var lerpLook = (a, b, t) => ({ + yaw: lerp(a.yaw, b.yaw, t), + pitch: lerp(a.pitch, b.pitch, t), + mix: lerp(a.mix, b.mix, t), + spin: lerp(a.spin, b.spin, t), + wander: lerp(a.wander, b.wander, t) +}); +var lerpEye = (a, b, t) => ({ + w: lerp(a.w, b.w, t), + h: lerp(a.h, b.h, t), + open: lerp(a.open, b.open, t), + tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t) +}); +function blendPose(a, b, t) { + const out = 1 - t; + return { + sil: blend(a.sil, b.sil, t), + offX: lerp(a.offX, b.offX, t), + offY: lerp(a.offY, b.offY, t), + gaze: { + yaw: lerp(a.gaze.yaw, b.gaze.yaw, t), + pitch: lerp(a.gaze.pitch, b.gaze.pitch, t), + roll: lerp(a.gaze.roll, b.gaze.roll, t) + }, + split: lerp(a.split, b.split, t), + eyes: [lerpEye(a.eyes[0], b.eyes[0], t), lerpEye(a.eyes[1], b.eyes[1], t)], + eyeAlpha: lerp(a.eyeAlpha, b.eyeAlpha, t), + bodyAlpha: lerp(a.bodyAlpha, b.bodyAlpha, t), + dots: [ + ...a.dots.map((d) => ({ ...d, opacity: d.opacity * out })), + ...b.dots.map((d) => ({ ...d, opacity: d.opacity * t })) + ], + arcs: [ + ...a.arcs.map((r) => ({ ...r, id: `a${r.id}`, opacity: r.opacity * out })), + ...b.arcs.map((r) => ({ ...r, id: `b${r.id}`, opacity: r.opacity * t })) + ], + // la pastille appartient a un seul des deux etats, elle ne se melange pas + notif: t < 0.5 ? a.notif : b.notif, + dotsBehind: t < 0.5 ? a.dotsBehind : b.dotsBehind + }; +} +var BotEngine = class _BotEngine { + /** rayon de la boule au repos, en unites de viewBox */ + scale; + cur; + prev = null; + /** + * Pose de depart FIGEE, posee seulement quand un changement d'etat arrive alors qu'un + * fondu est deja en cours. Cf. `setState`. + */ + departFige = null; + tCur = 0; + tPrev = 0; + blinkAt = -10; + pts = []; + shape = null; + shapePrev = null; + shapeAt = -10; + expr = null; + exprPrev = null; + exprAt = -10; + look = NO_LOOK; + lookPrev = NO_LOOK; + lookAt = -10; + /** duree de rattrapage en cours ; voir `LOOK_MORPH`, sa valeur par defaut */ + lookMorph = 0.24; + /** duree du morph quand on change la forme du corps */ + static SHAPE_MORPH = 0.45; + /** + * Duree de rattrapage du regard vers la cible. Plus court que `SHAPE_MORPH` : + * un regard qui suit doit paraitre attentif, pas visqueux. Comme la cible est + * reposee a chaque mouvement de souris, c'est cette duree qui donne au suivi + * son inertie — le regard n'atteint jamais tout a fait un curseur qui bouge. + */ + static LOOK_MORPH = 0.24; + constructor(scale = 100, initial = "idle", shape = null, expression = null) { + this.scale = scale; + this.cur = initial; + this.shape = shape; + this.expr = expression; + } + /** + * Expression de repos choisie dans le personnalisateur. Comme la forme, elle + * glisse vers la nouvelle valeur au lieu de sauter. + */ + setExpression(expression, now = 0) { + if (expression === this.expr) return; + this.exprPrev = this.expr; + this.expr = expression; + this.exprAt = now; + } + /** Expression effective a l'instant `now`, morph en cours compris. */ + exprAtTime(now) { + const to = this.expr; + const from = this.exprPrev; + if (!to || !from) return to; + const k = (now - this.exprAt) / _BotEngine.SHAPE_MORPH; + if (k >= 1) return to; + return blendExpression(from, to, easings.easeOutQuint(clamp(k))); + } + /** + * Forme choisie dans le personnalisateur. Elle ne remplace le corps que sur + * les etats au repos (`baseBody`) : sur les autres, la silhouette EST + * l'animation et ne doit pas etre ecrasee. + * + * Le changement se fait en morph, pas d'un coup : comme toutes les formes sont + * echantillonnees aux memes angles, il suffit d'interpoler les rayons. + */ + setShape(radii, now = 0) { + if (radii === this.shape) return; + this.shapePrev = this.shape; + this.shape = radii; + this.shapeAt = now; + } + /** + * Forme effective a l'instant `now`, morph en cours compris. + * + * Ne remet PAS `shapePrev` a null en fin de morph : `sample` doit rester une + * fonction pure du temps, donc relire une date passee doit redonner l'image + * intermediaire. On garde juste une reference de plus. + */ + shapeAtTime(now) { + const to = this.shape; + const from = this.shapePrev; + if (!to || !from) return to; + const k = (now - this.shapeAt) / _BotEngine.SHAPE_MORPH; + if (k >= 1) return to; + const t = easings.easeOutQuint(clamp(k)); + return to.map((r, i) => lerp(from[i] ?? r, r, t)); + } + /** + * Nouvelle cible de regard, `null` pour revenir a celui de l'etat. + * + * Elle repart de la valeur COURANTE, et non de la cible precedente comme + * `setShape` : cette methode est appelee a chaque mouvement de pointeur, et + * repartir de l'ancienne cible ferait reculer le regard d'un cran avant + * chaque rattrapage — le suivi tremblerait au lieu de glisser. + * + * Meme contrat que `setShape` par ailleurs : l'etat externe entre par un + * setter horodate, jamais par une variable lue pendant `sample`, sinon le + * moteur cesse d'etre une fonction pure du temps. + */ + setLook(look, now, morph = _BotEngine.LOOK_MORPH) { + if (look && !Number.isFinite(look.yaw + look.pitch + look.mix + look.spin + look.wander)) { + return; + } + this.lookPrev = this.lookAtTime(now); + this.look = look ?? NO_LOOK; + this.lookAt = now; + this.lookMorph = morph; + } + /** Regard effectif a l'instant `now`, rattrapage en cours compris. */ + lookAtTime(now) { + const k = (now - this.lookAt) / this.lookMorph; + if (k >= 1) return this.look; + return lerpLook(this.lookPrev, this.look, easings.easeOutQuint(clamp(k))); + } + posed(def, t, shape, expr) { + let pose = def.pose(t); + if (def.baseBody && shape) { + pose = { ...pose, sil: { ...pose.sil, radii: shape } }; + } + if (def.baseFace && expr) { + pose = { ...pose, gaze: expr.gaze, split: expr.split, eyes: expr.eyes }; + } + return pose; + } + /** + * Decalage des yeux a l'instant `now` pour un etat donne, en unites de rayon de boule. + * + * Il est LU dans une table et interpole, jamais recalcule : `eyefit.ts` explique + * pourquoi cette distinction est tout le correctif. Ici il ne reste qu'a l'interpoler + * sur l'axe de la forme, avec exactement la courbe et la duree du morph de silhouette + * — c'est la meme cause, donc ce doit etre le meme mouvement. + * + * On interroge la table sur les BORNES du morph (`shapePrev` et `shape`) et non sur le + * profil que rend `shapeAtTime` : celui-la est un tableau neuf alloue a chaque image, + * donc sans identite, et il n'existe dans aucune table. + */ + decalageAtTime(now, state) { + const surAxe = (debut, duree, a, b) => { + if (a === b) return b; + const k = (now - debut) / duree; + if (k >= 1) return b; + const t = easings.easeOutQuint(clamp(k)); + return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) }; + }; + const parForme = (radii) => surAxe( + this.exprAt, + _BotEngine.SHAPE_MORPH, + decalageDesYeux(radii, state, this.exprPrev?.id ?? null), + decalageDesYeux(radii, state, this.expr?.id ?? null) + ); + return surAxe( + this.shapeAt, + _BotEngine.SHAPE_MORPH, + parForme(this.shapePrev), + parForme(this.shape) + ); + } + get state() { + return this.cur; + } + /** + * Repart sur `id` SANS etat precedent, comme un moteur neuf pose sur cet etat. + * + * C'est ce que veut dire « rembobiner » pour ce moteur. `setState` seul ne peut pas le + * faire : il garde l'etat quitte pour le fondre, ce qui est exactement son role en + * lecture, et exactement ce qu'il ne faut pas quand on revient au debut d'une sequence. + * Rejouer l'image 0 apres une passe complete melangeait le premier etat avec le DERNIER, + * et l'export GIF s'ouvrait sur une boule sans yeux — la comete a un `eyeAlpha` nul. + * + * `sample` reste une fonction pure du temps : comme `setState`, ceci est un setter DATE, + * appele par le pilote de la sequence, jamais pendant un echantillonnage. + */ + reset(id, now) { + this.cur = id; + this.prev = null; + this.departFige = null; + this.tCur = now; + this.tPrev = now; + this.blinkAt = -10; + } + /** + * Origine du fondu en cours : la pose figee s'il y en a une, sinon l'etat quitte evalue + * a son propre temps ecoule — donc encore en train de s'animer, ce qui est voulu. + */ + origine(now, shape, expr) { + if (this.departFige) return this.departFige; + if (!this.prev) return null; + const prevDef = STATE_BY_ID.get(this.prev); + return this.posed(prevDef, Math.max(0, now - this.tPrev), shape, expr); + } + /** + * Pose composite a l'instant `now`, fondu en cours compris : exactement ce que `sample` + * melange, avant la couche de vie au repos et de regard. Extraite pour que `setState` + * puisse la figer. + */ + poseComposee(now) { + const def = STATE_BY_ID.get(this.cur); + const shape = this.shapeAtTime(now); + const expr = this.exprAtTime(now); + const pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr); + const since = now - this.tCur; + if (since >= def.morph) return pose; + const origine = this.origine(now, shape, expr); + if (!origine) return pose; + return blendPose(origine, pose, easings.easeOutQuint(clamp(since / def.morph))); + } + /** + * Changement d'etat, date. + * + * Le moteur ne garde qu'UNE case d'historique, donc un changement qui arrive pendant un + * fondu remplacait l'origine du melange par la pose PLEINE de l'etat qu'on quittait, au + * lieu de l'image partiellement melangee qui etait a l'ecran. Mesure sur + * `idle -> wide -> idle` a 100 ms : 35,9 px de saut contre 8,0 px de mouvement normal. + * + * On fige donc la pose composite courante et on melange depuis elle. Continu par + * construction, quel que soit le nombre de changements enchaines. + * + * Et SEULEMENT dans ce cas. Figer a chaque changement arreterait net l'animation de + * l'etat qu'on quitte pendant tout le fondu — le « ! » d'`alert` se figerait en pleine + * course — alors qu'il n'y a rien a corriger hors morph : l'etat quitte y est deja + * exactement l'image affichee. La lecture d'un montage, dont les blocs durent au moins + * le plus long fondu (`MIN_BLOCK`), ne fige donc jamais rien et rend au bit ce qu'elle + * rendait. + */ + setState(id, now) { + if (id === this.cur) return; + const morph = STATE_BY_ID.get(this.cur).morph; + const enPleinFondu = this.prev !== null && now - this.tCur < morph; + this.departFige = enPleinFondu ? this.poseComposee(now) : null; + this.prev = this.cur; + this.tPrev = this.tCur; + this.cur = id; + this.tCur = now; + if (STATE_BY_ID.get(id)?.blinkIn) this.blinkAt = now; + } + sample(now) { + const R2 = this.scale; + const def = STATE_BY_ID.get(this.cur); + const shape = this.shapeAtTime(now); + const expr = this.exprAtTime(now); + let pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr); + let decalage = this.decalageAtTime(now, this.cur); + const since = now - this.tCur; + const origine = since < def.morph ? this.origine(now, shape, expr) : null; + if (origine) { + const ratio = easings.easeOutQuint(clamp(since / def.morph)); + pose = blendPose(origine, pose, ratio); + const quitte = this.prev; + if (quitte) { + const avant = this.decalageAtTime(now, quitte); + decalage = { + x: lerp(avant.x, decalage.x, ratio), + y: lerp(avant.y, decalage.y, ratio) + }; + } + } + const alive = pose.eyeAlpha > 0.01; + const look = this.lookAtTime(now); + const life = liveliness(now, { wander: alive ? look.wander : 0, blink: alive }); + const gaze = { + // Les deux visees REMPLACENT celles de la pose au lieu de s'y ajouter (voir + // `Look`), et le tour se retranche en chemin. La derive s'ajoute APRES le + // melange, sinon la cible l'annulerait en meme temps que la pose — or elle + // doit survivre a une tete tournee sans pointeur. + yaw: lerp(pose.gaze.yaw, look.yaw, look.mix) + life.dYaw - look.spin, + pitch: lerp(pose.gaze.pitch, look.pitch, look.mix) + life.dPitch, + // le roulis, lui, ne suit rien : la tete du bot est penchee de -13deg dans + // la video, et la faire rouler avec le curseur casse cette signature + roll: pose.gaze.roll + life.dRoll + }; + const forced = clamp((now - this.blinkAt) / 0.2); + const forcedLid = forced < 1 ? Math.abs(forced * 2 - 1) : 1; + const lid = Math.min(life.lid, forcedLid); + const offX = pose.offX + life.driftX; + const offY = pose.offY + life.driftY; + const sil = { + ...pose.sil, + cx: pose.sil.cx + offX, + cy: pose.sil.cy + offY, + sy: pose.sil.sy * life.breath + }; + const bodyPath = closedPath(toPoints(sil, R2, this.pts)); + const bodyRadius = (x, y) => radiusAtAngle(pose.sil.radii, Math.atan2(y, x) - pose.sil.rot); + const eyes = []; + if (pose.eyeAlpha > 0.01) { + const poses = eyePoses(gaze, R2, pose.split); + for (let i = 0; i < 2; i++) { + const e = poses[i]; + if (e.depth <= 0.02) continue; + const cfg = pose.eyes[i]; + const fit = bodyRadius(e.x, e.y); + const phi = (cfg.tilt ?? 0) * Math.PI / 180; + const cp = Math.cos(phi); + const sp = Math.sin(phi); + const ax = e.a * cp + e.c * sp; + const ay = e.b * cp + e.d * sp; + const cx2 = -e.a * sp + e.c * cp; + const cy2 = -e.b * sp + e.d * cp; + const k = blinkScale(Math.min(lid, cfg.open)); + eyes.push({ + d: capsulePath(cfg.w * R2, cfg.h * R2), + matrix: `matrix(${r2(ax)},${r2(ay * k)},${r2(cx2)},${r2(cy2 * k)},${r2(e.x * fit + (offX + decalage.x) * R2)},${r2(e.y * fit + (offY + decalage.y) * R2)})`, + alpha: pose.eyeAlpha * clamp(e.depth / 0.12) + }); + } + } + const dots = pose.dots.filter((p) => p.opacity > 0.01 && p.r > 5e-4).map((p) => ({ ...p, x: (p.x + offX) * R2, y: (p.y + offY) * R2, r: p.r * R2 })); + const nFit = pose.notif ? bodyRadius(pose.notif.x, pose.notif.y) : 1; + const nx = pose.notif ? (pose.notif.x * nFit + offX) * R2 : 0; + const ny = pose.notif ? (pose.notif.y * nFit + offY) * R2 : 0; + const notif = pose.notif ? { x: nx, y: ny, r: pose.notif.r * R2 } : null; + const notch = pose.notif ? { x: nx, y: ny, r: pose.notif.notch * R2 } : null; + return { + bodyPath, + bodyAlpha: pose.bodyAlpha, + eyes, + dots, + dotsBehind: pose.dotsBehind, + // Les etats declarent des arcs en unites de rayon de boule ; le moteur + // est le seul a connaitre l'echelle du viewBox, donc c'est lui qui trace. + arcs: pose.arcs.filter((a) => a.opacity > 0.01).map((a) => arcRender(a.seed, a.t, R2, a.id, a.opacity)), + notif, + notch + }; + } +}; + +// vendor/bloub/src/repere.ts +var RAYON = 100; +var DEMI_VIEWBOX = 158; +export { + BotEngine, + DEMI_VIEWBOX, + NOTIF_BLUE, + RAYON, + STATE_BY_ID +}; diff --git a/apps/openlive-gateway/web/vendor/bloub/entry.js b/apps/openlive-gateway/web/vendor/bloub/entry.js new file mode 100644 index 0000000..6618b91 --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/entry.js @@ -0,0 +1,4 @@ +export { BotEngine } from "./src/engine.ts"; +export { STATE_BY_ID } from "./src/states.ts"; +export { RAYON, DEMI_VIEWBOX } from "./src/repere.ts"; +export { NOTIF_BLUE } from "./src/decor.ts"; diff --git a/apps/openlive-gateway/web/vendor/bloub/src/cycles.ts b/apps/openlive-gateway/web/vendor/bloub/src/cycles.ts new file mode 100644 index 0000000..9b4f4b0 --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/src/cycles.ts @@ -0,0 +1,225 @@ +import { SEQUENCE, STATES, STATE_BY_ID, type StateId } from './states' + +/** + * Un cycle est un montage : une suite de blocs, chacun un etat tenu pendant une + * duree choisie. C'est la partie "editeur" du dossier, et elle en garde les + * regles — donnees pures, aucune horloge, aucun import Vue : le meme cycle doit + * pouvoir etre relu par les tests, par le lecteur et par la timeline. + * + * Un bloc n'a pas d'identifiant : c'est une position dans une liste, la cle de + * rendu est l'index. Ca garde le JSON du localStorage lisible et les tests + * deterministes. + */ +export interface Block { + state: StateId + duration: number +} + +export interface Cycle { + id: string + name: string + blocks: Block[] +} + +/** + * Plancher commun a tous les blocs. Le moteur ne garde qu'une case d'historique + * (`BotEngine.setState` ecrase `prev`), donc un bloc plus court que le fondu d'entree du + * bloc suivant saute a l'image au lieu de se fondre. + * + * DERIVE du catalogue et non ecrit a la main. La valeur etait 0,6, ce qui marchait + * uniquement parce que 0,6 se trouvait etre le plus long `morph` du catalogue — celui + * d'`orbit`. Rien ne le garantissait : ajouter un etat qui morphe en 0,8 s aurait fait + * trembler l'editeur sans qu'aucun test ne bronche. Maintenant le plancher suit. + */ +export const MIN_BLOCK = Math.max(...STATES.map((s) => s.morph)) + +/** + * Garde-fou d'editeur, pas une mesure : allonger un bloc est sans risque (les + * etats saturent leurs rampes et tiennent leur pose finale), mais une piste de + * blocs d'une minute n'est plus lisible. + */ +export const MAX_BLOCK = 10 + +/** + * Combien de blocs et de montages on accepte, a l'edition comme a la relecture. + * + * Ce ne sont pas des limites de produit mais des bornes contre un stockage hostile, qui + * est modifiable et tient quelques megaoctets alors que rien en aval n'est dimensionne + * pour ca : un seul cycle de 150 000 blocs, soit environ 4 Mo de JSON, donne 1 500 000 s + * de duree, autant de graduations a allouer et une piste de 29 700 000 px de large. + * L'onglet figeait en entrant dans la vue Animations. + * + * 200 blocs font une demi-heure de montage, largement au-dela de tout usage. + */ +export const MAX_BLOCS = 200 +export const MAX_CYCLES = 50 + +/** Pas de la molette et du redimensionnement, en secondes. */ +export const STEP = 0.1 + +const DEFAULT_CYCLE_ID = 'defaut' + +/** Duree minimale d'un bloc : le plancher moteur, ou la mesure de l'etat. */ +export function minDurationOf(state: StateId): number { + return Math.max(MIN_BLOCK, STATE_BY_ID.get(state)?.minDuration ?? MIN_BLOCK) +} + +/** Ramene une duree dans ses bornes et sur le pas, sans trainee de flottants. */ +export function clampDuration(state: StateId, seconds: number): number { + const snapped = Math.round(seconds / STEP) * STEP + const bounded = Math.min(MAX_BLOCK, Math.max(minDurationOf(state), snapped)) + return Math.round(bounded * 100) / 100 +} + +export function makeBlock(state: StateId): Block { + // la duree de reference est celle relevee sur la video pour cet etat + return { state, duration: clampDuration(state, STATE_BY_ID.get(state)?.duration ?? 2) } +} + +/** + * Le montage releve sur la video : l'ordre de `SEQUENCE`, chaque etat tenu sa + * duree mesuree. Il sert d'amorce au premier lancement, puis il appartient a + * l'utilisateur — il s'edite et se stocke comme les autres. La reference, elle, + * reste dans le code : vider le stockage la fait revenir. + */ +export function defaultCycle(): Cycle { + return { + /** + * Nom vide = « jamais nomme par l'utilisateur », donc affiche dans la langue + * courante. Ecrire ici « Cycle par defaut » l'aurait fige : le nom part au + * localStorage des la premiere visite et redevient une donnee utilisateur, + * que changer de langue ne retraduirait plus. + */ + name: '', + id: DEFAULT_CYCLE_ID, + blocks: SEQUENCE.map(makeBlock) + } +} + +export function totalDuration(blocks: Block[]): number { + return blocks.reduce((sum, b) => sum + b.duration, 0) +} + +/** Date de debut d'un bloc dans le montage. */ +export function offsetOf(blocks: Block[], index: number): number { + let acc = 0 + for (let i = 0; i < index && i < blocks.length; i++) acc += blocks[i]!.duration + return acc +} + +/** + * Bloc joue a la date `t` et temps ecoule dedans. Au-dela du dernier bloc on + * retombe au debut : la lecture boucle. L'appelant verifie que le montage n'est + * pas vide. + */ +export function blockAt(blocks: Block[], t: number): { index: number; elapsed: number } { + const total = totalDuration(blocks) + if (!blocks.length || total <= 0) return { index: 0, elapsed: 0 } + // le modulo n'est applique que s'il sert : sur une date deja dans le cycle il + // n'ajouterait qu'une trainee de flottants au temps ecoule + const wrapped = t >= 0 && t < total ? t : ((t % total) + total) % total + let acc = 0 + for (let i = 0; i < blocks.length; i++) { + const end = acc + blocks[i]!.duration + if (wrapped < end) return { index: i, elapsed: wrapped - acc } + acc = end + } + return { index: blocks.length - 1, elapsed: 0 } +} + +/** + * Ajoute une animation a la fin du montage (palette de droite ou carte « + »). + * + * Plafonnee a `MAX_BLOCS`, comme la relecture. Sans ca l'editeur laissait construire un + * montage plus grand que ce que le stockage rend au rechargement, et le travail + * disparaissait en silence — une borne de relecture qui n'est pas aussi une borne d'edition + * est un piege, pas une protection. + */ +export function blocksWith(blocks: Block[], state: StateId): Block[] { + if (blocks.length >= MAX_BLOCS) return blocks + return [...blocks, makeBlock(state)] +} + +/** Deplace un bloc, en rendant une nouvelle liste (les etats Vue sont remplaces). */ +export function moveBlock(blocks: Block[], from: number, to: number): Block[] { + const next = blocks.slice() + const [moved] = next.splice(from, 1) + if (!moved) return blocks + next.splice(Math.min(Math.max(to, 0), next.length), 0, moved) + return next +} + +/** `Mon cycle`, `Mon cycle 2`, `Mon cycle 3`... — jamais deux fois le meme nom. */ +export function uniqueName(base: string, cycles: Cycle[]): string { + const taken = new Set(cycles.map((c) => c.name)) + if (!taken.has(base)) return base + let n = 2 + while (taken.has(`${base} ${n}`)) n++ + return `${base} ${n}` +} + +/** Identifiant sans collision, y compris avec un localStorage bricole a la main. */ +export function nextCycleId(cycles: Cycle[]): string { + const taken = new Set(cycles.map((c) => c.id)) + let n = 1 + while (taken.has(`c${n}`)) n++ + return `c${n}` +} + +/* ------------------------------------------------------- lecture du stockage */ + +function parseBlock(raw: unknown): Block | null { + if (typeof raw !== 'object' || raw === null) return null + const { state, duration } = raw as { state?: unknown; duration?: unknown } + /* + * Valide contre SEQUENCE et non contre `STATE_BY_ID` : ce dernier contient `swirl`, qui + * est deliberement hors du catalogue — c'est la transition d'entree des reglages, un + * test la verrouille hors de la palette et de la planche. Un montage utilisateur ne se + * construit qu'a partir de la palette, donc un `swirl` ne peut y arriver que par un + * stockage bricole a la main, et il n'y a aucune raison de l'y tolerer quand on l'exclut + * partout ailleurs. + */ + if (typeof state !== 'string' || !SEQUENCE.includes(state as StateId)) return null + if (typeof duration !== 'number' || !Number.isFinite(duration)) return null + return { state: state as StateId, duration: clampDuration(state as StateId, duration) } +} + +function parseCycle(raw: unknown, seen: Cycle[]): Cycle | null { + if (typeof raw !== 'object' || raw === null) return null + const { id, name, blocks } = raw as { id?: unknown; name?: unknown; blocks?: unknown } + if (typeof id !== 'string' || !id) return null + // le nom peut etre vide — c'est le montage d'amorce, qui suit la langue + if (typeof name !== 'string') return null + if (!Array.isArray(blocks)) return null + // on tronque AVANT de relire : valider 150 000 blocs pour n'en garder que 200 serait + // faire le travail qu'on cherche justement a eviter + const kept = blocks + .slice(0, MAX_BLOCS) + .map(parseBlock) + .filter((b): b is Block => b !== null) + if (!kept.length) return null + if (seen.some((c) => c.id === id)) return null + return { id, name, blocks: kept } +} + +/** + * Le localStorage est modifiable a la main : on ne lui fait pas confiance, meme + * regle que pour le hash de l'URL. Tout ce qui ne se relit pas est jete + * silencieusement plutot que de casser l'application au demarrage. + */ +export function parseCycles(raw: string | null): Cycle[] { + if (!raw) return [] + let data: unknown + try { + data = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(data)) return [] + const out: Cycle[] = [] + for (const item of data.slice(0, MAX_CYCLES)) { + const cycle = parseCycle(item, out) + if (cycle) out.push(cycle) + } + return out +} diff --git a/apps/openlive-gateway/web/vendor/bloub/src/decor.ts b/apps/openlive-gateway/web/vendor/bloub/src/decor.ts new file mode 100644 index 0000000..d4f3e5c --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/src/decor.ts @@ -0,0 +1,285 @@ +import { TAU, clamp, createRng, r2 } from './math' + +/* ------------------------------------------------------------------ couleurs */ + +/** + * Les anneaux ne sont pas des couleurs plates : la video montre une roue de + * teintes complete a luminosite constante, avec un degrade le long de chaque + * trace. Mesure : S 45-62 %, L 50-67 %. + */ +function wheel(hue: number, s = 0.55, l = 0.62): string { + const h = ((hue % 360) + 360) % 360 + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - c / 2 + const [r, g, b] = + h < 60 + ? [c, x, 0] + : h < 120 + ? [x, c, 0] + : h < 180 + ? [0, c, x] + : h < 240 + ? [0, x, c] + : h < 300 + ? [x, 0, c] + : [c, 0, x] + const hex = (v: number) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, '0') + return `#${hex(r)}${hex(g)}${hex(b)}` +} + +/* ------------------------------------------------------------- types de rendu */ + +export interface DotRender { + x: number + y: number + r: number + opacity: number + /** couleur explicite ; par defaut le rendu prend celle du corps */ + color?: string + /** + * Brume de profondeur : 0 = fondu dans le fond, 1 = couleur du corps pleine. + * Le melange se fait au rendu, qui seul connait la couleur choisie. + */ + depth?: number + /** + * Forme non circulaire, en unites de rayon de boule et centree sur l'origine + * (le point du "!" penche est une goutte, pas un disque). Quand elle est + * fournie, `r` n'est plus utilise pour le trace. + */ + d?: string + /** rotation appliquee a `d`, en degres */ + rot?: number +} + +/** + * Ce qu'un etat declare : la geometrie de l'arc reste en unites de rayon de + * boule, c'est le moteur (seul a connaitre l'echelle du viewBox) qui la + * rasterise. Sans ca les etats devraient connaitre le viewBox. + */ +export interface ArcSpec { + id: string + seed: ArcSeed + t: number + opacity: number +} + +export interface ArcRender { + id: string + /** portion devant le corps */ + front: string + /** portion derriere le corps (dessinee avant, donc masquee par la silhouette) */ + back: string + width: number + opacity: number + /** degrade de teinte le long du trace */ + grad: { x1: number; y1: number; x2: number; y2: number; stops: string[] } +} + +/* --------------------------------------------------------- arc elliptique 3D */ + +export interface ArcSeed { + /** demi-grand axe, en unites de rayon de boule */ + a: number + /** aplatissement b/a : mesure <= 0.45, les plans d'orbite sont vus par la tranche */ + k: number + /** inclinaison du grand axe a l'ecran, radians */ + tilt: number + /** tours par seconde */ + speed: number + phase: number + /** fraction du tour reellement tracee */ + sweep: number + hue: number + hueSpan: number + width: number + cx: number + cy: number +} + +/** + * Projette un cercle 3D incline en orthographique. + * + * Le cercle vit dans le plan engendre par u (dans l'ecran) et v (qui plonge + * dans la profondeur). La composante z sert a couper l'arc en deux : la moitie + * arriere est dessinee avant le corps, donc occultee par lui. C'est ce vrai tri + * en profondeur qui fait lire les anneaux comme des orbites et pas comme un + * dessin plat. + */ +export function arcRender(seed: ArcSeed, t: number, scale: number, id: string, opacity = 1): ArcRender { + const spin = seed.phase + t * seed.speed * TAU + const cu = Math.cos(seed.tilt) + const su = Math.sin(seed.tilt) + const kz = Math.sqrt(Math.max(0, 1 - seed.k * seed.k)) + + const N = 64 + const span = seed.sweep * TAU + let front = '' + let back = '' + let prev: boolean | null = null + + for (let i = 0; i <= N; i++) { + const th = spin + (i / N) * span + const ct = Math.cos(th) + const st = Math.sin(th) + // u = (cos tilt, sin tilt, 0) ; v = (-sin tilt * k, cos tilt * k, kz) + const x = seed.a * (ct * cu + st * -su * seed.k) + seed.cx + const y = seed.a * (ct * su + st * cu * seed.k) + seed.cy + const z = seed.a * st * kz + + const behind = z < 0 + const sx = r2(x * scale) + const sy = r2(y * scale) + const cmd = behind !== prev ? 'M' : 'L' + if (behind) back += `${cmd}${sx} ${sy}` + else front += `${cmd}${sx} ${sy}` + prev = behind + } + + const gx = Math.cos(seed.tilt) * seed.a * scale + const gy = Math.sin(seed.tilt) * seed.a * scale + return { + id, + front, + back, + width: seed.width * scale, + opacity, + grad: { + x1: r2(seed.cx * scale - gx), + y1: r2(seed.cy * scale - gy), + x2: r2(seed.cx * scale + gx), + y2: r2(seed.cy * scale + gy), + stops: [wheel(seed.hue), wheel(seed.hue + seed.hueSpan * 0.5), wheel(seed.hue + seed.hueSpan)] + } + } +} + +/* ---------------------------------------------------------------- anneaux */ + +const RING_RNG = createRng(0xa11ce) + +/** + * 6 anneaux, demi-grand axe 1.30-1.40 (donc nettement plus grands que la + * boule), aplatissement toujours <= 0.45, epaisseur 0.055, ~3.3 tours/s. + */ +export const RINGS: ArcSeed[] = Array.from({ length: 6 }, (_, i) => ({ + a: 1.3 + RING_RNG() * 0.1, + k: 0.05 + RING_RNG() * 0.4, + tilt: (i / 6) * Math.PI + RING_RNG() * 0.5, + speed: 3 + RING_RNG() * 0.7, + phase: RING_RNG() * TAU, + sweep: 0.6 + RING_RNG() * 0.25, + hue: (i * 360) / 6 + RING_RNG() * 30, + hueSpan: 60 + RING_RNG() * 60, + width: 0.05 + RING_RNG() * 0.012, + cx: 0, + cy: 0.1 +})) + +/** + * Bouquet d'arcs emboites qui balaie le triangle juste avant les orbites. + * Vus quasiment par la tranche (d'ou la forme en epingle a cheveux), rmax 1.37. + */ +export const SWOOSH: ArcSeed[] = Array.from({ length: 4 }, (_, i) => ({ + a: 0.78 + i * 0.2, + k: 0.05 + i * 0.02, + tilt: -0.62 + i * 0.05, + speed: 0.3, + phase: 0.06 * i, + sweep: 0.4, + hue: 95 + i * 62, + hueSpan: 100, + width: 0.05, + cx: 0, + cy: -0.12 +})) + +/* ------------------------------------------------------------- 3 points */ + +/** x mesures : -0.557 / -0.013 / +0.532, y = 0. */ +export const DOT_X = [-0.557, -0.013, 0.532] as const +export const DOT_R = 0.165 +export const DOT_PEAK = 1.25 + +/* ------------------------------------------------------------ particules */ + +const P_RNG = createRng(0xbeef) + +/** 5 particules, une nouvelle toutes les 0.2 s, duree de vie 0.55 s. */ +const PARTICLES = Array.from({ length: 5 }, (_, i) => ({ + birth: i * 0.2, + angle: P_RNG() * TAU, + rho: 0.58 + P_RNG() * 0.18 +})) + +/** + * Les particules ne partent pas en ligne droite : elles spiralent vers le + * centre (rayon x0.75 par frame, angle +100 deg/s) en grossissant, et passent + * derriere le noyau ou elles sont avalees. + */ +export function particles(t: number, scale: number): DotRender[] { + const out: DotRender[] = [] + for (const p of PARTICLES) { + const u = t - p.birth + if (u < 0 || u > 0.62) continue + const rho = p.rho * Math.pow(0.75, u * 10) + const a = p.angle + (u * 100 * Math.PI) / 180 + out.push({ + x: Math.cos(a) * rho * scale, + y: Math.sin(a) * rho * scale, + r: (0.04 + 0.028 * clamp(u / 0.55)) * scale, + depth: clamp(1 - rho / 0.8), + opacity: clamp(u / 0.06) * clamp((0.62 - u) / 0.08) + }) + } + return out +} + +/* ------------------------------------------------------------------ comete */ + +/** + * Contrairement a l'intuition, le point ne traverse pas l'ecran : il reste au + * centre et c'est la trainee qui l'orbite. Ellipse a = 0.85, b = 0.15, + * grand axe incline de +34deg, 4 rubans, ~210 deg/s. + */ +const COMET_RNG = createRng(0xc0e7) +export const COMET_RIBBONS: ArcSeed[] = Array.from({ length: 4 }, (_, i) => { + const d = i - 1.5 + return { + a: 0.85 * (1 + d * 0.03), + // meme aplatissement a +-5 % pres : les rubans forment un faisceau serre + k: (0.15 / 0.85) * (1 + d * 0.16), + tilt: (34 * Math.PI) / 180 + d * 0.035, + speed: 210 / 360, + // dephasage mesure : 10 a 20 degres entre rubans, pas davantage + phase: -i * 0.045 + COMET_RNG() * 0.012, + sweep: 0.34, + hue: i * 85 + COMET_RNG() * 20, + hueSpan: 80, + width: 0.095, + cx: 0, + cy: 0 + } +}) + +/** Rayon du point de la comete, mesure a 0.129. */ +export const COMET_DOT = 0.129 + +/* --------------------------------------------------- pastille notification */ + +/** Bleu releve au pixel. */ +export const NOTIF_BLUE = '#2496e8' +/** La pastille est posee exactement sur la circonference, a -42deg. */ +export const NOTIF_ANGLE = -42 +export const NOTIF_DIST = 1.003 +/** Rayon au repos ; le pop culmine 14 % au-dessus. */ +export const NOTIF_R = 0.15 +export const NOTIF_POP = 1.14 +/** + * L'encoche est un disque concentrique a la pastille, soustrait du corps. + * La marge est constante (0.054 R) et suit l'echelle du corps. + */ +export const NOTIF_MARGIN = 0.054 diff --git a/apps/openlive-gateway/web/vendor/bloub/src/engine.ts b/apps/openlive-gateway/web/vendor/bloub/src/engine.ts new file mode 100644 index 0000000..f8aa414 --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/src/engine.ts @@ -0,0 +1,558 @@ +import { arcRender, type ArcRender, type DotRender } from './decor' +import { blendExpression, type BotExpression } from './expressions' +import { decalageDesYeux } from './eyefit' +import { blinkScale, eyePoses, liveliness } from './face' +import { clamp, easings, lerp, r2 } from './math' +import { + blend, + capsulePath, + closedPath, + radiusAtAngle, + toPoints, + type Point, + type Silhouette +} from './shape' +import { STATE_BY_ID, type Pose, type StateDef, type StateId } from './states' + +export interface RenderedEye { + d: string + matrix: string + alpha: number +} + +export interface BotFrame { + bodyPath: string + bodyAlpha: number + eyes: RenderedEye[] + dots: DotRender[] + /** true = les points passent derriere le corps (particules de l'eclatement) */ + dotsBehind: boolean + arcs: ArcRender[] + notif: { x: number; y: number; r: number } | null + notch: { x: number; y: number; r: number } | null +} + +/** + * Ou le bot porte son regard quand quelque chose d'exterieur le pilote — le + * pointeur de la souris, aujourd'hui. + * + * `yaw` et `pitch` sont des directions ABSOLUES, qui remplacent celles de la pose + * a mesure que `mix` monte. Deux raisons, chacune un piege deja tombe : + * + * - c'est le MOTEUR qui doit faire ce melange, pas l'appelant, parce que lui seul + * connait la pose A CET INSTANT. Un appelant qui compenserait l'orientation de + * l'expression lirait sa valeur d'arrivee pendant que le morph est encore en + * cours, et les yeux sautaient a chaque changement d'humeur ; + * - et il faut que ce soit absolu sur les DEUX axes. En relatif, la hauteur des + * yeux suivait celle de chaque expression — « neutre » regarde a +28,6deg quand + * les autres sont entre -9 et +9 — donc les yeux tombaient d'un coup au premier + * changement d'humeur. Ce qui fait le caractere d'une expression pendant le + * suivi, c'est la FORME de ses yeux (plisses, ronds, dissymetriques), pas + * l'endroit ou elle regarde : celui-la, c'est le curseur qui le decide. + * + * `mix` dit a quel point l'exterieur commande la DIRECTION (0 = pas du tout). + * + * `wander` dit, separement, ce qui reste de derive automatique. Les deux ne se + * confondent pas : quand le pointeur bouge, la derive doit s'eteindre — cumulees, + * le bot aurait l'air de chercher le curseur sans jamais le tenir. Mais quand il + * n'y a PAS de pointeur (arrivee au clavier, au tactile, ou souris sortie de la + * fenetre), la tete doit rester tournee ET continuer de vivre. Les confondre + * figeait le regard des que la vue s'ouvrait. + * + * `spin` est un tour a parcourir EN CHEMIN, en degres, qu'on fait fondre vers 0 + * avec l'arrivee. Comme les yeux vivent sur une sphere, un tour les fait passer + * derriere la boule et revenir de l'autre cote — et `-360deg` etant le meme + * angle que `0`, il ne change rien a l'endroit ou ils se posent. + */ +export interface Look { + yaw: number + pitch: number + mix: number + spin: number + wander: number +} + +const NO_LOOK: Look = { yaw: 0, pitch: 0, mix: 0, spin: 0, wander: 1 } + +const lerpLook = (a: Look, b: Look, t: number): Look => ({ + yaw: lerp(a.yaw, b.yaw, t), + pitch: lerp(a.pitch, b.pitch, t), + mix: lerp(a.mix, b.mix, t), + spin: lerp(a.spin, b.spin, t), + wander: lerp(a.wander, b.wander, t) +}) + +const lerpEye = (a: Pose['eyes'][number], b: Pose['eyes'][number], t: number) => ({ + w: lerp(a.w, b.w, t), + h: lerp(a.h, b.h, t), + open: lerp(a.open, b.open, t), + tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t) +}) + +/** Interpolation de deux poses. Le decor se croise en opacite, pas en geometrie. */ +function blendPose(a: Pose, b: Pose, t: number): Pose { + const out = 1 - t + return { + sil: blend(a.sil, b.sil, t), + offX: lerp(a.offX, b.offX, t), + offY: lerp(a.offY, b.offY, t), + gaze: { + yaw: lerp(a.gaze.yaw, b.gaze.yaw, t), + pitch: lerp(a.gaze.pitch, b.gaze.pitch, t), + roll: lerp(a.gaze.roll, b.gaze.roll, t) + }, + split: lerp(a.split, b.split, t), + eyes: [lerpEye(a.eyes[0], b.eyes[0], t), lerpEye(a.eyes[1], b.eyes[1], t)], + eyeAlpha: lerp(a.eyeAlpha, b.eyeAlpha, t), + bodyAlpha: lerp(a.bodyAlpha, b.bodyAlpha, t), + dots: [ + ...a.dots.map((d) => ({ ...d, opacity: d.opacity * out })), + ...b.dots.map((d) => ({ ...d, opacity: d.opacity * t })) + ], + arcs: [ + ...a.arcs.map((r) => ({ ...r, id: `a${r.id}`, opacity: r.opacity * out })), + ...b.arcs.map((r) => ({ ...r, id: `b${r.id}`, opacity: r.opacity * t })) + ], + // la pastille appartient a un seul des deux etats, elle ne se melange pas + notif: t < 0.5 ? a.notif : b.notif, + dotsBehind: t < 0.5 ? a.dotsBehind : b.dotsBehind + } +} + +/** + * Moteur sans horloge : `sample(t)` est une fonction pure du temps. + * + * Consequence pratique : pause, reprise, ralenti et saut a une date arbitraire + * donnent exactement la meme image, et le rendu est testable sans DOM. + */ +export class BotEngine { + /** rayon de la boule au repos, en unites de viewBox */ + readonly scale: number + + private cur: StateId + private prev: StateId | null = null + /** + * Pose de depart FIGEE, posee seulement quand un changement d'etat arrive alors qu'un + * fondu est deja en cours. Cf. `setState`. + */ + private departFige: Pose | null = null + private tCur = 0 + private tPrev = 0 + private blinkAt = -10 + private pts: Point[] = [] + private shape: number[] | null = null + private shapePrev: number[] | null = null + private shapeAt = -10 + private expr: BotExpression | null = null + private exprPrev: BotExpression | null = null + private exprAt = -10 + private look: Look = NO_LOOK + private lookPrev: Look = NO_LOOK + private lookAt = -10 + /** duree de rattrapage en cours ; voir `LOOK_MORPH`, sa valeur par defaut */ + private lookMorph = 0.24 + + /** duree du morph quand on change la forme du corps */ + static readonly SHAPE_MORPH = 0.45 + + /** + * Duree de rattrapage du regard vers la cible. Plus court que `SHAPE_MORPH` : + * un regard qui suit doit paraitre attentif, pas visqueux. Comme la cible est + * reposee a chaque mouvement de souris, c'est cette duree qui donne au suivi + * son inertie — le regard n'atteint jamais tout a fait un curseur qui bouge. + */ + static readonly LOOK_MORPH = 0.24 + + constructor( + scale = 100, + initial: StateId = 'idle', + shape: number[] | null = null, + expression: BotExpression | null = null + ) { + this.scale = scale + this.cur = initial + this.shape = shape + this.expr = expression + } + + /** + * Expression de repos choisie dans le personnalisateur. Comme la forme, elle + * glisse vers la nouvelle valeur au lieu de sauter. + */ + setExpression(expression: BotExpression | null, now = 0) { + if (expression === this.expr) return + this.exprPrev = this.expr + this.expr = expression + this.exprAt = now + } + + /** Expression effective a l'instant `now`, morph en cours compris. */ + private exprAtTime(now: number): BotExpression | null { + const to = this.expr + const from = this.exprPrev + if (!to || !from) return to + const k = (now - this.exprAt) / BotEngine.SHAPE_MORPH + if (k >= 1) return to + return blendExpression(from, to, easings.easeOutQuint(clamp(k))) + } + + /** + * Forme choisie dans le personnalisateur. Elle ne remplace le corps que sur + * les etats au repos (`baseBody`) : sur les autres, la silhouette EST + * l'animation et ne doit pas etre ecrasee. + * + * Le changement se fait en morph, pas d'un coup : comme toutes les formes sont + * echantillonnees aux memes angles, il suffit d'interpoler les rayons. + */ + setShape(radii: number[] | null, now = 0) { + if (radii === this.shape) return + this.shapePrev = this.shape + this.shape = radii + this.shapeAt = now + } + + /** + * Forme effective a l'instant `now`, morph en cours compris. + * + * Ne remet PAS `shapePrev` a null en fin de morph : `sample` doit rester une + * fonction pure du temps, donc relire une date passee doit redonner l'image + * intermediaire. On garde juste une reference de plus. + */ + private shapeAtTime(now: number): number[] | null { + const to = this.shape + const from = this.shapePrev + if (!to || !from) return to + const k = (now - this.shapeAt) / BotEngine.SHAPE_MORPH + if (k >= 1) return to + const t = easings.easeOutQuint(clamp(k)) + // alloue seulement pendant le morph ; hors morph on rend le tableau tel quel + return to.map((r, i) => lerp(from[i] ?? r, r, t)) + } + + /** + * Nouvelle cible de regard, `null` pour revenir a celui de l'etat. + * + * Elle repart de la valeur COURANTE, et non de la cible precedente comme + * `setShape` : cette methode est appelee a chaque mouvement de pointeur, et + * repartir de l'ancienne cible ferait reculer le regard d'un cran avant + * chaque rattrapage — le suivi tremblerait au lieu de glisser. + * + * Meme contrat que `setShape` par ailleurs : l'etat externe entre par un + * setter horodate, jamais par une variable lue pendant `sample`, sinon le + * moteur cesse d'etre une fonction pure du temps. + */ + setLook(look: Look | null, now: number, morph = BotEngine.LOOK_MORPH) { + /* + * Une cible non finie est refusee. Le moteur GARDE la derniere : un `NaN` + * pose une seule fois se propagerait a chaque image et le bot ne se + * reposerait plus jamais. C'est arrive pour de vrai — un + * `getBoundingClientRect` sur une boite de taille nulle donne `0 / 0` chez + * l'appelant. Celui-la est corrige, mais le moteur n'a pas a dependre de la + * prudence de ses appelants pour rester rejouable. + */ + if (look && !Number.isFinite(look.yaw + look.pitch + look.mix + look.spin + look.wander)) { + return + } + this.lookPrev = this.lookAtTime(now) + this.look = look ?? NO_LOOK + this.lookAt = now + this.lookMorph = morph + } + + /** Regard effectif a l'instant `now`, rattrapage en cours compris. */ + private lookAtTime(now: number): Look { + const k = (now - this.lookAt) / this.lookMorph + if (k >= 1) return this.look + return lerpLook(this.lookPrev, this.look, easings.easeOutQuint(clamp(k))) + } + + private posed( + def: StateDef, + t: number, + shape: number[] | null, + expr: BotExpression | null + ): Pose { + let pose = def.pose(t) + if (def.baseBody && shape) { + // on garde la pose (rotation, decalage, squash) et on n'echange que le profil + pose = { ...pose, sil: { ...pose.sil, radii: shape } } + } + if (def.baseFace && expr) { + pose = { ...pose, gaze: expr.gaze, split: expr.split, eyes: expr.eyes } + } + return pose + } + + /** + * Decalage des yeux a l'instant `now` pour un etat donne, en unites de rayon de boule. + * + * Il est LU dans une table et interpole, jamais recalcule : `eyefit.ts` explique + * pourquoi cette distinction est tout le correctif. Ici il ne reste qu'a l'interpoler + * sur l'axe de la forme, avec exactement la courbe et la duree du morph de silhouette + * — c'est la meme cause, donc ce doit etre le meme mouvement. + * + * On interroge la table sur les BORNES du morph (`shapePrev` et `shape`) et non sur le + * profil que rend `shapeAtTime` : celui-la est un tableau neuf alloue a chaque image, + * donc sans identite, et il n'existe dans aucune table. + */ + private decalageAtTime(now: number, state: StateId): { x: number; y: number } { + /** + * Un axe de morph : on lit la table sur ses deux BORNES et on interpole avec sa + * courbe. Jamais sur la valeur interpolee — celle-la n'a pas d'identite et n'existe + * dans aucune table, et c'est en la lui donnant a manger que les versions + * precedentes tremblaient. + */ + const surAxe = ( + debut: number, + duree: number, + a: { x: number; y: number }, + b: { x: number; y: number } + ) => { + if (a === b) return b + const k = (now - debut) / duree + if (k >= 1) return b + const t = easings.easeOutQuint(clamp(k)) + return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) } + } + + // axe de l'expression, pour chacune des deux formes en presence + const parForme = (radii: number[] | null) => + surAxe( + this.exprAt, + BotEngine.SHAPE_MORPH, + decalageDesYeux(radii, state, this.exprPrev?.id ?? null), + decalageDesYeux(radii, state, this.expr?.id ?? null) + ) + + // puis axe de la forme + return surAxe( + this.shapeAt, + BotEngine.SHAPE_MORPH, + parForme(this.shapePrev), + parForme(this.shape) + ) + } + + get state(): StateId { + return this.cur + } + + /** + * Repart sur `id` SANS etat precedent, comme un moteur neuf pose sur cet etat. + * + * C'est ce que veut dire « rembobiner » pour ce moteur. `setState` seul ne peut pas le + * faire : il garde l'etat quitte pour le fondre, ce qui est exactement son role en + * lecture, et exactement ce qu'il ne faut pas quand on revient au debut d'une sequence. + * Rejouer l'image 0 apres une passe complete melangeait le premier etat avec le DERNIER, + * et l'export GIF s'ouvrait sur une boule sans yeux — la comete a un `eyeAlpha` nul. + * + * `sample` reste une fonction pure du temps : comme `setState`, ceci est un setter DATE, + * appele par le pilote de la sequence, jamais pendant un echantillonnage. + */ + reset(id: StateId, now: number) { + this.cur = id + this.prev = null + this.departFige = null + this.tCur = now + this.tPrev = now + this.blinkAt = -10 + } + + /** + * Origine du fondu en cours : la pose figee s'il y en a une, sinon l'etat quitte evalue + * a son propre temps ecoule — donc encore en train de s'animer, ce qui est voulu. + */ + private origine( + now: number, + shape: number[] | null, + expr: BotExpression | null + ): Pose | null { + if (this.departFige) return this.departFige + if (!this.prev) return null + const prevDef = STATE_BY_ID.get(this.prev)! + return this.posed(prevDef, Math.max(0, now - this.tPrev), shape, expr) + } + + /** + * Pose composite a l'instant `now`, fondu en cours compris : exactement ce que `sample` + * melange, avant la couche de vie au repos et de regard. Extraite pour que `setState` + * puisse la figer. + */ + private poseComposee(now: number): Pose { + const def = STATE_BY_ID.get(this.cur)! + const shape = this.shapeAtTime(now) + const expr = this.exprAtTime(now) + const pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr) + const since = now - this.tCur + if (since >= def.morph) return pose + const origine = this.origine(now, shape, expr) + if (!origine) return pose + return blendPose(origine, pose, easings.easeOutQuint(clamp(since / def.morph))) + } + + /** + * Changement d'etat, date. + * + * Le moteur ne garde qu'UNE case d'historique, donc un changement qui arrive pendant un + * fondu remplacait l'origine du melange par la pose PLEINE de l'etat qu'on quittait, au + * lieu de l'image partiellement melangee qui etait a l'ecran. Mesure sur + * `idle -> wide -> idle` a 100 ms : 35,9 px de saut contre 8,0 px de mouvement normal. + * + * On fige donc la pose composite courante et on melange depuis elle. Continu par + * construction, quel que soit le nombre de changements enchaines. + * + * Et SEULEMENT dans ce cas. Figer a chaque changement arreterait net l'animation de + * l'etat qu'on quitte pendant tout le fondu — le « ! » d'`alert` se figerait en pleine + * course — alors qu'il n'y a rien a corriger hors morph : l'etat quitte y est deja + * exactement l'image affichee. La lecture d'un montage, dont les blocs durent au moins + * le plus long fondu (`MIN_BLOCK`), ne fige donc jamais rien et rend au bit ce qu'elle + * rendait. + */ + setState(id: StateId, now: number) { + if (id === this.cur) return + const morph = STATE_BY_ID.get(this.cur)!.morph + const enPleinFondu = this.prev !== null && now - this.tCur < morph + this.departFige = enPleinFondu ? this.poseComposee(now) : null + this.prev = this.cur + this.tPrev = this.tCur + this.cur = id + this.tCur = now + // Dans la video, chaque changement de forme est masque par un clignement. + if (STATE_BY_ID.get(id)?.blinkIn) this.blinkAt = now + } + + sample(now: number): BotFrame { + const R = this.scale + const def = STATE_BY_ID.get(this.cur)! + const shape = this.shapeAtTime(now) + const expr = this.exprAtTime(now) + let pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr) + let decalage = this.decalageAtTime(now, this.cur) + + // --- transition ------------------------------------------------------- + const since = now - this.tCur + // L'etat precedent n'est jamais purge : `since < def.morph` suffit a + // l'ignorer une fois le fondu passe, et l'oublier rendrait le moteur non + // rejouable — relire une date d'avant la fin du fondu ne le retrouverait + // plus. C'est l'optimisation qui parait innocente et qui casse tout. + const origine = since < def.morph ? this.origine(now, shape, expr) : null + if (origine) { + // Ease-out exponentiel : c'est la courbe mesuree sur la video. Le corps + // n'a pas d'overshoot (seuls la pastille et l'ouverture des yeux en ont). + // Le ratio est borne : relire une date ANTERIEURE au changement d'etat + // donnerait un ratio negatif, que l'ease-out extrapole — la silhouette + // part alors trente fois trop loin. + const ratio = easings.easeOutQuint(clamp(since / def.morph)) + pose = blendPose(origine, pose, ratio) + // Le decalage des yeux suit la MEME courbe que la silhouette qui le motive. Il vient + // de l'etat quitte, que `setState` renseigne toujours en meme temps que l'origine — + // le test est la pour le typage, pas pour un cas reel. + const quitte = this.prev + if (quitte) { + const avant = this.decalageAtTime(now, quitte) + decalage = { + x: lerp(avant.x, decalage.x, ratio), + y: lerp(avant.y, decalage.y, ratio) + } + } + } + + // --- vie au repos ----------------------------------------------------- + const alive = pose.eyeAlpha > 0.01 + const look = this.lookAtTime(now) + const life = liveliness(now, { wander: alive ? look.wander : 0, blink: alive }) + + const gaze = { + // Les deux visees REMPLACENT celles de la pose au lieu de s'y ajouter (voir + // `Look`), et le tour se retranche en chemin. La derive s'ajoute APRES le + // melange, sinon la cible l'annulerait en meme temps que la pose — or elle + // doit survivre a une tete tournee sans pointeur. + yaw: lerp(pose.gaze.yaw, look.yaw, look.mix) + life.dYaw - look.spin, + pitch: lerp(pose.gaze.pitch, look.pitch, look.mix) + life.dPitch, + // le roulis, lui, ne suit rien : la tete du bot est penchee de -13deg dans + // la video, et la faire rouler avec le curseur casse cette signature + roll: pose.gaze.roll + life.dRoll + } + + // clignement declenche par le changement d'etat, en plus du calendrier + const forced = clamp((now - this.blinkAt) / 0.2) + const forcedLid = forced < 1 ? Math.abs(forced * 2 - 1) : 1 + const lid = Math.min(life.lid, forcedLid) + + const offX = pose.offX + life.driftX + const offY = pose.offY + life.driftY + + // --- corps ------------------------------------------------------------ + const sil: Silhouette = { + ...pose.sil, + cx: pose.sil.cx + offX, + cy: pose.sil.cy + offY, + sy: pose.sil.sy * life.breath + } + const bodyPath = closedPath(toPoints(sil, R, this.pts)) + + // --- yeux ------------------------------------------------------------- + // Les yeux vivent sur une sphere de rayon 1 ; des que la silhouette n'est + // plus un cercle, on les ramene au prorata du rayon reel dans leur + // direction, sinon ils debordent et le masque les coupe. + const bodyRadius = (x: number, y: number) => + radiusAtAngle(pose.sil.radii, Math.atan2(y, x) - pose.sil.rot) + + const eyes: RenderedEye[] = [] + if (pose.eyeAlpha > 0.01) { + const poses = eyePoses(gaze, R, pose.split) + for (let i = 0; i < 2; i++) { + const e = poses[i]! + if (e.depth <= 0.02) continue + const cfg = pose.eyes[i]! + const fit = bodyRadius(e.x, e.y) + // Inclinaison propre de l'oeil : on compose le repere tangent avec une + // rotation dans le plan de l'oeil (Basis x Rot). C'est ce qui permet des + // inclinaisons en miroir entre les deux yeux. + const phi = ((cfg.tilt ?? 0) * Math.PI) / 180 + const cp = Math.cos(phi) + const sp = Math.sin(phi) + const ax = e.a * cp + e.c * sp + const ay = e.b * cp + e.d * sp + const cx2 = -e.a * sp + e.c * cp + const cy2 = -e.b * sp + e.d * cp + // Le clignement s'applique APRES tout ca : c'est un ecrasement vertical + // a l'ecran, pas le long de l'axe de la gelule. + const k = blinkScale(Math.min(lid, cfg.open)) + eyes.push({ + d: capsulePath(cfg.w * R, cfg.h * R), + matrix: `matrix(${r2(ax)},${r2(ay * k)},${r2(cx2)},${r2(cy2 * k)},${r2(e.x * fit + (offX + decalage.x) * R)},${r2(e.y * fit + (offY + decalage.y) * R)})`, + alpha: pose.eyeAlpha * clamp(e.depth / 0.12) + }) + } + } + + // --- decor ------------------------------------------------------------ + const dots = pose.dots + .filter((p) => p.opacity > 0.01 && p.r > 0.0005) + .map((p) => ({ ...p, x: (p.x + offX) * R, y: (p.y + offY) * R, r: p.r * R })) + + // la pastille est posee sur le contour : elle suit donc la forme aussi + const nFit = pose.notif ? bodyRadius(pose.notif.x, pose.notif.y) : 1 + const nx = pose.notif ? (pose.notif.x * nFit + offX) * R : 0 + const ny = pose.notif ? (pose.notif.y * nFit + offY) * R : 0 + const notif = pose.notif ? { x: nx, y: ny, r: pose.notif.r * R } : null + const notch = pose.notif ? { x: nx, y: ny, r: pose.notif.notch * R } : null + + return { + bodyPath, + bodyAlpha: pose.bodyAlpha, + eyes, + dots, + dotsBehind: pose.dotsBehind, + // Les etats declarent des arcs en unites de rayon de boule ; le moteur + // est le seul a connaitre l'echelle du viewBox, donc c'est lui qui trace. + arcs: pose.arcs + .filter((a) => a.opacity > 0.01) + .map((a) => arcRender(a.seed, a.t, R, a.id, a.opacity)), + notif, + notch + } + } +} + diff --git a/apps/openlive-gateway/web/vendor/bloub/src/expressions.ts b/apps/openlive-gateway/web/vendor/bloub/src/expressions.ts new file mode 100644 index 0000000..5a9050b --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/src/expressions.ts @@ -0,0 +1,192 @@ +import { EYE_H, EYE_SPLIT, EYE_W, REST_GAZE, type HeadGaze } from './face' +import { lerp } from './math' +import type { EyeCfg } from './states' + +/** + * Expression de repos du bot. + * + * Le visage ne tient qu'à deux gélules, donc tout se joue sur quatre leviers : + * l'orientation de la tête, l'écart des yeux, leurs proportions, et + * l'inclinaison propre de chaque œil. C'est ce dernier qui permet la colère et + * la tristesse : elles demandent des inclinaisons EN MIROIR (les hauts qui + * convergent ou divergent), impossible avec le seul roulis de tête qui incline + * les deux yeux du même côté. + * + * Seul l'état de repos porte cette expression. Les états expressifs de la vidéo + * (clin d'œil, yeux écarquillés, notification) gardent la leur : c'est elle + * qu'on est venu reproduire. + * + * Les amplitudes s'appuient sur bible-strong-avatar-lab, qui expose le même + * modèle (tête X/Y/Z, largeur et hauteur par œil, écart, angle par œil) : chez + * eux la largeur va de 0,8 à 2,7 fois le neutre, la hauteur de 0,3 à 1,5, et + * les angles jusqu'à ±80°. On reste dans cette enveloppe. + */ +/** Enumeres pour que la couche i18n verifie leurs traductions a la compilation. */ +export type ExpressionId = + | 'neutre' + | 'attentif' + | 'surpris' + | 'excite' + | 'heureux' + | 'hilare' + | 'colere' + | 'triste' + | 'effraye' + | 'mefiant' + | 'confus' + | 'curieux' + | 'fier' + | 'timide' + | 'blase' + | 'somnolent' + +export interface BotExpression { + id: ExpressionId + gaze: HeadGaze + split: number + eyes: [EyeCfg, EyeCfg] +} + +/** `tilt` en degrés, positif = le haut de la gélule part vers la droite. */ +const eye = (w: number, h: number, tilt = 0, open = 1): EyeCfg => ({ w, h, tilt, open }) + +/** Les deux yeux identiques, inclinaisons en miroir si `tilt` est fourni. */ +const pair = (w: number, h: number, tilt = 0, open = 1): [EyeCfg, EyeCfg] => [ + eye(w, h, tilt, open), + eye(w, h, -tilt, open) +] + +export const EXPRESSIONS: BotExpression[] = [ + { + // la pose relevée image par image sur la vidéo de référence + id: 'neutre', + gaze: { ...REST_GAZE }, + split: EYE_SPLIT, + eyes: [eye(EYE_W, EYE_H), eye(EYE_W, EYE_H)] + }, + { + id: 'attentif', + gaze: { yaw: 4, pitch: 5, roll: -4 }, + split: 16, + eyes: pair(0.21, 0.44) + }, + { + id: 'surpris', + gaze: { yaw: 3, pitch: -3, roll: 0 }, + split: 19, + eyes: pair(0.45, 0.47) + }, + { + id: 'excite', + gaze: { yaw: 6, pitch: -14, roll: 0 }, + split: 19.5, + eyes: pair(0.4, 0.56, -10) + }, + { + // yeux plissés en arc : les hauts convergent légèrement + id: 'heureux', + gaze: { yaw: 5, pitch: 9, roll: 0 }, + split: 17, + eyes: pair(0.27, 0.17, 14) + }, + { + id: 'hilare', + gaze: { yaw: 4, pitch: 14, roll: 0 }, + split: 18, + eyes: pair(0.34, 0.13, 20) + }, + { + // hauts des yeux qui convergent fort vers le centre + yeux étrécis + id: 'colere', + gaze: { yaw: 3, pitch: 7, roll: 0 }, + split: 17, + eyes: pair(0.34, 0.15, 30) + }, + { + // l'inverse : les hauts divergent, et le regard tombe + id: 'triste', + gaze: { yaw: 3, pitch: -13, roll: 0 }, + split: 16, + eyes: pair(0.22, 0.4, -28) + }, + { + id: 'effraye', + gaze: { yaw: 2, pitch: -20, roll: 0 }, + split: 20.5, + eyes: pair(0.4, 0.6) + }, + { + // un œil franchement plus fermé que l'autre + id: 'mefiant', + gaze: { yaw: 12, pitch: 6, roll: -6 }, + split: 16, + eyes: [eye(0.21, 0.4), eye(0.22, 0.15)] + }, + { + // asymétrique sur les deux axes : tailles ET inclinaisons dépareillées. + // L'œil plissé est volontairement plat (rapport 1,6) : à un rapport proche + // de 1 il serait rond, et son inclinaison ne se verrait pas. + id: 'confus', + gaze: { yaw: -14, pitch: 3, roll: 8 }, + split: 16.5, + eyes: [eye(0.2, 0.44, -18), eye(0.28, 0.17, 14)] + }, + { + // la tête penche : c'est le roulis qui porte la curiosité + id: 'curieux', + gaze: { yaw: 16, pitch: -9, roll: -15 }, + split: 16.5, + eyes: [eye(0.24, 0.46, -8), eye(0.2, 0.38, -8)] + }, + { + id: 'fier', + gaze: { yaw: 5, pitch: 17, roll: 0 }, + split: 17, + eyes: pair(0.3, 0.15, 18) + }, + { + id: 'timide', + gaze: { yaw: -19, pitch: -14, roll: -7 }, + split: 14, + eyes: pair(0.17, 0.3) + }, + { + // fentes horizontales et regard qui part sur le côté + id: 'blase', + gaze: { yaw: -22, pitch: 2, roll: 0 }, + split: 16, + eyes: pair(0.3, 0.12) + }, + { + // paupières à moitié tombées : on passe par `open`, donc l'écrasement + // vertical à l'écran, le même mécanisme que le clignement + id: 'somnolent', + gaze: { yaw: 6, pitch: -9, roll: -3 }, + split: 16, + eyes: pair(0.2, 0.42, 0, 0.42) + } +] + +export const EXPRESSION_BY_ID = new Map(EXPRESSIONS.map((e) => [e.id, e])) +export const DEFAULT_EXPRESSION = 'neutre' + +const lerpEyeCfg = (a: EyeCfg, b: EyeCfg, t: number): EyeCfg => ({ + w: lerp(a.w, b.w, t), + h: lerp(a.h, b.h, t), + tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t), + open: lerp(a.open, b.open, t) +}) + +/** Interpolation de deux expressions : le changement se fait en glissant. */ +export function blendExpression(a: BotExpression, b: BotExpression, t: number): BotExpression { + return { + id: b.id, + gaze: { + yaw: lerp(a.gaze.yaw, b.gaze.yaw, t), + pitch: lerp(a.gaze.pitch, b.gaze.pitch, t), + roll: lerp(a.gaze.roll, b.gaze.roll, t) + }, + split: lerp(a.split, b.split, t), + eyes: [lerpEyeCfg(a.eyes[0], b.eyes[0], t), lerpEyeCfg(a.eyes[1], b.eyes[1], t)] + } +} diff --git a/apps/openlive-gateway/web/vendor/bloub/src/eyefit.ts b/apps/openlive-gateway/web/vendor/bloub/src/eyefit.ts new file mode 100644 index 0000000..7ea575d --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/src/eyefit.ts @@ -0,0 +1,455 @@ +/** + * Ou poser le visage sur une forme du personnalisateur. + * + * Les yeux vivent sur une sphere, et `radiusAtAngle` les recolle au contour reel au + * prorata du rayon local. Ce prorata place bien leur CENTRE, mais l'oeil a une taille : + * la marge qui lui reste devant le bord est multipliee par le meme facteur, donc une + * silhouette etroite dans sa direction le pousse contre le bord jusqu'a ce que le + * masque l'ouvre vers l'exterieur. La gelule apparaissait comme une encoche dans le + * corps sur `capsule`, `triangle`, `nuage` et `goutte`. + * + * Ce module resout le probleme UNE FOIS, au chargement, et rend une table de decalages. + * Ce choix est l'essentiel du correctif, bien plus que la geometrie qui suit : + * + * Resolue dans la boucle de rendu, la correction reagit a tout ce qui bouge a soixante + * images par seconde — la derive du regard, le pointeur, l'expression en cours de + * morph, le bord le plus proche qui change, l'oeil le plus contraint qui change. Sept + * variantes ont ete ecrites ainsi et toutes produisaient un artefact de mouvement + * visible : tremblement permanent, saut de direction de 26 unites quand le bord de + * reference basculait, grossissement brusque quand la taille entrait dans le calcul. + * Le defaut n'etait dans aucune de leurs geometries, il etait dans le fait de resoudre + * par image. + * + * Le reste du moteur ne travaille pas comme ca : les poses sont DECLAREES et il ne fait + * que les interpoler avec des courbes connues. Un decalage tabule rentre dans ce moule. + * Il ne bouge pas quand le regard derive ni quand le pointeur bouge, et sur un changement + * de forme ou d'expression il ne fait qu'aller d'une entree de table a l'autre, sur la + * courbe de ce morph. Le tremblement devient impossible par construction, au lieu d'etre + * repousse : interpoler entre deux constantes est monotone, alors que re-resoudre le + * probleme sur un regard en cours d'interpolation ne l'est pas. + * + * Corollaire agreable : le solveur n'a plus aucune contrainte de continuite, puisqu'il ne + * tourne pas pendant l'animation. Il peut donc sonder tout un faisceau de directions et + * couvrir le pire cas de la derive du regard, ce qu'une version par image ne pouvait pas + * se permettre. + * + * La table est une constante de module, batie a l'import a partir de donnees pures : + * meme nature que le calendrier de clignements de `face.ts`, deterministe et sans etat, + * donc sans effet sur la purete de `engine.sample(t)`. + */ + +import { EXPRESSIONS, type BotExpression } from './expressions' +import { eyePoses } from './face' +import { radiusAtAngle, toPoints, type Point } from './shape' +import { SHAPES } from './skins' +import { STATES, type Pose, type StateDef, type StateId } from './states' + +/** Rayon de reference du solveur. Le decalage rendu est en unites de ce rayon. */ +const R = 100 + +/** + * Amplitudes maximales de la vie au repos, lues sur `liveliness` : `loopNoise` est + * borne a 1 en valeur absolue, donc ces sommes sont des bornes exactes et non des + * estimations. + * + * Il faut les couvrir, sinon la correction est juste sur la pose nominale et fausse une + * seconde plus tard : 7 degres de lacet deplacent l'oeil d'une douzaine d'unites sur une + * boule de rayon 100. C'est precisement ce qui faisait deborder `capsule` + `effraye` + * alors qu'une mesure a un seul instant le declarait bon. + */ +const DERIVE_YAW = 5.5 + 1.6 +const DERIVE_PITCH = 4.2 + 1.3 +/** Flottement du centre, en unites de rayon de boule. */ +const DERIVE_X = 0.006 +const DERIVE_Y = 0.007 + +/** Le visage d'une pose, ce dont le solveur a besoin pour placer ses gelules. */ +interface Visage { + gaze: Pose['gaze'] + split: number + eyes: Pose['eyes'] +} + +/** + * Une gelule prete a etre mesuree : le segment de son axe, et de quoi calculer le rayon + * a degager DANS UNE DIRECTION donnee. + * + * Une gelule est exactement un segment epaissi d'un disque de rayon `r`. Son image par la + * matrice tangente est donc un segment epaissi d'une ELLIPSE, et le rayon a degager + * depend de la direction : c'est la fonction d'appui de cette ellipse, `r * |A^T u|`. + * + * Prendre a la place sa plus grande valeur singuliere serait conservateur mais faux dans + * la seule direction qui compte, et ca se paie cher : la marge de reference sur le cercle + * ressortait NEGATIVE, donc la demande devenait sans dents et 34 combinaisons + * continuaient de deborder. + */ +interface Empreinte { + /** centre, en unites de viewBox */ + x: number + y: number + /** demi-vecteur de l'axe */ + ax: number + ay: number + /** rayon du disque local, avant transformation */ + r: number + /** colonnes de la matrice tangente, pour la fonction d'appui */ + m: [number, number, number, number] +} + +/** + * Empreintes des deux yeux d'un visage, posees sur un profil. + * + * Une gelule est exactement un segment epaissi d'un disque de rayon `r`. Son image par + * la matrice tangente est donc un segment epaissi d'une ELLIPSE, et un disque du rayon + * de son grand axe la couvre : d'ou la plus grande valeur singuliere. La mesure reste + * ainsi conservatrice au sens strict, une marge positive garantissant que la gelule est + * dedans. + * + * Le clignement n'y est pas : un oeil ferme n'a pas besoin qu'on lui fasse de la place. + */ +function empreintes(visage: Visage, sil: Pose['sil'], radii: number[]): Empreinte[] { + const out: Empreinte[] = [] + const poses = eyePoses(visage.gaze, R, visage.split) + for (let i = 0; i < 2; i++) { + const e = poses[i]! + if (e.depth <= 0.02) continue + const cfg = visage.eyes[i]! + const phi = ((cfg.tilt ?? 0) * Math.PI) / 180 + const cp = Math.cos(phi) + const sp = Math.sin(phi) + const ax = e.a * cp + e.c * sp + const ay = e.b * cp + e.d * sp + const cx = -e.a * sp + e.c * cp + const cy = -e.b * sp + e.d * cp + + const hw = Math.max(cfg.w * R, 0.01) / 2 + const hh = Math.max(cfg.h * R, 0.01) / 2 + const r = Math.min(hw, hh) + // l'axe est celui de la plus grande dimension + const long = hh > hw + const demi = long ? hh - r : hw - r + // le prorata du rayon local, exactement comme le fait le moteur + const fit = radiusAtAngle(radii, Math.atan2(e.y, e.x) - sil.rot) + out.push({ + x: e.x * fit, + y: e.y * fit, + ax: (long ? cx : ax) * demi, + ay: (long ? cy : ay) * demi, + r, + m: [ax, ay, cx, cy] + }) + } + return out +} + +/** + * Approche la plus courte entre un contour et un segment : la distance, et le vecteur + * qui va du contour vers le segment — le sens qui degage. + * + * Les deux sortent de la MEME passe. Les calculer separement doublait le seul vrai cout + * de ce module, qui est ce balayage. + */ +function approche(pts: Point[], x0: number, y0: number, x1: number, y1: number) { + const sx = x1 - x0 + const sy = y1 - y0 + const len2 = sx * sx + sy * sy + let best = Infinity + let vx = 0 + let vy = 0 + for (let i = 0; i < pts.length; i++) { + const p = pts[i]! + let t = len2 > 0 ? ((p.x - x0) * sx + (p.y - y0) * sy) / len2 : 0 + t = t < 0 ? 0 : t > 1 ? 1 : t + const ex = x0 + t * sx - p.x + const ey = y0 + t * sy - p.y + const d2 = ex * ex + ey * ey + if (d2 < best) { + best = d2 + vx = ex + vy = ey + } + } + const d = Math.sqrt(best) + return { d, ux: d > 1e-9 ? vx / d : 0, uy: d > 1e-9 ? vy / d : 0 } +} + +/** Une epreuve : des gelules a faire tenir dans un contour, et le contour de reference. */ +interface Epreuve { + empreintes: Empreinte[] + reference: Empreinte[] + contour: Point[] + calContour: Point[] +} + +/** + * Flottement du centre au repos, en unites de viewBox. Il est ajoute au rayon de la + * gelule : moins d'une unite, donc l'absorber ainsi coute moins cher que de multiplier les + * epreuves par ses quatre coins. + */ +const FLOTTEMENT = Math.hypot(DERIVE_X, DERIVE_Y) * R + +/** Marge de la gelule la plus serree, et le sens qui la degage. */ +function pire(pts: Point[], emps: Empreinte[], tx: number, ty: number) { + let marge = Infinity + let ux = 0 + let uy = 0 + for (const e of emps) { + const x = e.x + tx + const y = e.y + ty + const a = approche(pts, x - e.ax, y - e.ay, x + e.ax, y + e.ay) + // fonction d'appui de l'ellipse dans la direction de l'approche + const [m0, m1, m2, m3] = e.m + const rayon = + e.r * Math.hypot(m0 * a.ux + m1 * a.uy, m2 * a.ux + m3 * a.uy) + FLOTTEMENT + if (a.d - rayon < marge) { + marge = a.d - rayon + ux = a.ux + uy = a.uy + } + } + return { marge, ux, uy } +} + +/** + * Directions sondees et pas de la dichotomie. Le produit des deux est le cout de + * construction de la table, seul chiffre a surveiller ici. + */ +const DIRECTIONS = 12 +const DICHOTOMIE = 8 + +/** + * Le decalage a poser sur les deux yeux pour cette forme, cet etat et cette expression. + * + * Une TRANSLATION commune aux deux yeux, donc une isometrie : ecart entre les yeux, + * tailles et inclinaisons sont conserves au pixel. Le visage est seulement pose un peu + * plus bas sur un corps qui n'a pas de place en haut, ce qui est le geste qu'on ferait a + * la main. Les variantes qui bornaient chaque oeil separement ecartaient la paire, et + * celles qui mettaient le visage a l'echelle rapetissaient les yeux — visiblement. + * + * La marge visee est celle du profil D'ORIGINE, pas un degagement strict : sur le cercle + * l'oeil exterieur frole deja le bord, 17,3 unites pour une boule de rayon 100, et c'est + * voulu, c'est ce qui donne le volume. Elle est plafonnee par ce que la forme offre en son + * centre, sinon la demande est intenable sur un corps plat. + * + * RECHERCHE DIRECTIONNELLE et non descente. On cherche la translation de plus petite + * norme qui tient, donc on sonde une couronne de directions et on dichotomie la distance + * le long de chacune. Une descente de gradient a ete ecrite d'abord et elle ne converge + * pas : degager la paire d'un bord la rapproche de l'autre, si bien qu'elle tatonne et ne + * fait que garder son meilleur essai — passer ses tours de 40 a 18 suffisait a faire + * reapparaitre 34 debordements. Ici le resultat ne depend pas d'une convergence : chaque + * direction est resolue exactement, au pas de dichotomie pres. + */ +function resous(epreuves: Epreuve[]): { x: number; y: number } { + if (!epreuves.length) return { x: 0, y: 0 } + + /** La marge la plus serree sur toutes les epreuves, pour une translation donnee. */ + const marge = (tx: number, ty: number) => { + let m = Infinity + for (const ep of epreuves) m = Math.min(m, pire(ep.contour, ep.empreintes, tx, ty).marge) + return m + } + + // Marge exigee : la plus serree que le profil d'origine tolere, sur toutes les + // epreuves. Puis plafonnee par le plus degage que la forme puisse offrir a la paire, + // son centre. + let requis = Infinity + for (const ep of epreuves) { + requis = Math.min(requis, pire(ep.calContour, ep.reference, 0, 0).marge) + } + /* + * La course doit pouvoir atteindre le centre du corps : `wide` a des gelules de 87 + * unites de long, et sur un triangle elles ne tiennent que vers le milieu, a une + * cinquantaine d'unites de leur place nominale. Une course fixe les laissait dehors. + */ + let mx = 0 + let my = 0 + const emps = epreuves[0]!.empreintes + for (const e of emps) { + mx -= e.x / emps.length + my -= e.y / emps.length + } + const course = Math.max(0.35 * R, Math.hypot(mx, my) * 1.25) + + // Plafond de la demande : ce que la forme offre en son centre, toujours atteignable. + requis = Math.min(requis, marge(mx, my)) + + /* + * Deja bon : le cas du cercle, et de toute forme assez large. La gelule doit RENTRER + * en plus de n'etre pas plus serree que sur le profil d'origine — sans cette seconde + * condition, une forme ou rien ne rentre satisfait la premiere de facon degeneree et + * on abandonnait. `wide` a des gelules de 87 unites de long, `notify` de 50 de + * diametre : sur un triangle ou une goutte elles debordent quoi qu'on fasse, et il + * faut alors viser le moins pire, pas renoncer. + */ + const depart = marge(0, 0) + if (depart >= requis && depart >= 0) return { x: 0, y: 0 } + const cible = Math.max(requis, 0) + + let meilleurX = 0 + let meilleurY = 0 + let meilleureNorme = Infinity + // repli quand rien ne rentre : la translation qui degage le plus, sondee au passage + let secoursX = 0 + let secoursY = 0 + let secours = depart + + for (let d = 0; d < DIRECTIONS; d++) { + const a = (d / DIRECTIONS) * Math.PI * 2 + const ux = Math.cos(a) + const uy = Math.sin(a) + if (marge(ux * course, uy * course) < cible) { + // cette direction ne mene nulle part ; on garde quand meme le meilleur degagement + // pas de solution par la, mais peut-etre un meilleur degagement + for (const k of [0.3, 0.6, 1]) { + const m = marge(ux * course * k, uy * course * k) + if (m > secours) { + secours = m + secoursX = ux * course * k + secoursY = uy * course * k + } + } + continue + } + // la plus courte distance qui tient, le long de cette direction + let bas = 0 + let haut = course + for (let i = 0; i < DICHOTOMIE; i++) { + const mid = (bas + haut) / 2 + if (marge(ux * mid, uy * mid) >= cible) haut = mid + else bas = mid + } + if (haut < meilleureNorme) { + meilleureNorme = haut + meilleurX = ux * haut + meilleurY = uy * haut + } + } + + const x = meilleureNorme === Infinity ? secoursX : meilleurX + const y = meilleureNorme === Infinity ? secoursY : meilleurY + // rendu en unites de RAYON DE BOULE : le moteur le remet a son echelle + return { x: +(x / R).toFixed(6), y: +(y / R).toFixed(6) } +} + +/** + * Le visage a couvrir : celui de l'expression si l'etat l'accepte, le sien sinon. + * + * UNE entree de table par expression, et non un pire cas commun a toutes. Un pire cas + * paraissait plus sur — un decalage constant ne peut pas bouger quand l'expression change + * — mais il est intenable : sur une capsule, `neutre` a les yeux hauts et demande a + * descendre quand `effraye` les a bas et demande a monter. Aucune translation unique ne + * satisfait les deux, et la mesure le confirme (4 debordements de 4,8 unites). + * + * Une entree par expression n'est pas moins fluide pour autant : le moteur interpole + * entre DEUX CONSTANTES, ce qui est monotone par construction. Ce qui tremblait, c'etait + * de re-resoudre le probleme sur un regard en cours d'interpolation. + */ +function visageDe(def: StateDef, pose: Pose, expr: BotExpression | null): Visage { + if (def.baseFace && expr) return { gaze: expr.gaze, split: expr.split, eyes: expr.eyes } + return { gaze: pose.gaze, split: pose.split, eyes: pose.eyes } +} + +/** Les dates a echantillonner dans un etat : une seule si sa pose ne bouge pas. */ +function dates(def: StateDef): number[] { + /** Tout ce dont le solveur se sert : si rien ne bouge, une date suffit. */ + const signature = (p: Pose) => + JSON.stringify([p.gaze, p.split, p.eyes, p.sil.rot, p.sil.cx, p.sil.cy, p.sil.sx, p.sil.sy]) + if (signature(def.pose(0)) === signature(def.pose(def.duration))) return [0] + const n = 3 + return Array.from({ length: n }, (_, i) => (i / (n - 1)) * def.duration) +} + +/** Le decalage d'une forme sur un etat et une expression, derive comprise. */ +function decalagePour( + def: StateDef, + radii: number[], + expr: BotExpression | null +): { x: number; y: number } { + const epreuves: Epreuve[] = [] + for (const t of dates(def)) { + const pose = def.pose(t) + const contour = toPoints({ ...pose.sil, radii }, R) + const calContour = toPoints(pose.sil, R) + const v = visageDe(def, pose, expr) + // Les quatre coins de la derive bornent la pose nominale, qui est leur centre : la + // tester en plus ne changerait aucune marge et coute une epreuve sur cinq. + const coins: Visage[] = [] + for (const dy of [-DERIVE_YAW, DERIVE_YAW]) { + for (const dp of [-DERIVE_PITCH, DERIVE_PITCH]) { + coins.push({ + ...v, + gaze: { yaw: v.gaze.yaw + dy, pitch: v.gaze.pitch + dp, roll: v.gaze.roll } + }) + } + } + for (const c of coins) { + epreuves.push({ + empreintes: empreintes(c, pose.sil, radii), + reference: empreintes(c, pose.sil, pose.sil.radii), + contour, + calContour + }) + } + } + return resous(epreuves) +} + +/** Zero, la valeur commune a tout ce qui n'a rien a corriger. */ +const NUL = { x: 0, y: 0 } as const + +/** Clef d'une entree : l'etat, et l'expression quand l'etat l'accepte. */ +const clef = (state: StateId, expr: string | null) => `${state}|${expr ?? ''}` + +/** + * Table des decalages, batie a l'import : une entree par (forme, etat a corps de base, + * expression). Seuls `idle` et `swirl` portent le visage de repos, donc seuls eux se + * declinent par expression — les trois autres etats a corps de base ont un visage releve + * sur la video et une seule entree. + * + * Clef par REFERENCE du tableau de rayons, ce qui est deja la convention du moteur : ses + * gardes `radii === this.shape` et `expression === this.expr` reposent sur la meme + * stabilite. Un profil inconnu, ou `null`, ne corrige rien — l'API accepte n'importe quel + * tableau et le moteur n'a pas a dependre de la prudence de ses appelants. + */ +function batir(): Map> { + return new Map( + SHAPES.map((forme) => { + const par = new Map() + for (const def of STATES) { + if (!def.baseBody) continue + const expressions = def.baseFace ? [null, ...EXPRESSIONS] : [null] + for (const expr of expressions) { + par.set(clef(def.id, expr?.id ?? null), decalagePour(def, forme.radii, expr)) + } + } + return [forme.radii, par] + }) + ) +} + +const DECALAGES = batir() + +/** + * Decalage a appliquer aux deux yeux pour cette forme sur cet etat, en unites de rayon + * de boule — le moteur le remet a son echelle. + * + * Vaut zero des que la forme n'est pas au catalogue, ce qui couvre `null` et le cercle : + * sur le cercle les deux profils sont le meme, donc la marge est deja celle exigee et la + * descente sort au premier tour. La forme relevee sur la video ne bouge donc pas, sans + * cas particulier. + */ +export function decalageDesYeux( + radii: number[] | null, + state: StateId, + expr: string | null +): { x: number; y: number } { + if (!radii) return NUL + const par = DECALAGES.get(radii) + if (!par) return NUL + // un etat sans visage de repos n'a qu'une entree, quelle que soit l'expression + return par.get(clef(state, expr)) ?? par.get(clef(state, null)) ?? NUL +} + +/** Pour les tests : de quoi verifier la table sans refaire la geometrie. */ +/** Pour les tests : de quoi chronometrer la construction de la table. */ +export const POUR_TESTS = { batir } diff --git a/apps/openlive-gateway/web/vendor/bloub/src/face.ts b/apps/openlive-gateway/web/vendor/bloub/src/face.ts new file mode 100644 index 0000000..5abac45 --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/src/face.ts @@ -0,0 +1,179 @@ +import { clamp, createRng, loopNoise } from './math' + +/** + * Les yeux sont peints sur une sphere, pas poses a plat. + * + * Mesure sur la video : l'oeil le plus proche du bord fait 0.69 fois la largeur + * de l'autre, et son aire 0.663 fois — exactement le facteur de profondeur + * (z = 0.669) d'un point de sphere a cette distance du centre. On modelise donc + * une vraie orientation de tete : chaque oeil recupere le repere tangent de la + * sphere, projete en orthographique. La compression et l'inclinaison en + * decoulent toutes seules, c'est ce qui donne le volume. + * + * Les constantes ci-dessous ne sont pas choisies a la main : elles sortent d'un + * ajustement du modele sur les positions et tailles relevees image par image + * (erreur residuelle ~1 px sur un rayon de 190 px). + */ + +type Vec3 = [number, number, number] + +/** Demi-ecart des yeux sur la sphere, en degres (separation totale ~31deg). */ +export const EYE_SPLIT = 15.46 +/** Taille de l'oeil au repos, en unites de rayon de boule. */ +export const EYE_W = 0.186 +export const EYE_H = 0.412 + +/** Orientation de tete au repos, ajustee sur les frames de reference. */ +export const REST_GAZE: HeadGaze = { yaw: 28.49, pitch: 28.62, roll: -13 } + +export interface EyePose { + x: number + y: number + /** matrice tangente 2x2 : [a b c d] au sens SVG matrix(a,b,c,d,e,f) */ + a: number + b: number + c: number + d: number + /** composante z de la normale : > 0 = face visible */ + depth: number +} + +export interface HeadGaze { + /** lacet, degres, positif = regarde a droite */ + yaw: number + /** tangage, degres, positif = regarde en haut */ + pitch: number + /** roulis, degres, inclinaison de la tete */ + roll: number +} + +const deg = (d: number) => (d * Math.PI) / 180 + +/** Fait tourner deux vecteurs d'un repere orthonorme dans leur plan commun. */ +function spin(u: Vec3, v: Vec3, angle: number): [Vec3, Vec3] { + const c = Math.cos(angle) + const s = Math.sin(angle) + return [ + [u[0] * c + v[0] * s, u[1] * c + v[1] * s, u[2] * c + v[2] * s], + [v[0] * c - u[0] * s, v[1] * c - u[1] * s, v[2] * c - u[2] * s] + ] +} + +/** + * Repere de la tete puis des deux yeux. + * Repere ecran : x a droite, y vers le bas, z vers le spectateur. + * L'indice 0 est l'oeil interieur, l'indice 1 l'oeil exterieur. + */ +export function eyePoses(gaze: HeadGaze, scale: number, split = EYE_SPLIT): [EyePose, EyePose] { + let f: Vec3 = [0, 0, 1] + let right: Vec3 = [1, 0, 0] + let down: Vec3 = [0, 1, 0] + + // lacet : forward bascule vers right + ;[f, right] = spin(f, right, deg(gaze.yaw)) + // tangage : forward bascule vers le haut (donc a l'oppose de down) + ;[down, f] = spin(down, f, deg(gaze.pitch)) + // roulis : la tete penche dans son propre plan + ;[right, down] = spin(right, down, deg(gaze.roll)) + + const build = (side: number): EyePose => { + const [ef, er] = spin(f, right, deg(split * side)) + return { + x: ef[0] * scale, + y: ef[1] * scale, + a: er[0], + b: er[1], + c: down[0], + d: down[1], + depth: ef[2] + } + } + + return [build(-1), build(1)] +} + +/** + * Vie au repos : derive lente du regard, saccades, clignements. + * + * Fonction pure du temps (aucun etat interne), donc pause, reprise et saut a + * une date arbitraire donnent toujours la meme image. Les valeurs sont des + * ECARTS a ajouter a la pose de l'etat courant. + */ +export interface Liveliness { + dYaw: number + dPitch: number + dRoll: number + /** 1 = oeil ouvert, 0 = ferme (ecrasement vertical en repere ecran) */ + lid: number + driftX: number + driftY: number + breath: number +} + +const BLINK_RNG = createRng(0x5eed) +/** Calendrier de clignements pre-tire : deterministe et sans etat. */ +const BLINKS: number[] = (() => { + const out: number[] = [] + let t = 1.4 + while (t < 900) { + out.push(t) + // 1.9 a 4.6 s entre deux clignements, plus un double clignement parfois + t += 1.9 + BLINK_RNG() * 2.7 + if (BLINK_RNG() < 0.18) { + out.push(t) + t += 0.24 + } + } + return out +})() + +/** Mesure : 1 a 2 frames a 10 fps. */ +const BLINK_DUR = 0.18 + +function blinkLid(t: number): number { + for (let i = 0; i < BLINKS.length; i++) { + const start = BLINKS[i]! + if (t < start) break + const k = (t - start) / BLINK_DUR + if (k >= 0 && k <= 1) { + // fermeture rapide, reouverture un peu plus lente + return k < 0.45 ? 1 - k / 0.45 : (k - 0.45) / 0.55 + } + } + return 1 +} + +export interface LivelinessOptions { + wander?: number + blink?: boolean + float?: boolean +} + +export function liveliness(t: number, opt: LivelinessOptions = {}): Liveliness { + const { wander = 1, blink = true, float = true } = opt + + // Periodes premieres entre elles : la derive ne se repete jamais a l'oeil. + return { + dYaw: (loopNoise(t, 11.3, 0.4) * 5.5 + loopNoise(t, 3.7, 2.1) * 1.6) * wander, + dPitch: (loopNoise(t, 9.1, 1.3) * 4.2 + loopNoise(t, 4.3, 0.7) * 1.3) * wander, + dRoll: loopNoise(t, 13.7, 3.2) * 2.2 * wander, + lid: blink ? blinkLid(t) : 1, + // Au repos la video est quasiment immobile (centre stable a +-0.003, rayon + // constant) : toute la vie passe par le regard et les clignements. On garde + // juste de quoi ne pas figer completement l'image. + driftX: float ? loopNoise(t, 7.9, 1.9) * 0.006 : 0, + driftY: float ? loopNoise(t, 5.3, 0.3) * 0.007 : 0, + // La largeur est constante, seule la hauteur respire tres legerement. + breath: float ? 1 + Math.sin((t / 3.4) * Math.PI * 2) * 0.005 : 1 + } +} + +/** + * Le clignement est un ecrasement VERTICAL en repere ecran autour du centre de + * l'oeil (mesure : la largeur de bbox est conservee, la hauteur tombe a ~0.35), + * pas un retrecissement le long de l'axe incline de la gelule. On le compose + * donc apres la matrice tangente, en n'affectant que les sorties en y. + */ +export function blinkScale(lid: number): number { + return 0.06 + 0.94 * clamp(lid) +} diff --git a/apps/openlive-gateway/web/vendor/bloub/src/math.ts b/apps/openlive-gateway/web/vendor/bloub/src/math.ts new file mode 100644 index 0000000..9de00b2 --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/src/math.ts @@ -0,0 +1,42 @@ +export const TAU = Math.PI * 2 + +export const clamp = (v: number, lo = 0, hi = 1) => (v < lo ? lo : v > hi ? hi : v) +export const lerp = (a: number, b: number, t: number) => a + (b - a) * t + +export type Easing = (t: number) => number + +/** + * Mesure sur la video : les transitions sont des ease-out exponentiels, sans + * depassement du corps. Les seuls effets de ressort sont locaux (le pop de la + * pastille de notification, l'ouverture des yeux) et sont ecrits directement + * dans l'etat concerne. + */ +export const easings = { + easeOutCubic: (t: number) => 1 - (1 - t) ** 3, + easeInOutCubic: (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2), + easeOutQuint: (t: number) => 1 - (1 - t) ** 5 +} satisfies Record + +/** Bruit 1D periodique : boucle sans couture sur `period`, utile pour la derive du regard. */ +export function loopNoise(t: number, period: number, seed = 0): number { + const p = (t / period) * TAU + return ( + 0.55 * Math.sin(p + seed) + + 0.3 * Math.sin(2 * p + seed * 1.7 + 1.1) + + 0.15 * Math.sin(3 * p + seed * 2.3 + 2.4) + ) +} + +/** PRNG deterministe (mulberry32) : meme sequence a chaque lecture. */ +export function createRng(seed: number) { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** Arrondi court : divise par ~2 le poids des chaines de path generees a 60 fps. */ +export const r2 = (v: number) => Math.round(v * 100) / 100 diff --git a/apps/openlive-gateway/web/vendor/bloub/src/profiles.ts b/apps/openlive-gateway/web/vendor/bloub/src/profiles.ts new file mode 100644 index 0000000..fd8bef1 --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/src/profiles.ts @@ -0,0 +1,22 @@ +// Profils radiaux r(theta) releves au pixel sur la video de reference. +// theta = 0 pointe vers la droite et croit dans le sens horaire (y vers le bas). +// Unite : rayon de la boule au repos = 1. +// +// Genere par tools/extract-profiles.py — ne pas editer a la main. + +export const PROFILE_SAMPLES = 64 + +export const PROFILES = { + // oeuf : meme hauteur que la boule, retreci en largeur + // image 164, empreinte mesuree 1.647 x 2.000 + egg: [0.8369,0.8424,0.8497,0.8585,0.8674,0.8775,0.8878,0.8983,0.9089,0.9185,0.9288,0.9374,0.9445,0.9504,0.9543,0.9559,0.9555,0.9519,0.9466,0.9389,0.9302,0.9193,0.9085,0.8969,0.8852,0.8734,0.8625,0.8513,0.8411,0.8325,0.8243,0.8179,0.8137,0.8112,0.8102,0.8128,0.8178,0.8262,0.8374,0.8518,0.8702,0.8922,0.9169,0.9446,0.9741,1.0023,1.0267,1.0433,1.0481,1.0393,1.0216,0.9970,0.9697,0.9418,0.9169,0.8949,0.8760,0.8604,0.8490,0.8394,0.8337,0.8314,0.8305,0.8326], + // hexagone pointe en haut, coins tres arrondis + // image 174, empreinte mesuree 1.826 x 2.011 + hexagon: [0.9210,0.9282,0.9441,0.9706,0.9984,1.0059,0.9896,0.9562,0.9290,0.9124,0.9047,0.9058,0.9157,0.9349,0.9642,0.9873,0.9882,0.9665,0.9336,0.9105,0.8968,0.8918,0.8955,0.9080,0.9293,0.9611,0.9820,0.9812,0.9590,0.9282,0.9089,0.8978,0.8964,0.9026,0.9189,0.9439,0.9778,0.9990,0.9964,0.9713,0.9439,0.9274,0.9196,0.9206,0.9308,0.9502,0.9799,1.0121,1.0226,1.0071,0.9752,0.9510,0.9366,0.9316,0.9351,0.9485,0.9711,1.0026,1.0213,1.0155,0.9863,0.9547,0.9347,0.9232], + // triangle pointe en haut, coins tres arrondis + // image 190, empreinte mesuree 1.995 x 1.884 + triangle: [0.7819,0.8211,0.8747,0.9440,1.0223,1.0960,1.1401,1.1340,1.0808,1.0047,0.9265,0.8603,0.8104,0.7730,0.7450,0.7273,0.7151,0.7118,0.7148,0.7245,0.7427,0.7680,0.8037,0.8518,0.9148,0.9876,1.0583,1.1073,1.1109,1.0667,0.9940,0.9164,0.8482,0.7948,0.7555,0.7261,0.7056,0.6925,0.6859,0.6869,0.6938,0.7084,0.7305,0.7615,0.8040,0.8595,0.9311,1.0092,1.0791,1.1171,1.1054,1.0501,0.9779,0.9050,0.8450,0.7990,0.7656,0.7413,0.7258,0.7160,0.7146,0.7204,0.7330,0.7528], +} as const + +export type ProfileName = keyof typeof PROFILES + diff --git a/apps/openlive-gateway/web/vendor/bloub/src/repere.ts b/apps/openlive-gateway/web/vendor/bloub/src/repere.ts new file mode 100644 index 0000000..c8c8b9a --- /dev/null +++ b/apps/openlive-gateway/web/vendor/bloub/src/repere.ts @@ -0,0 +1,31 @@ +/** + * Le repere de tout ce que le moteur rend. + * + * `engine.sample()` sort des coordonnees en unites de viewBox, et ces deux nombres en sont + * la definition : sans eux, une sortie du moteur ne veut rien dire. Ils vivaient dans + * `BloubBot.vue`, donc hors d'atteinte — un `