lucasfiotti commited on
Commit
a5ec907
·
1 Parent(s): ef0438c

Mise à jour de l'interface et de la récupération et du rejeu des signaux

Browse files
coquille/Jalon2OPTI.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import cv2
2
  from mediapipe.tasks import python
3
  from mediapipe.tasks.python import vision
@@ -5,224 +6,265 @@ from mediapipe import Image, ImageFormat
5
  import numpy as np
6
  import urllib.request
7
  import csv
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- ### ÉTAPE 1 : CHARGER LE MODÈLE MEDIAPIPE ###
10
-
11
- urllib.request.urlretrieve(
12
- "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
13
- "face_landmarker.task"
 
14
  )
 
15
 
16
- ### ÉTAPE 2 : CONFIGURATION DU MODÈLE ###
17
-
18
- options = vision.FaceLandmarkerOptions( # Configuration d'un modèle de détection de visage
19
- base_options = python.BaseOptions(model_asset_path="face_landmarker.task"), # Indique le chemin vers le fichier du modèle
20
- running_mode=vision.RunningMode.VIDEO, # Activation du mode vidéo
21
- num_faces = 1, # Nombre de visage à détecter
22
- min_face_detection_confidence = 0.7, # Seuil minimum de confiance
23
- min_tracking_confidence = 0.5) # Seuil de confiance dans le tracking
24
-
25
- detector = vision.FaceLandmarker.create_from_options(options) # Création du détecteur
26
-
27
- ### ÉTAPE 3 : CHARGER LA VIDÉO ###
28
-
29
- video_path = "video.mp4" # Chemin de la vidéo
30
- cap = cv2.VideoCapture(video_path) # Créer un objet qui permet de lire la vidéo frame par frame
31
 
32
- fps = cap.get(cv2.CAP_PROP_FPS) # Récupère le nombre de frame par seconde de la vidéo
33
- frame_index = 0 # Initialise un compteur de frame
34
 
35
- ### ÉTAPE 4 : CRÉATION DU FICHIER CSV ###
 
 
36
 
37
- csv_file = open("resultats.csv", "w", newline="", encoding="utf-8") # Crée/Ouvre un fichier csv en mode écriture
38
- csv_writer = csv.writer(csv_file) # Crée un objet pour écrire sur le fichier csv
39
-
40
- csv_writer.writerow([
41
- "timestamp", "bouche", "oeil_g", "oeil_d",
42
- "pitch", "yaw", "roll", "norm_x", "norm_y" #Écrit l'en-tête du fichier
43
- ])
44
-
45
- ### FONCTIONS UTILITAIRES ###
46
 
47
  def generic_optic(width, height):
48
- focal_length = width # Estimation d'une optique générique en fonction d'une largeur et d'un centre moyens
49
  cx, cy = width / 2, height / 2
50
- camera_matrix = np.array([
51
- [focal_length, 0, cx],
52
- [0, focal_length, cy],
53
- [0, 0, 1 ]
54
- ], dtype=np.float64)
55
 
56
- dist_coeffs = np.zeros((4, 1)) # Définition des distortions de l'objectif à 0
57
-
58
- return camera_matrix, dist_coeffs
59
 
60
  def compute_head_pose(landmarks, frame_w, frame_h):
61
-
62
- model_points = np.array([ # Modèle 3D générique d'un visage humain moyen
63
- [0.0, 0.0, 0.0 ], # Bout du nez
64
- [0.0, -330.0, -65.0 ], # Menton
65
- [-225.0, 170.0, -135.0], # Coin oeil gauche
66
- [225.0, 170.0, -135.0], # Coin oeil droit
67
- [-150.0,-150.0, -125.0], # Coin bouche gauche
68
- [150.0, -150.0, -125.0], # Coin bouche droit
69
  ], dtype=np.float64)
70
-
71
- indices = [1, 152, 33, 263, 61, 291] # Indices des landmarks correspondant à ces derniers
72
-
73
- image_points = np.array([ # Récupère la position 2D des 6 landmarks et les convertit en pixels
74
- [landmarks[i].x * frame_w, landmarks[i].y * frame_h]
75
- for i in indices
76
- ], dtype=np.float64)
77
-
78
  camera_matrix, dist_coeffs = generic_optic(frame_w, frame_h)
79
-
80
-
81
- success, rotation_vec, translation_vec = cv2.solvePnP( # Permet de trouver l'orientation de la tête à partir des différents points
82
- model_points, image_points, camera_matrix, dist_coeffs,
83
- flags=cv2.SOLVEPNP_ITERATIVE
84
- )
85
-
86
- if not success: # Si détection ne marche pas alors ne renvoie rien
87
  return None, None, None, None, None
88
-
89
- rotation_mat, _ = cv2.Rodrigues(rotation_vec) # Conversion vecteur rotation -> matrice -> angles d'Euler
90
  pose_mat = cv2.hconcat([rotation_mat, translation_vec])
91
  _, _, _, _, _, _, euler_angles = cv2.decomposeProjectionMatrix(pose_mat)
92
-
93
- pitch = euler_angles[0, 0] # Haut / bas
94
- yaw = euler_angles[1, 0] # Gauche / droite
95
- roll = euler_angles[2, 0] # Inclinaison latérale
96
-
97
- return pitch, yaw, roll, rotation_vec, translation_vec
98
 
99
 
100
- def compute_face_position(landmarks, frame_w, frame_h): # Calcule la position et la taille du visage dans l'image
101
-
102
  xs = [lm.x for lm in landmarks]
103
  ys = [lm.y for lm in landmarks]
104
-
105
  x_min, x_max = min(xs), max(xs)
106
  y_min, y_max = min(ys), max(ys)
107
-
108
- center_x = int(((x_min + x_max) / 2) * frame_w) # Centre du visage en pixels
109
  center_y = int(((y_min + y_max) / 2) * frame_h)
110
-
111
- norm_x = ((x_min + x_max) / 2 - 0.5) * 2 # Position normalisée : 0,0 = centre de l'image, +=1 = bords
112
  norm_y = ((y_min + y_max) / 2 - 0.5) * 2
113
-
114
- face_w = int((x_max - x_min) * frame_w) # Taille de la boite en pixels
115
  face_h = int((y_max - y_min) * frame_h)
116
-
117
- face_size_ratio = ((x_max - x_min) * (y_max - y_min)) ** 0.5 # Ratio taille visage / taille image
118
-
119
  return center_x, center_y, norm_x, norm_y, face_w, face_h, face_size_ratio, \
120
- (int(x_min * frame_w), int(y_min * frame_h),
121
- int(x_max * frame_w), int(y_max * frame_h)) # Renvoie la position du visage à partir des variables d'avant
122
 
123
 
124
- def draw_axes(frame, rotation_vec, translation_vec, camera_matrix, dist_coeffs, origin): # Affiche les axes 3D : X=rouge, Y=vert et Z=bleu
125
-
126
- axis_length = 160.0
127
- axis_points = np.array([
128
- [axis_length, 0.0, 0.0 ], # X
129
- [0.0, axis_length, 0.0 ], # Y
130
- [0.0, 0.0, -axis_length], # Z (vers nous)
131
- ], dtype=np.float64)
132
-
133
- projected, _ = cv2.projectPoints( # Projette les 3 points en coordonnées 2D
134
- axis_points, rotation_vec, translation_vec,
135
- camera_matrix, dist_coeffs
136
- )
137
-
138
- o = (int(origin[0]), int(origin[1])) # Convertit le point d'origine en un entier
139
 
140
- cv2.arrowedLine(frame, o, (int(projected[0][0][0]), int(projected[0][0][1])), # Dessine l'axe X en rouge
141
- (0, 0, 255), 2, tipLength=0.3)
142
- cv2.arrowedLine(frame, o, (int(projected[1][0][0]), int(projected[1][0][1])), # Dessine l'axe Y en vert
143
- (0, 255, 0), 2, tipLength=0.3)
144
- cv2.arrowedLine(frame, o, (int(projected[2][0][0]), int(projected[2][0][1])), # Dessine l'axe Z en bleu
145
- (255, 0, 0), 2, tipLength=0.3)
146
 
147
- ### ÉTAPE 5 : BOUCLE PRINCIPALE ###
 
 
148
 
149
  while True:
150
- ret, frame = cap.read() # Lit la frame suivante dans la vidéo
151
  if not ret:
152
  break
153
 
154
- timestamp = frame_index / fps # Calcule le temps en secondes de la frame actuelle
155
- frame_index += 1 # Incrémente le compteur de frame
 
156
 
157
- h, w, _ = frame.shape # Extrait les dimensions de l'image
 
 
158
 
159
- rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # Convertit l'image BGR en RGB
160
- mp_image = Image(image_format=ImageFormat.SRGB, data=rgb_frame) # Englobe le tableau numpy dans un objet Image Mediapipe
161
-
162
- timestamp_ms = int(timestamp * 1000)
163
- results = detector.detect_for_video(mp_image, timestamp_ms) # Detection des landmarks
164
-
165
- if results.face_landmarks: # Si un visage est détécté :
166
- landmarks = results.face_landmarks[0] # Récupère le premier et le seul visage détécté
167
-
168
- mouth_open = abs(landmarks[13].y - landmarks[14].y) # Ouverture de la bouche
169
-
170
- left_eye_open = abs(landmarks[159].y - landmarks[145].y) # Ouverture des yeux
171
  right_eye_open = abs(landmarks[386].y - landmarks[374].y)
172
-
173
- pitch, yaw, roll, rot_vec, trans_vec = compute_head_pose(landmarks, w, h) # Orientation complète de la tête (pitch / yaw / roll)
174
-
175
- (center_x, center_y, # Position et taille du visage dans l'image
176
- norm_x, norm_y,
177
- face_w, face_h,
178
- face_size_ratio,
179
- bbox) = compute_face_position(landmarks, w, h)
180
-
181
- print(
182
- f"t={timestamp:.2f}s | "
183
- f"bouche={mouth_open:.3f} | "
184
- f"oeil_g={left_eye_open:.3f} | oeil_d={right_eye_open:.3f} | "
185
- f"pitch={pitch:+.1f}° | yaw={yaw:+.1f}° | roll={roll:+.1f}° | "
186
- f"pos=({norm_x:+.2f}, {norm_y:+.2f})"
187
- )
188
- csv_writer.writerow([
189
- round(timestamp, 2),
190
- round(mouth_open, 3),
191
- round(left_eye_open, 3),
192
- round(right_eye_open, 3),
193
- round(pitch, 1),
194
- round(yaw, 1),
195
- round(roll, 1),
196
- round(norm_x, 2),
197
- round(norm_y, 2),
198
- ]) # Sauvegarde les informations de l'image dans le fichier csv
199
-
200
- for landmark in landmarks: # Affichage des landmarks
201
- x = int(landmark.x * w)
202
- y = int(landmark.y * h)
203
- cv2.circle(frame, (x, y), 1, (0, 255, 0), -1)
204
-
205
- x1, y1, x2, y2 = bbox # Dessine le rectangle autour du visage
206
- cv2.rectangle(frame, (x1, y1), (x2, y2), (255, 165, 0), 1)
207
-
208
- cv2.circle(frame, (center_x, center_y), 5, (0, 165, 255), -1) # Dessine le centre du visage en orange
209
-
210
- cv2.drawMarker(frame, (w // 2, h // 2), (128, 128, 128), # Croix au centre de l'image
211
- cv2.MARKER_CROSS, 20, 1)
212
-
213
- if rot_vec is not None: # Si compute_head_pose réussit on execute sinon on arrête pour éviter le crash
214
- camera_matrix, dist_coeffs = generic_optic(w, h)
215
-
216
- nose_tip = (int(landmarks[1].x * w), int(landmarks[1].y * h)) # Calcule la position du nez
217
- draw_axes(frame, rot_vec, trans_vec, camera_matrix, dist_coeffs, nose_tip) # Dessine les axes
218
-
219
- cv2.imshow("Motion Capture", frame) # Affiche la frame
220
- if cv2.waitKey(25) & 0xFF == ord('q'): # Quitte la vidéo avec q
221
- break
222
-
223
- ### ÉTAPE 6 : NETTOYAGE ###
224
-
225
- cap.release() # Ferme la vidéo et libère la mémoire associée
226
- cv2.destroyAllWindows() # Ferme la fenêtre
227
- detector.close() # Libère le modèle
228
- csv_file.close() # Ferme le ficher csv
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
  import cv2
3
  from mediapipe.tasks import python
4
  from mediapipe.tasks.python import vision
 
6
  import numpy as np
7
  import urllib.request
8
  import csv
9
+ import os
10
+ import sys
11
+ import subprocess
12
+ import shutil
13
+
14
+ if len(sys.argv) < 2:
15
+ print("Usage: python Jalon2OPTI.py <video_file> [nom_personnalise]")
16
+ sys.exit(1)
17
+
18
+ video_path = sys.argv[1]
19
+ if not os.path.exists(video_path):
20
+ print(f"Erreur: Le fichier '{video_path}' n'existe pas.")
21
+ sys.exit(1)
22
+
23
+ video_name_with_ext = os.path.basename(video_path)
24
+ video_ext = os.path.splitext(video_name_with_ext)[1]
25
+
26
+ if len(sys.argv) >= 3 and sys.argv[2].strip():
27
+ video_name = sys.argv[2].strip()
28
+ else:
29
+ video_name = os.path.splitext(video_name_with_ext)[0]
30
+
31
+ dossier = os.path.dirname(os.path.abspath(sys.argv[0]))
32
+
33
+ output_video_path = os.path.join(dossier, f"{video_name}(landmarks){video_ext}")
34
+ output_csv_path = os.path.join(dossier, f"{video_name}.csv")
35
+ temp_video_path = os.path.join(dossier, f"{video_name}_temp_raw.mp4")
36
+
37
+ print(f"Vidéo d'entrée : {video_path}")
38
+ print(f"CSV de sortie : {output_csv_path}")
39
+ print(f"Vidéo annotée : {output_video_path}")
40
+
41
+ model_path = os.path.join(dossier, "face_landmarker.task")
42
+ if not os.path.exists(model_path):
43
+ print("Téléchargement du modèle face_landmarker...")
44
+ urllib.request.urlretrieve(
45
+ "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
46
+ model_path
47
+ )
48
 
49
+ options = vision.FaceLandmarkerOptions(
50
+ base_options=python.BaseOptions(model_asset_path=model_path),
51
+ running_mode=vision.RunningMode.VIDEO,
52
+ num_faces=1,
53
+ min_face_detection_confidence=0.7,
54
+ min_tracking_confidence=0.5
55
  )
56
+ detector = vision.FaceLandmarker.create_from_options(options)
57
 
58
+ cap = cv2.VideoCapture(video_path)
59
+ fps = cap.get(cv2.CAP_PROP_FPS)
60
+ frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
61
+ frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
62
+ frame_index = 0
 
 
 
 
 
 
 
 
 
 
63
 
64
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
65
+ out = cv2.VideoWriter(temp_video_path, fourcc, fps, (frame_width, frame_height))
66
 
67
+ csv_file = open(output_csv_path, "w", newline="", encoding="utf-8")
68
+ csv_writer = csv.writer(csv_file)
69
+ csv_writer.writerow(["timestamp", "bouche", "oeil_g", "oeil_d", "pitch", "yaw", "roll", "norm_x", "norm_y"])
70
 
 
 
 
 
 
 
 
 
 
71
 
72
  def generic_optic(width, height):
73
+ focal_length = width
74
  cx, cy = width / 2, height / 2
75
+ camera_matrix = np.array([[focal_length, 0, cx], [0, focal_length, cy], [0, 0, 1]], dtype=np.float64)
76
+ return camera_matrix, np.zeros((4, 1))
 
 
 
77
 
 
 
 
78
 
79
  def compute_head_pose(landmarks, frame_w, frame_h):
80
+ model_points = np.array([
81
+ [0.0, 0.0, 0.0], [0.0, -330.0, -65.0], [-225.0, 170.0, -135.0],
82
+ [225.0, 170.0, -135.0], [-150.0, -150.0, -125.0], [150.0, -150.0, -125.0]
 
 
 
 
 
83
  ], dtype=np.float64)
84
+ indices = [1, 152, 33, 263, 61, 291]
85
+ image_points = np.array([[landmarks[i].x * frame_w, landmarks[i].y * frame_h] for i in indices], dtype=np.float64)
 
 
 
 
 
 
86
  camera_matrix, dist_coeffs = generic_optic(frame_w, frame_h)
87
+ success, rotation_vec, translation_vec = cv2.solvePnP(model_points, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE)
88
+ if not success:
 
 
 
 
 
 
89
  return None, None, None, None, None
90
+ rotation_mat, _ = cv2.Rodrigues(rotation_vec)
 
91
  pose_mat = cv2.hconcat([rotation_mat, translation_vec])
92
  _, _, _, _, _, _, euler_angles = cv2.decomposeProjectionMatrix(pose_mat)
93
+ return euler_angles[0, 0], euler_angles[1, 0], euler_angles[2, 0], rotation_vec, translation_vec
 
 
 
 
 
94
 
95
 
96
+ def compute_face_position(landmarks, frame_w, frame_h):
 
97
  xs = [lm.x for lm in landmarks]
98
  ys = [lm.y for lm in landmarks]
 
99
  x_min, x_max = min(xs), max(xs)
100
  y_min, y_max = min(ys), max(ys)
101
+ center_x = int(((x_min + x_max) / 2) * frame_w)
 
102
  center_y = int(((y_min + y_max) / 2) * frame_h)
103
+ norm_x = ((x_min + x_max) / 2 - 0.5) * 2
 
104
  norm_y = ((y_min + y_max) / 2 - 0.5) * 2
105
+ face_w = int((x_max - x_min) * frame_w)
 
106
  face_h = int((y_max - y_min) * frame_h)
107
+ face_size_ratio = ((x_max - x_min) * (y_max - y_min)) ** 0.5
 
 
108
  return center_x, center_y, norm_x, norm_y, face_w, face_h, face_size_ratio, \
109
+ (int(x_min * frame_w), int(y_min * frame_h), int(x_max * frame_w), int(y_max * frame_h))
 
110
 
111
 
112
+ def draw_axes(frame, rotation_vec, translation_vec, camera_matrix, dist_coeffs, origin):
113
+ axis_points = np.array([[160.0, 0.0, 0.0], [0.0, 160.0, 0.0], [0.0, 0.0, -160.0]], dtype=np.float64)
114
+ projected, _ = cv2.projectPoints(axis_points, rotation_vec, translation_vec, camera_matrix, dist_coeffs)
115
+ o = (int(origin[0]), int(origin[1]))
116
+ cv2.arrowedLine(frame, o, (int(projected[0][0][0]), int(projected[0][0][1])), (0, 0, 255), 2, tipLength=0.3)
117
+ cv2.arrowedLine(frame, o, (int(projected[1][0][0]), int(projected[1][0][1])), (0, 255, 0), 2, tipLength=0.3)
118
+ cv2.arrowedLine(frame, o, (int(projected[2][0][0]), int(projected[2][0][1])), (255, 0, 0), 2, tipLength=0.3)
 
 
 
 
 
 
 
 
119
 
 
 
 
 
 
 
120
 
121
+ list_timestamp, list_mouth_open, list_left_eye_open = [], [], []
122
+ list_right_eye_open, list_pitch, list_yaw = [], [], []
123
+ list_roll, list_norm_x, list_norm_y = [], [], []
124
 
125
  while True:
126
+ ret, frame = cap.read()
127
  if not ret:
128
  break
129
 
130
+ timestamp = frame_index / fps
131
+ frame_index += 1
132
+ h, w, _ = frame.shape
133
 
134
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
135
+ mp_image = Image(image_format=ImageFormat.SRGB, data=rgb_frame)
136
+ results = detector.detect_for_video(mp_image, int(timestamp * 1000))
137
 
138
+ if results.face_landmarks:
139
+ landmarks = results.face_landmarks[0]
140
+ mouth_open = abs(landmarks[13].y - landmarks[14].y)
141
+ left_eye_open = abs(landmarks[159].y - landmarks[145].y)
 
 
 
 
 
 
 
 
142
  right_eye_open = abs(landmarks[386].y - landmarks[374].y)
143
+ pitch, yaw, roll, rot_vec, trans_vec = compute_head_pose(landmarks, w, h)
144
+ center_x, center_y, norm_x, norm_y, face_w, face_h, face_size_ratio, bbox = compute_face_position(landmarks, w, h)
145
+
146
+ if pitch is not None:
147
+ yaw = -yaw
148
+ pitch = ((pitch + 180) % 360)
149
+ roll = max(-40, min(40, roll))
150
+ pitch = max(-40, min(40, pitch))
151
+ yaw = max(-180, min(180, yaw))
152
+
153
+ list_timestamp.append(round(timestamp, 2))
154
+ list_mouth_open.append(round(mouth_open, 3))
155
+ list_left_eye_open.append(round(left_eye_open, 3))
156
+ list_right_eye_open.append(round(right_eye_open, 3))
157
+ list_pitch.append(round(pitch, 1))
158
+ list_yaw.append(round(yaw, 1))
159
+ list_roll.append(round(roll, 1))
160
+ list_norm_x.append(round(norm_x, 2))
161
+ list_norm_y.append(round(norm_y, 2))
162
+
163
+ for landmark in landmarks:
164
+ cv2.circle(frame, (int(landmark.x * w), int(landmark.y * h)), 1, (0, 255, 0), -1)
165
+ cv2.rectangle(frame, (bbox[0], bbox[1]), (bbox[2], bbox[3]), (255, 165, 0), 1)
166
+ cv2.circle(frame, (center_x, center_y), 5, (0, 165, 255), -1)
167
+ cv2.drawMarker(frame, (w // 2, h // 2), (128, 128, 128), cv2.MARKER_CROSS, 20, 1)
168
+ if rot_vec is not None:
169
+ camera_matrix, dist_coeffs = generic_optic(w, h)
170
+ draw_axes(frame, rot_vec, trans_vec, camera_matrix, dist_coeffs, (int(landmarks[1].x * w), int(landmarks[1].y * h)))
171
+ elif list_mouth_open:
172
+ list_timestamp.append(round(timestamp, 2))
173
+ list_mouth_open.append(list_mouth_open[-1])
174
+ list_left_eye_open.append(list_left_eye_open[-1])
175
+ list_right_eye_open.append(list_right_eye_open[-1])
176
+ list_pitch.append(list_pitch[-1])
177
+ list_yaw.append(list_yaw[-1])
178
+ list_roll.append(list_roll[-1])
179
+ list_norm_x.append(list_norm_x[-1])
180
+ list_norm_y.append(list_norm_y[-1])
181
+ else:
182
+ list_timestamp.append(round(timestamp, 2))
183
+ list_mouth_open.append(0.0)
184
+ list_left_eye_open.append(0.0)
185
+ list_right_eye_open.append(0.0)
186
+ list_pitch.append(0.0)
187
+ list_yaw.append(0.0)
188
+ list_roll.append(0.0)
189
+ list_norm_x.append(0.0)
190
+ list_norm_y.append(0.0)
191
+
192
+ out.write(frame)
193
+
194
+ if not list_mouth_open:
195
+ print("Erreur : aucun visage détecté.")
196
+ cap.release()
197
+ out.release()
198
+ cv2.destroyAllWindows()
199
+ detector.close()
200
+ csv_file.close()
201
+ sys.exit(1)
202
+
203
+ def norm_list(lst, mi, ma, n_mi, n_ma):
204
+ return [((i - mi) / (ma - mi)) * (n_ma - n_mi) + n_mi if ma != mi else 0.0 for i in lst]
205
+
206
+ list_mouth_open = norm_list(list_mouth_open, min(list_mouth_open), max(list_mouth_open), -0.01, 0.01)
207
+ list_norm_x = norm_list(list_norm_x, min(list_norm_x), max(list_norm_x), -0.03, 0.03)
208
+ list_norm_y = norm_list(list_norm_y, min(list_norm_y), max(list_norm_y), -0.0075, 0.0075)
209
+ list_left_eye_open = norm_list(list_left_eye_open, min(list_left_eye_open), max(list_left_eye_open), 0, -50.0)
210
+ list_right_eye_open = norm_list(list_right_eye_open, min(list_right_eye_open), max(list_right_eye_open), 0, 50.0)
211
+
212
+ for i in range(len(list_timestamp)):
213
+ csv_writer.writerow([list_timestamp[i], list_mouth_open[i], list_left_eye_open[i],
214
+ list_right_eye_open[i], list_pitch[i], list_yaw[i],
215
+ list_roll[i], list_norm_x[i], list_norm_y[i]])
216
+
217
+ cap.release()
218
+ out.release()
219
+ cv2.destroyAllWindows()
220
+ detector.close()
221
+ csv_file.close()
222
+
223
+ print("\n[H264] Conversion pour compatibilité navigateur...")
224
+
225
+ ffmpeg_cmd = shutil.which("ffmpeg")
226
+ if not ffmpeg_cmd:
227
+ candidates = [
228
+ r"C:\ffmpeg\bin\ffmpeg.exe",
229
+ r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
230
+ r"C:\Users\adril\scoop\shims\ffmpeg.exe",
231
+ ]
232
+ for c in candidates:
233
+ if os.path.exists(c):
234
+ ffmpeg_cmd = c
235
+ break
236
+
237
+ if ffmpeg_cmd:
238
+ result = subprocess.run(
239
+ [ffmpeg_cmd, "-y", "-i", temp_video_path,
240
+ "-c:v", "libx264", "-preset", "fast", "-crf", "23",
241
+ "-pix_fmt", "yuv420p", "-movflags", "+faststart",
242
+ "-an", output_video_path],
243
+ capture_output=True, text=True
244
+ )
245
+ if result.returncode == 0:
246
+ os.remove(temp_video_path)
247
+ print("[H264] Succès.")
248
+ else:
249
+ os.rename(temp_video_path, output_video_path)
250
+ print(f"[H264] ffmpeg a échoué : {result.stderr[-300:]}")
251
+ else:
252
+ os.rename(temp_video_path, output_video_path)
253
+ print("[H264] ffmpeg introuvable, vidéo en mp4v (illisible dans le navigateur).")
254
+ print("Installez ffmpeg : winget install ffmpeg")
255
+
256
+ print(f"\nTerminé : {video_name}")
257
+
258
+ if ffmpeg_cmd:
259
+ output_wav_path = os.path.join(dossier, f"{video_name}.wav")
260
+ wav_result = subprocess.run(
261
+ [ffmpeg_cmd, "-y", "-i", video_path, "-vn", "-acodec", "pcm_s16le",
262
+ "-ar", "44100", "-ac", "2", output_wav_path],
263
+ capture_output=True, text=True
264
+ )
265
+ if wav_result.returncode == 0:
266
+ print(f"[WAV] Audio extrait : {output_wav_path}")
267
+ else:
268
+ print(f"[WAV] Échec extraction audio : {wav_result.stderr[-200:]}")
269
+ else:
270
+ print("[WAV] ffmpeg introuvable, pas d'extraction audio.")
coquille/Jalon3.py CHANGED
@@ -5,14 +5,23 @@ import numpy as np
5
  import time
6
  from pydantic import BaseModel
7
  import csv
 
 
 
 
 
 
 
 
 
8
 
9
 
10
  class Test(ReachyMiniApp):
11
  # Optional: URL to a custom configuration page for the app
12
- # eg. "http://localhost:8042"
13
- custom_app_url: str | None = "http://0.0.0.0:8042"
14
  # Optional: specify a media backend ("gstreamer", "gstreamer_no_video", "default", etc.)
15
- # On the wireless, use gstreamer_no_video to optimise CPU usage if the app does not use video streaming
16
  request_media_backend: str | None = None
17
 
18
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
@@ -21,71 +30,53 @@ class Test(ReachyMiniApp):
21
  antennas_enabled = True
22
  sound_play_requested = False
23
 
24
- # You can ignore this part if you don't want to add settings to your app. If you set custom_app_url to None, you have to remove this part as well.
25
- # === vvv ===
26
- class AntennaState(BaseModel):
27
- enabled: bool
 
 
28
 
29
- @self.settings_app.post("/antennas")
30
- def update_antennas_state(state: AntennaState):
31
- nonlocal antennas_enabled
32
- antennas_enabled = state.enabled
33
- return {"antennas_enabled": antennas_enabled}
34
 
35
- @self.settings_app.post("/play_sound")
36
- def request_sound_play():
37
- nonlocal sound_play_requested
38
- sound_play_requested = True
39
 
40
- # === ^^^ ===
 
 
 
 
 
41
 
42
- # Main control loop
43
- while not stop_event.is_set():
44
- with open('resultats.csv', newline='') as csvfile:
45
- reader = csv.DictReader(csvfile)
46
- rows = list(reader)
47
-
48
- val_bouche = [float(r["bouche"]) for r in rows]
49
- bouche_min = min(val_bouche)
50
- bouche_max = max(val_bouche)
51
-
52
- val_norm_x = [float(r["norm_x"]) for r in rows]
53
- norm_x_min = min(val_norm_x)
54
- norm_x_max = max(val_norm_x)
55
-
56
- val_norm_y = [float(r["norm_y"]) for r in rows]
57
- norm_y_min = min(val_norm_y)
58
- norm_y_max = max(val_norm_y)
59
-
60
- val_oeil_g = [float(r["oeil_g"]) for r in rows]
61
- oeil_g_min = min(val_oeil_g)
62
- oeil_g_max = max(val_oeil_g)
63
-
64
- val_oeil_d = [float(r["oeil_d"]) for r in rows]
65
- oeil_d_min = min(val_oeil_d)
66
- oeil_d_max = max(val_oeil_d)
67
-
68
- last_timestamp = 0
69
  for row in rows:
70
- x_pos = ((float(row["bouche"]) - bouche_min) / (bouche_max - bouche_min))*0.02-0.01
71
- y_pos = ((float(row["norm_x"]) - norm_x_min) / (norm_x_max - norm_x_min))*0.06-0.03
72
- z_pos = ((float(row["norm_y"]) - norm_y_min) / (norm_y_max - norm_y_min))*0.015-0.0075
73
- pitch_deg = ((float(row["pitch"])+180) % 360)
74
  yaw_deg = float(row["yaw"])
75
  roll_deg = float(row["roll"])
 
76
  if antennas_enabled:
77
- amp_deg = 50.0
78
- g = ((float(row["oeil_g"]) - oeil_g_min) / (oeil_g_max - oeil_g_min))*amp_deg
79
- d = ((float(row["oeil_d"]) - oeil_d_min) / (oeil_d_max - oeil_d_min)) * amp_deg
80
- antennas_deg = np.array([-g, d])
81
  else:
82
  antennas_deg = np.array([0.0, 0.0])
83
  antennas_rad = np.deg2rad(antennas_deg)
84
- head_pose = create_head_pose(x=x_pos, y=y_pos, z=z_pos, pitch = pitch_deg, yaw = -yaw_deg, roll = roll_deg)
 
85
  reachy_mini.set_target(head=head_pose, antennas=antennas_rad)
 
86
  print(row["timestamp"])
87
  time.sleep(float(row['timestamp']) - last_timestamp)
88
  last_timestamp = float(row['timestamp'])
 
 
 
89
 
90
 
91
  if __name__ == "__main__":
@@ -93,4 +84,4 @@ if __name__ == "__main__":
93
  try:
94
  app.wrapped_run()
95
  except KeyboardInterrupt:
96
- app.stop()
 
5
  import time
6
  from pydantic import BaseModel
7
  import csv
8
+ import soundfile as sf
9
+ import scipy.signal
10
+ import sys
11
+
12
+ if len(sys.argv) < 3:
13
+ sys.exit(1)
14
+
15
+ csv_path = sys.argv[1]
16
+ wav_path = sys.argv[2]
17
 
18
 
19
  class Test(ReachyMiniApp):
20
  # Optional: URL to a custom configuration page for the app
21
+ # eg. "http://localhost:8043"
22
+ custom_app_url: str | None = "http://0.0.0.0:8043"
23
  # Optional: specify a media backend ("gstreamer", "gstreamer_no_video", "default", etc.)
24
+ #  On the wireless, use gstreamer_no_video to optimise CPU usage if the app does not use video streaming
25
  request_media_backend: str | None = None
26
 
27
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
 
30
  antennas_enabled = True
31
  sound_play_requested = False
32
 
33
+ # Main control loop
34
+ def apply_signals(signals_way, audio_way):
35
+ while not stop_event.is_set():
36
+ with open(signals_way, newline='') as csvfile:
37
+ reader = csv.DictReader(csvfile)
38
+ rows = list(reader)
39
 
40
+ last_timestamp = 0
41
+ audio, samplerate_in = sf.read(audio_way, dtype="float32")
 
 
 
42
 
43
+ if audio.ndim > 1:
44
+ audio = np.mean(audio, axis=1)
 
 
45
 
46
+ output_sr = reachy_mini.media.get_output_audio_samplerate()
47
+ if samplerate_in != output_sr:
48
+ audio = scipy.signal.resample(
49
+ audio,
50
+ int(len(audio) * (output_sr / samplerate_in))
51
+ )
52
 
53
+ reachy_mini.media.start_playing()
54
+ reachy_mini.media.push_audio_sample(audio)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  for row in rows:
56
+ x_pos = float(row["bouche"])
57
+ y_pos = float(row["norm_x"])
58
+ z_pos = float(row["norm_y"])
59
+ pitch_deg = float(row["pitch"])
60
  yaw_deg = float(row["yaw"])
61
  roll_deg = float(row["roll"])
62
+
63
  if antennas_enabled:
64
+ g = float(row["oeil_g"])
65
+ d = float(row["oeil_d"])
66
+ antennas_deg = np.array([g, d])
 
67
  else:
68
  antennas_deg = np.array([0.0, 0.0])
69
  antennas_rad = np.deg2rad(antennas_deg)
70
+
71
+ head_pose = create_head_pose(x=x_pos, y=y_pos, z=z_pos, pitch=pitch_deg, yaw=yaw_deg, roll=roll_deg)
72
  reachy_mini.set_target(head=head_pose, antennas=antennas_rad)
73
+
74
  print(row["timestamp"])
75
  time.sleep(float(row['timestamp']) - last_timestamp)
76
  last_timestamp = float(row['timestamp'])
77
+ reachy_mini.media.stop_playing()
78
+ stop_event.set()
79
+ apply_signals(csv_path, wav_path)
80
 
81
 
82
  if __name__ == "__main__":
 
84
  try:
85
  app.wrapped_run()
86
  except KeyboardInterrupt:
87
+ app.stop()
coquille/main.py CHANGED
@@ -1,75 +1,158 @@
1
  import threading
2
- from reachy_mini import ReachyMini, ReachyMiniApp
3
- from reachy_mini.utils import create_head_pose
4
- import numpy as np
5
  import time
6
- from pydantic import BaseModel
 
 
 
7
 
 
 
8
 
9
  class Coquille(ReachyMiniApp):
10
- # Optional: URL to a custom configuration page for the app
11
- # eg. "http://localhost:8042"
12
- custom_app_url: str | None = "http://0.0.0.0:8042"
13
- # Optional: specify a media backend ("gstreamer", "gstreamer_no_video", "default", etc.)
14
- # On the wireless, use gstreamer_no_video to optimise CPU usage if the app does not use video streaming
15
  request_media_backend: str | None = None
16
 
17
- def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
18
- t0 = time.time()
 
 
19
 
20
- antennas_enabled = True
21
- sound_play_requested = False
 
 
 
22
 
23
- # You can ignore this part if you don't want to add settings to your app. If you set custom_app_url to None, you have to remove this part as well.
24
- # === vvv ===
25
- class AntennaState(BaseModel):
26
- enabled: bool
27
 
28
- @self.settings_app.post("/antennas")
29
- def update_antennas_state(state: AntennaState):
30
- nonlocal antennas_enabled
31
- antennas_enabled = state.enabled
32
- return {"antennas_enabled": antennas_enabled}
33
 
34
- @self.settings_app.post("/play_sound")
35
- def request_sound_play():
36
- nonlocal sound_play_requested
37
- sound_play_requested = True
38
 
39
- # === ^^^ ===
 
 
40
 
41
- # Main control loop
42
- while not stop_event.is_set():
43
- t = time.time() - t0
 
 
44
 
45
- yaw_deg = 30.0 * np.sin(2.0 * np.pi * 0.2 * t)
46
- head_pose = create_head_pose(yaw=yaw_deg, degrees=True)
 
 
 
47
 
48
- if antennas_enabled:
49
- amp_deg = 25.0
50
- a = amp_deg * np.sin(2.0 * np.pi * 0.5 * t)
51
- antennas_deg = np.array([a, -a])
52
- else:
53
- antennas_deg = np.array([0.0, 0.0])
54
 
55
- if sound_play_requested:
56
- print("Playing sound...")
57
- reachy_mini.media.play_sound("wake_up.wav")
58
- sound_play_requested = False
59
 
60
- antennas_rad = np.deg2rad(antennas_deg)
61
-
62
- reachy_mini.set_target(
63
- head=head_pose,
64
- antennas=antennas_rad,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  )
66
-
67
- time.sleep(0.02)
68
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  if __name__ == "__main__":
71
- app = Coquille()
72
- try:
73
- app.wrapped_run()
74
- except KeyboardInterrupt:
75
- app.stop()
 
1
  import threading
2
+ import subprocess
3
+ import sys
4
+ import os
5
  import time
6
+ from fastapi import Request
7
+ from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, Response
8
+ from fastapi.staticfiles import StaticFiles
9
+ from reachy_mini import ReachyMini, ReachyMiniApp
10
 
11
+ DOSSIER = os.path.dirname(os.path.abspath(__file__))
12
+ STATIC = os.path.join(DOSSIER, "static")
13
 
14
  class Coquille(ReachyMiniApp):
15
+ custom_app_url: str | None = "http://localhost:8043"
 
 
 
 
16
  request_media_backend: str | None = None
17
 
18
+ def __init__(self, *args, **kwargs):
19
+ super().__init__(*args, **kwargs)
20
+ self.robot = None
21
+ self.stop_event = threading.Event()
22
 
23
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
24
+ self.robot = reachy_mini
25
+ self.stop_event = stop_event
26
+ while not stop_event.is_set():
27
+ time.sleep(0.1)
28
 
29
+ app = Coquille()
 
 
 
30
 
31
+ app.settings_app.mount("/static", StaticFiles(directory=STATIC), name="static")
 
 
 
 
32
 
33
+ @app.settings_app.get("/")
34
+ def index():
35
+ return FileResponse(os.path.join(STATIC, "index.html"))
 
36
 
37
+ @app.settings_app.get("/favicon.ico")
38
+ def favicon():
39
+ return Response(status_code=204)
40
 
41
+ @app.settings_app.get("/liste")
42
+ def liste():
43
+ fichiers = os.listdir(DOSSIER)
44
+ noms = [f.replace(".csv", "") for f in fichiers if f.endswith(".csv") and f != "resultats.csv" and "video_temp" not in f]
45
+ return JSONResponse({"noms": noms})
46
 
47
+ @app.settings_app.post("/charger")
48
+ async def charger(request: Request):
49
+ form = await request.form()
50
+ video_file = form.get("video")
51
+ nom = (form.get("nom") or "").strip()
52
 
53
+ if not video_file or not nom:
54
+ return JSONResponse({"succes": False, "message": "Vidéo ou nom manquant."}, status_code=400)
 
 
 
 
55
 
56
+ video_path = os.path.join(DOSSIER, "video_temp.mp4")
57
+ contents = await video_file.read()
58
+ with open(video_path, "wb") as f:
59
+ f.write(contents)
60
 
61
+ # Étape 1 : extraction landmarks + génération CSV + vidéo mp4v brute
62
+ try:
63
+ result = subprocess.run(
64
+ [sys.executable, os.path.join(DOSSIER, "Jalon2OPTI.py"), video_path, nom],
65
+ cwd=DOSSIER,
66
+ capture_output=True,
67
+ text=True,
68
+ check=True
69
+ )
70
+ print(f"[PIPELINE OK]\n{result.stdout}")
71
+ except subprocess.CalledProcessError as e:
72
+ print(f"[PIPELINE ERREUR]\nstdout: {e.stdout}\nstderr: {e.stderr}")
73
+ return JSONResponse({
74
+ "succes": False,
75
+ "message": f"Pipeline échoué : {e.stderr or e.stdout or 'erreur inconnue'}"
76
+ }, status_code=500)
77
+
78
+ # Étape 2 : réencodage H.264 via ffmpeg pour compatibilité navigateur
79
+ raw_path = os.path.join(DOSSIER, f"{nom}(landmarks).mp4")
80
+ tmp_path = os.path.join(DOSSIER, f"{nom}(landmarks)_raw.mp4")
81
+
82
+ if os.path.exists(raw_path):
83
+ try:
84
+ os.rename(raw_path, tmp_path)
85
+ ffmpeg = subprocess.run(
86
+ ["ffmpeg", "-y", "-i", tmp_path,
87
+ "-vcodec", "libx264", "-pix_fmt", "yuv420p",
88
+ "-movflags", "+faststart",
89
+ "-an", raw_path],
90
+ capture_output=True, text=True
91
  )
92
+ if ffmpeg.returncode == 0:
93
+ os.remove(tmp_path)
94
+ print("[FFMPEG] Encodage H.264 réussi")
95
+ else:
96
+ # ffmpeg absent ou erreur : on remet le fichier brut
97
+ os.rename(tmp_path, raw_path)
98
+ print(f"[FFMPEG] Échec (ffmpeg absent ?), on garde mp4v : {ffmpeg.stderr}")
99
+ except Exception as e:
100
+ if os.path.exists(tmp_path) and not os.path.exists(raw_path):
101
+ os.rename(tmp_path, raw_path)
102
+ print(f"[FFMPEG] Exception : {e}")
103
+
104
+ return JSONResponse({"succes": True, "nom": nom})
105
+
106
+ @app.settings_app.post("/play")
107
+ async def play(request: Request):
108
+ data = await request.json()
109
+ return JSONResponse({"succes": True, "nom": data.get("nom")})
110
+
111
+ @app.settings_app.get("/video/{nom}")
112
+ def video(nom: str, request: Request):
113
+ path = os.path.join(DOSSIER, f"{nom}(landmarks).mp4")
114
+
115
+ print(f"[VIDEO] Recherche : {path} | Existe : {os.path.exists(path)}")
116
+ if not os.path.exists(path):
117
+ mp4s = [f for f in os.listdir(DOSSIER) if f.endswith(".mp4")]
118
+ print(f"[VIDEO] MP4 disponibles : {mp4s}")
119
+ return JSONResponse({"succes": False, "message": f"Vidéo introuvable : {path}"}, status_code=404)
120
+
121
+ file_size = os.path.getsize(path)
122
+ range_header = request.headers.get("range")
123
+
124
+ if range_header:
125
+ range_val = range_header.replace("bytes=", "")
126
+ parts = range_val.split("-")
127
+ start = int(parts[0])
128
+ end = int(parts[1]) if parts[1] else file_size - 1
129
+ end = min(end, file_size - 1)
130
+ chunk_size = end - start + 1
131
+
132
+ def iter_file():
133
+ with open(path, "rb") as f:
134
+ f.seek(start)
135
+ remaining = chunk_size
136
+ while remaining > 0:
137
+ chunk = f.read(min(65536, remaining))
138
+ if not chunk:
139
+ break
140
+ remaining -= len(chunk)
141
+ yield chunk
142
+
143
+ return StreamingResponse(
144
+ iter_file(),
145
+ status_code=206,
146
+ headers={
147
+ "Content-Range": f"bytes {start}-{end}/{file_size}",
148
+ "Accept-Ranges": "bytes",
149
+ "Content-Length": str(chunk_size),
150
+ },
151
+ media_type="video/mp4"
152
+ )
153
+ else:
154
+ return FileResponse(path, media_type="video/mp4", headers={"Accept-Ranges": "bytes"})
155
 
156
  if __name__ == "__main__":
157
+ import uvicorn
158
+ uvicorn.run(app.settings_app, host="127.0.0.1", port=8043)
 
 
 
coquille/programme1.py CHANGED
@@ -8,8 +8,8 @@ from pydantic import BaseModel
8
 
9
  class Test(ReachyMiniApp):
10
  # Optional: URL to a custom configuration page for the app
11
- # eg. "http://localhost:8042"
12
- custom_app_url: str | None = "http://0.0.0.0:8042"
13
  # Optional: specify a media backend ("gstreamer", "gstreamer_no_video", "default", etc.)
14
  # On the wireless, use gstreamer_no_video to optimise CPU usage if the app does not use video streaming
15
  request_media_backend: str | None = None
 
8
 
9
  class Test(ReachyMiniApp):
10
  # Optional: URL to a custom configuration page for the app
11
+ # eg. "http://localhost:8043"
12
+ custom_app_url: str | None = "http://0.0.0.0:8043"
13
  # Optional: specify a media backend ("gstreamer", "gstreamer_no_video", "default", etc.)
14
  # On the wireless, use gstreamer_no_video to optimise CPU usage if the app does not use video streaming
15
  request_media_backend: str | None = None
coquille/programme2.py CHANGED
@@ -8,8 +8,8 @@ from pydantic import BaseModel
8
 
9
  class Test(ReachyMiniApp):
10
  # Optional: URL to a custom configuration page for the app
11
- # eg. "http://localhost:8042"
12
- custom_app_url: str | None = "http://0.0.0.0:8042"
13
  # Optional: specify a media backend ("gstreamer", "gstreamer_no_video", "default", etc.)
14
  # On the wireless, use gstreamer_no_video to optimise CPU usage if the app does not use video streaming
15
  request_media_backend: str | None = None
 
8
 
9
  class Test(ReachyMiniApp):
10
  # Optional: URL to a custom configuration page for the app
11
+ # eg. "http://localhost:8043"
12
+ custom_app_url: str | None = "http://0.0.0.0:8043"
13
  # Optional: specify a media backend ("gstreamer", "gstreamer_no_video", "default", etc.)
14
  # On the wireless, use gstreamer_no_video to optimise CPU usage if the app does not use video streaming
15
  request_media_backend: str | None = None
coquille/static/index.html CHANGED
@@ -1,27 +1,71 @@
1
  <!DOCTYPE html>
2
- <html lang="en">
3
-
4
  <head>
5
  <meta charset="UTF-8">
6
- <title>Reachy Mini example app template</title>
7
- <meta name="viewport" content="width=device-width, initial-scale=1">
 
 
 
8
  <link rel="stylesheet" href="/static/style.css">
9
  </head>
10
-
11
  <body>
12
- <h1>Reachy Mini – Control Panel</h1>
13
 
14
- <div id="controls">
15
- <label style="display:flex; align-items:center; gap:8px;">
16
- <input type="checkbox" id="antenna-checkbox" checked>
17
- Antennas
18
- </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- <button id="sound-btn">Play Sound</button>
21
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- <div id="status">Antennas status: running</div>
24
  <script src="/static/main.js"></script>
25
  </body>
26
-
27
  </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="fr">
 
3
  <head>
4
  <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Reachy Pipeline</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;600;700&display=swap" rel="stylesheet">
10
  <link rel="stylesheet" href="/static/style.css">
11
  </head>
 
12
  <body>
 
13
 
14
+ <header>
15
+ <div class="header-left">
16
+ <span class="logo-mark"></span>
17
+ <span class="logo-text">reachy<em>pipeline</em></span>
18
+ </div>
19
+ <div class="header-right">
20
+ <span class="status-indicator" id="daemon-status">
21
+ <span class="dot"></span>daemon offline
22
+ </span>
23
+ </div>
24
+ </header>
25
+
26
+ <main>
27
+
28
+ <section class="panel" id="panel-source">
29
+ <div class="panel-label">01 — source</div>
30
+
31
+ <div class="video-zone" id="video-zone">
32
+ <div class="video-placeholder" id="video-placeholder">
33
+ <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
34
+ <path d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.894L15 14M3 8a2 2 0 012-2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z"/>
35
+ </svg>
36
+ <span>aucune vidéo</span>
37
+ </div>
38
+ <video id="video-preview" style="display:none;" controls></video>
39
+ </div>
40
+
41
+ <div class="input-row">
42
+ <input type="text" id="recording-name" placeholder="nom de l'enregistrement" autocomplete="off" />
43
+ <label class="btn btn-ghost" for="file-input">
44
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
45
+ charger
46
+ </label>
47
+ <input type="file" id="file-input" accept="video/*" style="display:none;" />
48
+ </div>
49
 
50
+ <button class="btn btn-primary" id="btn-run" disabled>
51
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
52
+ lancer le pipeline
53
+ </button>
54
+ </section>
55
+
56
+ <section class="panel" id="panel-library">
57
+ <div class="panel-label">02 — bibliothèque</div>
58
+
59
+ <ul class="library-list" id="library-list"></ul>
60
+ </section>
61
+
62
+ </main>
63
+
64
+ <footer>
65
+ <div class="status-line" id="status-line">en attente</div>
66
+ <div class="progress-bar" id="progress-bar"></div>
67
+ </footer>
68
 
 
69
  <script src="/static/main.js"></script>
70
  </body>
 
71
  </html>
coquille/static/main.js CHANGED
@@ -1,47 +1,218 @@
1
- let antennasEnabled = true;
 
 
 
 
 
2
 
3
- async function updateAntennasState(enabled) {
4
- try {
5
- const resp = await fetch("/antennas", {
6
- method: "POST",
7
- headers: { "Content-Type": "application/json" },
8
- body: JSON.stringify({ enabled }),
9
- });
10
- const data = await resp.json();
11
- antennasEnabled = data.antennas_enabled;
12
- updateUI();
13
- } catch (e) {
14
- document.getElementById("status").textContent = "Backend error";
15
- }
16
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- async function playSound() {
19
  try {
20
- await fetch("/play_sound", { method: "POST" });
21
- } catch (e) {
22
- console.error("Error triggering sound:", e);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  }
24
  }
25
 
26
- function updateUI() {
27
- const checkbox = document.getElementById("antenna-checkbox");
28
- const status = document.getElementById("status");
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- checkbox.checked = antennasEnabled;
 
 
 
 
 
31
 
32
- if (antennasEnabled) {
33
- status.textContent = "Antennas status: running";
 
 
 
 
 
 
 
 
 
 
 
 
34
  } else {
35
- status.textContent = "Antennas status: stopped";
36
  }
37
- }
38
 
39
- document.getElementById("antenna-checkbox").addEventListener("change", (e) => {
40
- updateAntennasState(e.target.checked);
41
- });
 
 
 
 
 
42
 
43
- document.getElementById("sound-btn").addEventListener("click", () => {
44
- playSound();
45
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- updateUI();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ const fileInput = document.getElementById('file-input');
3
+ const runBtn = document.getElementById('btn-run');
4
+ const nameInput = document.getElementById('recording-name');
5
+ const preview = document.getElementById('video-preview');
6
+ const placeholder = document.getElementById('video-placeholder');
7
 
8
+ let selectedFile = null;
9
+
10
+ // Chargement initial des éléments enregistrés
11
+ chargerBibliotheque();
12
+
13
+ // 1. Sélection et prévisualisation de la vidéo source brute
14
+ fileInput.addEventListener('change', (e) => {
15
+ const file = e.target.files[0];
16
+ if (!file) return;
17
+
18
+ selectedFile = file;
19
+ preview.src = URL.createObjectURL(file);
20
+ preview.style.display = 'block';
21
+ placeholder.style.display = 'none';
22
+
23
+ runBtn.disabled = false;
24
+ updateStatus(`Vidéo prête : ${file.name}`, 'active');
25
+ });
26
+
27
+ // 2. Traitement et envoi au pipeline Jalon2OPTI
28
+ runBtn.addEventListener('click', async () => {
29
+ const nom = nameInput.value.trim();
30
+ if (!nom) return updateStatus("Donnez un nom à l'enregistrement", 'error');
31
+ if (!selectedFile) return updateStatus("Chargez une vidéo d'abord", 'error');
32
+
33
+ runBtn.disabled = true;
34
+ updateStatus("Extraction des landmarks et génération des mouvements...", 'active');
35
+ setProgress(30);
36
+
37
+ const formData = new FormData();
38
+ formData.append('video', selectedFile);
39
+ formData.append('nom', nom);
40
+
41
+ try {
42
+ console.log("[DEBUG] Envoi POST à /charger...");
43
+ const response = await fetch('/charger', { method: 'POST', body: formData });
44
+ console.log("[DEBUG] Réponse reçue, status:", response.status);
45
+
46
+ if (!response.ok) {
47
+ console.error("[ERROR] Serveur retourne:", response.status, response.statusText);
48
+ const text = await response.text();
49
+ console.error("[ERROR] Body:", text);
50
+ updateStatus(`Erreur serveur (${response.status}): ${text}`, 'error');
51
+ runBtn.disabled = false;
52
+ return;
53
+ }
54
+
55
+ const data = await response.json();
56
+ console.log("[DEBUG] Données reçues:", data);
57
+
58
+ if (data.succes) {
59
+ setProgress(100);
60
+ updateStatus(`Enregistrement "${nom}" ajouté ! Lancement de la vidéo...`, 'done');
61
+
62
+ // Petit délai pour s'assurer que le fichier vidéo est bien écrit
63
+ setTimeout(() => {
64
+ // Chargement et lecture de la vidéo landmarks générée
65
+ preview.src = `/video/${encodeURIComponent(nom)}?t=${Date.now()}`;
66
+ preview.style.display = 'block';
67
+ placeholder.style.display = 'none';
68
+
69
+ // Fonction pour lancer la vidéo
70
+ const playVideo = () => {
71
+ preview.play().catch(() => {
72
+ // Autoplay bloqué : on affiche un bouton play natif
73
+ preview.controls = true;
74
+ });
75
+ };
76
+
77
+ // Si la vidéo est déjà prête, on lance directement
78
+ if (preview.readyState >= 3) { // HAVE_FUTURE_DATA ou mieux
79
+ playVideo();
80
+ } else {
81
+ // Sinon, on attend
82
+ preview.addEventListener('canplay', playVideo, { once: true });
83
+ }
84
+
85
+ // Nettoyage des inputs pour le prochain enregistrement
86
+ nameInput.value = '';
87
+ selectedFile = null;
88
+ chargerBibliotheque();
89
+ }, 500); // Délai d'attente pour la génération vidéo
90
+ } else {
91
+ setProgress(0);
92
+ updateStatus(`Erreur : ${data.message}`, 'error');
93
+ }
94
+ } catch (err) {
95
+ setProgress(0);
96
+ console.error("[ERROR] Exception:", err);
97
+ updateStatus(`Erreur: ${err.message}`, 'error');
98
+ } finally {
99
+ runBtn.disabled = false;
100
+ }
101
+ });
102
+ });
103
+
104
+ // --- Fonctions Globales de la Bibliothèque ---
105
 
106
+ async function chargerBibliotheque() {
107
  try {
108
+ const response = await fetch('/liste');
109
+ if (!response.ok) throw new Error();
110
+ const data = await response.json();
111
+
112
+ const liste = document.getElementById('library-list');
113
+ liste.innerHTML = '';
114
+
115
+ if (data && data.noms) {
116
+ data.noms.forEach((nom, i) => {
117
+ const item = document.createElement('li');
118
+ item.className = 'library-item';
119
+ item.innerHTML = `
120
+ <span class="item-index">${String(i + 1).padStart(2, '0')}</span>
121
+ <span class="item-name">${nom}</span>
122
+ <button class="btn btn-play" onclick="playRecording('${nom}', this)">play</button>
123
+ `;
124
+ liste.appendChild(item);
125
+ });
126
+ }
127
+ } catch (err) {
128
+ console.error("Erreur de chargement de la bibliothèque graphique", err);
129
  }
130
  }
131
 
132
+ async function playRecording(nom, btn) {
133
+ // Nettoyage visuel des anciens boutons en cours de lecture
134
+ document.querySelectorAll('.library-item').forEach(el => el.classList.remove('playing'));
135
+ document.querySelectorAll('.btn-play').forEach(b => {
136
+ b.classList.remove('active');
137
+ b.textContent = 'play';
138
+ });
139
+
140
+ // Activation visuelle de la ligne sélectionnée
141
+ btn.classList.add('active');
142
+ btn.textContent = '...';
143
+ btn.closest('.library-item').classList.add('playing');
144
+ updateStatus(`Synchronisation vidéo & simulation pour : ${nom}`, 'active');
145
+ setProgress(50);
146
 
147
+ // Lancement de la vidéo annotée contenant les repères faciaux (landmarks)
148
+ const preview = document.getElementById('video-preview');
149
+ const placeholder = document.getElementById('video-placeholder');
150
+ preview.src = `/video/${encodeURIComponent(nom)}`;
151
+ preview.style.display = 'block';
152
+ placeholder.style.display = 'none';
153
 
154
+ // Fonction pour lancer la vidéo
155
+ const playVideo = () => {
156
+ preview.play().catch(err => {
157
+ console.error('Erreur lors de la lecture:', err);
158
+ updateStatus(`Erreur de lecture vidéo : ${err.message}`, 'error');
159
+ btn.classList.remove('active');
160
+ btn.textContent = 'play';
161
+ btn.closest('.library-item').classList.remove('playing');
162
+ });
163
+ };
164
+
165
+ // Attendre que la vidéo soit chargée
166
+ if (preview.readyState >= 3) {
167
+ playVideo();
168
  } else {
169
+ preview.addEventListener('canplay', playVideo, { once: true });
170
  }
 
171
 
172
+ // Envoi synchrone de la commande pour faire réagir la simulation en direct
173
+ try {
174
+ const response = await fetch('/play', {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ nom: nom })
178
+ });
179
+ const data = await response.json();
180
 
181
+ if (data.succes) {
182
+ setProgress(100);
183
+ updateStatus(`Lecture synchronisée en cours...`, 'done');
184
+ } else {
185
+ setProgress(0);
186
+ updateStatus(`Erreur simulation : ${data.message}`, 'error');
187
+ }
188
+ } catch (err) {
189
+ setProgress(0);
190
+ updateStatus("Erreur de communication de mouvements", 'error');
191
+ }
192
+
193
+ // Reset automatique de l'état du bouton dès que l'animation vidéo prend fin
194
+ preview.onended = () => {
195
+ btn.classList.remove('active');
196
+ btn.textContent = 'play';
197
+ btn.closest('.library-item').classList.remove('playing');
198
+ updateStatus(`Séquence "${nom}" terminée`, 'done');
199
+ };
200
+ }
201
 
202
+ function updateStatus(msg, type) {
203
+ const el = document.getElementById('status-line');
204
+ if (el) {
205
+ el.textContent = msg;
206
+ el.className = `status-line ${type}`;
207
+ }
208
+ }
209
+
210
+ function setProgress(pct) {
211
+ const bar = document.getElementById('progress-bar');
212
+ if (bar) {
213
+ bar.style.width = `${pct}%`;
214
+ if (pct === 100) {
215
+ setTimeout(() => { bar.style.width = '0%'; }, 1200);
216
+ }
217
+ }
218
+ }
coquille/static/style.css CHANGED
@@ -1,25 +1,330 @@
1
- body {
2
- font-family: sans-serif;
3
- margin: 24px;
 
4
  }
5
 
6
- #sound-btn {
7
- padding: 10px 20px;
8
- border: none;
9
- color: white;
10
- cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  font-size: 16px;
12
- border-radius: 6px;
13
- background-color: #3498db;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  }
15
 
16
- #status {
17
- margin-top: 16px;
18
- font-weight: bold;
19
  }
20
 
21
- #controls {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  display: flex;
23
  align-items: center;
24
- gap: 20px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  }
 
1
+ *, *::before, *::after {
2
+ box-sizing: border-box;
3
+ margin: 0;
4
+ padding: 0;
5
  }
6
 
7
+ :root {
8
+ --bg: #0e0e0f;
9
+ --bg-panel: #141416;
10
+ --bg-input: #1a1a1d;
11
+ --border: #2a2a2e;
12
+ --border-active: #404046;
13
+ --text-primary: #f0eee8;
14
+ --text-secondary: #6b6b72;
15
+ --text-muted: #3d3d42;
16
+ --accent: #c8fa64;
17
+ --accent-dim: #8fba2a;
18
+ --accent-bg: #1a2408;
19
+ --font-display: 'Syne', sans-serif;
20
+ --font-mono: 'DM Mono', monospace;
21
+ --radius: 6px;
22
+ --radius-lg: 10px;
23
+ }
24
+
25
+ html, body {
26
+ height: 100%;
27
+ background: var(--bg);
28
+ color: var(--text-primary);
29
+ font-family: var(--font-mono);
30
+ font-size: 13px;
31
+ line-height: 1.5;
32
+ -webkit-font-smoothing: antialiased;
33
+ }
34
+
35
+ header {
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: space-between;
39
+ padding: 18px 28px;
40
+ border-bottom: 1px solid var(--border);
41
+ }
42
+
43
+ .logo-mark {
44
+ display: inline-block;
45
+ width: 8px;
46
+ height: 8px;
47
+ background: var(--accent);
48
+ border-radius: 50%;
49
+ margin-right: 10px;
50
+ animation: pulse 2.4s ease-in-out infinite;
51
+ }
52
+
53
+ @keyframes pulse {
54
+ 0%, 100% { opacity: 1; transform: scale(1); }
55
+ 50% { opacity: 0.5; transform: scale(0.85); }
56
+ }
57
+
58
+ .logo-text {
59
+ font-family: var(--font-display);
60
  font-size: 16px;
61
+ font-weight: 700;
62
+ letter-spacing: -0.02em;
63
+ color: var(--text-primary);
64
+ }
65
+
66
+ .logo-text em {
67
+ font-style: normal;
68
+ color: var(--accent);
69
+ }
70
+
71
+ .status-indicator {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 7px;
75
+ font-size: 11px;
76
+ color: var(--text-secondary);
77
+ letter-spacing: 0.04em;
78
+ }
79
+
80
+ .status-indicator .dot {
81
+ width: 6px;
82
+ height: 6px;
83
+ border-radius: 50%;
84
+ background: var(--text-muted);
85
+ transition: background 0.3s;
86
+ }
87
+
88
+ .status-indicator.online .dot { background: var(--accent); }
89
+ .status-indicator.online { color: var(--accent-dim); }
90
+
91
+ main {
92
+ display: grid;
93
+ grid-template-columns: 1fr 1fr;
94
+ gap: 1px;
95
+ background: var(--border);
96
+ height: calc(100vh - 56px - 48px);
97
+ }
98
+
99
+ .panel {
100
+ background: var(--bg-panel);
101
+ padding: 28px;
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: 18px;
105
+ overflow: hidden;
106
+ }
107
+
108
+ .panel-label {
109
+ font-family: var(--font-mono);
110
+ font-size: 10px;
111
+ font-weight: 500;
112
+ letter-spacing: 0.12em;
113
+ color: var(--text-muted);
114
+ text-transform: uppercase;
115
+ }
116
+
117
+ .video-zone {
118
+ flex: 1;
119
+ background: var(--bg);
120
+ border: 1px solid var(--border);
121
+ border-radius: var(--radius-lg);
122
+ overflow: hidden;
123
+ position: relative;
124
+ min-height: 0;
125
+ }
126
+
127
+ .video-placeholder {
128
+ position: absolute;
129
+ inset: 0;
130
+ display: flex;
131
+ flex-direction: column;
132
+ align-items: center;
133
+ justify-content: center;
134
+ gap: 12px;
135
+ color: var(--text-muted);
136
+ font-size: 11px;
137
+ letter-spacing: 0.08em;
138
+ }
139
+
140
+ #video-preview {
141
+ width: 100%;
142
+ height: 100%;
143
+ object-fit: cover;
144
+ display: block;
145
+ }
146
+
147
+ .input-row {
148
+ display: flex;
149
+ gap: 8px;
150
+ }
151
+
152
+ input[type="text"] {
153
+ flex: 1;
154
+ background: var(--bg-input);
155
+ border: 1px solid var(--border);
156
+ border-radius: var(--radius);
157
+ padding: 0 12px;
158
+ height: 36px;
159
+ color: var(--text-primary);
160
+ font-family: var(--font-mono);
161
+ font-size: 12px;
162
+ outline: none;
163
+ transition: border-color 0.15s;
164
+ }
165
+
166
+ input[type="text"]:focus {
167
+ border-color: var(--border-active);
168
  }
169
 
170
+ input[type="text"]::placeholder {
171
+ color: var(--text-muted);
 
172
  }
173
 
174
+ .btn {
175
+ display: inline-flex;
176
+ align-items: center;
177
+ gap: 7px;
178
+ padding: 0 14px;
179
+ height: 36px;
180
+ border-radius: var(--radius);
181
+ border: 1px solid var(--border);
182
+ background: transparent;
183
+ color: var(--text-secondary);
184
+ font-family: var(--font-mono);
185
+ font-size: 11px;
186
+ font-weight: 500;
187
+ letter-spacing: 0.06em;
188
+ cursor: pointer;
189
+ white-space: nowrap;
190
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
191
+ }
192
+
193
+ .btn:hover {
194
+ border-color: var(--border-active);
195
+ color: var(--text-primary);
196
+ }
197
+
198
+ .btn:active { transform: scale(0.98); }
199
+
200
+ .btn-ghost {
201
+ background: var(--bg-input);
202
+ }
203
+
204
+ .btn-primary {
205
+ background: var(--accent-bg);
206
+ border-color: var(--accent-dim);
207
+ color: var(--accent);
208
+ width: 100%;
209
+ justify-content: center;
210
+ font-size: 12px;
211
+ }
212
+
213
+ .btn-primary:hover:not(:disabled) {
214
+ background: #223210;
215
+ border-color: var(--accent);
216
+ }
217
+
218
+ .btn-primary:disabled {
219
+ opacity: 0.3;
220
+ cursor: not-allowed;
221
+ border-color: var(--border);
222
+ color: var(--text-muted);
223
+ background: transparent;
224
+ }
225
+
226
+ .library-list {
227
+ list-style: none;
228
+ display: flex;
229
+ flex-direction: column;
230
+ gap: 2px;
231
+ flex: 1;
232
+ overflow-y: auto;
233
+ }
234
+
235
+ .library-list::-webkit-scrollbar { width: 3px; }
236
+ .library-list::-webkit-scrollbar-track { background: transparent; }
237
+ .library-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
238
+
239
+ .library-item {
240
+ display: flex;
241
+ align-items: center;
242
+ gap: 12px;
243
+ padding: 11px 14px;
244
+ border-radius: var(--radius);
245
+ border: 1px solid transparent;
246
+ transition: border-color 0.15s, background 0.15s;
247
+ animation: slide-in 0.25s ease both;
248
+ }
249
+
250
+ @keyframes slide-in {
251
+ from { opacity: 0; transform: translateY(6px); }
252
+ to { opacity: 1; transform: translateY(0); }
253
+ }
254
+
255
+ .library-item:hover {
256
+ background: var(--bg-input);
257
+ border-color: var(--border);
258
+ }
259
+
260
+ .library-item.playing {
261
+ background: var(--accent-bg);
262
+ border-color: var(--accent-dim);
263
+ }
264
+
265
+ .item-index {
266
+ font-size: 10px;
267
+ color: var(--text-muted);
268
+ font-weight: 500;
269
+ min-width: 20px;
270
+ }
271
+
272
+ .item-name {
273
+ flex: 1;
274
+ font-size: 12px;
275
+ color: var(--text-primary);
276
+ white-space: nowrap;
277
+ overflow: hidden;
278
+ text-overflow: ellipsis;
279
+ }
280
+
281
+ .item-date {
282
+ font-size: 10px;
283
+ color: var(--text-muted);
284
+ }
285
+
286
+ .btn-play {
287
+ padding: 0 10px;
288
+ height: 26px;
289
+ font-size: 10px;
290
+ border-radius: 4px;
291
+ flex-shrink: 0;
292
+ }
293
+
294
+ .btn-play.active {
295
+ border-color: var(--accent-dim);
296
+ color: var(--accent);
297
+ background: var(--accent-bg);
298
+ }
299
+
300
+ footer {
301
+ height: 48px;
302
+ border-top: 1px solid var(--border);
303
  display: flex;
304
  align-items: center;
305
+ padding: 0 28px;
306
+ position: relative;
307
+ overflow: hidden;
308
+ }
309
+
310
+ .status-line {
311
+ font-size: 11px;
312
+ color: var(--text-muted);
313
+ letter-spacing: 0.06em;
314
+ transition: color 0.3s;
315
+ z-index: 1;
316
+ }
317
+
318
+ .status-line.active { color: var(--accent-dim); }
319
+ .status-line.done { color: var(--accent); }
320
+ .status-line.error { color: #f56565; }
321
+
322
+ .progress-bar {
323
+ position: absolute;
324
+ left: 0;
325
+ bottom: 0;
326
+ height: 2px;
327
+ width: 0%;
328
+ background: var(--accent);
329
+ transition: width 0.4s ease;
330
  }