tperm-visor 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/backend/cube/__init__.py +0 -0
- package/backend/cube/renderer.py +397 -0
- package/backend/cube/rubiks.py +260 -0
- package/backend/gesture_engine.py +106 -0
- package/backend/hand_landmarker.task +0 -0
- package/backend/hand_tracker.py +199 -0
- package/backend/hud.py +136 -0
- package/backend/requirements.txt +9 -0
- package/backend/server.py +864 -0
- package/backend/utils/__init__.py +0 -0
- package/backend/utils/smoothing.py +40 -0
- package/backend/utils/transforms.py +148 -0
- package/bin/t-perm.js +148 -0
- package/frontend/css/style.css +330 -0
- package/frontend/index.html +96 -0
- package/frontend/js/app.js +105 -0
- package/package.json +49 -0
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EMA:
|
|
5
|
+
"""Exponential Moving Average — same pattern as Gesture-Media-control baseline (alpha=0.25)."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, alpha: float = 0.25):
|
|
8
|
+
self.alpha = alpha
|
|
9
|
+
self.value = None
|
|
10
|
+
|
|
11
|
+
def update(self, new_value):
|
|
12
|
+
if self.value is None:
|
|
13
|
+
self.value = np.array(new_value, dtype=float) if hasattr(new_value, '__len__') else float(new_value)
|
|
14
|
+
else:
|
|
15
|
+
self.value = self.alpha * np.array(new_value, dtype=float) + (1 - self.alpha) * self.value
|
|
16
|
+
return self.value
|
|
17
|
+
|
|
18
|
+
def reset(self):
|
|
19
|
+
self.value = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class QuatEMA:
|
|
23
|
+
"""EMA for quaternions — lerps component-wise then renormalises."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, alpha: float = 0.15):
|
|
26
|
+
self.alpha = alpha
|
|
27
|
+
self.value = np.array([0.0, 0.0, 0.0, 1.0]) # identity [x,y,z,w]
|
|
28
|
+
|
|
29
|
+
def update(self, q: np.ndarray) -> np.ndarray:
|
|
30
|
+
# Ensure same hemisphere (avoid double-cover flip)
|
|
31
|
+
if np.dot(self.value, q) < 0:
|
|
32
|
+
q = -q
|
|
33
|
+
self.value = self.alpha * q + (1 - self.alpha) * self.value
|
|
34
|
+
norm = np.linalg.norm(self.value)
|
|
35
|
+
if norm > 1e-6:
|
|
36
|
+
self.value /= norm
|
|
37
|
+
return self.value.copy()
|
|
38
|
+
|
|
39
|
+
def reset(self):
|
|
40
|
+
self.value = np.array([0.0, 0.0, 0.0, 1.0])
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.spatial.transform import Rotation
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def compute_palm_normal(landmarks) -> np.ndarray:
|
|
6
|
+
"""
|
|
7
|
+
Cross-product of wrist→index_MCP and wrist→pinky_MCP vectors.
|
|
8
|
+
Gives the 3-D outward-facing normal of the palm.
|
|
9
|
+
landmarks: list of 21 (x,y,z) normalized tuples/objects with .x .y .z
|
|
10
|
+
"""
|
|
11
|
+
def lm(idx):
|
|
12
|
+
p = landmarks[idx]
|
|
13
|
+
return np.array([p.x, p.y, p.z])
|
|
14
|
+
|
|
15
|
+
v1 = lm(5) - lm(0) # wrist → index MCP
|
|
16
|
+
v2 = lm(17) - lm(0) # wrist → pinky MCP
|
|
17
|
+
n = np.cross(v1, v2)
|
|
18
|
+
norm = np.linalg.norm(n)
|
|
19
|
+
return n / norm if norm > 1e-6 else np.array([0.0, 0.0, 1.0])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compute_finger_direction(landmarks) -> np.ndarray:
|
|
23
|
+
"""
|
|
24
|
+
Direction from wrist to middle finger MCP.
|
|
25
|
+
Combined with palm_normal this gives the full hand orientation frame.
|
|
26
|
+
"""
|
|
27
|
+
def lm(idx):
|
|
28
|
+
p = landmarks[idx]
|
|
29
|
+
return np.array([p.x, p.y, p.z])
|
|
30
|
+
|
|
31
|
+
v = lm(9) - lm(0) # wrist → middle MCP
|
|
32
|
+
norm = np.linalg.norm(v)
|
|
33
|
+
return v / norm if norm > 1e-6 else np.array([0.0, 1.0, 0.0])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def delta_rotation(prev_normal: np.ndarray, curr_normal: np.ndarray,
|
|
37
|
+
prev_finger: np.ndarray = None, curr_finger: np.ndarray = None) -> np.ndarray:
|
|
38
|
+
"""
|
|
39
|
+
Rotation (as quaternion [x,y,z,w]) that takes prev orientation to curr.
|
|
40
|
+
If finger directions are provided, uses both vectors for full 3D tracking.
|
|
41
|
+
Otherwise falls back to single-vector alignment (yaw only).
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
if prev_finger is not None and curr_finger is not None:
|
|
45
|
+
# Full 3D: align two vectors (palm normal + finger direction)
|
|
46
|
+
R, _ = Rotation.align_vectors(
|
|
47
|
+
[curr_normal, curr_finger],
|
|
48
|
+
[prev_normal, prev_finger],
|
|
49
|
+
weights=[1.0, 0.7] # normal is primary, finger is secondary
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
R, _ = Rotation.align_vectors([curr_normal], [prev_normal])
|
|
53
|
+
return R.as_quat() # [x, y, z, w]
|
|
54
|
+
except Exception:
|
|
55
|
+
return np.array([0.0, 0.0, 0.0, 1.0])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def hand_orientation_quat(palm_normal: np.ndarray,
|
|
59
|
+
finger_direction: np.ndarray) -> np.ndarray:
|
|
60
|
+
"""Absolute orientation of a hand as a quaternion [x, y, z, w].
|
|
61
|
+
|
|
62
|
+
Builds an orthonormal frame from the two hand vectors and returns the
|
|
63
|
+
rotation that carries the reference frame onto it. Absolute, not a
|
|
64
|
+
frame-to-frame delta: the cube can then be driven straight from where the
|
|
65
|
+
hand actually points, instead of integrating deltas whose small errors
|
|
66
|
+
accumulate until the cube no longer corresponds to the hand at all.
|
|
67
|
+
"""
|
|
68
|
+
forward = np.asarray(palm_normal, dtype=float)
|
|
69
|
+
n = np.linalg.norm(forward)
|
|
70
|
+
if n < 1e-6:
|
|
71
|
+
return np.array([0.0, 0.0, 0.0, 1.0])
|
|
72
|
+
forward = forward / n
|
|
73
|
+
|
|
74
|
+
# Gram-Schmidt the finger direction against the palm normal so the two axes
|
|
75
|
+
# are exactly orthogonal even though the measured vectors never quite are.
|
|
76
|
+
up = np.asarray(finger_direction, dtype=float)
|
|
77
|
+
up = up - np.dot(up, forward) * forward
|
|
78
|
+
n = np.linalg.norm(up)
|
|
79
|
+
if n < 1e-6: # degenerate: fingers along the normal
|
|
80
|
+
return np.array([0.0, 0.0, 0.0, 1.0])
|
|
81
|
+
up = up / n
|
|
82
|
+
|
|
83
|
+
right = np.cross(up, forward)
|
|
84
|
+
n = np.linalg.norm(right)
|
|
85
|
+
if n < 1e-6:
|
|
86
|
+
return np.array([0.0, 0.0, 0.0, 1.0])
|
|
87
|
+
right = right / n
|
|
88
|
+
|
|
89
|
+
return Rotation.from_matrix(np.column_stack((right, up, forward))).as_quat()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def quat_conjugate(q: np.ndarray) -> np.ndarray:
|
|
93
|
+
"""Inverse of a unit quaternion [x, y, z, w]."""
|
|
94
|
+
return np.array([-q[0], -q[1], -q[2], q[3]])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def quat_multiply(q1: np.ndarray, q2: np.ndarray) -> np.ndarray:
|
|
98
|
+
"""Hamilton product of two quaternions [x,y,z,w]."""
|
|
99
|
+
x1, y1, z1, w1 = q1
|
|
100
|
+
x2, y2, z2, w2 = q2
|
|
101
|
+
return np.array([
|
|
102
|
+
w1*x2 + x1*w2 + y1*z2 - z1*y2,
|
|
103
|
+
w1*y2 - x1*z2 + y1*w2 + z1*x2,
|
|
104
|
+
w1*z2 + x1*y2 - y1*x2 + z1*w2,
|
|
105
|
+
w1*w2 - x1*x2 - y1*y2 - z1*z2,
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def snap_to_nearest_90(angle_degrees: float) -> float:
|
|
110
|
+
"""Round to nearest multiple of 90°."""
|
|
111
|
+
return round(angle_degrees / 90.0) * 90.0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cube_axes_on_screen(q: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
115
|
+
"""
|
|
116
|
+
Given cube rotation quaternion, return the cube's local X and Y
|
|
117
|
+
axes projected to 2D screen space (as unit vectors in pixel-direction).
|
|
118
|
+
|
|
119
|
+
Uses the SAME quaternion convention as the renderer: [x, y, z, w].
|
|
120
|
+
|
|
121
|
+
Returns (screen_x, screen_y) — each is a 2D numpy array.
|
|
122
|
+
"""
|
|
123
|
+
x, y, z, w = q
|
|
124
|
+
|
|
125
|
+
# Rotation matrix columns (local axes in world space)
|
|
126
|
+
# Same formula as renderer.py's quat_to_matrix
|
|
127
|
+
local_x = np.array([
|
|
128
|
+
1 - 2*y*y - 2*z*z,
|
|
129
|
+
2*x*y + 2*z*w,
|
|
130
|
+
2*x*z - 2*y*w
|
|
131
|
+
])
|
|
132
|
+
local_y = np.array([
|
|
133
|
+
2*x*y - 2*z*w,
|
|
134
|
+
1 - 2*x*x - 2*z*z,
|
|
135
|
+
2*y*z + 2*x*w
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
# Project to screen: take x,y components, flip y for pixel coords
|
|
139
|
+
sx = np.array([local_x[0], -local_x[1]])
|
|
140
|
+
sy = np.array([local_y[0], -local_y[1]])
|
|
141
|
+
|
|
142
|
+
# Normalize
|
|
143
|
+
nx = np.linalg.norm(sx)
|
|
144
|
+
ny = np.linalg.norm(sy)
|
|
145
|
+
if nx > 1e-6: sx /= nx
|
|
146
|
+
if ny > 1e-6: sy /= ny
|
|
147
|
+
|
|
148
|
+
return sx, sy
|
package/bin/t-perm.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { execSync, spawn } = require('child_process');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
|
|
10
|
+
// Under `npx` these files live in the npm cache next to this script; run from a
|
|
11
|
+
// clone, the same relative path applies.
|
|
12
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
13
|
+
const BACKEND = path.join(ROOT, 'backend');
|
|
14
|
+
const PORT = Number(process.env.T_PERM_PORT) || 5000;
|
|
15
|
+
const URL = `http://localhost:${PORT}`;
|
|
16
|
+
|
|
17
|
+
let serverExited = false;
|
|
18
|
+
|
|
19
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function run(cmd, opts = {}) {
|
|
22
|
+
return execSync(cmd, { stdio: 'inherit', ...opts });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tryRun(cmd) {
|
|
26
|
+
try { execSync(cmd, { stdio: 'ignore' }); return true; } catch { return false; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function capture(cmd) {
|
|
30
|
+
try { return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); }
|
|
31
|
+
catch { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function openBrowser(url) {
|
|
35
|
+
const cmd =
|
|
36
|
+
process.platform === 'win32' ? `start "" "${url}"` :
|
|
37
|
+
process.platform === 'darwin' ? `open "${url}"` :
|
|
38
|
+
`xdg-open "${url}"`;
|
|
39
|
+
try { execSync(cmd); } catch { /* not fatal - the URL is printed anyway */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function abort(msg) {
|
|
43
|
+
console.error('\n' + msg + '\n');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Poll /health until the engine reports ready. The old code slept a flat 3s,
|
|
48
|
+
// which opened the browser onto a dead port on a slow machine and wasted time
|
|
49
|
+
// on a fast one.
|
|
50
|
+
function waitForServer(timeoutMs = 90000) {
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
(function poll() {
|
|
54
|
+
if (serverExited) return reject(new Error('server exited during startup'));
|
|
55
|
+
if (Date.now() > deadline) return reject(new Error('timed out waiting for the server'));
|
|
56
|
+
const req = http.get(`${URL}/health`, (res) => {
|
|
57
|
+
res.resume();
|
|
58
|
+
if (res.statusCode === 200) resolve();
|
|
59
|
+
else setTimeout(poll, 300);
|
|
60
|
+
});
|
|
61
|
+
req.on('error', () => setTimeout(poll, 300));
|
|
62
|
+
req.setTimeout(1000, () => { req.destroy(); });
|
|
63
|
+
})();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Step 1: Detect Python ────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
const py =
|
|
70
|
+
tryRun('python --version') ? 'python' :
|
|
71
|
+
tryRun('python3 --version') ? 'python3' :
|
|
72
|
+
null;
|
|
73
|
+
|
|
74
|
+
if (!py) {
|
|
75
|
+
abort(
|
|
76
|
+
'Python not found.\n' +
|
|
77
|
+
' Install Python 3.9+ from https://python.org and make sure it is on your PATH.'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Step 2: Confirm the MediaPipe model shipped ─────────────────────────────
|
|
82
|
+
|
|
83
|
+
const TASK = path.join(BACKEND, 'hand_landmarker.task');
|
|
84
|
+
if (!fs.existsSync(TASK)) {
|
|
85
|
+
abort(
|
|
86
|
+
`MediaPipe model missing: ${TASK}\n` +
|
|
87
|
+
' If you installed via npx: npm cache clean --force && npx t-perm\n' +
|
|
88
|
+
' If you cloned the repo: re-clone; the model is committed at\n' +
|
|
89
|
+
' backend/hand_landmarker.task'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Step 3: Install Python dependencies, only if missing ────────────────────
|
|
94
|
+
|
|
95
|
+
// Re-running pip every launch costs seconds and writes to the user's system
|
|
96
|
+
// Python for no reason. Import the heavy deps first; install only if that fails.
|
|
97
|
+
const DEPS_OK = !process.argv.includes('--deps') &&
|
|
98
|
+
tryRun(`${py} -c "import cv2, mediapipe, flask, flask_cors, OpenGL, pyglet, scipy"`);
|
|
99
|
+
|
|
100
|
+
if (!DEPS_OK) {
|
|
101
|
+
console.log('\nInstalling Python dependencies (first run only, ~1-2 min)...\n');
|
|
102
|
+
try {
|
|
103
|
+
run(`${py} -m pip install -r "${path.join(BACKEND, 'requirements.txt')}"`);
|
|
104
|
+
} catch {
|
|
105
|
+
abort('pip install failed. Check the error above and retry.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Step 4: Start the backend ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
console.log('\nStarting T-PERM...\n');
|
|
112
|
+
|
|
113
|
+
const server = spawn(py, ['server.py'], {
|
|
114
|
+
cwd: BACKEND,
|
|
115
|
+
stdio: 'inherit',
|
|
116
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1', T_PERM_PORT: String(PORT) },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
server.on('error', (err) => abort('Failed to start the Python server: ' + err.message));
|
|
120
|
+
server.on('exit', (code) => {
|
|
121
|
+
serverExited = true;
|
|
122
|
+
process.exit(code ?? 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── Step 5: Open the browser once the server actually answers ───────────────
|
|
126
|
+
|
|
127
|
+
waitForServer()
|
|
128
|
+
.then(() => {
|
|
129
|
+
console.log(`\n T-PERM is running at ${URL}`);
|
|
130
|
+
console.log(' Press the "Stop server" button in the page, or Ctrl+C here, to quit.\n');
|
|
131
|
+
openBrowser(URL);
|
|
132
|
+
})
|
|
133
|
+
.catch((err) => {
|
|
134
|
+
if (!serverExited) console.error('\n' + err.message + `\n Try opening ${URL} manually.\n`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── Graceful shutdown ────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
let shuttingDown = false;
|
|
140
|
+
function shutdown() {
|
|
141
|
+
if (shuttingDown) return;
|
|
142
|
+
shuttingDown = true;
|
|
143
|
+
server.kill('SIGINT');
|
|
144
|
+
// If the engine is wedged mid-frame, do not hang the terminal forever.
|
|
145
|
+
setTimeout(() => { server.kill('SIGKILL'); process.exit(0); }, 5000).unref();
|
|
146
|
+
}
|
|
147
|
+
process.on('SIGINT', shutdown);
|
|
148
|
+
process.on('SIGTERM', shutdown);
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--graphite: #14171a;
|
|
3
|
+
--panel: #1b1f23;
|
|
4
|
+
--panel-2: #20252a;
|
|
5
|
+
--hairline: #2c3238;
|
|
6
|
+
--ink: #edebe4;
|
|
7
|
+
--ink-dim: #9098a1;
|
|
8
|
+
--amber: #ffa23c;
|
|
9
|
+
--amber-dim: #a8752f;
|
|
10
|
+
--cyan: #63e6d4;
|
|
11
|
+
--red: #ff5a5a;
|
|
12
|
+
--green: #3ce87f;
|
|
13
|
+
--font-display: 'Space Grotesk', sans-serif;
|
|
14
|
+
--font-mono: 'JetBrains Mono', monospace;
|
|
15
|
+
--font-body: 'Inter', sans-serif;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
|
|
20
|
+
html, body {
|
|
21
|
+
margin: 0;
|
|
22
|
+
padding: 0;
|
|
23
|
+
background: var(--graphite);
|
|
24
|
+
color: var(--ink);
|
|
25
|
+
font-family: var(--font-body);
|
|
26
|
+
min-height: 100%;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@media (prefers-reduced-motion: reduce) {
|
|
30
|
+
*, *::before, *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
:focus-visible {
|
|
34
|
+
outline: 2px solid var(--amber);
|
|
35
|
+
outline-offset: 2px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.dot {
|
|
39
|
+
display: inline-block;
|
|
40
|
+
width: 7px; height: 7px;
|
|
41
|
+
border-radius: 50%;
|
|
42
|
+
background: var(--amber);
|
|
43
|
+
box-shadow: 0 0 8px var(--amber);
|
|
44
|
+
margin-right: 8px;
|
|
45
|
+
vertical-align: middle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ── Boot screen ─────────────────────────────────────────────────────────── */
|
|
49
|
+
|
|
50
|
+
#boot-screen {
|
|
51
|
+
position: relative;
|
|
52
|
+
min-height: 100vh;
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
padding: 48px 20px;
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
background:
|
|
59
|
+
radial-gradient(ellipse 900px 500px at 50% -10%, rgba(255,162,60,0.08), transparent),
|
|
60
|
+
var(--graphite);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.scanlines {
|
|
64
|
+
position: absolute; inset: 0;
|
|
65
|
+
pointer-events: none;
|
|
66
|
+
background: repeating-linear-gradient(
|
|
67
|
+
to bottom, rgba(255,255,255,0.018) 0px, rgba(255,255,255,0.018) 1px,
|
|
68
|
+
transparent 1px, transparent 3px
|
|
69
|
+
);
|
|
70
|
+
mix-blend-mode: overlay;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.boot-frame {
|
|
74
|
+
position: relative;
|
|
75
|
+
width: 100%;
|
|
76
|
+
max-width: 620px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.boot-eyebrow {
|
|
80
|
+
font-family: var(--font-mono);
|
|
81
|
+
font-size: 11.5px;
|
|
82
|
+
letter-spacing: 0.12em;
|
|
83
|
+
color: var(--ink-dim);
|
|
84
|
+
margin-bottom: 20px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.boot-title {
|
|
88
|
+
font-family: var(--font-display);
|
|
89
|
+
font-weight: 700;
|
|
90
|
+
font-size: clamp(42px, 9vw, 76px);
|
|
91
|
+
line-height: 0.98;
|
|
92
|
+
letter-spacing: -0.01em;
|
|
93
|
+
margin: 0 0 20px;
|
|
94
|
+
color: var(--ink);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.boot-sub {
|
|
98
|
+
font-size: 15.5px;
|
|
99
|
+
line-height: 1.6;
|
|
100
|
+
color: var(--ink-dim);
|
|
101
|
+
max-width: 46ch;
|
|
102
|
+
margin: 0 0 32px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.boot-panel {
|
|
106
|
+
border: 1px solid var(--hairline);
|
|
107
|
+
border-radius: 10px;
|
|
108
|
+
background: var(--panel);
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
margin-bottom: 28px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.panel-row {
|
|
114
|
+
display: grid;
|
|
115
|
+
grid-template-columns: 1.15fr 1fr;
|
|
116
|
+
gap: 12px;
|
|
117
|
+
padding: 11px 16px;
|
|
118
|
+
font-family: var(--font-mono);
|
|
119
|
+
font-size: 12.5px;
|
|
120
|
+
border-top: 1px solid var(--hairline);
|
|
121
|
+
}
|
|
122
|
+
.panel-row:first-child { border-top: none; }
|
|
123
|
+
.panel-row span:first-child { color: var(--ink); }
|
|
124
|
+
.panel-row span:last-child { color: var(--cyan); text-align: right; }
|
|
125
|
+
.panel-head {
|
|
126
|
+
background: var(--panel-2);
|
|
127
|
+
color: var(--ink-dim) !important;
|
|
128
|
+
letter-spacing: 0.08em;
|
|
129
|
+
}
|
|
130
|
+
.panel-head span { color: var(--ink-dim) !important; font-size: 10.5px; }
|
|
131
|
+
|
|
132
|
+
.btn-engage {
|
|
133
|
+
font-family: var(--font-mono);
|
|
134
|
+
font-size: 14px;
|
|
135
|
+
font-weight: 600;
|
|
136
|
+
letter-spacing: 0.04em;
|
|
137
|
+
color: #171310;
|
|
138
|
+
background: var(--amber);
|
|
139
|
+
border: none;
|
|
140
|
+
border-radius: 8px;
|
|
141
|
+
padding: 15px 26px;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
144
|
+
box-shadow: 0 0 0 rgba(255,162,60,0);
|
|
145
|
+
}
|
|
146
|
+
.btn-engage:hover { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(255,162,60,0.25); }
|
|
147
|
+
.btn-engage:active { transform: translateY(0); }
|
|
148
|
+
.btn-engage:disabled { opacity: 0.6; cursor: wait; transform: none; box-shadow: none; }
|
|
149
|
+
|
|
150
|
+
.boot-error {
|
|
151
|
+
color: var(--red);
|
|
152
|
+
font-family: var(--font-mono);
|
|
153
|
+
font-size: 12.5px;
|
|
154
|
+
margin: 14px 0 0;
|
|
155
|
+
min-height: 1em;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.boot-footnote {
|
|
159
|
+
font-size: 12px;
|
|
160
|
+
color: var(--ink-dim);
|
|
161
|
+
margin: 18px 0 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── Live app stage ──────────────────────────────────────────────────────── */
|
|
165
|
+
|
|
166
|
+
#stage {
|
|
167
|
+
min-height: 100vh;
|
|
168
|
+
display: flex;
|
|
169
|
+
flex-direction: column;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.topbar {
|
|
173
|
+
display: flex;
|
|
174
|
+
align-items: center;
|
|
175
|
+
justify-content: space-between;
|
|
176
|
+
padding: 16px 20px;
|
|
177
|
+
border-bottom: 1px solid var(--hairline);
|
|
178
|
+
font-family: var(--font-mono);
|
|
179
|
+
font-size: 12.5px;
|
|
180
|
+
}
|
|
181
|
+
.brand { letter-spacing: 0.05em; color: var(--ink-dim); }
|
|
182
|
+
.topbar-actions { display: flex; gap: 10px; }
|
|
183
|
+
|
|
184
|
+
.btn-ghost {
|
|
185
|
+
font-family: var(--font-mono);
|
|
186
|
+
font-size: 11.5px;
|
|
187
|
+
letter-spacing: 0.04em;
|
|
188
|
+
color: var(--ink);
|
|
189
|
+
background: transparent;
|
|
190
|
+
border: 1px solid var(--hairline);
|
|
191
|
+
border-radius: 6px;
|
|
192
|
+
padding: 8px 14px;
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
transition: border-color 0.15s ease, color 0.15s ease;
|
|
195
|
+
}
|
|
196
|
+
.btn-ghost:hover { border-color: var(--amber-dim); color: var(--amber); }
|
|
197
|
+
|
|
198
|
+
.viewport-wrap {
|
|
199
|
+
flex: 1;
|
|
200
|
+
display: flex;
|
|
201
|
+
flex-direction: column;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: center;
|
|
204
|
+
padding: 24px;
|
|
205
|
+
gap: 16px;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.viewport-frame {
|
|
209
|
+
position: relative;
|
|
210
|
+
width: min(100%, 980px);
|
|
211
|
+
aspect-ratio: 16 / 9;
|
|
212
|
+
background: #000;
|
|
213
|
+
border-radius: 4px;
|
|
214
|
+
overflow: hidden;
|
|
215
|
+
box-shadow: 0 0 0 1px var(--hairline), 0 30px 80px rgba(0,0,0,0.55);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* The MJPEG stream from the Python backend fills the frame. */
|
|
219
|
+
#backend-stream {
|
|
220
|
+
position: absolute;
|
|
221
|
+
inset: 0;
|
|
222
|
+
width: 100%;
|
|
223
|
+
height: 100%;
|
|
224
|
+
object-fit: cover;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.bracket {
|
|
228
|
+
position: absolute;
|
|
229
|
+
width: 26px; height: 26px;
|
|
230
|
+
border: 2px solid var(--amber);
|
|
231
|
+
opacity: 0.85;
|
|
232
|
+
pointer-events: none;
|
|
233
|
+
}
|
|
234
|
+
.bracket.tl { top: 10px; left: 10px; border-right: none; border-bottom: none; }
|
|
235
|
+
.bracket.tr { top: 10px; right: 10px; border-left: none; border-bottom: none; }
|
|
236
|
+
.bracket.bl { bottom: 10px; left: 10px; border-right: none; border-top: none; }
|
|
237
|
+
.bracket.br { bottom: 10px; right: 10px; border-left: none; border-top: none; }
|
|
238
|
+
|
|
239
|
+
.legend-panel {
|
|
240
|
+
position: absolute;
|
|
241
|
+
top: 14px; right: 14px;
|
|
242
|
+
width: 300px;
|
|
243
|
+
background: rgba(20,23,26,0.82);
|
|
244
|
+
backdrop-filter: blur(10px);
|
|
245
|
+
border: 1px solid var(--hairline);
|
|
246
|
+
border-radius: 8px;
|
|
247
|
+
overflow: hidden;
|
|
248
|
+
transform: translateY(-8px);
|
|
249
|
+
opacity: 0;
|
|
250
|
+
pointer-events: none;
|
|
251
|
+
transition: transform 0.18s ease, opacity 0.18s ease;
|
|
252
|
+
}
|
|
253
|
+
.legend-panel.open { transform: translateY(0); opacity: 1; pointer-events: auto; }
|
|
254
|
+
.legend-panel .panel-row { padding: 9px 13px; font-size: 11.5px; }
|
|
255
|
+
|
|
256
|
+
.telemetry {
|
|
257
|
+
display: flex;
|
|
258
|
+
gap: 28px;
|
|
259
|
+
font-family: var(--font-mono);
|
|
260
|
+
padding: 4px 6px;
|
|
261
|
+
}
|
|
262
|
+
.tel-item { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
|
263
|
+
.tel-label { font-size: 10px; letter-spacing: 0.1em; color: var(--ink-dim); }
|
|
264
|
+
.tel-value { font-size: 16px; color: var(--cyan); font-weight: 600; }
|
|
265
|
+
|
|
266
|
+
.footbar {
|
|
267
|
+
text-align: center;
|
|
268
|
+
font-size: 12px;
|
|
269
|
+
color: var(--ink-dim);
|
|
270
|
+
padding: 16px 20px 24px;
|
|
271
|
+
}
|
|
272
|
+
.footbar strong { color: var(--ink); }
|
|
273
|
+
|
|
274
|
+
@media (max-width: 640px) {
|
|
275
|
+
.boot-title { font-size: 44px; }
|
|
276
|
+
.panel-row { font-size: 11.5px; }
|
|
277
|
+
.legend-panel { width: 230px; }
|
|
278
|
+
.telemetry { gap: 18px; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ── Stop server ─────────────────────────────────────────────────────────── */
|
|
282
|
+
|
|
283
|
+
.btn-quit {
|
|
284
|
+
font-family: var(--font-mono);
|
|
285
|
+
font-size: 11.5px;
|
|
286
|
+
letter-spacing: 0.04em;
|
|
287
|
+
color: var(--red);
|
|
288
|
+
background: transparent;
|
|
289
|
+
border: 1px solid var(--hairline);
|
|
290
|
+
border-radius: 6px;
|
|
291
|
+
padding: 8px 14px;
|
|
292
|
+
cursor: pointer;
|
|
293
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
294
|
+
}
|
|
295
|
+
.btn-quit:hover { border-color: var(--red); background: rgba(255,90,90,0.08); }
|
|
296
|
+
|
|
297
|
+
#quit-screen {
|
|
298
|
+
position: fixed;
|
|
299
|
+
inset: 0;
|
|
300
|
+
display: flex;
|
|
301
|
+
align-items: center;
|
|
302
|
+
justify-content: center;
|
|
303
|
+
background: rgba(20,23,26,0.94);
|
|
304
|
+
backdrop-filter: blur(6px);
|
|
305
|
+
z-index: 10;
|
|
306
|
+
}
|
|
307
|
+
.quit-card {
|
|
308
|
+
text-align: center;
|
|
309
|
+
max-width: 420px;
|
|
310
|
+
padding: 32px;
|
|
311
|
+
}
|
|
312
|
+
.quit-card h2 {
|
|
313
|
+
font-family: var(--font-display);
|
|
314
|
+
font-size: 26px;
|
|
315
|
+
margin: 0 0 12px;
|
|
316
|
+
color: var(--ink);
|
|
317
|
+
}
|
|
318
|
+
.quit-card p {
|
|
319
|
+
font-size: 14px;
|
|
320
|
+
line-height: 1.6;
|
|
321
|
+
color: var(--ink-dim);
|
|
322
|
+
margin: 0;
|
|
323
|
+
}
|
|
324
|
+
.quit-card code {
|
|
325
|
+
font-family: var(--font-mono);
|
|
326
|
+
color: var(--amber);
|
|
327
|
+
background: var(--panel);
|
|
328
|
+
padding: 2px 6px;
|
|
329
|
+
border-radius: 4px;
|
|
330
|
+
}
|