import threading # Lancement du thread de lecture CSV sans bloquer le serveur import subprocess # Exécution de Jalon2OPTI.py comme sous-processus Python import sys import os import time # Gestion précise du timing pour la synchronisation import csv from fastapi import Request # Objet requête HTTP from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, Response # Différents types de réponses from fastapi.staticfiles import StaticFiles # Fichiers statiques from reachy_mini import ReachyMini, ReachyMiniApp from reachy_mini.utils import create_head_pose import numpy as np # Conversion degrés → radians pour les antennes import soundfile as sf # Lecture de fichiers audio WAV import scipy.signal import yt_dlp # Téléchargement de vidéos YouTube ### CHEMINS DE SAUVEGARDE ### DOSSIER = os.path.dirname(os.path.abspath(__file__)) STATIC = os.path.join(DOSSIER, "static") DOSSIER_CSV = os.path.join(DOSSIER, "liste", "csv") DOSSIER_LANDMARKS = os.path.join(DOSSIER, "liste", "landmarks") DOSSIER_AUDIO = os.path.join(DOSSIER, "liste", "audio") # Crée les dossiers si jamais ils sont absents os.makedirs(DOSSIER_CSV, exist_ok=True) os.makedirs(DOSSIER_LANDMARKS, exist_ok=True) os.makedirs(DOSSIER_AUDIO, exist_ok=True) ### CLASSE PRINCIPALE POUR L'APP ### class Coquille(ReachyMiniApp): custom_app_url: str | None = "http://localhost:8042" # URL interne ouverte en local request_media_backend: str | None = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.robot: ReachyMini | None = None # Instance du robot # Le robot et la lecture de la vidéo sont prets à démarrer self.robot_ready = threading.Event() self.playback_ready = threading.Event() self.playback_paused = False # Lecture en pause self.playback_stop = False # Arret def run(self, reachy_mini: ReachyMini, stop_event: threading.Event): self.robot = reachy_mini self.robot_ready.set() print("[ROBOT] Connecté et prêt") stop_event.wait() # Bloque jusqu'à l'arret de l'app ### FONCTIONS UTILITAIRES ### def interruptible_sleep(seconds: float, app_instance: Coquille): deadline = time.perf_counter() + seconds while time.perf_counter() < deadline: if app_instance.playback_stop: return if app_instance.playback_paused: deadline += 0.01 time.sleep(0.01) ### PERMET D'ATTENDRE QUE LE ROBOT SOIT LA 2PUIS EXECUTE LE REJEU DE LA LOGIQUE DU JALON 3 ### def execute_jalon3_thread(csv_path: str, wav_path: str, app_instance: Coquille): # Attente que le robot robot soit là if not app_instance.robot_ready.wait(timeout=15): print("[JALON3] Timeout : robot non connecté") app_instance.playback_ready.set() return robot = app_instance.robot if robot is None: print("[JALON3] Robot None — abandon") app_instance.playback_ready.set() return try: # Chargement du csv with open(csv_path, newline='') as f: rows = list(csv.DictReader(f)) # Préparer l'audio audio_ok = False if os.path.exists(wav_path): try: # Lecture du .wav audio, samplerate_in = sf.read(wav_path, dtype="float32") # Conversion stéréo mono if audio.ndim > 1: audio = np.mean(audio, axis=1) output_sr = robot.media.get_output_audio_samplerate() if samplerate_in != output_sr: audio = scipy.signal.resample( audio, int(len(audio) * (output_sr / samplerate_in)) ) audio_ok = True print(f"[JALON3] Audio chargé ({len(audio)} samples)") except Exception as e: print(f"[JALON3] Erreur chargement audio : {e}") else: print(f"[JALON3] Pas de fichier WAV : {wav_path}") print(f"[JALON3] {len(rows)} frames — signal prêt envoyé") # Signaler au JS que tout est prêt app_instance.playback_ready.set() # Démarrer l'audio if audio_ok: try: robot.media.start_playing() robot.media.push_audio_sample(audio) print("[JALON3] Audio lancé") except Exception as e: print(f"[JALON3] Erreur démarrage audio : {e}") # Boucle de lecture image par image start_time = time.perf_counter() # T0 de référence pour la synchronisation for idx, row in enumerate(rows): # Vérification de l'arrêt demandé if app_instance.playback_stop: print(f"[JALON3] Stop à frame {idx}") break # Attente de la reprise en pause while app_instance.playback_paused and not app_instance.playback_stop: time.sleep(0.01) if app_instance.playback_stop: break try: # Construction de la pose de la tête # x = ouverture de bouche (lèvre du robot) # y = position horizontale du visage # z = position verticale du visage # pitch = inclinaison avant/arrière # yaw = rotation gauche/droite # roll = inclinaison latérale head_pose = create_head_pose( x=float(row["bouche"]), y=float(row["norm_x"]), z=float(row["norm_y"]), pitch=float(row["pitch"]), yaw=float(row["yaw"]), roll=float(row["roll"]), ) # Angles des antennes # oeil g (antenne gauche) et d (antenne droite) en degrés puis conversion en radian pour Reachy antennas_rad = np.deg2rad(np.array([float(row["oeil_g"]), float(row["oeil_d"])])) # Envoi de la commande au robot robot.set_target(head=head_pose, antennas=antennas_rad) # Syncrhonisation temporelle # Calcul en fonction du tps réel et du timestamp csv avant d'envoyer la prochaine frame ts = float(row["timestamp"]) elapsed = time.perf_counter() - start_time sleep_time = ts - elapsed if sleep_time > 0.001: interruptible_sleep(sleep_time, app_instance) except Exception as e: print(f"[JALON3] Erreur frame {idx}: {e}") # Arrêter l'audio proprement if audio_ok: try: robot.media.stop_playing() except Exception as e: print(f"[JALON3] Erreur arrêt audio : {e}") print("[JALON3] Terminé") except Exception as e: print(f"[JALON3] Erreur générale: {e}") app_instance.playback_ready.set() # Evite le blocage du JS ### APP ### app = Coquille() # Appelle le dossier static pour le navigateur app.settings_app.mount("/static", StaticFiles(directory=STATIC), name="static") @app.settings_app.get("/") def index(): # Appelle la page index.html du dossier static return FileResponse(os.path.join(STATIC, "index.html")) @app.settings_app.get("/favicon.ico") def favicon(): # Evite les erreurs 404 return Response(status_code=204) @app.settings_app.get("/liste") def liste(): # Retourne la liste des enregistrements en scannant le dossier csv pour avoir les noms de chaque enregistrement noms = [f[:-4] for f in os.listdir(DOSSIER_CSV) if f.endswith(".csv")] return JSONResponse({"noms": noms}) @app.settings_app.post("/charger") async def charger(request: Request): # Recoit une video locale pour lancer le pipeline ensuite # Sauvegarde la vidéo sur le disque en vdieo_temp.mp4 # Lance le Jalon2OPTI puis retourne succès ou erreur en fonction form = await request.form() video_file = form.get("video") nom = (form.get("nom") or "").strip() if not video_file or not nom: return JSONResponse({"succes": False, "message": "Vidéo ou nom manquant."}, status_code=400) # Sauvegarde temporaire de la vidéo chargée video_path = os.path.join(DOSSIER, "video_temp.mp4") with open(video_path, "wb") as f: f.write(await video_file.read()) try: subprocess.run( [sys.executable, os.path.join(DOSSIER, "Jalon2OPTI.py"), video_path, nom], cwd=DOSSIER, capture_output=True, text=True, check=True, timeout=300 ) print(f"[PIPELINE] OK pour {nom}") except subprocess.CalledProcessError: return JSONResponse({"succes": False, "message": "Pipeline échoué"}, status_code=500) except subprocess.TimeoutExpired: return JSONResponse({"succes": False, "message": "Pipeline timeout"}, status_code=500) return JSONResponse({"succes": True, "nom": nom}) @app.settings_app.post("/supprimer") async def supprimer(request: Request): # Supprime les fichiers wav video landmarkée et csv d'un enregistrement # Si une vidéo n'a par exemple pas d'audio on l'ignore data = await request.json() nom = data.get("nom") if not nom: return JSONResponse({"succes": False, "message": "Nom manquant"}, status_code=400) # Suppression de chaque fichier for path in [ os.path.join(DOSSIER_CSV, f"{nom}.csv"), os.path.join(DOSSIER_LANDMARKS, f"{nom}(landmarks).mp4"), os.path.join(DOSSIER_AUDIO, f"{nom}.wav"), ]: if os.path.exists(path): os.remove(path) print(f"[DELETE] {nom} supprimé") return JSONResponse({"succes": True}) @app.settings_app.post("/play") async def play(request: Request): # Lance un enregistrement sur le robot # Dans un premier tps, vérifie sur le csv est présent # Réinitialise les play et pause # Remet à 0 l'évent du robot pret # Lance le rejeu dans un thread pour reachy mini # Le JS interroge le /is-ready pour savoir quand commencer la vidéo data = await request.json() nom = data.get("nom", "").strip() if not nom: return JSONResponse({"succes": False, "message": "Nom manquant"}, status_code=400) csv_path = os.path.join(DOSSIER_CSV, f"{nom}.csv") wav_path = os.path.join(DOSSIER_AUDIO, f"{nom}.wav") if not os.path.exists(csv_path): return JSONResponse({"succes": False, "message": f"CSV introuvable : {nom}"}, status_code=404) # Réinitialisation de l'état de lecture app.playback_paused = False app.playback_stop = False app.playback_ready.clear() # Reset de l'évent pour pouvoir relancer thread = threading.Thread(target=execute_jalon3_thread, args=(csv_path, wav_path, app), daemon=True) thread.start() print(f"[PLAY] Thread lancé pour {nom}") return JSONResponse({"succes": True}) @app.settings_app.get("/is-ready") async def is_ready(): # Vérifie si on peut lancer la lecture # Si ready=True le JS lance la vidéo en même tps que le rejeu de reachy return JSONResponse({"ready": app.playback_ready.is_set()}) @app.settings_app.post("/pause") async def pause(request: Request): # Met la lecture en pause app.playback_paused = True return JSONResponse({"succes": True}) @app.settings_app.post("/resume") async def resume(request: Request): # Reprend la lecture après une pause app.playback_paused = False return JSONResponse({"succes": True}) @app.settings_app.post("/stop") async def stop(request: Request): # Arret complet de la lecture app.playback_stop = True return JSONResponse({"succes": True}) @app.settings_app.get("/video/{nom}") def video(nom: str, request: Request): # lis la vidéo landmarkée path = os.path.join(DOSSIER_LANDMARKS, f"{nom}(landmarks).mp4") if not os.path.exists(path): return JSONResponse({"succes": False, "message": f"Vidéo introuvable : {nom}"}, status_code=404) file_size = os.path.getsize(path) range_header = request.headers.get("range") if range_header: start, _, end_str = range_header.replace("bytes=", "").partition("-") start = int(start) end = int(end_str) if end_str else file_size - 1 end = min(end, file_size - 1) chunk = end - start + 1 def stream(): with open(path, "rb") as f: f.seek(start) rem = chunk while rem > 0: data = f.read(min(65536, rem)) if not data: break rem -= len(data) yield data return StreamingResponse(stream(), status_code=206, media_type="video/mp4", headers={ "Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(chunk), }) return FileResponse(path, media_type="video/mp4", headers={"Accept-Ranges": "bytes"}) @app.settings_app.post("/telecharger") async def telecharger(request: Request): # Télécharge une vidéo YouTube dans video_temp.mp4 via yt_dlp # la vidéo est sauvegardée en vidéo_temp qu'on utilisera pour la lancer dans le navigateur data = await request.json() url = data.get("url", "").strip() if not url: return JSONResponse({"succes": False, "message": "URL manquante."}, status_code=400) video_path = os.path.join(DOSSIER, "video_temp.mp4") if os.path.exists(video_path): os.remove(video_path) # Supprime une ancienne vidéo temp pour qu'elle soit bien temporaire try: ydl_opts = { "outtmpl": video_path, # Chemin de sortie # Fusion de l'audio et de la vidéo et choix des meilleurs qualités de vidéo "format": "mp4/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]", "merge_output_format": "mp4", } with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) except Exception as e: return JSONResponse({"succes": False, "message": str(e)}, status_code=500) return JSONResponse({"succes": True}) @app.settings_app.post("/lancer-youtube") async def lancer_youtube(request: Request): # Lance Jalon2OPTI sur la video_temp.mp4 déjà téléchargée # Séparé de /telecharger pour permettre au JS d'attendre la fin du téléchargement avant de demander le traitement (qui peut être long). data = await request.json() nom = (data.get("nom") or "").strip() if not nom: return JSONResponse({"succes": False, "message": "Nom manquant."}, status_code=400) video_path = os.path.join(DOSSIER, "video_temp.mp4") if not os.path.exists(video_path): return JSONResponse({"succes": False, "message": "Aucune vidéo téléchargée."}, status_code=404) try: subprocess.run( [sys.executable, os.path.join(DOSSIER, "Jalon2OPTI.py"), video_path, nom], cwd=DOSSIER, capture_output=True, text=True, encoding="utf-8", errors="replace", check=True ) print(f"[PIPELINE] OK pour {nom}") except subprocess.CalledProcessError as e: return JSONResponse({ "succes": False, "message": e.stderr or e.stdout or "erreur inconnue" }, status_code=500) return JSONResponse({"succes": True, "nom": nom}) if __name__ == "__main__": import uvicorn # Lance le thread de connexion robot en arrière-plan (daemon=True : s'arrête automatiquement à la fermeture du processus principal) threading.Thread(target=app.wrapped_run, daemon=True).start() # Démarre le serveur HTTP FastAPI sur le port 8042 uvicorn.run(app.settings_app, host="127.0.0.1", port=8042)