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 +224 -182
- coquille/Jalon3.py +45 -54
- coquille/main.py +138 -55
- coquille/programme1.py +2 -2
- coquille/programme2.py +2 -2
- coquille/static/index.html +59 -15
- coquille/static/main.js +205 -34
- coquille/static/style.css +320 -15
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 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
| 14 |
)
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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 |
-
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
| 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
|
| 49 |
cx, cy = width / 2, height / 2
|
| 50 |
-
camera_matrix = np.array([
|
| 51 |
-
|
| 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 |
-
|
| 63 |
-
[0.0,
|
| 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 |
-
|
| 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):
|
| 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):
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 148 |
|
| 149 |
while True:
|
| 150 |
-
ret, frame = cap.read()
|
| 151 |
if not ret:
|
| 152 |
break
|
| 153 |
|
| 154 |
-
timestamp
|
| 155 |
-
frame_index += 1
|
|
|
|
| 156 |
|
| 157 |
-
|
|
|
|
|
|
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 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 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
round(
|
| 190 |
-
round(
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
cap.release()
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 13 |
-
custom_app_url: str | None = "http://0.0.0.0:
|
| 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 |
-
#
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
nonlocal antennas_enabled
|
| 32 |
-
antennas_enabled = state.enabled
|
| 33 |
-
return {"antennas_enabled": antennas_enabled}
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
nonlocal sound_play_requested
|
| 38 |
-
sound_play_requested = True
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 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 =
|
| 71 |
-
y_pos =
|
| 72 |
-
z_pos =
|
| 73 |
-
pitch_deg =
|
| 74 |
yaw_deg = float(row["yaw"])
|
| 75 |
roll_deg = float(row["roll"])
|
|
|
|
| 76 |
if antennas_enabled:
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
import
|
| 5 |
import time
|
| 6 |
-
from
|
|
|
|
|
|
|
|
|
|
| 7 |
|
|
|
|
|
|
|
| 8 |
|
| 9 |
class Coquille(ReachyMiniApp):
|
| 10 |
-
|
| 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
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
# === vvv ===
|
| 25 |
-
class AntennaState(BaseModel):
|
| 26 |
-
enabled: bool
|
| 27 |
|
| 28 |
-
|
| 29 |
-
def update_antennas_state(state: AntennaState):
|
| 30 |
-
nonlocal antennas_enabled
|
| 31 |
-
antennas_enabled = state.enabled
|
| 32 |
-
return {"antennas_enabled": antennas_enabled}
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
sound_play_requested = True
|
| 38 |
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 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 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
if __name__ == "__main__":
|
| 71 |
-
|
| 72 |
-
|
| 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:
|
| 12 |
-
custom_app_url: str | None = "http://0.0.0.0:
|
| 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:
|
| 12 |
-
custom_app_url: str | None = "http://0.0.0.0:
|
| 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="
|
| 3 |
-
|
| 4 |
<head>
|
| 5 |
<meta charset="UTF-8">
|
| 6 |
-
<
|
| 7 |
-
<
|
|
|
|
|
|
|
|
|
|
| 8 |
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
</head>
|
| 10 |
-
|
| 11 |
<body>
|
| 12 |
-
<h1>Reachy Mini – Control Panel</h1>
|
| 13 |
|
| 14 |
-
<
|
| 15 |
-
<
|
| 16 |
-
<
|
| 17 |
-
|
| 18 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
const
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
async function
|
| 19 |
try {
|
| 20 |
-
await fetch(
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
}
|
| 25 |
|
| 26 |
-
function
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
} else {
|
| 35 |
-
|
| 36 |
}
|
| 37 |
-
}
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
margin:
|
|
|
|
| 4 |
}
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
font-size: 16px;
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
font-weight: bold;
|
| 19 |
}
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
display: flex;
|
| 23 |
align-items: center;
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|