Domotick commited on
Commit
9ad2616
·
0 Parent(s):

Initial commit: Reachy Mirror app

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.gif filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ __pycache__/
3
+ *.egg-info/
4
+ build/
5
+ *.lock
6
+ ._*
README.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Reachy Mirror
3
+ emoji: 🪞
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Mimic your moves
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mirror
12
+ - communication
13
+ - emotion
14
+ - reachy_mini_python_app
15
+ ---
index.html ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Reachy Mirror</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🪞</text></svg>" />
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+
11
+ <body>
12
+ <header class="hero">
13
+ <div class="topline">
14
+ <div class="brand">
15
+ <span class="logo">🪞</span>
16
+ <span class="brand-name">Reachy Mirror</span>
17
+ </div>
18
+ <div class="pill">Realtime · Fluid · Expressive · Customizable</div>
19
+ </div>
20
+ <div class="hero-grid">
21
+ <div class="hero-copy">
22
+ <p class="eyebrow">Communication App</p>
23
+ <h1>Mimic your moves</h1>
24
+ <p class="lede">Use your laptop's camera to reproduce your moves on Reachy Mini!</p>
25
+ <p class="lede">Head and hands detected (using <a href="https://github.com/google-ai-edge/mediapipe" target="_blank">MediaPipe</a>)</p>
26
+ <p class="lede">Adjustable frame rate and motion, mirror mode, and automatic reconnection are available through a lovely web interface.</p>
27
+ </p>
28
+ </div>
29
+ <div class="hero-visual">
30
+ <div class="glass-card">
31
+ <img src="reachy_mirror.gif" alt="Reachy Mini dancing" class="hero-gif">
32
+ <p class="caption">Reachy Mini reproduce head and hand moves</p>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </header>
37
+
38
+ <div class="footer">
39
+ Made with ❤︎ by <a href="https://github.com/domotick" target="_blank">@domotick</a>
40
+ </div>
41
+ </body>
42
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "reachy_mirror"
7
+ version = "0.1.0"
8
+ description = "Add your description here"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "reachy-mini>=1.2.5",
13
+ "opencv-python>=4.12.0",
14
+ "numpy>=1.24.0",
15
+ "mediapipe==0.10.14"
16
+ ]
17
+ keywords = ["reachy-mini-app","reachy-mirror","mirror"]
18
+
19
+ [project.entry-points."reachy_mini_apps"]
20
+ reachy_mirror = "reachy_mirror.main:ReachyMirror"
21
+
22
+ [tool.setuptools]
23
+ package-dir = { "" = "." }
24
+ include-package-data = true
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["."]
28
+
29
+ [tool.setuptools.package-data]
30
+ reachy_mirror = ["**/*"] # Also include all non-.py files
reachy_mirror.gif ADDED

Git LFS Details

  • SHA256: c69ea95997383b5fb21ce34a3066b0a4c25fb041bdd47db6f746cc133a0e6404
  • Pointer size: 132 Bytes
  • Size of remote file: 4.79 MB
reachy_mirror/__init__.py ADDED
File without changes
reachy_mirror/main.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import time
3
+ import math
4
+ from typing import Optional
5
+
6
+ import cv2
7
+ import base64
8
+ import numpy as np
9
+ import mediapipe as mp
10
+ import math
11
+ from pydantic import BaseModel
12
+ from reachy_mini.utils import create_head_pose
13
+ from reachy_mini import ReachyMini, ReachyMiniApp
14
+ from reachy_mini.motion.recorded_move import RecordedMoves
15
+ from fastapi.responses import StreamingResponse
16
+
17
+ mpFaceMesh = mp.solutions.face_mesh
18
+ faceMesh = mpFaceMesh.FaceMesh(static_image_mode=True, max_num_faces=1, min_detection_confidence=0.5, min_tracking_confidence=0.5)
19
+ mpHands = mp.solutions.hands
20
+ hands = mpHands.Hands()
21
+ mpDraw = mp.solutions.drawing_utils
22
+ mpDrawingStyles = mp.solutions.drawing_styles
23
+
24
+ VERTICAL_PITCH_CORRECTION=-5
25
+
26
+ class ReachyMirror(ReachyMiniApp):
27
+ custom_app_url: str | None = "http://0.0.0.0:7860"
28
+ request_media_backend: str | None = None
29
+
30
+ def __init__(self):
31
+ super().__init__()
32
+ self.appReady: bool = False
33
+ self.isProcessing = False
34
+ self.width: int = 640
35
+ self.height: int = 480
36
+ self.lastFrame: Optional[np.ndarray] = None
37
+ self.headAngles = [0,0,0]
38
+ self.antennaAngles = [0,0]
39
+ self.frameRateDown = 30
40
+ self.motionReduction = 60
41
+ self.showProcessing = True
42
+ self.isMirror = True
43
+ self.frameCount = 0
44
+ self.frameCountTotal = 0
45
+ self.downValue = 0
46
+ self.downValueAvg = 0
47
+ self._setupEndpoints()
48
+
49
+ def _setupEndpoints(self):
50
+ """Set up FastAPI endpoints for the web UI"""
51
+ @self.settings_app.get("/ready")
52
+ async def ready():
53
+ return {"ready": self.appReady}
54
+
55
+ @self.settings_app.get("/webcam_feed")
56
+ def webcam_feed():
57
+ return StreamingResponse(
58
+ self._frameGenerator(),
59
+ media_type="multipart/x-mixed-replace; boundary=frame"
60
+ )
61
+
62
+ class UIState(BaseModel):
63
+ frameRateDown: int | None = None
64
+ motionReduction: int | None = None
65
+ showProcessing: bool | None = None
66
+ isMirror: bool | None = None
67
+
68
+ @self.settings_app.post("/settings")
69
+ async def update_settings(state: UIState):
70
+ if state.motionReduction is not None:
71
+ self.motionReduction = state.motionReduction
72
+ if state.frameRateDown is not None:
73
+ self.frameRateDown = state.frameRateDown
74
+ if state.showProcessing is not None:
75
+ self.showProcessing = state.showProcessing
76
+ if state.isMirror is not None:
77
+ self.isMirror = state.isMirror
78
+
79
+ class FrameData(BaseModel):
80
+ image: str | None = None
81
+
82
+ @self.settings_app.post("/process_frame")
83
+ def process_frame(data: FrameData):
84
+ if self.isProcessing is False:
85
+ self.isProcessing = True
86
+ try:
87
+ # Remove data:image/jpeg;base64, prefix
88
+ image_data = data.image.split(',')[1]
89
+ image_bytes = base64.b64decode(image_data)
90
+
91
+ # Convert to numpy array
92
+ nparr = np.frombuffer(image_bytes, np.uint8)
93
+ frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
94
+
95
+ if frame is None:
96
+ self.isProcessing = False
97
+ return {'error': 'Could not decode frame'}
98
+
99
+ # Process frame and get image with landmarks
100
+ image = self._drawLandmarksToFrame(frame)
101
+
102
+ if image is not None and self.showProcessing is True:
103
+ ret, buffer = cv2.imencode('.jpg', image)
104
+ if ret is None:
105
+ return {'error': 'Could not convert frame'}
106
+ processed_frame = base64.b64encode(buffer).decode('utf-8')
107
+ self.isProcessing = False
108
+ return {
109
+ 'image': f'data:image/jpeg;base64,{processed_frame}',
110
+ 'head': [int(self.headAngles[0]), int(self.headAngles[1]), int(self.headAngles[2])],
111
+ 'hands': [int(180*self.antennaAngles[0]/math.pi), int(180*self.antennaAngles[1]/math.pi)],
112
+ 'downValue': self.downValue,
113
+ 'downValueAvg': self.downValueAvg
114
+ }
115
+
116
+ self.isProcessing = False
117
+ return {}
118
+ except Exception as e:
119
+ self.isProcessing = False
120
+ return {'error': str(e)}
121
+ return {}
122
+
123
+ def _frameGenerator(self):
124
+ """Generate MJPEG frames for streaming"""
125
+ while True:
126
+ if self.lastFrame is None:
127
+ time.sleep(0.05)
128
+ continue
129
+ ret, jpeg = cv2.imencode(".jpg", self.lastFrame)
130
+ if ret:
131
+ yield (
132
+ b"--frame\r\n"
133
+ b"Content-Type: image/jpeg\r\n\r\n" + jpeg.tobytes() + b"\r\n"
134
+ )
135
+ time.sleep(0.05)
136
+
137
+ # inspired by https://github.com/shenasa-ai/head-pose-estimation/blob/main/estimator.py
138
+ def _rotationMatrixToAngles(self, rotationMatrix):
139
+ """
140
+ Calculate Euler angles from rotation matrix.
141
+ :param rotationMatrix: A 3*3 matrix with the following structure
142
+ [Cosz*Cosy Cosz*Siny*Sinx - Sinz*Cosx Cosz*Siny*Cosx + Sinz*Sinx]
143
+ [Sinz*Cosy Sinz*Siny*Sinx + Sinz*Cosx Sinz*Siny*Cosx - Cosz*Sinx]
144
+ [ -Siny CosySinx Cosy*Cosx ]
145
+ :return: Image with landmarks for head and hands
146
+ """
147
+ x = math.atan2(rotationMatrix[2, 1], rotationMatrix[2, 2])
148
+ y = math.atan2(-rotationMatrix[2, 0], math.sqrt(rotationMatrix[0, 0] ** 2 + rotationMatrix[1, 0] ** 2))
149
+ z = math.atan2(rotationMatrix[1, 0], rotationMatrix[0, 0])
150
+ return np.array([x, y, z]) * 180. / math.pi
151
+
152
+ def _drawLandmarksToFrame(self, image):
153
+ if image is None:
154
+ return None
155
+
156
+ if self.isMirror:
157
+ image = cv2.flip(image, 1)
158
+ h, w, _ = image.shape
159
+ faceCoordinationInImage = []
160
+ imgRGB = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
161
+
162
+ resultsHand = hands.process(imgRGB)
163
+ handsAngle = []
164
+ handsType=[]
165
+ if resultsHand.multi_hand_landmarks:
166
+ for hand in resultsHand.multi_handedness:
167
+ handType=hand.classification[0].label
168
+ handsType.append(handType)
169
+ for handLms in resultsHand.multi_hand_landmarks:
170
+ mpDraw.draw_landmarks(image, handLms, mpHands.HAND_CONNECTIONS, None)
171
+ base = None
172
+ tip = None
173
+ for id, lm in enumerate(handLms.landmark):
174
+ h, w, c = image.shape
175
+ cx, cy = int(lm.x * w), int(lm.y * h)
176
+ if id == 0:
177
+ base = [cx, cy]
178
+ cv2.circle(image, (cx, cy), 8, (255, 196, 69), cv2.FILLED)
179
+ elif id == 8:
180
+ cv2.circle(image, (cx, cy), 8, (255, 196, 69), cv2.FILLED)
181
+ tip = [cx, cy]
182
+ else:
183
+ cv2.circle(image, (cx, cy), 4, (255, 196, 69), cv2.FILLED)
184
+ handsAngle.append(math.atan2(tip[0]-base[0],tip[1]-base[1]))
185
+
186
+ angles = [0,0]
187
+ if len(handsAngle) > 0:
188
+ if handsType[0] == 'Left':
189
+ angles[0] = -handsAngle[0]+math.pi
190
+ else:
191
+ angles[1] = -handsAngle[0]-math.pi
192
+ if len(handsAngle) > 1:
193
+ if handsType[1] == 'Left':
194
+ angles[0] = -handsAngle[1]+math.pi
195
+ else:
196
+ angles[1] = -handsAngle[1]-math.pi
197
+ if abs(angles[0]) > math.pi:
198
+ angles[0] = angles[0]-2*math.pi
199
+ if abs(angles[1]) > math.pi:
200
+ angles[1] = angles[1]+2*math.pi
201
+ self.antennaAngles = angles
202
+
203
+ resultsHead = faceMesh.process(imgRGB)
204
+ faceCoordinationInRealWorld = np.array([
205
+ [285, 528, 200],
206
+ [285, 371, 152],
207
+ [197, 574, 128],
208
+ [173, 425, 108],
209
+ [360, 574, 128],
210
+ [391, 425, 108]
211
+ ], dtype=np.float64)
212
+ if resultsHead.multi_face_landmarks:
213
+ for face_landmarks in resultsHead.multi_face_landmarks:
214
+ for idx, lm in enumerate(face_landmarks.landmark):
215
+ h, w, _ = image.shape
216
+ x, y, z = int(lm.x * w), int(lm.y * h), lm.z
217
+ if self.showProcessing is True:
218
+ cv2.circle(image, (x, y), 1, (255, 196, 69), cv2.FILLED)
219
+ if idx in [1, 9, 57, 130, 287, 359]:
220
+ x, y = int(lm.x * w), int(lm.y * h)
221
+ faceCoordinationInImage.append([x, y])
222
+
223
+ faceCoordinationInImage = np.array(faceCoordinationInImage, dtype=np.float64)
224
+ focalLength = 1 * w
225
+ camMatrix = np.array([[focalLength, 0, w / 2], [0, focalLength, h / 2], [0, 0, 1]])
226
+ distMatrix = np.zeros((4, 1), dtype=np.float64)
227
+ _, rotationVec, _ = cv2.solvePnP(faceCoordinationInRealWorld, faceCoordinationInImage, camMatrix, distMatrix)
228
+ rotationMatrix, _ = cv2.Rodrigues(rotationVec)
229
+ self.headAngles = self._rotationMatrixToAngles(rotationMatrix)
230
+
231
+ return image
232
+ return None
233
+
234
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
235
+ """
236
+ Main loop
237
+ """
238
+ t0Total = time.time()
239
+ t0Last = time.time()
240
+ t0 = time.time()
241
+ frame = None
242
+
243
+ # Load recorded moves for sounds
244
+ try:
245
+ self.recorded_moves = RecordedMoves("pollen-robotics/reachy-mini-emotions-library")
246
+ except Exception as e:
247
+ print(f"Could not load emotions library: {e}")
248
+
249
+ try:
250
+ head_pose = create_head_pose(pitch=VERTICAL_PITCH_CORRECTION)
251
+ reachy_mini.goto_target(head_pose, antennas=[-.5, .5], body_yaw=0.0, duration=1.0)
252
+ reachy_mini.media.play_sound("wake_up.wav")
253
+ except Exception as e:
254
+ print(f"Greeting failed: {e}")
255
+
256
+ self.appReady = True
257
+ print("🪞 Reachy Mirror is ready !")
258
+
259
+ # Main control loop
260
+ while not stop_event.is_set():
261
+ frame = reachy_mini.media.get_frame()
262
+
263
+ if frame is None:
264
+ print("Failed to grab frame.")
265
+ continue
266
+
267
+ # Resize and flip frame if isMirror
268
+ image = cv2.resize(frame, (640, 480))
269
+ if self.isMirror:
270
+ image = cv2.flip(image, 1)
271
+ self.lastFrame = image
272
+
273
+ # Set head pose with motion reduction param
274
+ r=self.motionReduction/100
275
+ if self.isMirror:
276
+ reachy_mini.set_target(
277
+ head=create_head_pose(pitch=-self.headAngles[0]*r+VERTICAL_PITCH_CORRECTION, yaw=self.headAngles[1]*r, roll=-self.headAngles[2]*r),
278
+ antennas=self.antennaAngles
279
+ )
280
+ else:
281
+ reachy_mini.set_target(
282
+ head=create_head_pose(pitch=-self.headAngles[0]*r+VERTICAL_PITCH_CORRECTION, yaw=self.headAngles[1]*r, roll=-self.headAngles[2]*r),
283
+ antennas=[self.antennaAngles[0], self.antennaAngles[1]]
284
+ )
285
+
286
+ self.frameCount += 1
287
+ self.frameCountTotal += 1
288
+ now = time.time()
289
+ elapsed = now - t0Last
290
+ sleep_time = max(0, (1.0 / self.frameRateDown) - elapsed)
291
+ t0Last = now
292
+
293
+ if now - t0 > 1.0:
294
+ self.downValue = math.floor(self.frameCount/(now - t0)*10)/10
295
+ self.downValueAvg = math.floor(self.frameCountTotal/(now - t0Total)*10)/10
296
+ t0 = now
297
+ self.frameCount = 0
298
+
299
+ time.sleep(sleep_time)
300
+
301
+ if __name__ == "__main__":
302
+ app = ReachyMirror()
303
+ try:
304
+ app.wrapped_run()
305
+ except KeyboardInterrupt:
306
+ app.stop()
reachy_mirror/static/index.html ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Reachy Mirror</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🪞</text></svg>" />
8
+ <link rel="stylesheet" href="/static/style.css" />
9
+ </head>
10
+ <body>
11
+ <h1>Reachy Mirror</h1>
12
+ <div class="container">
13
+
14
+ <div class="controls">
15
+ <div class="controls-panel panel">
16
+ <div class="setting-item">
17
+ <div class="setting-label">
18
+ <label for="motionReductionSlider"><span class="emoji">👋</span> Motion </label>
19
+ </div>
20
+ <div class="slider-wrapper">
21
+ <input type="range" id="motionReductionSlider" min="20" max="100" class="slider" />
22
+ </div>
23
+ <div class="setting-value">
24
+ <span id="motionReductionValue"></span>%
25
+ </div>
26
+ </div>
27
+
28
+ <div class="setting-item">
29
+ <div class="setting-label">
30
+ <label for="frameRateUpSlider"><span class="emoji">⬆️</span> UP Limit</label>
31
+ </div>
32
+ <div class="slider-wrapper">
33
+ <input type="range" id="frameRateUpSlider" min="1" max="30" class="slider" />
34
+ </div>
35
+ <div class="setting-value">
36
+ <span id="frameRateUpValue"></span> FPS
37
+ </div>
38
+ </div>
39
+
40
+ <div class="setting-item">
41
+ <div class="setting-label">
42
+ <label for="frameRateDownSlider"><span class="emoji">⬇️</span> DOWN Limit</label>
43
+ </div>
44
+ <div class="slider-wrapper">
45
+ <input type="range" id="frameRateDownSlider" min="1" max="60" class="slider" />
46
+ </div>
47
+ <div class="setting-value">
48
+ <span id="frameRateDownValue"></span> FPS
49
+ </div>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="controls-panel panel">
54
+ <div class="setting-item">
55
+ <div class="setting-label-right">
56
+ <label for="showProcessingToggle"><span class="emoji">⚙️</span> Show Process</label>
57
+ </div>
58
+ <label class="switch">
59
+ <input type="checkbox" id="showProcessingToggle" checked />
60
+ <span class="switch-slider"></span>
61
+ </label>
62
+ </div>
63
+
64
+ <div class="setting-item">
65
+ <div class="setting-label-right">
66
+ <label for="isMirrorToggle"><span class="emoji">🪞</span> Mirror Mode</label>
67
+ </div>
68
+ <label class="switch">
69
+ <input type="checkbox" id="isMirrorToggle" checked />
70
+ <span class="switch-slider"></span>
71
+ </label>
72
+ </div>
73
+
74
+ <div class="setting-button">
75
+ <button id="resetButton">RESET</button>
76
+ </div>
77
+ </div>
78
+ </div>
79
+
80
+ <div class="video-container">
81
+ <video id="webcam" autoplay playsinline></video>
82
+ <div id="processed-wrapper" style="display: none;">
83
+ <img id="processed" />
84
+ <div id="processed-head"></div>
85
+ <div id="processed-hands"></div>
86
+ </div>
87
+ <img id="remote" style="display: none;" />
88
+ </div>
89
+
90
+ <div class="status">
91
+ <div id="spinner"></div>
92
+ <div id="status">🪞 Loading…</div>
93
+ </div>
94
+ </div>
95
+ <div class="footer">
96
+ Made with ❤︎ by <a href="https://github.com/domotick" target="_blank">@domotick</a>
97
+ </div>
98
+ <script src="/static/main.js"></script>
99
+ </body>
100
+ </html>
reachy_mirror/static/main.js ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ** INITIAL VARS
2
+ let appState
3
+ let stream
4
+ let frameRateUp = 10
5
+ let frameCount = 0
6
+ let frameCountTotal = 0
7
+ let streamInterval
8
+ let readyInterval
9
+ let ready = false
10
+ let t0 = new Date()
11
+ let t0Total = new Date()
12
+
13
+ // ** PROCESS STATUS
14
+ async function checkStatus() {
15
+ try {
16
+ const resp = await fetch('/ready')
17
+ const data = await resp.json()
18
+ if( data.ready ) {
19
+ status.textContent = '✅ Streaming'
20
+ remote.style.display = ''
21
+ spinner.style.display = 'none'
22
+ remote.setAttribute('src', '/webcam_feed')
23
+ await startWebcam()
24
+ await setAppState()
25
+ setStreamInterval()
26
+ setReadyInterval()
27
+ }
28
+ else {
29
+ status.textContent = '⏳ Initializing...'
30
+ processedWrapper.style.display = 'none'
31
+ spinner.style.display = 'block'
32
+ }
33
+ } catch( e ) {
34
+ status.textContent = '🔄 Connecting...'
35
+ processedWrapper.style.display = 'none'
36
+ remote.style.display = 'none'
37
+ remote.setAttribute('src', '')
38
+ spinner.style.display = 'block'
39
+ }
40
+ }
41
+
42
+ function setReadyInterval(running) {
43
+ if( readyInterval ) {
44
+ clearInterval(readyInterval)
45
+ readyInterval = null
46
+ }
47
+ if( running ) readyInterval = setInterval(checkStatus, 1000)
48
+ }
49
+
50
+ function setStreamInterval() {
51
+ if( streamInterval ) clearInterval(streamInterval)
52
+ streamInterval = setInterval(sendFrameToBackend, 1000/appState.frameRateUp)
53
+ }
54
+
55
+ async function startWebcam() {
56
+ try {
57
+ stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 } } })
58
+ webcam.srcObject = stream
59
+ frameCount = 0
60
+ frameCountTotal = 0
61
+ t0 = new Date()
62
+ t0Total = new Date()
63
+ } catch (err) {
64
+ console.error('Erreur video:', err)
65
+ }
66
+ }
67
+
68
+ function stopWebcam() {
69
+ if( stream ) {
70
+ stream.getTracks().forEach(track => track.stop())
71
+ webcam.srcObject = null
72
+ stream = null
73
+ }
74
+
75
+ if( streamInterval ) {
76
+ clearInterval(streamInterval)
77
+ streamInterval = null
78
+ }
79
+
80
+ processed.src = ''
81
+ processedHead.textContent = ''
82
+ processedHands.textContent = ''
83
+ processedWrapper.style.display = 'none'
84
+ }
85
+
86
+ // ** HTML ELEMENT
87
+ const status = document.getElementById('status')
88
+ const spinner = document.getElementById('spinner')
89
+ const webcam = document.getElementById('webcam')
90
+ const remote = document.getElementById('remote')
91
+ const processed = document.getElementById('processed')
92
+ const processedWrapper = document.getElementById('processed-wrapper')
93
+ const processedHead = document.getElementById('processed-head')
94
+ const processedHands = document.getElementById('processed-hands')
95
+ const frameRateUpValue = document.getElementById('frameRateUpValue')
96
+ const frameRateDownValue = document.getElementById('frameRateDownValue')
97
+ const motionReductionValue = document.getElementById('motionReductionValue')
98
+ const showProcessingToggle = document.getElementById('showProcessingToggle')
99
+ const isMirrorToggle = document.getElementById('isMirrorToggle')
100
+
101
+ // * FPS UP
102
+ document.getElementById('frameRateUpSlider').addEventListener('input', function() {
103
+ document.getElementById('frameRateUpValue').textContent = parseInt(this.value)
104
+ })
105
+ document.getElementById('frameRateUpSlider').addEventListener('change', function() {
106
+ const value = parseInt(this.value)
107
+ appState.frameRateUp = value
108
+ setCookie('frameRateUp', value)
109
+ setStreamInterval()
110
+ })
111
+
112
+ // * FPS DOWN
113
+ document.getElementById('frameRateDownSlider').addEventListener('input', function() {
114
+ document.getElementById('frameRateDownValue').textContent = parseInt(this.value)
115
+ })
116
+ document.getElementById('frameRateDownSlider').addEventListener('change', async function() {
117
+ const value = parseInt(this.value)
118
+ appState.frameRateDown = value
119
+ setCookie('frameRateDown', value)
120
+ await fetch('/settings', {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({ frameRateDown: value })
124
+ })
125
+ })
126
+
127
+ // * MOTION REDUCTION
128
+ document.getElementById('motionReductionSlider').addEventListener('input', function() {
129
+ document.getElementById('motionReductionValue').textContent = parseInt(this.value)
130
+ })
131
+ document.getElementById('motionReductionSlider').addEventListener('change', async function() {
132
+ const value = parseInt(this.value)
133
+ appState.motionReduction = value
134
+ await fetch('/settings', {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body: JSON.stringify({ motionReduction: value })
138
+ })
139
+ setCookie('motionReduction', value)
140
+ })
141
+
142
+ // * PROCESSING DISPLAY
143
+ document.getElementById('showProcessingToggle').addEventListener('change', async function() {
144
+ appState.showProcessing = this.checked
145
+ await fetch('/settings', {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({ showProcessing: this.checked })
149
+ })
150
+ setCookie('showProcessing', this.checked ? 'true' : 'false')
151
+ processedWrapper.style.display = this.checked ? 'block' : 'none'
152
+ })
153
+
154
+ // * MIRROR MODE
155
+ document.getElementById('isMirrorToggle').addEventListener('change', async function() {
156
+ appState.isMirror = this.checked
157
+ await fetch('/settings', {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({ isMirror: this.checked })
161
+ })
162
+ setCookie('isMirror', this.checked ? 'true' : 'false')
163
+ })
164
+
165
+ document.getElementById('resetButton').addEventListener('click', async function() {
166
+ reset()
167
+ setAppState()
168
+ })
169
+
170
+
171
+ // ** COOKIE MANAGEMENT
172
+ function setCookie(key, value) {
173
+ const expires = new Date()
174
+ expires.setTime(expires.getTime() + (1 * 24 * 60 * 60 * 1000))
175
+ document.cookie = key + '=' + value + ';expires=' + expires.toUTCString()
176
+ }
177
+
178
+ function getCookie(key) {
179
+ const keyValue = document.cookie.match('(^|;) ?' + key + '=([^;]*)(;|$)')
180
+ return keyValue ? keyValue[2] : null
181
+ }
182
+
183
+ function deleteCookie(name) {
184
+ document.cookie = name+'=; Max-Age=-99999999;';
185
+ }
186
+
187
+ // ** BACKEND HANDLER
188
+ async function sendFrameToBackend() {
189
+ if (!webcam || webcam.paused || webcam.ended) return
190
+
191
+ const canvas = document.createElement('canvas')
192
+ canvas.width = webcam.videoWidth
193
+ canvas.height = webcam.videoHeight
194
+ const ctx = canvas.getContext('2d')
195
+ ctx.drawImage(webcam, 0, 0)
196
+ const imageData = canvas.toDataURL('image/jpeg', 0.8)
197
+
198
+ try {
199
+ const resp = await fetch('/process_frame', {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({ image: imageData })
203
+ })
204
+ if( appState.showProcessing ) {
205
+ const data = await resp.json()
206
+ if( data.image ) {
207
+ processed.src = data.image
208
+ frameCount++
209
+ frameCountTotal++
210
+ const now = new Date()
211
+ if( now - t0 > 1000 ) { // 1 second
212
+ status.textContent = `✅ Streaming `
213
+ status.textContent += `• UP: ${Math.floor(frameCount/(now - t0)*10000)/10} FPS (~avg ${Math.floor(frameCountTotal/(now - t0Total)*10000)/10})`
214
+ if( data.downValue && data.downValueAvg ) status.textContent += ` • DOWN: ${data.downValue} FPS (~avg ${data.downValueAvg})`
215
+ t0 = now
216
+ frameCount = 0
217
+ }
218
+ if( data.head ) processedHead.textContent = `pitch: ${data.head[0]}\nyaw: ${data.head[1]}\nroll: ${data.head[2]}`
219
+ if( data.hands ) processedHands.textContent = `left: ${data.hands[0]}\nright: ${data.hands[1]}`
220
+ processedWrapper.style.display = 'block'
221
+ }
222
+ else if( data.error ) console.error('Erreur backend:', data.error)
223
+ }
224
+ }
225
+ catch(e) {
226
+ console.error('Erreur réseau:', e)
227
+ stopWebcam()
228
+ setReadyInterval(true)
229
+ }
230
+ }
231
+
232
+ function reset() {
233
+ deleteCookie('frameRateUp')
234
+ deleteCookie('frameRateDown')
235
+ deleteCookie('motionReduction')
236
+ deleteCookie('showProcessing')
237
+ deleteCookie('isMirror')
238
+ }
239
+
240
+ async function setAppState() {
241
+ appState = {}
242
+ appState.frameRateUp = frameRateUpSlider.value = frameRateUpValue.textContent = getCookie('frameRateUp') ?? 30, // 30 FPS UP
243
+ appState.frameRateDown = frameRateDownSlider.value = frameRateDownValue.textContent = getCookie('frameRateDown') ?? 60, // 60 FPS DOWN
244
+ appState.motionReduction = motionReductionSlider.value = motionReductionValue.textContent = getCookie('motionReduction') ?? 60, // 60% by default
245
+ appState.showProcessing = showProcessingToggle.checked = getCookie('showProcessing') === null ? true : getCookie('showProcessing') === 'true' ? true : false // Show processed
246
+ processedWrapper.style.display = 'none'
247
+ appState.isMirror = isMirrorToggle.checked = getCookie('isMirror') === null ? true : getCookie('isMirror') === 'true' ? true : false // Mirror mode
248
+
249
+ await fetch('/settings', {
250
+ method: 'POST',
251
+ headers: { 'Content-Type': 'application/json' },
252
+ body: JSON.stringify({
253
+ frameRateDown: appState.frameRateDown,
254
+ motionReduction: appState.motionReduction,
255
+ showProcessing: appState.showProcessing,
256
+ isMirror: appState.isMirror
257
+ })
258
+ })
259
+ }
260
+
261
+ window.addEventListener('beforeunload', _ => {
262
+ stopWebcam()
263
+ setReadyInterval()
264
+ })
265
+
266
+ setReadyInterval(true)
reachy_mirror/static/style.css ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #667eea;
3
+ --bg-2: #764ba2;
4
+ --panel: rgba(11, 18, 36, 0.8);
5
+ --border: rgba(255, 255, 255, 0.08);
6
+ --text: #eaf2ff;
7
+ --muted: #9fb6d7;
8
+ --ok: #4ce0b3;
9
+ --warn: #ffb547;
10
+ --error: #ff5c70;
11
+ --accent: #45c4ff;
12
+ --hover: #1a739d;
13
+ --accent-2: #5ef0c1;
14
+ --background: #e9ecef;
15
+ --shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
16
+ }
17
+
18
+ * {
19
+ margin: 0;
20
+ padding: 0;
21
+ box-sizing: border-box;
22
+ }
23
+
24
+ body {
25
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
26
+ background: radial-gradient(circle at 20% 20%, rgba(69, 196, 255, 0.16), transparent 35%),
27
+ radial-gradient(circle at 80% 0%, rgba(94, 240, 193, 0.16), transparent 32%),
28
+ linear-gradient(135deg, var(--bg), var(--bg-2));
29
+ color: var(--text);
30
+ min-height: 100vh;
31
+ display: flex;
32
+ flex-direction: column;
33
+ align-items: center;
34
+ justify-content: center;
35
+ color: var(--text);
36
+ padding: 20px;
37
+ }
38
+
39
+ a,
40
+ a:hover,
41
+ &:link,
42
+ &:visited,
43
+ &:active {
44
+ color: white;
45
+ text-decoration: none;
46
+ }
47
+
48
+ .footer {
49
+ position: sticky;
50
+ bottom: 10px;
51
+ left: 50%;
52
+ transform: translateX(-50%);
53
+ }
54
+
55
+ #spinner {
56
+ width: 46px;
57
+ height: 46px;
58
+ border: 4px solid rgba(255,255,255,0.15);
59
+ border-top-color: var(--accent);
60
+ border-radius: 50%;
61
+ animation: spin 1s linear infinite;
62
+ margin-bottom: 12px;
63
+ }
64
+ @keyframes spin { to { transform: rotate(360deg); } }
65
+ #status { color: var(--text); margin: 0; letter-spacing: 0.4px; }
66
+
67
+ .panel {
68
+ background: var(--panel);
69
+ border: 1px solid var(--border);
70
+ border-radius: 14px;
71
+ padding: 16px 24px;
72
+ box-shadow: var(--shadow);
73
+ backdrop-filter: blur(10px);
74
+ }
75
+
76
+ .container {
77
+ text-align: center;
78
+ width: 100%;
79
+ padding-bottom: 20px;
80
+ }
81
+
82
+ .video-container {
83
+ justify-content: center;
84
+ display: flex;
85
+ gap: 20px;
86
+ margin-bottom: 10px;
87
+ }
88
+
89
+ h1 {
90
+ display: flex;
91
+ align-items: center;
92
+ margin-bottom: 15px;
93
+ }
94
+
95
+ .emoji {
96
+ font-size: 24px;
97
+ margin-right: 10px;
98
+ width: 32px;
99
+ height: 32px;
100
+ background-color: white;
101
+ border-radius: 50%;
102
+ display: flex;
103
+ justify-content: center;
104
+ align-items: center;
105
+ }
106
+
107
+ .status {
108
+ display: flex;
109
+ flex-direction: column;
110
+ justify-content: center;
111
+ align-items: center;
112
+ gap: 10px;
113
+ }
114
+
115
+ #processed-wrapper {
116
+ position: relative;
117
+ width: 50%;
118
+ }
119
+
120
+ #processed-head,
121
+ #processed-hands {
122
+ position: absolute;
123
+ top: 20px;
124
+ color: var(--text);
125
+ white-space: pre-wrap;
126
+ text-transform: capitalize;
127
+ font-family: 'Courier New', Courier, monospace;
128
+ text-align: left;
129
+ }
130
+
131
+ #processed-head {
132
+ left: 20px;
133
+ }
134
+
135
+ #processed-hands {
136
+ right: 20px;
137
+ }
138
+
139
+ #remote,
140
+ #webcam,
141
+ #processed {
142
+ background: white;
143
+ width: 50%;
144
+ border-radius: 1em;
145
+ padding: 4px;
146
+ box-shadow: var(--shadow);
147
+ backdrop-filter: blur(10px);
148
+ }
149
+
150
+ #processed {
151
+ width: 100%;
152
+ }
153
+
154
+ #webcam {
155
+ display: none;
156
+ visibility: hidden;
157
+ }
158
+
159
+ .controls {
160
+ border-radius: 8px;
161
+ margin: 20px 0;
162
+ display: flex;
163
+ justify-content: center;
164
+ gap: 20px;
165
+ }
166
+
167
+ .controls-panel {
168
+ display: flex;
169
+ flex-direction: column;
170
+ gap: 10px;
171
+ }
172
+
173
+ .setting-item {
174
+ display: flex;
175
+ align-items: center;
176
+ padding: 8px 0;
177
+ }
178
+
179
+ .setting-button {
180
+ display: flex;
181
+ justify-content: end;
182
+ padding: 8px 0;
183
+ }
184
+
185
+ label {
186
+ display: flex;
187
+ align-items: center;
188
+ }
189
+
190
+ .setting-label {
191
+ width: 160px;
192
+ text-align: left;
193
+ }
194
+
195
+ .setting-label-right {
196
+ width: 190px;
197
+ text-align: left;
198
+ }
199
+
200
+ .setting-value {
201
+ width: 80px;
202
+ text-align: right;
203
+ }
204
+
205
+
206
+ button {
207
+ display: inline-flex;
208
+ align-items: center;
209
+ justify-content: center;
210
+ padding: 11px 16px;
211
+ border: none;
212
+ border-radius: 10px;
213
+ background: linear-gradient(120deg, var(--accent), var(--accent-2));
214
+ color: var(--text);
215
+ cursor: pointer;
216
+ font-weight: 600;
217
+ letter-spacing: 0.2px;
218
+ box-shadow: 0 14px 40px rgba(69, 196, 255, 0.25);
219
+ transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease;
220
+ }
221
+ button:hover { filter: brightness(1.06); transform: translateY(-1px); }
222
+ button:active { transform: translateY(0); }
223
+ button.ghost {
224
+ background: rgba(255, 255, 255, 0.05);
225
+ color: var(--text);
226
+ box-shadow: none;
227
+ border: 1px solid var(--border);
228
+ }
229
+ button.ghost:hover { border-color: rgba(94, 240, 193, 0.4); }
230
+
231
+ .slider-container {
232
+ width: 100%;
233
+ margin: 15px 0;
234
+ }
235
+
236
+ .slider-container label {
237
+ display: block;
238
+ margin-bottom: 5px;
239
+ font-weight: 500;
240
+ color: #495057;
241
+ }
242
+
243
+ .slider-wrapper {
244
+ display: flex;
245
+ align-items: center;
246
+ gap: 10px;
247
+ }
248
+
249
+ .slider-value {
250
+ min-width: 30px;
251
+ text-align: right;
252
+ font-weight: bold;
253
+ color: var(--accent);
254
+ }
255
+
256
+ input[type="range"] {
257
+ -webkit-appearance: none;
258
+ appearance: none;
259
+ width: 100%;
260
+ height: 10px;
261
+ border-radius: 4px;
262
+ background: var(--background);
263
+ outline: none;
264
+ margin: 10px 0;
265
+ }
266
+
267
+ .switch {
268
+ position: relative;
269
+ display: inline-block;
270
+ width: 50px;
271
+ height: 24px;
272
+ }
273
+
274
+ .switch input {
275
+ opacity: 0;
276
+ width: 0;
277
+ height: 0;
278
+ }
279
+
280
+ .switch-slider {
281
+ position: absolute;
282
+ cursor: pointer;
283
+ top: 0;
284
+ left: 0;
285
+ right: 0;
286
+ bottom: 0;
287
+ background-color: var(--muted);
288
+ transition: 0.4s;
289
+ border-radius: 24px;
290
+ }
291
+
292
+ .switch-slider:before {
293
+ position: absolute;
294
+ content: "";
295
+ height: 16px;
296
+ width: 16px;
297
+ left: 4px;
298
+ bottom: 4px;
299
+ background-color: var(--text);
300
+ transition: 0.4s;
301
+ border-radius: 50%;
302
+ }
303
+
304
+ input:checked + .switch-slider {
305
+ background-color: var(--accent);
306
+ }
307
+
308
+ input:focus + .switch-slider {
309
+ box-shadow: 0 0 1px var(--accent);
310
+ }
311
+
312
+ input[type="range"]::-moz-range-track {
313
+ width: 100%;
314
+ height: 10px;
315
+ border-radius: 4px;
316
+ background: var(--background);
317
+ border: none;
318
+ }
319
+
320
+ input[type="range"]::-webkit-slider-runnable-track {
321
+ height: 10px;
322
+ border-radius: 4px;
323
+ background: var(--background);
324
+ }
325
+
326
+ input[type="range"]::-moz-range-progress {
327
+ height: 10px;
328
+ border-radius: 4px;
329
+ background-color: var(--accent);
330
+ }
331
+
332
+ input[type="range"]::-webkit-slider-thumb {
333
+ -webkit-appearance: none;
334
+ appearance: none;
335
+ width: 24px;
336
+ height: 24px;
337
+ border-radius: 50%;
338
+ background: var(--accent);
339
+ cursor: pointer;
340
+ transition: all 0.15s ease;
341
+ transform: translateY(-8px);
342
+ }
343
+
344
+ input[type="range"]::-moz-range-thumb {
345
+ width: 24px;
346
+ height: 24px;
347
+ border: none;
348
+ border-radius: 50%;
349
+ background: var(--accent);
350
+ cursor: pointer;
351
+ transition: all 0.15s ease;
352
+ }
353
+
354
+ input[type="range"]::-webkit-slider-thumb:hover {
355
+ background: var(--hover);
356
+ transform: translateY(-8px) scale(1.2);
357
+ }
358
+
359
+ input[type="range"]::-moz-range-thumb:hover {
360
+ background: var(--hover);
361
+ transform: scale(1.2);
362
+ }
363
+
364
+ input:checked + .switch-slider:before {
365
+ transform: translateX(26px);
366
+ }
367
+
style.css ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #667eea;
3
+ --bg-2: #764ba2;
4
+ --panel: rgba(11, 18, 36, 0.8);
5
+ --border: rgba(255, 255, 255, 0.08);
6
+ --text: #eaf2ff;
7
+ --content: #d1deee;
8
+ --ok: #4ce0b3;
9
+ --warn: #ffb547;
10
+ --error: #ff5c70;
11
+ --accent: #45c4ff;
12
+ --hover: #1a739d;
13
+ --accent-2: #5ef0c1;
14
+ --background: #e9ecef;
15
+ --shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
16
+ }
17
+
18
+ * {
19
+ margin: 0;
20
+ padding: 0;
21
+ box-sizing: border-box;
22
+ }
23
+
24
+ body {
25
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
26
+ background: radial-gradient(circle at 20% 20%, rgba(69, 196, 255, 0.16), transparent 35%),
27
+ radial-gradient(circle at 80% 0%, rgba(94, 240, 193, 0.16), transparent 32%),
28
+ linear-gradient(135deg, var(--bg), var(--bg-2));
29
+ color: var(--text);
30
+ min-height: 100vh;
31
+ color: var(--text);
32
+ line-height: 1.6;
33
+ }
34
+
35
+ a,
36
+ a:hover,
37
+ &:link,
38
+ &:visited,
39
+ &:active {
40
+ color: white;
41
+ text-decoration: none;
42
+ }
43
+
44
+ p {
45
+ margin-bottom: 1em;
46
+ }
47
+
48
+ .footer {
49
+ color: white;
50
+ position: absolute;
51
+ bottom: 10px;
52
+ left: 50%;
53
+ transform: translateX(-50%);
54
+ }
55
+
56
+ .hero {
57
+ padding: 3.5rem clamp(1.5rem, 3vw, 3rem) 2.5rem;
58
+ position: relative;
59
+ overflow: hidden;
60
+ min-height: 100vh;
61
+ }
62
+
63
+ .hero::after {
64
+ content: "";
65
+ position: absolute;
66
+ inset: 0;
67
+ background: linear-gradient(120deg, rgba(122, 245, 196, 0.12), rgba(246, 196, 82, 0.08), transparent);
68
+ pointer-events: none;
69
+ }
70
+
71
+ .topline {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: space-between;
75
+ max-width: 1200px;
76
+ margin: 0 auto 2rem;
77
+ position: relative;
78
+ z-index: 2;
79
+ }
80
+
81
+ .brand {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 0.5rem;
85
+ font-weight: 700;
86
+ letter-spacing: 0.5px;
87
+ color: var(--text);
88
+ }
89
+
90
+ .logo {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ width: 2.2rem;
95
+ height: 2.2rem;
96
+ border-radius: 10px;
97
+ background: linear-gradient(145deg, rgba(122, 245, 196, 0.15), rgba(124, 142, 255, 0.15));
98
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
99
+ }
100
+
101
+ .brand-name {
102
+ font-size: 1.1rem;
103
+ }
104
+
105
+ .pill {
106
+ background: rgba(255, 255, 255, 0.06);
107
+ border: 1px solid var(--border);
108
+ padding: 0.6rem 1rem;
109
+ border-radius: 999px;
110
+ color: var(--content);
111
+ font-size: 0.9rem;
112
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
113
+ }
114
+
115
+ .hero-grid {
116
+ display: grid;
117
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
118
+ gap: clamp(1.5rem, 2.5vw, 2.5rem);
119
+ max-width: 1200px;
120
+ margin: 0 auto;
121
+ position: relative;
122
+ z-index: 2;
123
+ align-items: center;
124
+ }
125
+
126
+ .hero-copy h1 {
127
+ font-size: clamp(2.6rem, 4vw, 3.6rem);
128
+ margin-bottom: 1rem;
129
+ line-height: 1.1;
130
+ letter-spacing: -0.5px;
131
+ }
132
+
133
+ .eyebrow {
134
+ display: inline-flex;
135
+ align-items: center;
136
+ gap: 0.5rem;
137
+ text-transform: uppercase;
138
+ letter-spacing: 1px;
139
+ font-size: 0.8rem;
140
+ color: var(--content);
141
+ margin-bottom: 0.75rem;
142
+ }
143
+
144
+ .eyebrow::before {
145
+ content: "";
146
+ display: inline-block;
147
+ width: 24px;
148
+ height: 2px;
149
+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
150
+ border-radius: 999px;
151
+ }
152
+
153
+ .lede {
154
+ font-size: 1.1rem;
155
+ color: var(--content);
156
+ max-width: 620px;
157
+ }
158
+
159
+ .hero-actions {
160
+ display: flex;
161
+ gap: 1rem;
162
+ align-items: center;
163
+ margin: 1.6rem 0 1.2rem;
164
+ flex-wrap: wrap;
165
+ }
166
+
167
+ .btn {
168
+ display: inline-flex;
169
+ align-items: center;
170
+ justify-content: center;
171
+ gap: 0.6rem;
172
+ padding: 0.85rem 1.4rem;
173
+ border-radius: 12px;
174
+ font-weight: 700;
175
+ border: 1px solid transparent;
176
+ cursor: pointer;
177
+ transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease;
178
+ }
179
+
180
+ .btn.primary {
181
+ background: linear-gradient(135deg, #7af5c4, #7c8eff);
182
+ color: white;
183
+ box-shadow: 0 15px 30px rgba(122, 245, 196, 0.25);
184
+ }
185
+
186
+ .btn.primary:hover {
187
+ transform: translateY(-2px);
188
+ box-shadow: 0 25px 45px rgba(122, 245, 196, 0.35);
189
+ }
190
+
191
+ .btn.ghost {
192
+ background: rgba(255, 255, 255, 0.05);
193
+ border-color: var(--border);
194
+ color: var(--text);
195
+ }
196
+
197
+ .btn.ghost:hover {
198
+ border-color: rgba(255, 255, 255, 0.3);
199
+ transform: translateY(-2px);
200
+ }
201
+
202
+ .btn.wide {
203
+ width: 100%;
204
+ justify-content: center;
205
+ }
206
+
207
+ .hero-badges {
208
+ display: flex;
209
+ flex-wrap: wrap;
210
+ gap: 0.6rem;
211
+ color: var(--content);
212
+ font-size: 0.9rem;
213
+ }
214
+
215
+ .hero-badges span {
216
+ padding: 0.5rem 0.8rem;
217
+ border-radius: 10px;
218
+ border: 1px solid var(--border);
219
+ background: rgba(255, 255, 255, 0.04);
220
+ }
221
+
222
+ .hero-visual .glass-card {
223
+ background: rgba(255, 255, 255, 0.03);
224
+ border: 1px solid var(--border);
225
+ border-radius: 18px;
226
+ padding: 1.2rem;
227
+ box-shadow: var(--shadow);
228
+ backdrop-filter: blur(10px);
229
+ }
230
+
231
+ .hero-gif {
232
+ width: 100%;
233
+ display: block;
234
+ border-radius: 14px;
235
+ border: 1px solid var(--border);
236
+ box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35);
237
+ }
238
+
239
+ .caption {
240
+ margin-top: 0.75rem;
241
+ color: var(--content);
242
+ font-size: 0.95rem;
243
+ }
244
+
245
+ .section {
246
+ max-width: 1200px;
247
+ margin: 0 auto;
248
+ padding: clamp(2rem, 4vw, 3.5rem) clamp(1.5rem, 3vw, 3rem);
249
+ }
250
+
251
+ .section-header {
252
+ text-align: center;
253
+ max-width: 780px;
254
+ margin: 0 auto 2rem;
255
+ }
256
+
257
+ .section-header h2 {
258
+ font-size: clamp(2rem, 3vw, 2.6rem);
259
+ margin-bottom: 0.5rem;
260
+ }
261
+
262
+ .intro {
263
+ color: var(--content);
264
+ font-size: 1.05rem;
265
+ }
266
+
267
+ .feature-grid {
268
+ display: grid;
269
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
270
+ gap: 1rem;
271
+ }
272
+
273
+ .feature-card {
274
+ background: rgba(255, 255, 255, 0.03);
275
+ border: 1px solid var(--border);
276
+ border-radius: 16px;
277
+ padding: 1.25rem;
278
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
279
+ transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
280
+ }
281
+
282
+ .feature-card:hover {
283
+ transform: translateY(-4px);
284
+ border-color: rgba(122, 245, 196, 0.3);
285
+ box-shadow: 0 18px 40px rgba(0, 0, 0, 0.3);
286
+ }
287
+
288
+ .feature-card .icon {
289
+ width: 48px;
290
+ height: 48px;
291
+ border-radius: 12px;
292
+ display: grid;
293
+ place-items: center;
294
+ background: rgba(122, 245, 196, 0.14);
295
+ margin-bottom: 0.8rem;
296
+ font-size: 1.4rem;
297
+ }
298
+
299
+ .feature-card h3 {
300
+ margin-bottom: 0.35rem;
301
+ }
302
+
303
+ .feature-card p {
304
+ color: var(--content);
305
+ }
306
+
307
+ .story {
308
+ padding-top: 1rem;
309
+ }
310
+
311
+ .story-grid {
312
+ display: grid;
313
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
314
+ gap: 1rem;
315
+ }
316
+
317
+ .story-card {
318
+ background: rgba(255, 255, 255, 0.03);
319
+ border: 1px solid var(--border);
320
+ border-radius: 18px;
321
+ padding: 1.5rem;
322
+ box-shadow: var(--shadow);
323
+ }
324
+
325
+ .story-card.secondary {
326
+ background: linear-gradient(145deg, rgba(124, 142, 255, 0.08), rgba(122, 245, 196, 0.06));
327
+ }
328
+
329
+ .story-card h3 {
330
+ margin-bottom: 0.8rem;
331
+ }
332
+
333
+ .story-list {
334
+ list-style: none;
335
+ display: grid;
336
+ gap: 0.7rem;
337
+ color: var(--content);
338
+ font-size: 0.98rem;
339
+ }
340
+
341
+ .story-list li {
342
+ display: flex;
343
+ gap: 0.7rem;
344
+ align-items: flex-start;
345
+ }
346
+
347
+ .story-text {
348
+ color: var(--content);
349
+ line-height: 1.7;
350
+ margin-bottom: 1rem;
351
+ }
352
+
353
+ .chips {
354
+ display: flex;
355
+ flex-wrap: wrap;
356
+ gap: 0.5rem;
357
+ }
358
+
359
+ .chip {
360
+ padding: 0.45rem 0.8rem;
361
+ border-radius: 12px;
362
+ background: rgba(0, 0, 0, 0.2);
363
+ border: 1px solid var(--border);
364
+ color: var(--text);
365
+ font-size: 0.9rem;
366
+ }
367
+
368
+ @media (max-width: 768px) {
369
+ .hero {
370
+ padding-top: 2.5rem;
371
+ }
372
+
373
+ .topline {
374
+ flex-direction: column;
375
+ gap: 0.8rem;
376
+ align-items: flex-start;
377
+ }
378
+
379
+ .hero-actions {
380
+ width: 100%;
381
+ }
382
+
383
+ .btn {
384
+ width: 100%;
385
+ justify-content: center;
386
+ }
387
+
388
+ .hero-badges {
389
+ gap: 0.4rem;
390
+ }
391
+ }