Spaces:
Running
Running
Hugo.V commited on
Commit ·
0cf1082
1
Parent(s): 50e4de8
Commentaire dans le main refait, url ytb, innovation
Browse files- jericho/main.py +543 -98
- jericho/static/index.html +88 -10
- jericho/static/main.js +316 -6
- jericho/static/style.css +33 -2
jericho/main.py
CHANGED
|
@@ -1,43 +1,44 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import time
|
| 3 |
-
import threading
|
| 4 |
-
import math
|
| 5 |
-
import os
|
| 6 |
-
import base64
|
| 7 |
-
import numpy as np
|
| 8 |
-
import sys
|
| 9 |
-
import subprocess
|
| 10 |
-
import shutil
|
| 11 |
-
import bisect
|
| 12 |
-
from pydantic import BaseModel
|
| 13 |
|
| 14 |
|
| 15 |
-
from fastapi.staticfiles import StaticFiles
|
| 16 |
-
from fastapi.responses import FileResponse
|
| 17 |
-
from fastapi import HTTPException, BackgroundTasks
|
| 18 |
|
| 19 |
|
| 20 |
-
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 21 |
-
from reachy_mini.utils import create_head_pose
|
| 22 |
|
| 23 |
|
| 24 |
-
def load_signals(path: str) -> tuple[dict, list[dict]]:
|
| 25 |
with open(path, "r", encoding="utf-8") as f:
|
| 26 |
data = json.load(f)
|
| 27 |
-
meta = {k: v for k, v in data.items() if k != "frames"}
|
| 28 |
-
frames = [fr for fr in data["frames"] if fr.get("face_detected", False)]
|
| 29 |
print(f"[Chargement] {len(frames)} frames avec visage.")
|
| 30 |
return meta, frames
|
| 31 |
|
| 32 |
-
def moving_average(values: list[float], window: int = 7) -> np.ndarray:
|
| 33 |
arr = np.asarray(values, dtype=float)
|
| 34 |
pad = window // 2
|
| 35 |
-
padded = np.pad(arr, pad_width=pad, mode="edge")
|
| 36 |
kernel = np.ones(window) / window
|
| 37 |
-
smoothed = np.convolve(padded, kernel, mode="valid")
|
| 38 |
return smoothed[: len(arr)]
|
| 39 |
|
| 40 |
-
class SignalMapper:
|
|
|
|
| 41 |
HEAD_YAW_MAX = np.deg2rad(5)
|
| 42 |
HEAD_PITCH_MAX = np.deg2rad(15)
|
| 43 |
HEAD_ROLL_MAX = np.deg2rad(15)
|
|
@@ -49,6 +50,7 @@ class SignalMapper:
|
|
| 49 |
ANTENNA_MAX_col = np.deg2rad(110)
|
| 50 |
COMBINED_YAW_LIMIT = np.deg2rad(60)
|
| 51 |
|
|
|
|
| 52 |
YAW_AMP = 8.0
|
| 53 |
PITCH_AMP = 5.0
|
| 54 |
ROLL_AMP = 3.0
|
|
@@ -56,15 +58,16 @@ class SignalMapper:
|
|
| 56 |
CX_TO_BODY_YAW = 0.8
|
| 57 |
CY_TO_Z_SCALE = -0.10
|
| 58 |
|
|
|
|
| 59 |
SMOOTH_WINDOW_HEAD = 7
|
| 60 |
SMOOTH_WINDOW_EMOTION = 11
|
| 61 |
|
| 62 |
def __init__(self, frames: list[dict]):
|
| 63 |
self.frames = frames
|
| 64 |
-
self._compute_baselines()
|
| 65 |
-
self.commands = self._build_commands()
|
| 66 |
|
| 67 |
-
def _compute_baselines(self):
|
| 68 |
def mean(key, calc_func=None):
|
| 69 |
if calc_func: return float(np.mean([calc_func(f) for f in self.frames]))
|
| 70 |
return float(np.mean([f[key] for f in self.frames]))
|
|
@@ -72,6 +75,7 @@ class SignalMapper:
|
|
| 72 |
if calc_func: return max(float(np.std([calc_func(f) for f in self.frames])), 1e-6)
|
| 73 |
return max(float(np.std([f[key] for f in self.frames])), 1e-6)
|
| 74 |
|
|
|
|
| 75 |
self.yaw_mean = mean("yaw")
|
| 76 |
self.pitch_mean = mean("pitch")
|
| 77 |
self.roll_mean = mean("roll")
|
|
@@ -88,13 +92,15 @@ class SignalMapper:
|
|
| 88 |
self.mar_mean = mean("mar")
|
| 89 |
self.mar_std = std("mar")
|
| 90 |
|
| 91 |
-
def _detect_emotion(self, smile, ear, mar, brow_raise, brow_furrow):
|
|
|
|
| 92 |
n_smile = (smile - self.smile_mean) / self.smile_std
|
| 93 |
n_ear = (ear - self.ear_mean) / self.ear_std
|
| 94 |
n_mar = (mar - self.mar_mean) / self.mar_std
|
| 95 |
n_brow_raise = (brow_raise - self.brow_raise_mean) / self.brow_raise_std
|
| 96 |
n_brow_furrow = (brow_furrow - self.brow_furrow_mean) / self.brow_furrow_std
|
| 97 |
|
|
|
|
| 98 |
if n_brow_raise > 0.8 and n_ear > 0.8: return "SURPRISE"
|
| 99 |
if n_brow_raise < -0.5 and n_smile < -0.5: return "COLERE"
|
| 100 |
if n_brow_raise < -0.3 and n_ear < -0.5: return "TRISTESSE"
|
|
@@ -103,7 +109,7 @@ class SignalMapper:
|
|
| 103 |
if n_smile > 0.3: return "CONTENT"
|
| 104 |
return "NEUTRE"
|
| 105 |
|
| 106 |
-
def _colere(self, t_state):
|
| 107 |
if t_state < 0.15:
|
| 108 |
angle = -1.0 * (t_state / 0.15) * self.ANTENNA_MAX_col
|
| 109 |
elif t_state < 0.40:
|
|
@@ -114,34 +120,34 @@ class SignalMapper:
|
|
| 114 |
angle = -1.0 * self.ANTENNA_MAX_col
|
| 115 |
return angle, -angle
|
| 116 |
|
| 117 |
-
def _tristesse(self, t_state):
|
| 118 |
target_angle = -1.0 * (1.0 - math.exp(-1.5 * t_state)) * self.ANTENNA_MAX
|
| 119 |
return (target_angle - 0.2), -(target_angle - 0.2)
|
| 120 |
|
| 121 |
-
def _joie(self, t_state):
|
| 122 |
oscillation = 0.5 * math.sin(2 * math.pi * 4.0 * t_state)
|
| 123 |
angle = (0.5 + oscillation) * self.ANTENNA_MAX_alt
|
| 124 |
return -angle, angle
|
| 125 |
|
| 126 |
-
def _content(self, t_state):
|
| 127 |
oscillation = 0.8 * math.sin(2 * math.pi * 0.5 * t_state)
|
| 128 |
angle = oscillation * self.ANTENNA_MAX_alt
|
| 129 |
return angle, angle
|
| 130 |
|
| 131 |
-
def _surprise(self, t_state):
|
| 132 |
angle = 1.0 * (1.0 - math.exp(-14.0 * t_state)) * self.ANTENNA_MAX_alt
|
| 133 |
return -(angle - 0.8), (angle - 0.8)
|
| 134 |
|
| 135 |
-
def _neutre(self, t_state):
|
| 136 |
angle = 1.0 * (1.0 - math.exp(-14.0 * t_state)) * self.ANTENNA_MAX_alt
|
| 137 |
return -(angle - 0.3), (angle - 0.3)
|
| 138 |
|
| 139 |
-
def _peur(self, t_state):
|
| 140 |
tremblement = 0.1 * math.sin(2 * math.pi * 15.0 * t_state)
|
| 141 |
angle = -1.0 * (1.0 - math.exp(-1.5 * t_state) + tremblement) * self.ANTENNA_MAX
|
| 142 |
return (angle - 0.2), -(angle - 0.2)
|
| 143 |
|
| 144 |
-
def _compute_antennas(self, emotion, t_state):
|
| 145 |
if emotion == "TRISTESSE":
|
| 146 |
return self._tristesse(t_state)
|
| 147 |
elif emotion == "JOIE":
|
|
@@ -157,15 +163,10 @@ class SignalMapper:
|
|
| 157 |
elif emotion == "PEUR":
|
| 158 |
return self._peur(t_state)
|
| 159 |
else:
|
| 160 |
-
|
| 161 |
angle = -1.0 * self.ANTENNA_MAX_col
|
| 162 |
return angle, -angle
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
def _build_commands(self) -> list[dict]:
|
| 169 |
n = len(self.frames)
|
| 170 |
raw_yaw = np.array([f["yaw"] - self.yaw_mean for f in self.frames])
|
| 171 |
raw_pitch = np.array([f["pitch"] - self.pitch_mean for f in self.frames])
|
|
@@ -181,6 +182,7 @@ class SignalMapper:
|
|
| 181 |
w_h = self.SMOOTH_WINDOW_HEAD
|
| 182 |
w_e = self.SMOOTH_WINDOW_EMOTION
|
| 183 |
|
|
|
|
| 184 |
s_yaw = moving_average(raw_yaw, w_h)
|
| 185 |
s_pitch = moving_average(raw_pitch, w_h)
|
| 186 |
s_roll = moving_average(raw_roll, w_h)
|
|
@@ -197,9 +199,10 @@ class SignalMapper:
|
|
| 197 |
current_emotion = "NEUTRE"
|
| 198 |
emotion_start_time = 0.0
|
| 199 |
|
| 200 |
-
for i in range(n):
|
| 201 |
t = self.frames[i]["timestamp_s"]
|
| 202 |
|
|
|
|
| 203 |
head_yaw = self._clamp(np.deg2rad(s_yaw[i] * self.YAW_AMP), self.HEAD_YAW_MAX)
|
| 204 |
head_pitch = self._clamp(np.deg2rad(s_pitch[i] * self.PITCH_AMP), self.HEAD_PITCH_MAX)
|
| 205 |
head_roll = self._clamp(np.deg2rad(s_roll[i] * self.ROLL_AMP), self.HEAD_ROLL_MAX)
|
|
@@ -207,22 +210,23 @@ class SignalMapper:
|
|
| 207 |
head_z = self._clamp(s_cy[i] * self.CY_TO_Z_SCALE, self.HEAD_Z_MAX)
|
| 208 |
body_yaw = self._clamp(s_cx[i] * self.CX_TO_BODY_YAW, self.BODY_YAW_MAX)
|
| 209 |
|
| 210 |
-
diff = body_yaw - head_yaw
|
| 211 |
if abs(diff) > self.COMBINED_YAW_LIMIT:
|
| 212 |
body_yaw = head_yaw + np.sign(diff) * self.COMBINED_YAW_LIMIT
|
| 213 |
|
|
|
|
| 214 |
detected_emotion = self._detect_emotion(
|
| 215 |
s_smile[i], s_ear[i], s_mar[i], s_brow_raise[i], s_brow_furrow[i]
|
| 216 |
)
|
| 217 |
|
| 218 |
-
if detected_emotion != current_emotion:
|
| 219 |
current_emotion = detected_emotion
|
| 220 |
emotion_start_time = t
|
| 221 |
|
| 222 |
t_state = t - emotion_start_time
|
| 223 |
-
ant_left, ant_right = self._compute_antennas(current_emotion, t_state)
|
| 224 |
|
| 225 |
-
commands.append({
|
| 226 |
"timestamp_s": round(t, 4),
|
| 227 |
"head": {
|
| 228 |
"x": 0.0,
|
|
@@ -242,23 +246,232 @@ class SignalMapper:
|
|
| 242 |
return commands
|
| 243 |
|
| 244 |
@staticmethod
|
| 245 |
-
def _clamp(value: float, limit: float) -> float:
|
| 246 |
return float(max(-limit, min(limit, value)))
|
| 247 |
|
| 248 |
|
| 249 |
-
class VideoPayload(BaseModel):
|
| 250 |
name: str
|
| 251 |
file_data: str
|
| 252 |
|
| 253 |
-
class
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
project_name: str | None = None
|
| 255 |
current_time: float = 0.0
|
| 256 |
playing: bool | None = None
|
| 257 |
action: str = "sync"
|
| 258 |
|
| 259 |
-
class
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
custom_app_url: str | None = "http://0.0.0.0:8042"
|
| 261 |
-
request_media_backend: str | None =
|
| 262 |
|
| 263 |
def __init__(self, **kwargs):
|
| 264 |
super().__init__(**kwargs)
|
|
@@ -266,11 +479,13 @@ class Jericho(ReachyMiniApp):
|
|
| 266 |
self.antennas_enabled = True
|
| 267 |
self.sound_play_requested = False
|
| 268 |
self.play_robot_requested = False
|
|
|
|
|
|
|
| 269 |
self.current_commands = []
|
| 270 |
self.current_timestamps = []
|
| 271 |
self.current_project_name = None
|
| 272 |
-
self.playback_lock = threading.Lock()
|
| 273 |
-
self.playback_state = {
|
| 274 |
"project_name": None,
|
| 275 |
"playing": False,
|
| 276 |
"current_time": 0.0,
|
|
@@ -284,10 +499,11 @@ class Jericho(ReachyMiniApp):
|
|
| 284 |
os.makedirs(self.projects_dir, exist_ok=True)
|
| 285 |
self.static_dir = os.path.join(self.current_dir, "static")
|
| 286 |
|
| 287 |
-
self.
|
|
|
|
|
|
|
| 288 |
|
| 289 |
-
def _load_project_commands(self, project_name: str) -> int:
|
| 290 |
-
|
| 291 |
with self.playback_lock:
|
| 292 |
if project_name == self.current_project_name and self.current_commands:
|
| 293 |
return len(self.current_commands)
|
|
@@ -296,22 +512,31 @@ class Jericho(ReachyMiniApp):
|
|
| 296 |
if not os.path.exists(signals_path):
|
| 297 |
raise HTTPException(status_code=404, detail="Fichier signals.json introuvable.")
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
self.current_project_name = project_name
|
| 309 |
self.current_commands = commands
|
| 310 |
self.current_timestamps = timestamps
|
| 311 |
|
| 312 |
return len(commands)
|
| 313 |
|
| 314 |
-
def _set_playback_state(self, project_name: str | None, current_time: float, playing: bool, *, dirty: bool = True, stop_requested: bool = False):
|
|
|
|
|
|
|
|
|
|
| 315 |
with self.playback_lock:
|
| 316 |
self.playback_state.update({
|
| 317 |
"project_name": project_name,
|
|
@@ -322,16 +547,16 @@ class Jericho(ReachyMiniApp):
|
|
| 322 |
"stop_requested": stop_requested,
|
| 323 |
})
|
| 324 |
|
| 325 |
-
def _setup_routes(self):
|
| 326 |
if os.path.exists(self.static_dir):
|
| 327 |
self.settings_app.mount("/static", StaticFiles(directory=self.static_dir), name="static")
|
| 328 |
self.settings_app.mount("/projects", StaticFiles(directory=self.projects_dir), name="projects")
|
| 329 |
|
| 330 |
-
@self.settings_app.get("/")
|
| 331 |
def serve_index():
|
| 332 |
return FileResponse(os.path.join(self.static_dir, "index.html"))
|
| 333 |
|
| 334 |
-
def process_in_background(video_path: str, proj_path: str, proj_name: str):
|
| 335 |
status_file = os.path.join(proj_path, "status.txt")
|
| 336 |
try:
|
| 337 |
with open(status_file, "w") as f: f.write("PROCESSING")
|
|
@@ -339,13 +564,12 @@ class Jericho(ReachyMiniApp):
|
|
| 339 |
|
| 340 |
emotion_script = os.path.join(self.current_dir, "emotion.py")
|
| 341 |
|
| 342 |
-
process = subprocess.run(
|
| 343 |
[sys.executable, emotion_script, "--video", video_path, "--output_dir", proj_path],
|
| 344 |
capture_output=True, text=True
|
| 345 |
)
|
| 346 |
|
| 347 |
if process.returncode == 0:
|
| 348 |
-
|
| 349 |
signals_path = os.path.join(proj_path, "signals.json")
|
| 350 |
valid_project = False
|
| 351 |
|
|
@@ -354,10 +578,8 @@ class Jericho(ReachyMiniApp):
|
|
| 354 |
with open(signals_path, "r", encoding="utf-8") as f:
|
| 355 |
data = json.load(f)
|
| 356 |
frames = [fr for fr in data.get("frames", []) if fr.get("face_detected", False)]
|
| 357 |
-
|
| 358 |
-
|
| 359 |
if frames and (frames[-1]["timestamp_s"] - frames[0]["timestamp_s"] >= 1.0):
|
| 360 |
-
valid_project = True
|
| 361 |
except Exception:
|
| 362 |
valid_project = False
|
| 363 |
|
|
@@ -365,11 +587,9 @@ class Jericho(ReachyMiniApp):
|
|
| 365 |
with open(status_file, "w") as f: f.write("DONE")
|
| 366 |
print(f"[{proj_name}] Analyse terminée avec succès !")
|
| 367 |
else:
|
| 368 |
-
|
| 369 |
output_video = os.path.join(proj_path, "output_annotated.mp4")
|
| 370 |
if os.path.exists(output_video):
|
| 371 |
os.remove(output_video)
|
| 372 |
-
|
| 373 |
with open(status_file, "w") as f: f.write("ERROR: NO_FACE_DETECTED")
|
| 374 |
print(f"[{proj_name}] Rejeté : pas de visage detecté")
|
| 375 |
else:
|
|
@@ -383,7 +603,8 @@ class Jericho(ReachyMiniApp):
|
|
| 383 |
error_msg = f"ERROR: {type(e).__name__} - {str(e)}"
|
| 384 |
with open(status_file, "w") as f: f.write(error_msg)
|
| 385 |
print(f"[{proj_name}] Exception système : {error_msg}")
|
| 386 |
-
|
|
|
|
| 387 |
def upload_video(payload: VideoPayload, bg_tasks: BackgroundTasks):
|
| 388 |
project_path = os.path.join(self.projects_dir, payload.name)
|
| 389 |
os.makedirs(project_path, exist_ok=True)
|
|
@@ -391,12 +612,124 @@ class Jericho(ReachyMiniApp):
|
|
| 391 |
|
| 392 |
header, encoded = payload.file_data.split(",", 1) if "," in payload.file_data else ("", payload.file_data)
|
| 393 |
with open(input_video_path, "wb") as f:
|
| 394 |
-
f.write(base64.b64decode(encoded))
|
| 395 |
|
| 396 |
bg_tasks.add_task(process_in_background, input_video_path, project_path, payload.name)
|
| 397 |
return {"status": "accepted"}
|
| 398 |
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
def get_library():
|
| 401 |
projects = []
|
| 402 |
for folder in os.listdir(self.projects_dir):
|
|
@@ -408,7 +741,13 @@ class Jericho(ReachyMiniApp):
|
|
| 408 |
with open(status_path, "r") as f:
|
| 409 |
status = f.read().strip()
|
| 410 |
|
| 411 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
projects.append({
|
| 413 |
"name": folder,
|
| 414 |
"url": f"/projects/{folder}/output_annotated.mp4",
|
|
@@ -422,23 +761,31 @@ class Jericho(ReachyMiniApp):
|
|
| 422 |
})
|
| 423 |
return projects
|
| 424 |
|
| 425 |
-
|
| 426 |
-
@self.settings_app.delete("/api/projects/{project_name}")
|
| 427 |
def delete_project(project_name: str):
|
| 428 |
-
|
| 429 |
project_path = os.path.join(self.projects_dir, project_name)
|
| 430 |
if os.path.exists(project_path) and os.path.isdir(project_path):
|
| 431 |
try:
|
| 432 |
shutil.rmtree(project_path)
|
|
|
|
| 433 |
print(f"[{project_name}] Projet supprimé avec succès.")
|
| 434 |
return {"status": "success"}
|
| 435 |
except Exception as e:
|
| 436 |
raise HTTPException(status_code=500, detail=f"Erreur de suppression: {str(e)}")
|
| 437 |
raise HTTPException(status_code=404, detail="Projet introuvable.")
|
| 438 |
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
def sync_playback(payload: PlaybackPayload):
|
| 441 |
-
|
| 442 |
action = (payload.action or "sync").lower()
|
| 443 |
project_name = payload.project_name or self.current_project_name
|
| 444 |
current_time = max(0.0, float(payload.current_time or 0.0))
|
|
@@ -475,9 +822,8 @@ class Jericho(ReachyMiniApp):
|
|
| 475 |
self._set_playback_state(project_name, current_time, playing, dirty=dirty, stop_requested=False)
|
| 476 |
return {"status": "ok", "action": action, "commands_loaded": commands_loaded}
|
| 477 |
|
| 478 |
-
@self.settings_app.post("/api/play_movement/{project_name}")
|
| 479 |
def trigger_movement(project_name: str):
|
| 480 |
-
|
| 481 |
try:
|
| 482 |
commands_loaded = self._load_project_commands(project_name)
|
| 483 |
self._set_playback_state(project_name, 0.0, True, dirty=True, stop_requested=False)
|
|
@@ -487,17 +833,113 @@ class Jericho(ReachyMiniApp):
|
|
| 487 |
except Exception as e:
|
| 488 |
raise HTTPException(status_code=500, detail=str(e))
|
| 489 |
|
| 490 |
-
def
|
| 491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
time.sleep(1.2)
|
| 493 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
while not stop_event.is_set():
|
| 495 |
with self.playback_lock:
|
| 496 |
commands_to_play = list(self.current_commands)
|
| 497 |
timestamps = list(self.current_timestamps)
|
| 498 |
state = dict(self.playback_state)
|
| 499 |
|
| 500 |
-
if state.get("stop_requested"):
|
|
|
|
| 501 |
reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0)
|
| 502 |
with self.playback_lock:
|
| 503 |
self.playback_state["stop_requested"] = False
|
|
@@ -516,22 +958,22 @@ class Jericho(ReachyMiniApp):
|
|
| 516 |
continue
|
| 517 |
|
| 518 |
now = time.time()
|
| 519 |
-
if playing:
|
| 520 |
target_t = float(state.get("current_time", 0.0)) + (now - float(state.get("updated_at", now)))
|
| 521 |
else:
|
| 522 |
target_t = float(state.get("current_time", 0.0))
|
| 523 |
|
| 524 |
-
if target_t > timestamps[-1] + 0.20:
|
| 525 |
self._set_playback_state(state.get("project_name"), timestamps[-1], False, dirty=False, stop_requested=True)
|
| 526 |
continue
|
| 527 |
|
| 528 |
-
idx = bisect.bisect_left(timestamps, max(0.0, target_t))
|
| 529 |
if idx >= len(commands_to_play):
|
| 530 |
idx = len(commands_to_play) - 1
|
| 531 |
|
| 532 |
cmd = commands_to_play[idx]
|
| 533 |
head_dict = cmd["head"]
|
| 534 |
-
head_pose = create_head_pose(
|
| 535 |
x=head_dict["x"], y=head_dict["y"], z=head_dict["z"],
|
| 536 |
roll=head_dict["roll"], pitch=head_dict["pitch"], yaw=head_dict["yaw"],
|
| 537 |
degrees=False, mm=False,
|
|
@@ -539,22 +981,25 @@ class Jericho(ReachyMiniApp):
|
|
| 539 |
|
| 540 |
antennas_target = cmd["antennas"] if self.antennas_enabled else [0.0, 0.0]
|
| 541 |
|
| 542 |
-
try:
|
| 543 |
reachy_mini.set_target(head=head_pose, antennas=antennas_target, body_yaw=cmd["body_yaw"])
|
| 544 |
except TypeError:
|
| 545 |
reachy_mini.set_target(head=head_pose, antennas=antennas_target)
|
| 546 |
|
|
|
|
|
|
|
| 547 |
if dirty:
|
| 548 |
with self.playback_lock:
|
| 549 |
-
|
| 550 |
if self.playback_state.get("updated_at") == state.get("updated_at"):
|
| 551 |
self.playback_state["dirty"] = False
|
| 552 |
|
| 553 |
time.sleep(0.01 if playing else 0.03)
|
| 554 |
|
|
|
|
|
|
|
| 555 |
if __name__ == "__main__":
|
| 556 |
app = Jericho()
|
| 557 |
try:
|
| 558 |
-
app.wrapped_run()
|
| 559 |
except KeyboardInterrupt:
|
| 560 |
app.stop()
|
|
|
|
| 1 |
+
import json # pour lire ecrire json
|
| 2 |
+
import time # gestion du temps
|
| 3 |
+
import threading # multi-taches
|
| 4 |
+
import math # fct maths
|
| 5 |
+
import os # gestion dossiers
|
| 6 |
+
import base64 # decodage video base64
|
| 7 |
+
import numpy as np # calculs vectors
|
| 8 |
+
import sys # acces syst
|
| 9 |
+
import subprocess # lance prog externes
|
| 10 |
+
import shutil # suppr dossiers
|
| 11 |
+
import bisect # rech rapide liste triee
|
| 12 |
+
from pydantic import BaseModel # structure donnees api
|
| 13 |
|
| 14 |
|
| 15 |
+
from fastapi.staticfiles import StaticFiles # sert fichiers statics
|
| 16 |
+
from fastapi.responses import FileResponse # sert index html
|
| 17 |
+
from fastapi import HTTPException, BackgroundTasks # gestion err et taches fond
|
| 18 |
|
| 19 |
|
| 20 |
+
from reachy_mini import ReachyMini, ReachyMiniApp # import brique robot
|
| 21 |
+
from reachy_mini.utils import create_head_pose # fct pose tete
|
| 22 |
|
| 23 |
|
| 24 |
+
def load_signals(path: str) -> tuple[dict, list[dict]]: # chargement signaux json
|
| 25 |
with open(path, "r", encoding="utf-8") as f:
|
| 26 |
data = json.load(f)
|
| 27 |
+
meta = {k: v for k, v in data.items() if k != "frames"} # recup infos hors frames
|
| 28 |
+
frames = [fr for fr in data["frames"] if fr.get("face_detected", False)] # garde frames avec visage
|
| 29 |
print(f"[Chargement] {len(frames)} frames avec visage.")
|
| 30 |
return meta, frames
|
| 31 |
|
| 32 |
+
def moving_average(values: list[float], window: int = 7) -> np.ndarray: # fct lissage signaux
|
| 33 |
arr = np.asarray(values, dtype=float)
|
| 34 |
pad = window // 2
|
| 35 |
+
padded = np.pad(arr, pad_width=pad, mode="edge") # evite bords vides
|
| 36 |
kernel = np.ones(window) / window
|
| 37 |
+
smoothed = np.convolve(padded, kernel, mode="valid") # calcul moyenne glissante
|
| 38 |
return smoothed[: len(arr)]
|
| 39 |
|
| 40 |
+
class SignalMapper: # classe map signaux visage vers robot
|
| 41 |
+
# constantes limites angles robot
|
| 42 |
HEAD_YAW_MAX = np.deg2rad(5)
|
| 43 |
HEAD_PITCH_MAX = np.deg2rad(15)
|
| 44 |
HEAD_ROLL_MAX = np.deg2rad(15)
|
|
|
|
| 50 |
ANTENNA_MAX_col = np.deg2rad(110)
|
| 51 |
COMBINED_YAW_LIMIT = np.deg2rad(60)
|
| 52 |
|
| 53 |
+
# multiplicateurs amplifs mouvements
|
| 54 |
YAW_AMP = 8.0
|
| 55 |
PITCH_AMP = 5.0
|
| 56 |
ROLL_AMP = 3.0
|
|
|
|
| 58 |
CX_TO_BODY_YAW = 0.8
|
| 59 |
CY_TO_Z_SCALE = -0.10
|
| 60 |
|
| 61 |
+
# fenetres lissage
|
| 62 |
SMOOTH_WINDOW_HEAD = 7
|
| 63 |
SMOOTH_WINDOW_EMOTION = 11
|
| 64 |
|
| 65 |
def __init__(self, frames: list[dict]):
|
| 66 |
self.frames = frames
|
| 67 |
+
self._compute_baselines() # calcul moyennes ref
|
| 68 |
+
self.commands = self._build_commands() # genere coms robot
|
| 69 |
|
| 70 |
+
def _compute_baselines(self): # fct valeurs moyennes tte la video
|
| 71 |
def mean(key, calc_func=None):
|
| 72 |
if calc_func: return float(np.mean([calc_func(f) for f in self.frames]))
|
| 73 |
return float(np.mean([f[key] for f in self.frames]))
|
|
|
|
| 75 |
if calc_func: return max(float(np.std([calc_func(f) for f in self.frames])), 1e-6)
|
| 76 |
return max(float(np.std([f[key] for f in self.frames])), 1e-6)
|
| 77 |
|
| 78 |
+
# calcul refs pour chaque signal
|
| 79 |
self.yaw_mean = mean("yaw")
|
| 80 |
self.pitch_mean = mean("pitch")
|
| 81 |
self.roll_mean = mean("roll")
|
|
|
|
| 92 |
self.mar_mean = mean("mar")
|
| 93 |
self.mar_std = std("mar")
|
| 94 |
|
| 95 |
+
def _detect_emotion(self, smile, ear, mar, brow_raise, brow_furrow): # fct regles pr emotions
|
| 96 |
+
# calcul ecarts par rapport ref
|
| 97 |
n_smile = (smile - self.smile_mean) / self.smile_std
|
| 98 |
n_ear = (ear - self.ear_mean) / self.ear_std
|
| 99 |
n_mar = (mar - self.mar_mean) / self.mar_std
|
| 100 |
n_brow_raise = (brow_raise - self.brow_raise_mean) / self.brow_raise_std
|
| 101 |
n_brow_furrow = (brow_furrow - self.brow_furrow_mean) / self.brow_furrow_std
|
| 102 |
|
| 103 |
+
# conditions choix emotions selon visage
|
| 104 |
if n_brow_raise > 0.8 and n_ear > 0.8: return "SURPRISE"
|
| 105 |
if n_brow_raise < -0.5 and n_smile < -0.5: return "COLERE"
|
| 106 |
if n_brow_raise < -0.3 and n_ear < -0.5: return "TRISTESSE"
|
|
|
|
| 109 |
if n_smile > 0.3: return "CONTENT"
|
| 110 |
return "NEUTRE"
|
| 111 |
|
| 112 |
+
def _colere(self, t_state): # bouge antennes mode colere
|
| 113 |
if t_state < 0.15:
|
| 114 |
angle = -1.0 * (t_state / 0.15) * self.ANTENNA_MAX_col
|
| 115 |
elif t_state < 0.40:
|
|
|
|
| 120 |
angle = -1.0 * self.ANTENNA_MAX_col
|
| 121 |
return angle, -angle
|
| 122 |
|
| 123 |
+
def _tristesse(self, t_state): # bouge antennes mode tristesse
|
| 124 |
target_angle = -1.0 * (1.0 - math.exp(-1.5 * t_state)) * self.ANTENNA_MAX
|
| 125 |
return (target_angle - 0.2), -(target_angle - 0.2)
|
| 126 |
|
| 127 |
+
def _joie(self, t_state): # bouge antennes mode joie (vibre)
|
| 128 |
oscillation = 0.5 * math.sin(2 * math.pi * 4.0 * t_state)
|
| 129 |
angle = (0.5 + oscillation) * self.ANTENNA_MAX_alt
|
| 130 |
return -angle, angle
|
| 131 |
|
| 132 |
+
def _content(self, t_state): # bouge antennes mode content
|
| 133 |
oscillation = 0.8 * math.sin(2 * math.pi * 0.5 * t_state)
|
| 134 |
angle = oscillation * self.ANTENNA_MAX_alt
|
| 135 |
return angle, angle
|
| 136 |
|
| 137 |
+
def _surprise(self, t_state): # bouge antennes mode surprise
|
| 138 |
angle = 1.0 * (1.0 - math.exp(-14.0 * t_state)) * self.ANTENNA_MAX_alt
|
| 139 |
return -(angle - 0.8), (angle - 0.8)
|
| 140 |
|
| 141 |
+
def _neutre(self, t_state): # bouge antennes mode neutre
|
| 142 |
angle = 1.0 * (1.0 - math.exp(-14.0 * t_state)) * self.ANTENNA_MAX_alt
|
| 143 |
return -(angle - 0.3), (angle - 0.3)
|
| 144 |
|
| 145 |
+
def _peur(self, t_state): # bouge antennes mode peur (tremble)
|
| 146 |
tremblement = 0.1 * math.sin(2 * math.pi * 15.0 * t_state)
|
| 147 |
angle = -1.0 * (1.0 - math.exp(-1.5 * t_state) + tremblement) * self.ANTENNA_MAX
|
| 148 |
return (angle - 0.2), -(angle - 0.2)
|
| 149 |
|
| 150 |
+
def _compute_antennas(self, emotion, t_state): # fct select mvt selon emotion
|
| 151 |
if emotion == "TRISTESSE":
|
| 152 |
return self._tristesse(t_state)
|
| 153 |
elif emotion == "JOIE":
|
|
|
|
| 163 |
elif emotion == "PEUR":
|
| 164 |
return self._peur(t_state)
|
| 165 |
else:
|
|
|
|
| 166 |
angle = -1.0 * self.ANTENNA_MAX_col
|
| 167 |
return angle, -angle
|
| 168 |
|
| 169 |
+
def _build_commands(self) -> list[dict]: # assemble signaux en tab commandes
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
n = len(self.frames)
|
| 171 |
raw_yaw = np.array([f["yaw"] - self.yaw_mean for f in self.frames])
|
| 172 |
raw_pitch = np.array([f["pitch"] - self.pitch_mean for f in self.frames])
|
|
|
|
| 182 |
w_h = self.SMOOTH_WINDOW_HEAD
|
| 183 |
w_e = self.SMOOTH_WINDOW_EMOTION
|
| 184 |
|
| 185 |
+
# applique fct lissage
|
| 186 |
s_yaw = moving_average(raw_yaw, w_h)
|
| 187 |
s_pitch = moving_average(raw_pitch, w_h)
|
| 188 |
s_roll = moving_average(raw_roll, w_h)
|
|
|
|
| 199 |
current_emotion = "NEUTRE"
|
| 200 |
emotion_start_time = 0.0
|
| 201 |
|
| 202 |
+
for i in range(n): # boucle pr chaque image
|
| 203 |
t = self.frames[i]["timestamp_s"]
|
| 204 |
|
| 205 |
+
# mise a echelle + clamp pos tete
|
| 206 |
head_yaw = self._clamp(np.deg2rad(s_yaw[i] * self.YAW_AMP), self.HEAD_YAW_MAX)
|
| 207 |
head_pitch = self._clamp(np.deg2rad(s_pitch[i] * self.PITCH_AMP), self.HEAD_PITCH_MAX)
|
| 208 |
head_roll = self._clamp(np.deg2rad(s_roll[i] * self.ROLL_AMP), self.HEAD_ROLL_MAX)
|
|
|
|
| 210 |
head_z = self._clamp(s_cy[i] * self.CY_TO_Z_SCALE, self.HEAD_Z_MAX)
|
| 211 |
body_yaw = self._clamp(s_cx[i] * self.CX_TO_BODY_YAW, self.BODY_YAW_MAX)
|
| 212 |
|
| 213 |
+
diff = body_yaw - head_yaw # verif coherence corps cou
|
| 214 |
if abs(diff) > self.COMBINED_YAW_LIMIT:
|
| 215 |
body_yaw = head_yaw + np.sign(diff) * self.COMBINED_YAW_LIMIT
|
| 216 |
|
| 217 |
+
# trouve emotion frame
|
| 218 |
detected_emotion = self._detect_emotion(
|
| 219 |
s_smile[i], s_ear[i], s_mar[i], s_brow_raise[i], s_brow_furrow[i]
|
| 220 |
)
|
| 221 |
|
| 222 |
+
if detected_emotion != current_emotion: # top chrono si changmt emo
|
| 223 |
current_emotion = detected_emotion
|
| 224 |
emotion_start_time = t
|
| 225 |
|
| 226 |
t_state = t - emotion_start_time
|
| 227 |
+
ant_left, ant_right = self._compute_antennas(current_emotion, t_state) # calcul mvt antennes
|
| 228 |
|
| 229 |
+
commands.append({ # ajoute com dico complet
|
| 230 |
"timestamp_s": round(t, 4),
|
| 231 |
"head": {
|
| 232 |
"x": 0.0,
|
|
|
|
| 246 |
return commands
|
| 247 |
|
| 248 |
@staticmethod
|
| 249 |
+
def _clamp(value: float, limit: float) -> float: # limite valeurs min max
|
| 250 |
return float(max(-limit, min(limit, value)))
|
| 251 |
|
| 252 |
|
| 253 |
+
class VideoPayload(BaseModel): # struct payload upload video web
|
| 254 |
name: str
|
| 255 |
file_data: str
|
| 256 |
|
| 257 |
+
class SequenceItem(BaseModel): # struct element pr list emotion cree
|
| 258 |
+
emotion: str
|
| 259 |
+
duration: float
|
| 260 |
+
|
| 261 |
+
class SequencePayload(BaseModel): # struct payload pack sequence cree
|
| 262 |
+
name: str
|
| 263 |
+
sequence: list[SequenceItem]
|
| 264 |
+
|
| 265 |
+
class YouTubePayload(BaseModel): # struct payload upload yt web
|
| 266 |
+
name: str
|
| 267 |
+
url: str
|
| 268 |
+
|
| 269 |
+
class PlaybackPayload(BaseModel): # struct payload synchro lecture
|
| 270 |
project_name: str | None = None
|
| 271 |
current_time: float = 0.0
|
| 272 |
playing: bool | None = None
|
| 273 |
action: str = "sync"
|
| 274 |
|
| 275 |
+
class RobotAudioStreamer: # classe envoi audio robot
|
| 276 |
+
SAMPLE_RATE = 16000
|
| 277 |
+
CHANNELS = 2
|
| 278 |
+
CHUNK_SECONDS = 0.08
|
| 279 |
+
QUEUE_AHEAD_SECONDS = 0.20
|
| 280 |
+
RESYNC_THRESHOLD_SECONDS = 0.25
|
| 281 |
+
|
| 282 |
+
def __init__(self, app: "Jericho"):
|
| 283 |
+
self.app = app
|
| 284 |
+
self.audio_cache: dict[str, np.ndarray] = {} # stock memoire sons charges
|
| 285 |
+
self.current_project: str | None = None
|
| 286 |
+
self.next_push_time = 0.0
|
| 287 |
+
self.output_started = False
|
| 288 |
+
self.last_state_signature = None
|
| 289 |
+
self.last_dirty_updated_at = None
|
| 290 |
+
self.last_playing = False
|
| 291 |
+
self.audio_available = True
|
| 292 |
+
|
| 293 |
+
def _project_video_path(self, project_name: str) -> str: # recup lien video mp4
|
| 294 |
+
return os.path.join(self.app.projects_dir, project_name, "output_annotated.mp4")
|
| 295 |
+
|
| 296 |
+
def _load_audio(self, project_name: str) -> np.ndarray: # extrait son via ffmpeg pipe
|
| 297 |
+
if project_name in self.audio_cache:
|
| 298 |
+
return self.audio_cache[project_name]
|
| 299 |
+
|
| 300 |
+
video_path = self._project_video_path(project_name)
|
| 301 |
+
if not os.path.exists(video_path):
|
| 302 |
+
self.audio_cache[project_name] = np.zeros((0, self.CHANNELS), dtype=np.float32)
|
| 303 |
+
return self.audio_cache[project_name]
|
| 304 |
+
|
| 305 |
+
cmd = [ # params ffmpeg pr recup format brut wav float
|
| 306 |
+
"ffmpeg", "-v", "error",
|
| 307 |
+
"-i", video_path,
|
| 308 |
+
"-vn",
|
| 309 |
+
"-ac", str(self.CHANNELS),
|
| 310 |
+
"-ar", str(self.SAMPLE_RATE),
|
| 311 |
+
"-f", "f32le",
|
| 312 |
+
"pipe:1",
|
| 313 |
+
]
|
| 314 |
+
|
| 315 |
+
try:
|
| 316 |
+
process = subprocess.run(cmd, capture_output=True, check=True)
|
| 317 |
+
audio = np.frombuffer(process.stdout, dtype=np.float32)
|
| 318 |
+
if audio.size == 0:
|
| 319 |
+
samples = np.zeros((0, self.CHANNELS), dtype=np.float32)
|
| 320 |
+
else:
|
| 321 |
+
usable = audio.size - (audio.size % self.CHANNELS)
|
| 322 |
+
samples = audio[:usable].reshape(-1, self.CHANNELS).astype(np.float32, copy=False)
|
| 323 |
+
except Exception as exc:
|
| 324 |
+
print(f"[Audio robot] Audio indisponible pour {project_name}: {exc}")
|
| 325 |
+
samples = np.zeros((0, self.CHANNELS), dtype=np.float32)
|
| 326 |
+
|
| 327 |
+
self.audio_cache[project_name] = samples
|
| 328 |
+
return samples
|
| 329 |
+
|
| 330 |
+
def _media(self, reachy_mini: ReachyMini): # racourci module son reachy
|
| 331 |
+
return getattr(reachy_mini, "media", None)
|
| 332 |
+
|
| 333 |
+
def _start_output(self, reachy_mini: ReachyMini): # lance le player reachy
|
| 334 |
+
if self.output_started or not self.audio_available:
|
| 335 |
+
return
|
| 336 |
+
|
| 337 |
+
media = self._media(reachy_mini)
|
| 338 |
+
if media is None:
|
| 339 |
+
self.audio_available = False
|
| 340 |
+
print("[Audio robot] Backend média non disponible : audio robot désactivé.")
|
| 341 |
+
return
|
| 342 |
+
|
| 343 |
+
try:
|
| 344 |
+
media.start_playing()
|
| 345 |
+
for obj in (media, getattr(media, "audio", None)):
|
| 346 |
+
if obj is not None and hasattr(obj, "set_max_output_buffers"):
|
| 347 |
+
obj.set_max_output_buffers(3) # limite latence tampons
|
| 348 |
+
self.output_started = True
|
| 349 |
+
except Exception as exc:
|
| 350 |
+
self.audio_available = False
|
| 351 |
+
print(f"[Audio robot] Impossible de démarrer la sortie audio : {exc}")
|
| 352 |
+
|
| 353 |
+
def _flush_output(self, reachy_mini: ReachyMini): # vide tampons audio si saut
|
| 354 |
+
media = self._media(reachy_mini)
|
| 355 |
+
if media is None or not self.output_started:
|
| 356 |
+
return
|
| 357 |
+
|
| 358 |
+
flushed = False
|
| 359 |
+
for obj in (media, getattr(media, "audio", None)):
|
| 360 |
+
if obj is None:
|
| 361 |
+
continue
|
| 362 |
+
for method_name in ("clear_player", "clear_output_buffer"):
|
| 363 |
+
method = getattr(obj, method_name, None)
|
| 364 |
+
if callable(method):
|
| 365 |
+
try:
|
| 366 |
+
method()
|
| 367 |
+
flushed = True
|
| 368 |
+
except Exception:
|
| 369 |
+
pass
|
| 370 |
+
|
| 371 |
+
if not flushed:
|
| 372 |
+
try:
|
| 373 |
+
media.stop_playing()
|
| 374 |
+
media.start_playing()
|
| 375 |
+
except Exception:
|
| 376 |
+
pass
|
| 377 |
+
|
| 378 |
+
def _stop_output(self, reachy_mini: ReachyMini): # stoppe audio reachy
|
| 379 |
+
media = self._media(reachy_mini)
|
| 380 |
+
if media is None or not self.output_started:
|
| 381 |
+
return
|
| 382 |
+
|
| 383 |
+
try:
|
| 384 |
+
self._flush_output(reachy_mini)
|
| 385 |
+
media.stop_playing()
|
| 386 |
+
except Exception:
|
| 387 |
+
pass
|
| 388 |
+
finally:
|
| 389 |
+
self.output_started = False
|
| 390 |
+
|
| 391 |
+
def _push_chunk(self, reachy_mini: ReachyMini, samples: np.ndarray, start_time: float) -> bool: # injecte bloc son robot
|
| 392 |
+
if samples.size == 0:
|
| 393 |
+
return False
|
| 394 |
+
|
| 395 |
+
start_idx = max(0, int(start_time * self.SAMPLE_RATE))
|
| 396 |
+
chunk_len = max(1, int(self.CHUNK_SECONDS * self.SAMPLE_RATE))
|
| 397 |
+
end_idx = min(start_idx + chunk_len, len(samples))
|
| 398 |
+
if start_idx >= len(samples) or end_idx <= start_idx:
|
| 399 |
+
return False
|
| 400 |
+
|
| 401 |
+
chunk = samples[start_idx:end_idx]
|
| 402 |
+
try:
|
| 403 |
+
media = self._media(reachy_mini)
|
| 404 |
+
if media is None:
|
| 405 |
+
return False
|
| 406 |
+
media.push_audio_sample(chunk.astype(np.float32, copy=False)) # pousse echantillons reachy
|
| 407 |
+
self.next_push_time = end_idx / self.SAMPLE_RATE
|
| 408 |
+
return True
|
| 409 |
+
except Exception as exc:
|
| 410 |
+
print(f"[Audio robot] push_audio_sample a échoué : {exc}")
|
| 411 |
+
self.audio_available = False
|
| 412 |
+
return False
|
| 413 |
+
|
| 414 |
+
def update(self, reachy_mini: ReachyMini, state: dict, stop_event: threading.Event): # synchro continue flux son
|
| 415 |
+
if stop_event.is_set() or not self.audio_available:
|
| 416 |
+
return
|
| 417 |
+
|
| 418 |
+
project_name = state.get("project_name")
|
| 419 |
+
playing = bool(state.get("playing", False))
|
| 420 |
+
updated_at = float(state.get("updated_at", time.time()))
|
| 421 |
+
current_time = max(0.0, float(state.get("current_time", 0.0)))
|
| 422 |
+
|
| 423 |
+
dirty = bool(state.get("dirty", False))
|
| 424 |
+
signature = (project_name, playing)
|
| 425 |
+
|
| 426 |
+
if not project_name or not playing:
|
| 427 |
+
if self.last_playing:
|
| 428 |
+
self._flush_output(reachy_mini)
|
| 429 |
+
self.last_playing = False
|
| 430 |
+
self.last_state_signature = signature
|
| 431 |
+
return
|
| 432 |
+
|
| 433 |
+
samples = self._load_audio(project_name)
|
| 434 |
+
if samples.size == 0:
|
| 435 |
+
self.last_playing = playing
|
| 436 |
+
self.last_state_signature = signature
|
| 437 |
+
return
|
| 438 |
+
|
| 439 |
+
self._start_output(reachy_mini)
|
| 440 |
+
if not self.output_started:
|
| 441 |
+
return
|
| 442 |
+
|
| 443 |
+
target_time = current_time + (time.time() - updated_at) # prend en compte delai reseau
|
| 444 |
+
target_time = max(0.0, min(target_time, len(samples) / self.SAMPLE_RATE))
|
| 445 |
+
|
| 446 |
+
must_reposition = ( # check si besoin repositionner lecture son
|
| 447 |
+
project_name != self.current_project
|
| 448 |
+
or (playing and not self.last_playing)
|
| 449 |
+
or (dirty and self.last_dirty_updated_at != updated_at)
|
| 450 |
+
)
|
| 451 |
+
if must_reposition:
|
| 452 |
+
self.current_project = project_name
|
| 453 |
+
self.next_push_time = target_time
|
| 454 |
+
self.last_dirty_updated_at = updated_at
|
| 455 |
+
self._flush_output(reachy_mini)
|
| 456 |
+
|
| 457 |
+
if abs(self.next_push_time - target_time) > self.RESYNC_THRESHOLD_SECONDS: # verif si decalage trop gros
|
| 458 |
+
self.next_push_time = target_time
|
| 459 |
+
self._flush_output(reachy_mini)
|
| 460 |
+
|
| 461 |
+
while ( # rempli tampon avance
|
| 462 |
+
not stop_event.is_set()
|
| 463 |
+
and self.next_push_time < target_time + self.QUEUE_AHEAD_SECONDS
|
| 464 |
+
):
|
| 465 |
+
if not self._push_chunk(reachy_mini, samples, self.next_push_time):
|
| 466 |
+
break
|
| 467 |
+
|
| 468 |
+
self.last_playing = playing
|
| 469 |
+
self.last_state_signature = signature
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
class Jericho(ReachyMiniApp): # appli principale serveur app backend
|
| 473 |
custom_app_url: str | None = "http://0.0.0.0:8042"
|
| 474 |
+
request_media_backend: str | None = "default"
|
| 475 |
|
| 476 |
def __init__(self, **kwargs):
|
| 477 |
super().__init__(**kwargs)
|
|
|
|
| 479 |
self.antennas_enabled = True
|
| 480 |
self.sound_play_requested = False
|
| 481 |
self.play_robot_requested = False
|
| 482 |
+
self.manual_emotion = None
|
| 483 |
+
self.manual_emotion_lock = threading.Lock() # verrou pr modifs interfs
|
| 484 |
self.current_commands = []
|
| 485 |
self.current_timestamps = []
|
| 486 |
self.current_project_name = None
|
| 487 |
+
self.playback_lock = threading.Lock() # verrou securite thread mvt
|
| 488 |
+
self.playback_state = { # infos etat lecture courante
|
| 489 |
"project_name": None,
|
| 490 |
"playing": False,
|
| 491 |
"current_time": 0.0,
|
|
|
|
| 499 |
os.makedirs(self.projects_dir, exist_ok=True)
|
| 500 |
self.static_dir = os.path.join(self.current_dir, "static")
|
| 501 |
|
| 502 |
+
self.robot_audio = RobotAudioStreamer(self) # init streamer audio
|
| 503 |
+
|
| 504 |
+
self._setup_routes() # configure routes fastapi
|
| 505 |
|
| 506 |
+
def _load_project_commands(self, project_name: str) -> int: # charge coms json en cache
|
|
|
|
| 507 |
with self.playback_lock:
|
| 508 |
if project_name == self.current_project_name and self.current_commands:
|
| 509 |
return len(self.current_commands)
|
|
|
|
| 512 |
if not os.path.exists(signals_path):
|
| 513 |
raise HTTPException(status_code=404, detail="Fichier signals.json introuvable.")
|
| 514 |
|
| 515 |
+
with open(signals_path, "r", encoding="utf-8") as f:
|
| 516 |
+
data = json.load(f)
|
| 517 |
+
|
| 518 |
+
if data.get("type") == "creation": # check si sequence creee a la main
|
| 519 |
+
commands = data.get("commands", [])
|
| 520 |
+
timestamps = [cmd["timestamp_s"] for cmd in commands]
|
| 521 |
+
else: # sinon charge video ia classique
|
| 522 |
+
meta, frames = load_signals(signals_path)
|
| 523 |
+
if not frames:
|
| 524 |
+
raise HTTPException(status_code=400, detail="Aucune frame avec visage dans signals.json.")
|
| 525 |
+
mapper = SignalMapper(frames)
|
| 526 |
+
commands = mapper.commands
|
| 527 |
+
timestamps = [cmd["timestamp_s"] for cmd in commands]
|
| 528 |
+
|
| 529 |
+
with self.playback_lock: # stocke listes pr thread mvt
|
| 530 |
self.current_project_name = project_name
|
| 531 |
self.current_commands = commands
|
| 532 |
self.current_timestamps = timestamps
|
| 533 |
|
| 534 |
return len(commands)
|
| 535 |
|
| 536 |
+
def _set_playback_state(self, project_name: str | None, current_time: float, playing: bool, *, dirty: bool = True, stop_requested: bool = False): # fct modif etat lecture safely
|
| 537 |
+
if playing:
|
| 538 |
+
with self.manual_emotion_lock:
|
| 539 |
+
self.manual_emotion = None # coupe emotion manuelle si play video
|
| 540 |
with self.playback_lock:
|
| 541 |
self.playback_state.update({
|
| 542 |
"project_name": project_name,
|
|
|
|
| 547 |
"stop_requested": stop_requested,
|
| 548 |
})
|
| 549 |
|
| 550 |
+
def _setup_routes(self): # routes api web fastapi
|
| 551 |
if os.path.exists(self.static_dir):
|
| 552 |
self.settings_app.mount("/static", StaticFiles(directory=self.static_dir), name="static")
|
| 553 |
self.settings_app.mount("/projects", StaticFiles(directory=self.projects_dir), name="projects")
|
| 554 |
|
| 555 |
+
@self.settings_app.get("/") # sert page html principale
|
| 556 |
def serve_index():
|
| 557 |
return FileResponse(os.path.join(self.static_dir, "index.html"))
|
| 558 |
|
| 559 |
+
def process_in_background(video_path: str, proj_path: str, proj_name: str): # fct fond run script ia
|
| 560 |
status_file = os.path.join(proj_path, "status.txt")
|
| 561 |
try:
|
| 562 |
with open(status_file, "w") as f: f.write("PROCESSING")
|
|
|
|
| 564 |
|
| 565 |
emotion_script = os.path.join(self.current_dir, "emotion.py")
|
| 566 |
|
| 567 |
+
process = subprocess.run( # lance script emotion.py avec arguments
|
| 568 |
[sys.executable, emotion_script, "--video", video_path, "--output_dir", proj_path],
|
| 569 |
capture_output=True, text=True
|
| 570 |
)
|
| 571 |
|
| 572 |
if process.returncode == 0:
|
|
|
|
| 573 |
signals_path = os.path.join(proj_path, "signals.json")
|
| 574 |
valid_project = False
|
| 575 |
|
|
|
|
| 578 |
with open(signals_path, "r", encoding="utf-8") as f:
|
| 579 |
data = json.load(f)
|
| 580 |
frames = [fr for fr in data.get("frames", []) if fr.get("face_detected", False)]
|
|
|
|
|
|
|
| 581 |
if frames and (frames[-1]["timestamp_s"] - frames[0]["timestamp_s"] >= 1.0):
|
| 582 |
+
valid_project = True # valide si visage tps min 1s
|
| 583 |
except Exception:
|
| 584 |
valid_project = False
|
| 585 |
|
|
|
|
| 587 |
with open(status_file, "w") as f: f.write("DONE")
|
| 588 |
print(f"[{proj_name}] Analyse terminée avec succès !")
|
| 589 |
else:
|
|
|
|
| 590 |
output_video = os.path.join(proj_path, "output_annotated.mp4")
|
| 591 |
if os.path.exists(output_video):
|
| 592 |
os.remove(output_video)
|
|
|
|
| 593 |
with open(status_file, "w") as f: f.write("ERROR: NO_FACE_DETECTED")
|
| 594 |
print(f"[{proj_name}] Rejeté : pas de visage detecté")
|
| 595 |
else:
|
|
|
|
| 603 |
error_msg = f"ERROR: {type(e).__name__} - {str(e)}"
|
| 604 |
with open(status_file, "w") as f: f.write(error_msg)
|
| 605 |
print(f"[{proj_name}] Exception système : {error_msg}")
|
| 606 |
+
|
| 607 |
+
@self.settings_app.post("/api/upload") # endpoint reception video upload web
|
| 608 |
def upload_video(payload: VideoPayload, bg_tasks: BackgroundTasks):
|
| 609 |
project_path = os.path.join(self.projects_dir, payload.name)
|
| 610 |
os.makedirs(project_path, exist_ok=True)
|
|
|
|
| 612 |
|
| 613 |
header, encoded = payload.file_data.split(",", 1) if "," in payload.file_data else ("", payload.file_data)
|
| 614 |
with open(input_video_path, "wb") as f:
|
| 615 |
+
f.write(base64.b64decode(encoded)) # decode et save mp4
|
| 616 |
|
| 617 |
bg_tasks.add_task(process_in_background, input_video_path, project_path, payload.name)
|
| 618 |
return {"status": "accepted"}
|
| 619 |
|
| 620 |
+
def download_and_process_youtube(url: str, proj_path: str, proj_name: str): # fct fond recup video youtube
|
| 621 |
+
status_file = os.path.join(proj_path, "status.txt")
|
| 622 |
+
input_video_path = os.path.join(proj_path, "input.mp4")
|
| 623 |
+
try:
|
| 624 |
+
with open(status_file, "w") as f:
|
| 625 |
+
f.write("DOWNLOADING")
|
| 626 |
+
print(f"[{proj_name}] Téléchargement YouTube : {url}")
|
| 627 |
+
|
| 628 |
+
cmd = [ # params yt-dlp pr dl format mp4 direct
|
| 629 |
+
"yt-dlp",
|
| 630 |
+
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
|
| 631 |
+
"--merge-output-format", "mp4",
|
| 632 |
+
"--no-playlist",
|
| 633 |
+
"--no-overwrites",
|
| 634 |
+
"-o", input_video_path,
|
| 635 |
+
url,
|
| 636 |
+
]
|
| 637 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
| 638 |
+
|
| 639 |
+
if result.returncode != 0:
|
| 640 |
+
err = result.stderr.strip().split('\n')[-1] if result.stderr.strip() else "Échec yt-dlp"
|
| 641 |
+
with open(status_file, "w") as f:
|
| 642 |
+
f.write(f"ERROR: Téléchargement échoué. {err}")
|
| 643 |
+
print(f"[{proj_name}] Erreur yt-dlp : {err}")
|
| 644 |
+
return
|
| 645 |
+
|
| 646 |
+
if not os.path.exists(input_video_path):
|
| 647 |
+
with open(status_file, "w") as f:
|
| 648 |
+
f.write("ERROR: Fichier vidéo introuvable après téléchargement.")
|
| 649 |
+
return
|
| 650 |
+
|
| 651 |
+
print(f"[{proj_name}] Téléchargement terminé, lancement de l'analyse...")
|
| 652 |
+
process_in_background(input_video_path, proj_path, proj_name) # enchaîne sur ia visage
|
| 653 |
+
|
| 654 |
+
except subprocess.TimeoutExpired:
|
| 655 |
+
with open(status_file, "w") as f:
|
| 656 |
+
f.write("ERROR: Téléchargement trop long (timeout 10 min).")
|
| 657 |
+
print(f"[{proj_name}] Timeout téléchargement YouTube.")
|
| 658 |
+
except Exception as e:
|
| 659 |
+
error_msg = f"ERROR: {type(e).__name__} - {str(e)}"
|
| 660 |
+
with open(status_file, "w") as f:
|
| 661 |
+
f.write(error_msg)
|
| 662 |
+
print(f"[{proj_name}] Exception YouTube : {error_msg}")
|
| 663 |
+
|
| 664 |
+
@self.settings_app.post("/api/upload_youtube") # endpoint dl video youtube
|
| 665 |
+
def upload_youtube(payload: YouTubePayload, bg_tasks: BackgroundTasks):
|
| 666 |
+
name = payload.name.strip()
|
| 667 |
+
url = payload.url.strip()
|
| 668 |
+
|
| 669 |
+
if not name:
|
| 670 |
+
raise HTTPException(status_code=400, detail="Nom du projet requis.")
|
| 671 |
+
if not url:
|
| 672 |
+
raise HTTPException(status_code=400, detail="URL requise.")
|
| 673 |
+
|
| 674 |
+
project_path = os.path.join(self.projects_dir, name)
|
| 675 |
+
os.makedirs(project_path, exist_ok=True)
|
| 676 |
+
|
| 677 |
+
status_file = os.path.join(project_path, "status.txt")
|
| 678 |
+
with open(status_file, "w") as f:
|
| 679 |
+
f.write("DOWNLOADING")
|
| 680 |
+
|
| 681 |
+
bg_tasks.add_task(download_and_process_youtube, url, project_path, name)
|
| 682 |
+
return {"status": "accepted"}
|
| 683 |
+
|
| 684 |
+
@self.settings_app.post("/api/create_sequence") # endpoint genere sequence manuelle
|
| 685 |
+
def create_sequence(payload: SequencePayload):
|
| 686 |
+
name = payload.name.strip()
|
| 687 |
+
if not name:
|
| 688 |
+
raise HTTPException(status_code=400, detail="Nom du projet requis.")
|
| 689 |
+
|
| 690 |
+
project_path = os.path.join(self.projects_dir, name)
|
| 691 |
+
os.makedirs(project_path, exist_ok=True)
|
| 692 |
+
|
| 693 |
+
commands = []
|
| 694 |
+
t = 0.0
|
| 695 |
+
fps = 30 # standard 30fps simulation
|
| 696 |
+
|
| 697 |
+
mapper = object.__new__(SignalMapper) # recup fct maths via instance factice
|
| 698 |
+
|
| 699 |
+
for item in payload.sequence: # loop ds bloc emotions recu
|
| 700 |
+
emo = item.emotion.upper()
|
| 701 |
+
frames_count = int(item.duration * fps)
|
| 702 |
+
|
| 703 |
+
for i in range(frames_count):
|
| 704 |
+
t_state = i / fps
|
| 705 |
+
try:
|
| 706 |
+
ant_left, ant_right = mapper._compute_antennas(emo, t_state) # math antennes
|
| 707 |
+
except Exception:
|
| 708 |
+
ant_left, ant_right = 0.0, 0.0
|
| 709 |
+
|
| 710 |
+
commands.append({ # cree frame robot static
|
| 711 |
+
"timestamp_s": round(t, 4),
|
| 712 |
+
"head": {"x": 0.0, "y": 0.0, "z": 0.0, "roll": 0.0, "pitch": 0.0, "yaw": 0.0},
|
| 713 |
+
"body_yaw": 0.0,
|
| 714 |
+
"antennas": [
|
| 715 |
+
round(float(SignalMapper._clamp(ant_left, SignalMapper.ANTENNA_MAX)), 5),
|
| 716 |
+
round(float(SignalMapper._clamp(ant_right, SignalMapper.ANTENNA_MAX)), 5)
|
| 717 |
+
],
|
| 718 |
+
"emotion": emo
|
| 719 |
+
})
|
| 720 |
+
t += 1.0 / fps
|
| 721 |
+
|
| 722 |
+
signals_path = os.path.join(project_path, "signals.json")
|
| 723 |
+
with open(signals_path, "w", encoding="utf-8") as f:
|
| 724 |
+
json.dump({"type": "creation", "commands": commands}, f, indent=2) # save format secu
|
| 725 |
+
|
| 726 |
+
status_file = os.path.join(project_path, "status.txt")
|
| 727 |
+
with open(status_file, "w") as f:
|
| 728 |
+
f.write("CREATION_DONE") # flag type creation dispo bibliothèque
|
| 729 |
+
|
| 730 |
+
return {"status": "accepted"}
|
| 731 |
+
|
| 732 |
+
@self.settings_app.get("/api/library") # endpoint liste videos dispo
|
| 733 |
def get_library():
|
| 734 |
projects = []
|
| 735 |
for folder in os.listdir(self.projects_dir):
|
|
|
|
| 741 |
with open(status_path, "r") as f:
|
| 742 |
status = f.read().strip()
|
| 743 |
|
| 744 |
+
if status == "CREATION_DONE": # charge sans lien mp4
|
| 745 |
+
projects.append({
|
| 746 |
+
"name": folder,
|
| 747 |
+
"url": None,
|
| 748 |
+
"status": "CREATION_DONE"
|
| 749 |
+
})
|
| 750 |
+
elif os.path.exists(os.path.join(folder_path, "output_annotated.mp4")):
|
| 751 |
projects.append({
|
| 752 |
"name": folder,
|
| 753 |
"url": f"/projects/{folder}/output_annotated.mp4",
|
|
|
|
| 761 |
})
|
| 762 |
return projects
|
| 763 |
|
| 764 |
+
@self.settings_app.delete("/api/projects/{project_name}") # endpoint suppr dossier projet
|
|
|
|
| 765 |
def delete_project(project_name: str):
|
|
|
|
| 766 |
project_path = os.path.join(self.projects_dir, project_name)
|
| 767 |
if os.path.exists(project_path) and os.path.isdir(project_path):
|
| 768 |
try:
|
| 769 |
shutil.rmtree(project_path)
|
| 770 |
+
self.robot_audio.audio_cache.pop(project_name, None) # vide cache du projet
|
| 771 |
print(f"[{project_name}] Projet supprimé avec succès.")
|
| 772 |
return {"status": "success"}
|
| 773 |
except Exception as e:
|
| 774 |
raise HTTPException(status_code=500, detail=f"Erreur de suppression: {str(e)}")
|
| 775 |
raise HTTPException(status_code=404, detail="Projet introuvable.")
|
| 776 |
|
| 777 |
+
class EmotionPayload(BaseModel):
|
| 778 |
+
emotion: str | None = None
|
| 779 |
+
|
| 780 |
+
@self.settings_app.post("/api/emotion") # endpoint clic bouton manuel web
|
| 781 |
+
def set_emotion(payload: EmotionPayload):
|
| 782 |
+
emotion = (payload.emotion or "").strip().upper() or None
|
| 783 |
+
with self.manual_emotion_lock:
|
| 784 |
+
self.manual_emotion = emotion # update emotion active globale
|
| 785 |
+
return {"status": "ok", "emotion": emotion}
|
| 786 |
+
|
| 787 |
+
@self.settings_app.post("/api/playback") # endpoint controle synchro video web
|
| 788 |
def sync_playback(payload: PlaybackPayload):
|
|
|
|
| 789 |
action = (payload.action or "sync").lower()
|
| 790 |
project_name = payload.project_name or self.current_project_name
|
| 791 |
current_time = max(0.0, float(payload.current_time or 0.0))
|
|
|
|
| 822 |
self._set_playback_state(project_name, current_time, playing, dirty=dirty, stop_requested=False)
|
| 823 |
return {"status": "ok", "action": action, "commands_loaded": commands_loaded}
|
| 824 |
|
| 825 |
+
@self.settings_app.post("/api/play_movement/{project_name}") # route pr lancer direct mvt
|
| 826 |
def trigger_movement(project_name: str):
|
|
|
|
| 827 |
try:
|
| 828 |
commands_loaded = self._load_project_commands(project_name)
|
| 829 |
self._set_playback_state(project_name, 0.0, True, dirty=True, stop_requested=False)
|
|
|
|
| 833 |
except Exception as e:
|
| 834 |
raise HTTPException(status_code=500, detail=str(e))
|
| 835 |
|
| 836 |
+
def _run_manual_emotion_loop(self, reachy_mini: ReachyMini, stop_event: threading.Event): # boucle thread mvt bouton manuel
|
| 837 |
+
ANTENNA_MAX = np.deg2rad(120)
|
| 838 |
+
ANTENNA_MAX_alt = np.deg2rad(45)
|
| 839 |
+
ANTENNA_MAX_col = np.deg2rad(110)
|
| 840 |
+
COLERE_PERIOD = 5.0
|
| 841 |
+
COLERE_ANIM = 1.0
|
| 842 |
+
|
| 843 |
+
current = None
|
| 844 |
+
t_start = time.time()
|
| 845 |
+
colere_last = 0.0
|
| 846 |
+
|
| 847 |
+
while not stop_event.is_set():
|
| 848 |
+
with self.manual_emotion_lock:
|
| 849 |
+
emotion = self.manual_emotion
|
| 850 |
+
with self.playback_lock:
|
| 851 |
+
video_playing = bool(self.playback_state.get("playing", False))
|
| 852 |
+
|
| 853 |
+
if video_playing or emotion is None: # skip si lecture video on
|
| 854 |
+
current = None
|
| 855 |
+
time.sleep(0.05)
|
| 856 |
+
continue
|
| 857 |
+
|
| 858 |
+
if emotion != current: # demarre decompte temps si modif
|
| 859 |
+
current = emotion
|
| 860 |
+
t_start = time.time()
|
| 861 |
+
colere_last = time.time() if emotion == "COLERE" else 0.0
|
| 862 |
+
|
| 863 |
+
t_state = time.time() - t_start
|
| 864 |
+
|
| 865 |
+
# anims specifiques pr chaque emotion du bouton
|
| 866 |
+
if emotion == "COLERE":
|
| 867 |
+
elapsed_since_last = time.time() - colere_last
|
| 868 |
+
if elapsed_since_last >= COLERE_PERIOD:
|
| 869 |
+
colere_last = time.time()
|
| 870 |
+
elapsed_since_last = 0.0
|
| 871 |
+
|
| 872 |
+
anim_t = elapsed_since_last
|
| 873 |
+
if anim_t < COLERE_ANIM:
|
| 874 |
+
if anim_t < 0.15:
|
| 875 |
+
angle = -1.0 * (anim_t / 0.15) * ANTENNA_MAX_col
|
| 876 |
+
elif anim_t < 0.40:
|
| 877 |
+
angle = (-1.0 + 1.0 * ((anim_t - 0.15) / 0.15)) * ANTENNA_MAX_col
|
| 878 |
+
elif anim_t < 0.65:
|
| 879 |
+
angle = -1.0 * ((anim_t - 0.3) / 0.15) * ANTENNA_MAX_col
|
| 880 |
+
else:
|
| 881 |
+
angle = -1.0 * ANTENNA_MAX_col
|
| 882 |
+
left_ant = angle
|
| 883 |
+
right_ant = -angle
|
| 884 |
+
else:
|
| 885 |
+
neutral_t = anim_t - COLERE_ANIM
|
| 886 |
+
angle = 1.0 * (1.0 - math.exp(-14.0 * neutral_t)) * ANTENNA_MAX_alt
|
| 887 |
+
left_ant = -(angle - 0.3)
|
| 888 |
+
right_ant = (angle - 0.3)
|
| 889 |
+
elif emotion == "JOIE":
|
| 890 |
+
oscillation = 0.5 * math.sin(2 * math.pi * 4.0 * t_state)
|
| 891 |
+
angle = (0.5 + oscillation) * ANTENNA_MAX_alt
|
| 892 |
+
left_ant = -angle
|
| 893 |
+
right_ant = angle
|
| 894 |
+
elif emotion == "CONTENT":
|
| 895 |
+
oscillation = 0.8 * math.sin(2 * math.pi * 0.5 * t_state)
|
| 896 |
+
angle = oscillation * ANTENNA_MAX_alt
|
| 897 |
+
left_ant = right_ant = angle
|
| 898 |
+
elif emotion == "SURPRISE":
|
| 899 |
+
angle = 1.0 * (1.0 - math.exp(-14.0 * t_state)) * ANTENNA_MAX_alt
|
| 900 |
+
left_ant = -(angle - 0.8)
|
| 901 |
+
right_ant = (angle - 0.8)
|
| 902 |
+
elif emotion == "TRISTESSE":
|
| 903 |
+
target = -1.0 * (1.0 - math.exp(-1.5 * t_state)) * ANTENNA_MAX
|
| 904 |
+
left_ant = target - 0.2
|
| 905 |
+
right_ant = -(target - 0.2)
|
| 906 |
+
elif emotion == "PEUR":
|
| 907 |
+
tremblement = 0.1 * math.sin(2 * math.pi * 15.0 * t_state)
|
| 908 |
+
angle = -1.0 * (1.0 - math.exp(-1.5 * t_state) + tremblement) * ANTENNA_MAX
|
| 909 |
+
left_ant = angle - 0.2
|
| 910 |
+
right_ant = -(angle - 0.2)
|
| 911 |
+
else:
|
| 912 |
+
angle = 1.0 * (1.0 - math.exp(-14.0 * t_state)) * ANTENNA_MAX_alt
|
| 913 |
+
left_ant = -(angle - 0.3)
|
| 914 |
+
right_ant = (angle - 0.3)
|
| 915 |
+
|
| 916 |
+
clamp = lambda v: float(max(-ANTENNA_MAX, min(ANTENNA_MAX, v)))
|
| 917 |
+
try:
|
| 918 |
+
reachy_mini.set_target(antennas=[clamp(left_ant), clamp(right_ant)]) # deplace antennes robot
|
| 919 |
+
except Exception:
|
| 920 |
+
pass
|
| 921 |
+
|
| 922 |
+
time.sleep(0.02)
|
| 923 |
+
|
| 924 |
+
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event): # boucle thread mvt principal synchro video
|
| 925 |
+
reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0) # pose initiale neutre
|
| 926 |
time.sleep(1.2)
|
| 927 |
|
| 928 |
+
emotion_thread = threading.Thread( # lance thread parallele boutons manuels
|
| 929 |
+
target=self._run_manual_emotion_loop,
|
| 930 |
+
args=(reachy_mini, stop_event),
|
| 931 |
+
daemon=True,
|
| 932 |
+
)
|
| 933 |
+
emotion_thread.start()
|
| 934 |
+
|
| 935 |
while not stop_event.is_set():
|
| 936 |
with self.playback_lock:
|
| 937 |
commands_to_play = list(self.current_commands)
|
| 938 |
timestamps = list(self.current_timestamps)
|
| 939 |
state = dict(self.playback_state)
|
| 940 |
|
| 941 |
+
if state.get("stop_requested"): # check si clic stop web
|
| 942 |
+
self.robot_audio.update(reachy_mini, {**state, "playing": False}, stop_event)
|
| 943 |
reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0)
|
| 944 |
with self.playback_lock:
|
| 945 |
self.playback_state["stop_requested"] = False
|
|
|
|
| 958 |
continue
|
| 959 |
|
| 960 |
now = time.time()
|
| 961 |
+
if playing: # calcul position temporelle cible dans le json
|
| 962 |
target_t = float(state.get("current_time", 0.0)) + (now - float(state.get("updated_at", now)))
|
| 963 |
else:
|
| 964 |
target_t = float(state.get("current_time", 0.0))
|
| 965 |
|
| 966 |
+
if target_t > timestamps[-1] + 0.20: # stop si video terminee
|
| 967 |
self._set_playback_state(state.get("project_name"), timestamps[-1], False, dirty=False, stop_requested=True)
|
| 968 |
continue
|
| 969 |
|
| 970 |
+
idx = bisect.bisect_left(timestamps, max(0.0, target_t)) # recup index commande par dichotomie
|
| 971 |
if idx >= len(commands_to_play):
|
| 972 |
idx = len(commands_to_play) - 1
|
| 973 |
|
| 974 |
cmd = commands_to_play[idx]
|
| 975 |
head_dict = cmd["head"]
|
| 976 |
+
head_pose = create_head_pose( # genere structure pose reachy mini
|
| 977 |
x=head_dict["x"], y=head_dict["y"], z=head_dict["z"],
|
| 978 |
roll=head_dict["roll"], pitch=head_dict["pitch"], yaw=head_dict["yaw"],
|
| 979 |
degrees=False, mm=False,
|
|
|
|
| 981 |
|
| 982 |
antennas_target = cmd["antennas"] if self.antennas_enabled else [0.0, 0.0]
|
| 983 |
|
| 984 |
+
try: # transmet commandes moteurs au robot
|
| 985 |
reachy_mini.set_target(head=head_pose, antennas=antennas_target, body_yaw=cmd["body_yaw"])
|
| 986 |
except TypeError:
|
| 987 |
reachy_mini.set_target(head=head_pose, antennas=antennas_target)
|
| 988 |
|
| 989 |
+
self.robot_audio.update(reachy_mini, state, stop_event) # maj synchro flux audio robot
|
| 990 |
+
|
| 991 |
if dirty:
|
| 992 |
with self.playback_lock:
|
|
|
|
| 993 |
if self.playback_state.get("updated_at") == state.get("updated_at"):
|
| 994 |
self.playback_state["dirty"] = False
|
| 995 |
|
| 996 |
time.sleep(0.01 if playing else 0.03)
|
| 997 |
|
| 998 |
+
self.robot_audio._stop_output(reachy_mini) # coupe audio fin appli
|
| 999 |
+
|
| 1000 |
if __name__ == "__main__":
|
| 1001 |
app = Jericho()
|
| 1002 |
try:
|
| 1003 |
+
app.wrapped_run() # run application reachy mini wrapper
|
| 1004 |
except KeyboardInterrupt:
|
| 1005 |
app.stop()
|
jericho/static/index.html
CHANGED
|
@@ -8,9 +8,13 @@
|
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<header>
|
| 11 |
-
<h1>Jericho projet : Reachy Mini</h1>
|
| 12 |
<div id="conteneurHeader">
|
| 13 |
-
<img
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
<div id="robot-status">Prêt</div>
|
| 15 |
</div>
|
| 16 |
</header>
|
|
@@ -20,37 +24,111 @@
|
|
| 20 |
<h2>[↓] Importer votre Vidéo</h2>
|
| 21 |
<div class="form-group">
|
| 22 |
<label>Nom du projet :</label>
|
| 23 |
-
<input
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</div>
|
| 25 |
<div class="form-group">
|
| 26 |
<label for="video-file">Fichier Vidéo :</label>
|
| 27 |
-
<input
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</div>
|
| 29 |
-
<button id="import-btn" class="btn-primary">
|
|
|
|
|
|
|
| 30 |
<p id="import-msg"></p>
|
| 31 |
</section>
|
| 32 |
|
| 33 |
<section class="card">
|
| 34 |
-
<h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
<div id="video-library" class="grid-library">
|
| 36 |
<p class="empty-text">Aucune vidéo enregistrée.</p>
|
| 37 |
</div>
|
| 38 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
<section id="player-section" class="card hidden">
|
| 41 |
<h2 id="current-video-title">Lecture</h2>
|
| 42 |
-
<video id="video-player" controls
|
| 43 |
|
| 44 |
<div class="sync-controls">
|
| 45 |
<button id="launch-sync-btn" class="btn-success">
|
| 46 |
-
[
|
|
|
|
|
|
|
|
|
|
| 47 |
</button>
|
| 48 |
<p class="sync-hint">
|
| 49 |
-
Les pauses, reprises et déplacements dans la vidéo sont envoyés au
|
| 50 |
-
|
| 51 |
</p>
|
| 52 |
</div>
|
| 53 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
</main>
|
| 55 |
|
| 56 |
<script src="/static/main.js"></script>
|
|
|
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<header>
|
| 11 |
+
<h1>【⚙】Jericho projet : Reachy Mini【⚙】</h1>
|
| 12 |
<div id="conteneurHeader">
|
| 13 |
+
<img
|
| 14 |
+
src="static/images/logoReachyMini.png"
|
| 15 |
+
alt="Photo de Reachy"
|
| 16 |
+
width="10%"
|
| 17 |
+
/>
|
| 18 |
<div id="robot-status">Prêt</div>
|
| 19 |
</div>
|
| 20 |
</header>
|
|
|
|
| 24 |
<h2>[↓] Importer votre Vidéo</h2>
|
| 25 |
<div class="form-group">
|
| 26 |
<label>Nom du projet :</label>
|
| 27 |
+
<input
|
| 28 |
+
type="text"
|
| 29 |
+
id="video-name"
|
| 30 |
+
placeholder="ex: session_joie_01"
|
| 31 |
+
/>
|
| 32 |
</div>
|
| 33 |
<div class="form-group">
|
| 34 |
<label for="video-file">Fichier Vidéo :</label>
|
| 35 |
+
<input
|
| 36 |
+
type="file"
|
| 37 |
+
id="video-file"
|
| 38 |
+
accept="video/mp4,video/quicktime"
|
| 39 |
+
/>
|
| 40 |
</div>
|
| 41 |
+
<button id="import-btn" class="btn-primary">
|
| 42 |
+
Analyser et Enregistrer
|
| 43 |
+
</button>
|
| 44 |
<p id="import-msg"></p>
|
| 45 |
</section>
|
| 46 |
|
| 47 |
<section class="card">
|
| 48 |
+
<h2>[▶] Importer depuis YouTube</h2>
|
| 49 |
+
<div class="form-group">
|
| 50 |
+
<label>Nom du projet :</label>
|
| 51 |
+
<input
|
| 52 |
+
type="text"
|
| 53 |
+
id="yt-name"
|
| 54 |
+
placeholder="ex: session_youtube_01"
|
| 55 |
+
/>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="form-group">
|
| 58 |
+
<label for="yt-url">URL YouTube :</label>
|
| 59 |
+
<input
|
| 60 |
+
type="text"
|
| 61 |
+
id="yt-url"
|
| 62 |
+
placeholder="https://www.youtube.com/watch?v=..."
|
| 63 |
+
/>
|
| 64 |
+
</div>
|
| 65 |
+
<button id="yt-import-btn" class="btn-primary">
|
| 66 |
+
Télécharger et Analyser
|
| 67 |
+
</button>
|
| 68 |
+
<p id="yt-msg"></p>
|
| 69 |
+
</section>
|
| 70 |
+
|
| 71 |
+
<section class="card">
|
| 72 |
+
<h2>►► Votre bibliothèque</h2>
|
| 73 |
<div id="video-library" class="grid-library">
|
| 74 |
<p class="empty-text">Aucune vidéo enregistrée.</p>
|
| 75 |
</div>
|
| 76 |
</section>
|
| 77 |
+
<section class="card">
|
| 78 |
+
<h2>[+] Créer une séquence d'émotions</h2>
|
| 79 |
+
<div class="form-group">
|
| 80 |
+
<label>Nom de la création :</label>
|
| 81 |
+
<input type="text" id="seq-name" placeholder="ex: sequence_test_01" />
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div id="sequence-list" style="margin-bottom: 15px"></div>
|
| 85 |
|
| 86 |
+
<button
|
| 87 |
+
id="add-emotion-btn"
|
| 88 |
+
class="btn-primary"
|
| 89 |
+
style="background-color: #27ae60"
|
| 90 |
+
>
|
| 91 |
+
+ Ajouter une émotion
|
| 92 |
+
</button>
|
| 93 |
+
<button
|
| 94 |
+
id="save-seq-btn"
|
| 95 |
+
class="btn-primary"
|
| 96 |
+
style="background-color: #8e44ad; margin-left: 10px"
|
| 97 |
+
>
|
| 98 |
+
Enregistrer la séquence
|
| 99 |
+
</button>
|
| 100 |
+
<p id="seq-msg"></p>
|
| 101 |
+
</section>
|
| 102 |
<section id="player-section" class="card hidden">
|
| 103 |
<h2 id="current-video-title">Lecture</h2>
|
| 104 |
+
<video id="video-player" controls></video>
|
| 105 |
|
| 106 |
<div class="sync-controls">
|
| 107 |
<button id="launch-sync-btn" class="btn-success">
|
| 108 |
+
[►] Lire vidéo + robot synchronisés
|
| 109 |
+
</button>
|
| 110 |
+
<button id="launch-seq-btn" class="btn-success" style="display: none">
|
| 111 |
+
[►] Lire la séquence (sans vidéo)
|
| 112 |
</button>
|
| 113 |
<p class="sync-hint">
|
| 114 |
+
Les pauses, reprises et déplacements dans la vidéo sont envoyés au
|
| 115 |
+
robot.
|
| 116 |
</p>
|
| 117 |
</div>
|
| 118 |
</section>
|
| 119 |
+
|
| 120 |
+
<section class="card">
|
| 121 |
+
<h2>☻ Les émotions</h2>
|
| 122 |
+
<div id="listeEmotions">
|
| 123 |
+
<button id="joie">Joie</button>
|
| 124 |
+
<button id="content">Content</button>
|
| 125 |
+
<button id="surprise">Surprise</button>
|
| 126 |
+
<button id="colere">Colère</button>
|
| 127 |
+
<button id="peur">Peur</button>
|
| 128 |
+
<button id="tristesse">Tristesse</button>
|
| 129 |
+
<button id="neutre">Neutre</button>
|
| 130 |
+
</div>
|
| 131 |
+
</section>
|
| 132 |
</main>
|
| 133 |
|
| 134 |
<script src="/static/main.js"></script>
|
jericho/static/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
let currentLoadedProject = null;
|
| 2 |
let syncTimer = null;
|
| 3 |
let lastSyncSent = 0;
|
| 4 |
const SYNC_INTERVAL_MS = 250;
|
|
@@ -6,6 +6,9 @@ const SYNC_INTERVAL_MS = 250;
|
|
| 6 |
const robotStatus = document.getElementById('robot-status');
|
| 7 |
const videoPlayer = document.getElementById('video-player');
|
| 8 |
|
|
|
|
|
|
|
|
|
|
| 9 |
function escapeHtml(value) {
|
| 10 |
return String(value)
|
| 11 |
.replaceAll('&', '&')
|
|
@@ -70,6 +73,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 70 |
if (currentLoadedProject) videoPlayer.play();
|
| 71 |
};
|
| 72 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
});
|
| 74 |
|
| 75 |
document.getElementById('import-btn').onclick = function() {
|
|
@@ -120,6 +142,47 @@ document.getElementById('import-btn').onclick = function() {
|
|
| 120 |
};
|
| 121 |
};
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
window.deleteProject = async function(event, name) {
|
| 124 |
event.stopPropagation();
|
| 125 |
|
|
@@ -163,7 +226,7 @@ async function loadLibrary() {
|
|
| 163 |
} else {
|
| 164 |
projects.forEach(video => {
|
| 165 |
const safeName = escapeHtml(video.name);
|
| 166 |
-
const deleteBtn = `<span onclick="deleteProject(event, ${JSON.stringify(video.name).replaceAll('"', '"')})" style="position:absolute; top:2px; right:4px; font-size:16px; cursor:pointer;" title="Supprimer définitivement">
|
| 167 |
|
| 168 |
if (video.status === 'DONE') {
|
| 169 |
const nameArg = JSON.stringify(video.name).replaceAll('"', '"');
|
|
@@ -172,6 +235,18 @@ async function loadLibrary() {
|
|
| 172 |
${deleteBtn}
|
| 173 |
<br><strong>${safeName}</strong><br><span style="color:green; font-size:12px;">Prêt</span>
|
| 174 |
</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
} else if (video.status === 'PROCESSING') {
|
| 176 |
newHTML += `<div class="video-item" style="position:relative; opacity: 0.7; cursor: wait; border: 2px dashed orange;">
|
| 177 |
${deleteBtn}
|
|
@@ -208,17 +283,43 @@ window.loadVideoPlayer = async function(name, url) {
|
|
| 208 |
await sendPlayback('stop', { projectName: currentLoadedProject, playing: false });
|
| 209 |
}
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
currentLoadedProject = name;
|
| 212 |
document.getElementById('player-section').classList.remove('hidden');
|
| 213 |
document.getElementById('current-video-title').textContent = 'Projet : ' + name;
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
};
|
| 218 |
|
| 219 |
videoPlayer.addEventListener('play', () => {
|
| 220 |
if (!currentLoadedProject) return;
|
| 221 |
-
|
|
|
|
| 222 |
sendPlayback('play', { playing: true });
|
| 223 |
startSyncLoop();
|
| 224 |
});
|
|
@@ -255,3 +356,212 @@ videoPlayer.addEventListener('ended', () => {
|
|
| 255 |
sendPlayback('stop', { playing: false });
|
| 256 |
stopSyncLoop();
|
| 257 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let currentLoadedProject = null;
|
| 2 |
let syncTimer = null;
|
| 3 |
let lastSyncSent = 0;
|
| 4 |
const SYNC_INTERVAL_MS = 250;
|
|
|
|
| 6 |
const robotStatus = document.getElementById('robot-status');
|
| 7 |
const videoPlayer = document.getElementById('video-player');
|
| 8 |
|
| 9 |
+
videoPlayer.muted = true;
|
| 10 |
+
videoPlayer.defaultMuted = true;
|
| 11 |
+
|
| 12 |
function escapeHtml(value) {
|
| 13 |
return String(value)
|
| 14 |
.replaceAll('&', '&')
|
|
|
|
| 73 |
if (currentLoadedProject) videoPlayer.play();
|
| 74 |
};
|
| 75 |
}
|
| 76 |
+
|
| 77 |
+
// Boutons d'émotions
|
| 78 |
+
const emotionIds = ['joie', 'content', 'surprise', 'colere', 'peur', 'tristesse', 'neutre'];
|
| 79 |
+
emotionIds.forEach(id => {
|
| 80 |
+
const el = document.getElementById(id);
|
| 81 |
+
if (!el) return;
|
| 82 |
+
el.addEventListener('click', async () => {
|
| 83 |
+
// Highlight du bouton actif
|
| 84 |
+
emotionIds.forEach(eid => document.getElementById(eid)?.classList.remove('active-emotion'));
|
| 85 |
+
el.classList.add('active-emotion');
|
| 86 |
+
|
| 87 |
+
await fetch('/api/emotion', {
|
| 88 |
+
method: 'POST',
|
| 89 |
+
headers: { 'Content-Type': 'application/json' },
|
| 90 |
+
body: JSON.stringify({ emotion: id.toUpperCase() })
|
| 91 |
+
});
|
| 92 |
+
robotStatus.textContent = `Émotion : ${el.textContent.trim()}`;
|
| 93 |
+
});
|
| 94 |
+
});
|
| 95 |
});
|
| 96 |
|
| 97 |
document.getElementById('import-btn').onclick = function() {
|
|
|
|
| 142 |
};
|
| 143 |
};
|
| 144 |
|
| 145 |
+
document.getElementById('yt-import-btn').onclick = async function() {
|
| 146 |
+
const name = document.getElementById('yt-name').value.trim();
|
| 147 |
+
const url = document.getElementById('yt-url').value.trim();
|
| 148 |
+
const msg = document.getElementById('yt-msg');
|
| 149 |
+
|
| 150 |
+
if (!name || !url) {
|
| 151 |
+
msg.textContent = 'Erreur : Nom et URL requis.';
|
| 152 |
+
msg.style.color = 'red';
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
msg.textContent = 'Envoi de la demande au serveur...';
|
| 157 |
+
msg.style.color = '#d35400';
|
| 158 |
+
document.getElementById('yt-import-btn').disabled = true;
|
| 159 |
+
|
| 160 |
+
try {
|
| 161 |
+
const response = await fetch('/api/upload_youtube', {
|
| 162 |
+
method: 'POST',
|
| 163 |
+
headers: { 'Content-Type': 'application/json' },
|
| 164 |
+
body: JSON.stringify({ name: name, url: url })
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
if (response.ok) {
|
| 168 |
+
msg.textContent = 'Téléchargement YouTube en cours, puis analyse automatique.';
|
| 169 |
+
msg.style.color = '#8269ae';
|
| 170 |
+
document.getElementById('yt-name').value = '';
|
| 171 |
+
document.getElementById('yt-url').value = '';
|
| 172 |
+
loadLibrary();
|
| 173 |
+
} else {
|
| 174 |
+
const result = await response.json();
|
| 175 |
+
msg.textContent = `Erreur du serveur : ${result.detail}`;
|
| 176 |
+
msg.style.color = 'red';
|
| 177 |
+
}
|
| 178 |
+
} catch (error) {
|
| 179 |
+
msg.textContent = 'Erreur de connexion au serveur.';
|
| 180 |
+
msg.style.color = 'red';
|
| 181 |
+
} finally {
|
| 182 |
+
document.getElementById('yt-import-btn').disabled = false;
|
| 183 |
+
}
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
window.deleteProject = async function(event, name) {
|
| 187 |
event.stopPropagation();
|
| 188 |
|
|
|
|
| 226 |
} else {
|
| 227 |
projects.forEach(video => {
|
| 228 |
const safeName = escapeHtml(video.name);
|
| 229 |
+
const deleteBtn = `<span onclick="deleteProject(event, ${JSON.stringify(video.name).replaceAll('"', '"')})" style="position:absolute; top:2px; right:4px; font-size:16px; cursor:pointer;" title="Supprimer définitivement">✗</span>`;
|
| 230 |
|
| 231 |
if (video.status === 'DONE') {
|
| 232 |
const nameArg = JSON.stringify(video.name).replaceAll('"', '"');
|
|
|
|
| 235 |
${deleteBtn}
|
| 236 |
<br><strong>${safeName}</strong><br><span style="color:green; font-size:12px;">Prêt</span>
|
| 237 |
</div>`;
|
| 238 |
+
} else if (video.status === 'CREATION_DONE') {
|
| 239 |
+
|
| 240 |
+
const nameArg = JSON.stringify(video.name).replaceAll('"', '"');
|
| 241 |
+
newHTML += `<div class="video-item" onclick="loadVideoPlayer(${nameArg}, null)" style="position:relative; border: 2px solid purple;">
|
| 242 |
+
${deleteBtn}
|
| 243 |
+
<br><strong>${safeName}</strong><br><span style="color:purple; font-size:12px;">Séquence manuelle</span>
|
| 244 |
+
</div>`;
|
| 245 |
+
} else if (video.status === 'DOWNLOADING') {
|
| 246 |
+
newHTML += `<div class="video-item" style="position:relative; opacity: 0.7; cursor: wait; border: 2px dashed #3498db;">
|
| 247 |
+
${deleteBtn}
|
| 248 |
+
<br><strong>${safeName}</strong><br><span style="color:#3498db; font-size:12px;">Téléchargement YouTube...</span>
|
| 249 |
+
</div>`;
|
| 250 |
} else if (video.status === 'PROCESSING') {
|
| 251 |
newHTML += `<div class="video-item" style="position:relative; opacity: 0.7; cursor: wait; border: 2px dashed orange;">
|
| 252 |
${deleteBtn}
|
|
|
|
| 283 |
await sendPlayback('stop', { projectName: currentLoadedProject, playing: false });
|
| 284 |
}
|
| 285 |
|
| 286 |
+
|
| 287 |
+
document.querySelectorAll('#listeEmotions button').forEach(b => b.classList.remove('active-emotion'));
|
| 288 |
+
await fetch('/api/emotion', {
|
| 289 |
+
method: 'POST',
|
| 290 |
+
headers: { 'Content-Type': 'application/json' },
|
| 291 |
+
body: JSON.stringify({ emotion: null })
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
currentLoadedProject = name;
|
| 295 |
document.getElementById('player-section').classList.remove('hidden');
|
| 296 |
document.getElementById('current-video-title').textContent = 'Projet : ' + name;
|
| 297 |
+
|
| 298 |
+
const btnSync = document.getElementById('launch-sync-btn');
|
| 299 |
+
const btnSeq = document.getElementById('launch-seq-btn');
|
| 300 |
+
|
| 301 |
+
if (url) {
|
| 302 |
+
|
| 303 |
+
videoPlayer.src = url;
|
| 304 |
+
videoPlayer.style.display = 'block';
|
| 305 |
+
if(btnSync) btnSync.style.display = 'inline-block';
|
| 306 |
+
if(btnSeq) btnSeq.style.display = 'none';
|
| 307 |
+
videoPlayer.load();
|
| 308 |
+
robotStatus.textContent = 'Vidéo chargée — son sur Reachy Mini';
|
| 309 |
+
} else {
|
| 310 |
+
|
| 311 |
+
videoPlayer.removeAttribute('src');
|
| 312 |
+
videoPlayer.style.display = 'none';
|
| 313 |
+
if(btnSync) btnSync.style.display = 'none';
|
| 314 |
+
if(btnSeq) btnSeq.style.display = 'inline-block';
|
| 315 |
+
robotStatus.textContent = 'Séquence chargée — Prêt à lire';
|
| 316 |
+
}
|
| 317 |
};
|
| 318 |
|
| 319 |
videoPlayer.addEventListener('play', () => {
|
| 320 |
if (!currentLoadedProject) return;
|
| 321 |
+
videoPlayer.muted = true;
|
| 322 |
+
robotStatus.textContent = 'Lecture synchronisée — audio robot';
|
| 323 |
sendPlayback('play', { playing: true });
|
| 324 |
startSyncLoop();
|
| 325 |
});
|
|
|
|
| 356 |
sendPlayback('stop', { playing: false });
|
| 357 |
stopSyncLoop();
|
| 358 |
});
|
| 359 |
+
|
| 360 |
+
let boutonJoie=document.getElementById('joie');
|
| 361 |
+
let boutonContent=document.getElementById('content');
|
| 362 |
+
let boutonSurprise=document.getElementById('surprise');
|
| 363 |
+
let boutonColere=document.getElementById('colere');
|
| 364 |
+
let boutonPeur=document.getElementById('peur');
|
| 365 |
+
let boutonTristesse=document.getElementById('tristesse');
|
| 366 |
+
let boutonNeutre=document.getElementById('neutre');
|
| 367 |
+
|
| 368 |
+
boutonJoie.addEventListener("click", async () => {
|
| 369 |
+
document.querySelectorAll('#listeEmotions button').forEach(b => b.classList.remove('active-emotion'));
|
| 370 |
+
boutonJoie.classList.add('active-emotion');
|
| 371 |
+
|
| 372 |
+
await fetch('/api/emotion', {
|
| 373 |
+
method: 'POST',
|
| 374 |
+
headers: { 'Content-Type': 'application/json' },
|
| 375 |
+
body: JSON.stringify({ emotion: 'JOIE' })
|
| 376 |
+
});
|
| 377 |
+
robotStatus.textContent = 'Émotion : Joie';
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
boutonContent.addEventListener("click", async () => {
|
| 381 |
+
document.querySelectorAll('#listeEmotions button').forEach(b => b.classList.remove('active-emotion'));
|
| 382 |
+
boutonJoie.classList.add('active-emotion');
|
| 383 |
+
|
| 384 |
+
await fetch('/api/emotion', {
|
| 385 |
+
method: 'POST',
|
| 386 |
+
headers: { 'Content-Type': 'application/json' },
|
| 387 |
+
body: JSON.stringify({ emotion: 'CONTENT' })
|
| 388 |
+
});
|
| 389 |
+
robotStatus.textContent = 'Émotion : Content';
|
| 390 |
+
});
|
| 391 |
+
|
| 392 |
+
boutonSurprise.addEventListener("click", async () => {
|
| 393 |
+
document.querySelectorAll('#listeEmotions button').forEach(b => b.classList.remove('active-emotion'));
|
| 394 |
+
boutonJoie.classList.add('active-emotion');
|
| 395 |
+
|
| 396 |
+
await fetch('/api/emotion', {
|
| 397 |
+
method: 'POST',
|
| 398 |
+
headers: { 'Content-Type': 'application/json' },
|
| 399 |
+
body: JSON.stringify({ emotion: 'SURPRISE' })
|
| 400 |
+
});
|
| 401 |
+
robotStatus.textContent = 'Émotion : Surprise';
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
boutonColere.addEventListener("click", async () => {
|
| 405 |
+
document.querySelectorAll('#listeEmotions button').forEach(b => b.classList.remove('active-emotion'));
|
| 406 |
+
boutonJoie.classList.add('active-emotion');
|
| 407 |
+
|
| 408 |
+
await fetch('/api/emotion', {
|
| 409 |
+
method: 'POST',
|
| 410 |
+
headers: { 'Content-Type': 'application/json' },
|
| 411 |
+
body: JSON.stringify({ emotion: 'COLERE' })
|
| 412 |
+
});
|
| 413 |
+
robotStatus.textContent = 'Émotion : Colère';
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
boutonPeur.addEventListener("click", async () => {
|
| 417 |
+
document.querySelectorAll('#listeEmotions button').forEach(b => b.classList.remove('active-emotion'));
|
| 418 |
+
boutonJoie.classList.add('active-emotion');
|
| 419 |
+
|
| 420 |
+
await fetch('/api/emotion', {
|
| 421 |
+
method: 'POST',
|
| 422 |
+
headers: { 'Content-Type': 'application/json' },
|
| 423 |
+
body: JSON.stringify({ emotion: 'PEUR' })
|
| 424 |
+
});
|
| 425 |
+
robotStatus.textContent = 'Émotion : Peur';
|
| 426 |
+
});
|
| 427 |
+
|
| 428 |
+
boutonTristesse.addEventListener("click", async () => {
|
| 429 |
+
document.querySelectorAll('#listeEmotions button').forEach(b => b.classList.remove('active-emotion'));
|
| 430 |
+
boutonJoie.classList.add('active-emotion');
|
| 431 |
+
|
| 432 |
+
await fetch('/api/emotion', {
|
| 433 |
+
method: 'POST',
|
| 434 |
+
headers: { 'Content-Type': 'application/json' },
|
| 435 |
+
body: JSON.stringify({ emotion: 'TRISTESSE' })
|
| 436 |
+
});
|
| 437 |
+
robotStatus.textContent = 'Émotion : Tristesse';
|
| 438 |
+
});
|
| 439 |
+
|
| 440 |
+
boutonNeutre.addEventListener("click", async () => {
|
| 441 |
+
document.querySelectorAll('#listeEmotions button').forEach(b => b.classList.remove('active-emotion'));
|
| 442 |
+
boutonJoie.classList.add('active-emotion');
|
| 443 |
+
|
| 444 |
+
await fetch('/api/emotion', {
|
| 445 |
+
method: 'POST',
|
| 446 |
+
headers: { 'Content-Type': 'application/json' },
|
| 447 |
+
body: JSON.stringify({ emotion: 'NEUTRE' })
|
| 448 |
+
});
|
| 449 |
+
robotStatus.textContent = 'Émotion : Neutre';
|
| 450 |
+
});
|
| 451 |
+
|
| 452 |
+
function createEmotionRow() {
|
| 453 |
+
const row = document.createElement('div');
|
| 454 |
+
row.className = 'emotion-row';
|
| 455 |
+
row.style.display = 'flex';
|
| 456 |
+
row.style.gap = '10px';
|
| 457 |
+
row.style.marginBottom = '10px';
|
| 458 |
+
row.style.alignItems = 'center';
|
| 459 |
+
|
| 460 |
+
const select = document.createElement('select');
|
| 461 |
+
const emotions = ['JOIE', 'CONTENT', 'SURPRISE', 'COLERE', 'PEUR', 'TRISTESSE', 'NEUTRE'];
|
| 462 |
+
emotions.forEach(emo => {
|
| 463 |
+
const opt = document.createElement('option');
|
| 464 |
+
opt.value = emo;
|
| 465 |
+
opt.textContent = emo;
|
| 466 |
+
select.appendChild(opt);
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
const inputTime = document.createElement('input');
|
| 470 |
+
inputTime.type = 'number';
|
| 471 |
+
inputTime.min = '0.5';
|
| 472 |
+
inputTime.step = '0.5';
|
| 473 |
+
inputTime.value = '2.0';
|
| 474 |
+
inputTime.style.width = '70px';
|
| 475 |
+
|
| 476 |
+
const span = document.createElement('span');
|
| 477 |
+
span.textContent = 'secondes';
|
| 478 |
+
|
| 479 |
+
const btnRemove = document.createElement('button');
|
| 480 |
+
btnRemove.textContent = '✗';
|
| 481 |
+
btnRemove.style.background = '#e74c3c';
|
| 482 |
+
btnRemove.style.color = 'white';
|
| 483 |
+
btnRemove.style.border = 'none';
|
| 484 |
+
btnRemove.style.cursor = 'pointer';
|
| 485 |
+
btnRemove.style.borderRadius = '3px';
|
| 486 |
+
btnRemove.style.padding = '5px 10px';
|
| 487 |
+
btnRemove.onclick = () => row.remove();
|
| 488 |
+
|
| 489 |
+
row.appendChild(select);
|
| 490 |
+
row.appendChild(inputTime);
|
| 491 |
+
row.appendChild(span);
|
| 492 |
+
row.appendChild(btnRemove);
|
| 493 |
+
|
| 494 |
+
document.getElementById('sequence-list').appendChild(row);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// Ajouter une ligne par défaut au chargement
|
| 498 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 499 |
+
const addEmotionBtn = document.getElementById('add-emotion-btn');
|
| 500 |
+
if(addEmotionBtn) {
|
| 501 |
+
addEmotionBtn.addEventListener('click', createEmotionRow);
|
| 502 |
+
createEmotionRow();
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
// Configurer le bouton Play de la séquence
|
| 506 |
+
const playSeqBtn = document.getElementById('launch-seq-btn');
|
| 507 |
+
if(playSeqBtn) {
|
| 508 |
+
playSeqBtn.onclick = () => {
|
| 509 |
+
if (currentLoadedProject) {
|
| 510 |
+
sendPlayback('play', { playing: true });
|
| 511 |
+
robotStatus.textContent = 'Lecture séquence en cours...';
|
| 512 |
+
}
|
| 513 |
+
};
|
| 514 |
+
}
|
| 515 |
+
});
|
| 516 |
+
|
| 517 |
+
document.getElementById('save-seq-btn').onclick = async function() {
|
| 518 |
+
const name = document.getElementById('seq-name').value.trim();
|
| 519 |
+
const msg = document.getElementById('seq-msg');
|
| 520 |
+
|
| 521 |
+
if (!name) {
|
| 522 |
+
msg.textContent = 'Erreur : Nom requis.';
|
| 523 |
+
msg.style.color = 'red';
|
| 524 |
+
return;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
const rows = document.querySelectorAll('.emotion-row');
|
| 528 |
+
if (rows.length === 0) {
|
| 529 |
+
msg.textContent = 'Erreur : Ajoutez au moins une émotion.';
|
| 530 |
+
msg.style.color = 'red';
|
| 531 |
+
return;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
const sequence = [];
|
| 535 |
+
rows.forEach(row => {
|
| 536 |
+
const emo = row.querySelector('select').value;
|
| 537 |
+
const dur = parseFloat(row.querySelector('input').value);
|
| 538 |
+
if (dur > 0) sequence.push({ emotion: emo, duration: dur });
|
| 539 |
+
});
|
| 540 |
+
|
| 541 |
+
msg.textContent = 'Création en cours...';
|
| 542 |
+
msg.style.color = '#d35400';
|
| 543 |
+
|
| 544 |
+
try {
|
| 545 |
+
const response = await fetch('/api/create_sequence', {
|
| 546 |
+
method: 'POST',
|
| 547 |
+
headers: { 'Content-Type': 'application/json' },
|
| 548 |
+
body: JSON.stringify({ name: name, sequence: sequence })
|
| 549 |
+
});
|
| 550 |
+
|
| 551 |
+
if (response.ok) {
|
| 552 |
+
msg.textContent = 'Séquence enregistrée !';
|
| 553 |
+
msg.style.color = 'green';
|
| 554 |
+
document.getElementById('seq-name').value = '';
|
| 555 |
+
document.getElementById('sequence-list').innerHTML = '';
|
| 556 |
+
createEmotionRow(); // Reset l'interface
|
| 557 |
+
loadLibrary(); // Met à jour la liste
|
| 558 |
+
} else {
|
| 559 |
+
const result = await response.json();
|
| 560 |
+
msg.textContent = `Erreur : ${result.detail}`;
|
| 561 |
+
msg.style.color = 'red';
|
| 562 |
+
}
|
| 563 |
+
} catch (error) {
|
| 564 |
+
msg.textContent = 'Erreur réseau.';
|
| 565 |
+
msg.style.color = 'red';
|
| 566 |
+
}
|
| 567 |
+
};
|
jericho/static/style.css
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
body {
|
| 2 |
-
|
| 3 |
font-family:'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif;
|
| 4 |
font-weight: lighter;
|
| 5 |
margin: 0;
|
|
@@ -62,13 +62,17 @@ label {
|
|
| 62 |
margin-bottom: 0.3rem;
|
| 63 |
}
|
| 64 |
|
| 65 |
-
input[type="text"], input[type="number"] {
|
| 66 |
width: 100%;
|
| 67 |
padding: 0.6rem;
|
| 68 |
border: 1px solid #ccc;
|
| 69 |
border-radius: 4px;
|
| 70 |
}
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
.btn-primary {
|
| 73 |
background-color: #a34952;
|
| 74 |
color: white;
|
|
@@ -114,6 +118,15 @@ input[type="text"], input[type="number"] {
|
|
| 114 |
border-top: 1px solid #eee;
|
| 115 |
}
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
#video-file{
|
| 118 |
color:#a34952;
|
| 119 |
}
|
|
@@ -122,3 +135,21 @@ input[type="text"], input[type="number"] {
|
|
| 122 |
color: #74424a;
|
| 123 |
margin-bottom: 0;
|
| 124 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
body {
|
| 2 |
+
|
| 3 |
font-family:'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif;
|
| 4 |
font-weight: lighter;
|
| 5 |
margin: 0;
|
|
|
|
| 62 |
margin-bottom: 0.3rem;
|
| 63 |
}
|
| 64 |
|
| 65 |
+
input[type="text"], input[type="number"], input[type="url"] {
|
| 66 |
width: 100%;
|
| 67 |
padding: 0.6rem;
|
| 68 |
border: 1px solid #ccc;
|
| 69 |
border-radius: 4px;
|
| 70 |
}
|
| 71 |
|
| 72 |
+
#yt-url {
|
| 73 |
+
color: #a34952;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
.btn-primary {
|
| 77 |
background-color: #a34952;
|
| 78 |
color: white;
|
|
|
|
| 118 |
border-top: 1px solid #eee;
|
| 119 |
}
|
| 120 |
|
| 121 |
+
#video-player {
|
| 122 |
+
height: 80vh;
|
| 123 |
+
width: auto;
|
| 124 |
+
max-width: 100%;
|
| 125 |
+
display: block;
|
| 126 |
+
margin: 0 auto;
|
| 127 |
+
object-fit: contain;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
#video-file{
|
| 131 |
color:#a34952;
|
| 132 |
}
|
|
|
|
| 135 |
color: #74424a;
|
| 136 |
margin-bottom: 0;
|
| 137 |
}
|
| 138 |
+
|
| 139 |
+
#listeEmotions{
|
| 140 |
+
button{
|
| 141 |
+
color : #a34952;
|
| 142 |
+
background-color: white;
|
| 143 |
+
font-family:'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif;
|
| 144 |
+
font-weight: lighter;
|
| 145 |
+
border-color: #ffffff;
|
| 146 |
+
border-radius: 10%;
|
| 147 |
+
margin-top : auto;
|
| 148 |
+
margin-bottom: auto;
|
| 149 |
+
padding-top : 0.7%;
|
| 150 |
+
padding-bottom : 0.7%;
|
| 151 |
+
font-size:1.2vw;
|
| 152 |
+
margin-right : 2.5%;
|
| 153 |
+
width : 11%;
|
| 154 |
+
}
|
| 155 |
+
}
|