Hugo.V commited on
Commit
0cf1082
·
1 Parent(s): 50e4de8

Commentaire dans le main refait, url ytb, innovation

Browse files
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 PlaybackPayload(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
254
  project_name: str | None = None
255
  current_time: float = 0.0
256
  playing: bool | None = None
257
  action: str = "sync"
258
 
259
- class Jericho(ReachyMiniApp):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  custom_app_url: str | None = "http://0.0.0.0:8042"
261
- request_media_backend: str | None = 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._setup_routes()
 
 
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
- meta, frames = load_signals(signals_path)
300
- if not frames:
301
- raise HTTPException(status_code=400, detail="Aucune frame avec visage dans signals.json.")
302
-
303
- mapper = SignalMapper(frames)
304
- commands = mapper.commands
305
- timestamps = [cmd["timestamp_s"] for cmd in commands]
306
-
307
- with self.playback_lock:
 
 
 
 
 
 
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
- @self.settings_app.post("/api/upload")
 
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
- @self.settings_app.get("/api/library")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 os.path.exists(os.path.join(folder_path, "output_annotated.mp4")):
 
 
 
 
 
 
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
- @self.settings_app.post("/api/playback")
 
 
 
 
 
 
 
 
 
 
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 run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
491
- reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 src="./image/logoReachyMini.png" alt="Photo de Reachy" width="10%" />
 
 
 
 
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 type="text" id="video-name" placeholder="ex: session_joie_01" />
 
 
 
 
24
  </div>
25
  <div class="form-group">
26
  <label for="video-file">Fichier Vidéo :</label>
27
- <input type="file" id="video-file" accept="video/mp4,video/quicktime" />
 
 
 
 
28
  </div>
29
- <button id="import-btn" class="btn-primary">Analyser et Enregistrer</button>
 
 
30
  <p id="import-msg"></p>
31
  </section>
32
 
33
  <section class="card">
34
- <h2>Votre bibliothèque</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 width="100%"></video>
43
 
44
  <div class="sync-controls">
45
  <button id="launch-sync-btn" class="btn-success">
46
- [->] Lire vidéo + robot synchronisés
 
 
 
47
  </button>
48
  <p class="sync-hint">
49
- Les pauses, reprises et déplacements dans la vidéo sont envoyés au robot.
50
- Les frames sans visage sont supprimées de la vidéo traitée et des signaux.
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; // stocke le nom du projet sélectionné
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('&', '&amp;')
@@ -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('"', '&quot;')})" style="position:absolute; top:2px; right:4px; font-size:16px; cursor:pointer;" title="Supprimer définitivement">x</span>`;
167
 
168
  if (video.status === 'DONE') {
169
  const nameArg = JSON.stringify(video.name).replaceAll('"', '&quot;');
@@ -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
- videoPlayer.src = url;
215
- videoPlayer.load();
216
- robotStatus.textContent = 'Vidéo chargée';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  };
218
 
219
  videoPlayer.addEventListener('play', () => {
220
  if (!currentLoadedProject) return;
221
- robotStatus.textContent = 'Lecture synchronisée';
 
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('&', '&amp;')
 
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('"', '&quot;')})" 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('"', '&quot;');
 
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('"', '&quot;');
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
- /*font-family: 'I', Tahoma, Geneva, Verdana, sans-serif;*/
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
+ }