Spaces:
Running
Running
revert to fix audio js
Browse files- .gitattributes +1 -0
- Rapport%20robot%20emotion.pdf +3 -0
- gitattributes +5 -0
- gitignore +7 -0
- jericho/emotion.py +16 -13
- jericho/main.py +3 -3
- jericho/static/main.js +0 -20
.gitattributes
CHANGED
|
@@ -3,3 +3,4 @@ Jalon3.pdf filter=lfs diff=lfs merge=lfs -text
|
|
| 3 |
tableaux.pdf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
jericho/static/images/logoReachyMini.png filter=lfs diff=lfs merge=lfs -text
|
| 5 |
FonctionsDesCodes.pdf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 3 |
tableaux.pdf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
jericho/static/images/logoReachyMini.png filter=lfs diff=lfs merge=lfs -text
|
| 5 |
FonctionsDesCodes.pdf filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
Rapport%20robot%20emotion.pdf filter=lfs diff=lfs merge=lfs -text
|
Rapport%20robot%20emotion.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6201240eab4b538e4a9d2c755a713e40289a89c0d1a3c6e03fa0401021a7d51f
|
| 3 |
+
size 380333
|
gitattributes
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Rapport[[:space:]]robot[[:space:]]emotion.pdf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
Jalon3.pdf filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
tableaux.pdf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
jericho/static/images/logoReachyMini.png filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
FonctionsDesCodes.pdf filter=lfs diff=lfs merge=lfs -text
|
gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.egg-info/
|
| 3 |
+
build/
|
| 4 |
+
projects/
|
| 5 |
+
*.mp4
|
| 6 |
+
*.png
|
| 7 |
+
*.task
|
jericho/emotion.py
CHANGED
|
@@ -55,13 +55,12 @@ class EMAFilter: # classe pour lisser les mouvements afin d eviter les micromouv
|
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
-
|
| 59 |
-
def _pts(lms, indices, w, h): # convertit point en px car mediapipe utilise les pourcentage donc c plus facile pour
|
| 60 |
return np.array([[lms[i].x * w, lms[i].y * h] for i in indices])
|
| 61 |
|
| 62 |
-
def ear(lms, indices, w, h): # calcule ouverture des yeaux
|
| 63 |
p = _pts(lms, indices, w, h) # coordonnée de l'oeil
|
| 64 |
-
A = np.linalg.norm(p[1]-p[5]) # distance
|
| 65 |
B = np.linalg.norm(p[2]-p[4]) # " " " droit
|
| 66 |
C = np.linalg.norm(p[0]-p[3]) # largeur oeil
|
| 67 |
return (A+B) / (2.0 * C + 1e-6) #formul calculant ear
|
|
@@ -129,7 +128,7 @@ def emotions_records(records): # fonction analyse la video pour trouver les émo
|
|
| 129 |
return float(np.mean([r[key] for r in valid_recs]))
|
| 130 |
|
| 131 |
def get_std(key, func=None): # calcul ecart type signal
|
| 132 |
-
if func: return max(float(np.std([func(r) for r in valid_recs])), 1e-6)
|
| 133 |
return max(float(np.std([r[key] for r in valid_recs])), 1e-6)
|
| 134 |
|
| 135 |
|
|
@@ -196,7 +195,7 @@ def emotions_records(records): # fonction analyse la video pour trouver les émo
|
|
| 196 |
else:
|
| 197 |
emotion = "NEUTRE"
|
| 198 |
|
| 199 |
-
#
|
| 200 |
ts = rec.get("timestamp_s", 0.0)
|
| 201 |
if emotion != current_emotion and (ts - emotion_change_time) >= EMOTION_MIN_DURATION:
|
| 202 |
current_emotion = emotion
|
|
@@ -233,6 +232,8 @@ def annotate_frame(frame, lms, sig, w, h): # fonction pour annoter la video
|
|
| 233 |
f"Yaw:{sig['yaw']:+.1f} Pitch:{sig['pitch']:+.1f} Roll:{sig['roll']:+.1f}", # orientation tete
|
| 234 |
f"EAR L:{sig['ear_left']:.3f} R:{sig['ear_right']:.3f}", # ouverture des yeux
|
| 235 |
f"MAR:{sig['mar']:.3f} Smile:{sig['smile']:.3f}", # ouverture de la bouche et le sourire
|
|
|
|
|
|
|
| 236 |
f"T:{sig['timestamp_s']:.2f}s", # Affiche le temps en secondes
|
| 237 |
]
|
| 238 |
for i, line in enumerate(lines): # boucle pour ecrire les lignes au dessus
|
|
@@ -285,7 +286,8 @@ def _face_frame_runs(records):
|
|
| 285 |
|
| 286 |
def merge_filtered_audio(original_video, silent_video, output_path, face_runs, fps):
|
| 287 |
|
| 288 |
-
|
|
|
|
| 289 |
|
| 290 |
if not _ffmpeg_available():
|
| 291 |
return False
|
|
@@ -293,9 +295,10 @@ def merge_filtered_audio(original_video, silent_video, output_path, face_runs, f
|
|
| 293 |
if not face_runs:
|
| 294 |
return False
|
| 295 |
|
| 296 |
-
# Trop de micro-coupures
|
|
|
|
| 297 |
if len(face_runs) > 300:
|
| 298 |
-
print("
|
| 299 |
return False
|
| 300 |
|
| 301 |
# aselect garde uniquement les morceaux où un visage existait, puis asetpts compacte la timeline audio.
|
|
@@ -345,9 +348,9 @@ def process_video(video_path, output_dir):
|
|
| 345 |
base_options=base_opts, # Passe les options de base
|
| 346 |
running_mode=mp_vision.RunningMode.VIDEO,
|
| 347 |
num_faces=1, #un visage
|
| 348 |
-
min_face_detection_confidence=0.5, #
|
| 349 |
-
min_face_presence_confidence=0.5, #
|
| 350 |
-
min_tracking_confidence=0.5, #
|
| 351 |
output_face_blendshapes=False,
|
| 352 |
output_facial_transformation_matrixes=True, # matrice pour suivi de l'orientation
|
| 353 |
)
|
|
@@ -493,7 +496,7 @@ def process_video(video_path, output_dir):
|
|
| 493 |
"timeline": "compact_face_only",
|
| 494 |
"frames": compact_records,
|
| 495 |
},
|
| 496 |
-
f, ensure_ascii=False, indent=2
|
| 497 |
)
|
| 498 |
print(f"\n fait, {skipped_count} frames sans visage supprimées. Fichiers dans : {os.path.abspath(output_dir)}/")
|
| 499 |
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
+
def _pts(lms, indices, w, h): # convertit point en px
|
|
|
|
| 59 |
return np.array([[lms[i].x * w, lms[i].y * h] for i in indices])
|
| 60 |
|
| 61 |
+
def ear(lms, indices, w, h): # calcule ouverture des yeaux
|
| 62 |
p = _pts(lms, indices, w, h) # coordonnée de l'oeil
|
| 63 |
+
A = np.linalg.norm(p[1]-p[5]) # distance vert oeil gauche
|
| 64 |
B = np.linalg.norm(p[2]-p[4]) # " " " droit
|
| 65 |
C = np.linalg.norm(p[0]-p[3]) # largeur oeil
|
| 66 |
return (A+B) / (2.0 * C + 1e-6) #formul calculant ear
|
|
|
|
| 128 |
return float(np.mean([r[key] for r in valid_recs]))
|
| 129 |
|
| 130 |
def get_std(key, func=None): # calcul ecart type signal
|
| 131 |
+
if func: return max(float(np.std([func(r) for r in valid_recs])), 1e-6)
|
| 132 |
return max(float(np.std([r[key] for r in valid_recs])), 1e-6)
|
| 133 |
|
| 134 |
|
|
|
|
| 195 |
else:
|
| 196 |
emotion = "NEUTRE"
|
| 197 |
|
| 198 |
+
# cooldown : on ne change d'emotion que si au moins 1 seconde s'est ecoulee depuis le dernier changement
|
| 199 |
ts = rec.get("timestamp_s", 0.0)
|
| 200 |
if emotion != current_emotion and (ts - emotion_change_time) >= EMOTION_MIN_DURATION:
|
| 201 |
current_emotion = emotion
|
|
|
|
| 232 |
f"Yaw:{sig['yaw']:+.1f} Pitch:{sig['pitch']:+.1f} Roll:{sig['roll']:+.1f}", # orientation tete
|
| 233 |
f"EAR L:{sig['ear_left']:.3f} R:{sig['ear_right']:.3f}", # ouverture des yeux
|
| 234 |
f"MAR:{sig['mar']:.3f} Smile:{sig['smile']:.3f}", # ouverture de la bouche et le sourire
|
| 235 |
+
# f"BrowRaise L:{sig['brow_raise_left']:.3f} R:{sig['brow_raise_right']:.3f}", # hauteur des sourcils
|
| 236 |
+
# f"BrowFurrow:{sig['brow_furrow']:.3f}", # froncement
|
| 237 |
f"T:{sig['timestamp_s']:.2f}s", # Affiche le temps en secondes
|
| 238 |
]
|
| 239 |
for i, line in enumerate(lines): # boucle pour ecrire les lignes au dessus
|
|
|
|
| 286 |
|
| 287 |
def merge_filtered_audio(original_video, silent_video, output_path, face_runs, fps):
|
| 288 |
|
| 289 |
+
#Recolle l'audio en supprimant les mêmes intervalles que les frames sans visage.
|
| 290 |
+
#Si l'audio n'est pas disponible, la vidéo muette reste utilisée.
|
| 291 |
|
| 292 |
if not _ffmpeg_available():
|
| 293 |
return False
|
|
|
|
| 295 |
if not face_runs:
|
| 296 |
return False
|
| 297 |
|
| 298 |
+
# Trop de micro-coupures peut créer une commande ffmpeg très longue.
|
| 299 |
+
# Dans ce cas, on garde la vidéo synchronisée robot/visuel sans audio.
|
| 300 |
if len(face_runs) > 300:
|
| 301 |
+
print(" Trop de segments audio à concaténer : export vidéo muette synchronisée.")
|
| 302 |
return False
|
| 303 |
|
| 304 |
# aselect garde uniquement les morceaux où un visage existait, puis asetpts compacte la timeline audio.
|
|
|
|
| 348 |
base_options=base_opts, # Passe les options de base
|
| 349 |
running_mode=mp_vision.RunningMode.VIDEO,
|
| 350 |
num_faces=1, #un visage
|
| 351 |
+
min_face_detection_confidence=0.5, # Tolérance de détection du visage à 50%
|
| 352 |
+
min_face_presence_confidence=0.5, # Tolérance de maintien du visage à 50%
|
| 353 |
+
min_tracking_confidence=0.5, # Tolérance de suivi de mouvement à 50%
|
| 354 |
output_face_blendshapes=False,
|
| 355 |
output_facial_transformation_matrixes=True, # matrice pour suivi de l'orientation
|
| 356 |
)
|
|
|
|
| 496 |
"timeline": "compact_face_only",
|
| 497 |
"frames": compact_records,
|
| 498 |
},
|
| 499 |
+
f, ensure_ascii=False, indent=2 # Avec une jolie indentation (2 espaces) pour que ça soit lisible
|
| 500 |
)
|
| 501 |
print(f"\n fait, {skipped_count} frames sans visage supprimées. Fichiers dans : {os.path.abspath(output_dir)}/")
|
| 502 |
|
jericho/main.py
CHANGED
|
@@ -38,7 +38,7 @@ def moving_average(values: list[float], window: int = 7) -> np.ndarray: # fct li
|
|
| 38 |
return smoothed[: len(arr)]
|
| 39 |
|
| 40 |
class SignalMapper: # classe map signaux visage vers robot
|
| 41 |
-
#
|
| 42 |
HEAD_YAW_MAX = np.deg2rad(5)
|
| 43 |
HEAD_PITCH_MAX = np.deg2rad(15)
|
| 44 |
HEAD_ROLL_MAX = np.deg2rad(15)
|
|
@@ -50,7 +50,7 @@ class SignalMapper: # classe map signaux visage vers robot
|
|
| 50 |
ANTENNA_MAX_col = np.deg2rad(110)
|
| 51 |
COMBINED_YAW_LIMIT = np.deg2rad(60)
|
| 52 |
|
| 53 |
-
# multiplicateurs
|
| 54 |
YAW_AMP = 8.0
|
| 55 |
PITCH_AMP = 5.0
|
| 56 |
ROLL_AMP = 3.0
|
|
@@ -148,7 +148,7 @@ class SignalMapper: # classe map signaux visage vers robot
|
|
| 148 |
|
| 149 |
w_h = self.SMOOTH_WINDOW_HEAD
|
| 150 |
|
| 151 |
-
# applique fct lissage tete + position
|
| 152 |
s_yaw = moving_average(raw_yaw, w_h)
|
| 153 |
s_pitch = moving_average(raw_pitch, w_h)
|
| 154 |
s_roll = moving_average(raw_roll, w_h)
|
|
|
|
| 38 |
return smoothed[: len(arr)]
|
| 39 |
|
| 40 |
class SignalMapper: # classe map signaux visage vers robot
|
| 41 |
+
# constantes limites angles robot
|
| 42 |
HEAD_YAW_MAX = np.deg2rad(5)
|
| 43 |
HEAD_PITCH_MAX = np.deg2rad(15)
|
| 44 |
HEAD_ROLL_MAX = np.deg2rad(15)
|
|
|
|
| 50 |
ANTENNA_MAX_col = np.deg2rad(110)
|
| 51 |
COMBINED_YAW_LIMIT = np.deg2rad(60)
|
| 52 |
|
| 53 |
+
# multiplicateurs amplifs mouvements
|
| 54 |
YAW_AMP = 8.0
|
| 55 |
PITCH_AMP = 5.0
|
| 56 |
ROLL_AMP = 3.0
|
|
|
|
| 148 |
|
| 149 |
w_h = self.SMOOTH_WINDOW_HEAD
|
| 150 |
|
| 151 |
+
# applique fct lissage (tete + position uniquement, emotions deja calculees par emotion.py)
|
| 152 |
s_yaw = moving_average(raw_yaw, w_h)
|
| 153 |
s_pitch = moving_average(raw_pitch, w_h)
|
| 154 |
s_roll = moving_average(raw_roll, w_h)
|
jericho/static/main.js
CHANGED
|
@@ -9,7 +9,6 @@ const videoPlayer = document.getElementById('video-player');
|
|
| 9 |
videoPlayer.muted = true;
|
| 10 |
videoPlayer.defaultMuted = true;
|
| 11 |
|
| 12 |
-
// Sécurise le texte pour éviter les bugs d'affichage si le nom de la vidéo contient des caractères bizarres
|
| 13 |
function escapeHtml(value) {
|
| 14 |
return String(value)
|
| 15 |
.replaceAll('&', '&')
|
|
@@ -19,7 +18,6 @@ function escapeHtml(value) {
|
|
| 19 |
.replaceAll("'", ''');
|
| 20 |
}
|
| 21 |
|
| 22 |
-
// Envoie l'état actuel de la vidéo (temps précis, si elle est en lecture ou pause) au serveur Python
|
| 23 |
async function sendPlayback(action, options = {}) {
|
| 24 |
const projectName = options.projectName || currentLoadedProject;
|
| 25 |
if (!projectName) return;
|
|
@@ -49,7 +47,6 @@ async function sendPlayback(action, options = {}) {
|
|
| 49 |
}
|
| 50 |
}
|
| 51 |
|
| 52 |
-
// Lance le chronomètre qui synchronise la vidéo avec le robot (s'active 2 fois par seconde)
|
| 53 |
function startSyncLoop() {
|
| 54 |
stopSyncLoop();
|
| 55 |
syncTimer = setInterval(() => {
|
|
@@ -59,7 +56,6 @@ function startSyncLoop() {
|
|
| 59 |
}, SYNC_INTERVAL_MS);
|
| 60 |
}
|
| 61 |
|
| 62 |
-
// Arrête le chronomètre de synchronisation
|
| 63 |
function stopSyncLoop() {
|
| 64 |
if (syncTimer) {
|
| 65 |
clearInterval(syncTimer);
|
|
@@ -67,7 +63,6 @@ function stopSyncLoop() {
|
|
| 67 |
}
|
| 68 |
}
|
| 69 |
|
| 70 |
-
// Initialisation au chargement de la page web : lance la bibliothèque et prépare les boutons d'émotions
|
| 71 |
document.addEventListener('DOMContentLoaded', () => {
|
| 72 |
loadLibrary();
|
| 73 |
setInterval(loadLibrary, 3000);
|
|
@@ -97,7 +92,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 97 |
});
|
| 98 |
});
|
| 99 |
|
| 100 |
-
// Gère l'envoi d'un fichier vidéo local depuis l'ordinateur vers le serveur
|
| 101 |
document.getElementById('import-btn').onclick = function() {
|
| 102 |
const name = document.getElementById('video-name').value.trim();
|
| 103 |
const file = document.getElementById('video-file').files[0];
|
|
@@ -116,7 +110,6 @@ document.getElementById('import-btn').onclick = function() {
|
|
| 116 |
const reader = new FileReader();
|
| 117 |
reader.readAsDataURL(file);
|
| 118 |
|
| 119 |
-
// S'exécute quand la vidéo est transformée en texte (Base64) et prête à partir
|
| 120 |
reader.onload = async function () {
|
| 121 |
const base64data = reader.result;
|
| 122 |
|
|
@@ -147,7 +140,6 @@ document.getElementById('import-btn').onclick = function() {
|
|
| 147 |
};
|
| 148 |
};
|
| 149 |
|
| 150 |
-
// Gère l'envoi d'un lien YouTube au serveur pour qu'il le télécharge
|
| 151 |
document.getElementById('yt-import-btn').onclick = async function() {
|
| 152 |
const name = document.getElementById('yt-name').value.trim();
|
| 153 |
const url = document.getElementById('yt-url').value.trim();
|
|
@@ -189,7 +181,6 @@ document.getElementById('yt-import-btn').onclick = async function() {
|
|
| 189 |
}
|
| 190 |
};
|
| 191 |
|
| 192 |
-
// Demande au serveur de supprimer définitivement un projet
|
| 193 |
window.deleteProject = async function(event, name) {
|
| 194 |
event.stopPropagation();
|
| 195 |
|
|
@@ -221,7 +212,6 @@ window.deleteProject = async function(event, name) {
|
|
| 221 |
}
|
| 222 |
};
|
| 223 |
|
| 224 |
-
// Interroge le serveur pour récupérer la liste des vidéos et met à jour l'affichage
|
| 225 |
async function loadLibrary() {
|
| 226 |
try {
|
| 227 |
const response = await fetch('/api/library');
|
|
@@ -286,7 +276,6 @@ async function loadLibrary() {
|
|
| 286 |
}
|
| 287 |
}
|
| 288 |
|
| 289 |
-
// Charge la vidéo cliquée dans le lecteur principal et réinitialise l'affichage
|
| 290 |
window.loadVideoPlayer = async function(name, url) {
|
| 291 |
if (currentLoadedProject && currentLoadedProject !== name) {
|
| 292 |
await sendPlayback('stop', { projectName: currentLoadedProject, playing: false });
|
|
@@ -322,7 +311,6 @@ window.loadVideoPlayer = async function(name, url) {
|
|
| 322 |
}
|
| 323 |
};
|
| 324 |
|
| 325 |
-
// Prévient le serveur quand l'utilisateur clique sur Lecture
|
| 326 |
videoPlayer.addEventListener('play', () => {
|
| 327 |
if (!currentLoadedProject) return;
|
| 328 |
robotStatus.textContent = 'Lecture synchronisée — audio robot';
|
|
@@ -330,7 +318,6 @@ videoPlayer.addEventListener('play', () => {
|
|
| 330 |
startSyncLoop();
|
| 331 |
});
|
| 332 |
|
| 333 |
-
// Prévient le serveur quand l'utilisateur met la vidéo en pause
|
| 334 |
videoPlayer.addEventListener('pause', () => {
|
| 335 |
if (!currentLoadedProject || videoPlayer.ended) return;
|
| 336 |
robotStatus.textContent = 'En pause';
|
|
@@ -338,31 +325,26 @@ videoPlayer.addEventListener('pause', () => {
|
|
| 338 |
stopSyncLoop();
|
| 339 |
});
|
| 340 |
|
| 341 |
-
// Prévient le serveur quand on clique sur la barre d'avancement pour faire un saut
|
| 342 |
videoPlayer.addEventListener('seeking', () => {
|
| 343 |
if (!currentLoadedProject) return;
|
| 344 |
sendPlayback('seek', { playing: !videoPlayer.paused && !videoPlayer.ended });
|
| 345 |
});
|
| 346 |
|
| 347 |
-
// Relance le processus juste après que le saut dans la vidéo soit terminé
|
| 348 |
videoPlayer.addEventListener('seeked', () => {
|
| 349 |
if (!currentLoadedProject) return;
|
| 350 |
sendPlayback('seek', { playing: !videoPlayer.paused && !videoPlayer.ended });
|
| 351 |
});
|
| 352 |
|
| 353 |
-
// Mouvement naturel de la vidéo (déjà géré par startSyncLoop, donc pas besoin de surcharger)
|
| 354 |
videoPlayer.addEventListener('timeupdate', () => {
|
| 355 |
// synchro geree par startSyncLoop(), ici pas de double envoi
|
| 356 |
});
|
| 357 |
|
| 358 |
-
// Prévient le serveur que la vidéo est complètement terminée
|
| 359 |
videoPlayer.addEventListener('ended', () => {
|
| 360 |
robotStatus.textContent = 'Terminé';
|
| 361 |
sendPlayback('stop', { playing: false });
|
| 362 |
stopSyncLoop();
|
| 363 |
});
|
| 364 |
|
| 365 |
-
// Ajoute une nouvelle ligne avec une émotion et un temps dans le créateur de séquence
|
| 366 |
function createEmotionRow() {
|
| 367 |
const row = document.createElement('div');
|
| 368 |
row.className = 'emotion-row';
|
|
@@ -408,7 +390,6 @@ function createEmotionRow() {
|
|
| 408 |
document.getElementById('sequence-list').appendChild(row);
|
| 409 |
}
|
| 410 |
|
| 411 |
-
// Configure le bouton "+" pour ajouter des lignes au créateur de séquence
|
| 412 |
document.addEventListener('DOMContentLoaded', () => {
|
| 413 |
const addEmotionBtn = document.getElementById('add-emotion-btn');
|
| 414 |
if(addEmotionBtn) {
|
|
@@ -427,7 +408,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 427 |
}
|
| 428 |
});
|
| 429 |
|
| 430 |
-
// Rassemble les émotions choisies et envoie l'ordre au serveur de créer la séquence
|
| 431 |
document.getElementById('save-seq-btn').onclick = async function() {
|
| 432 |
const name = document.getElementById('seq-name').value.trim();
|
| 433 |
const msg = document.getElementById('seq-msg');
|
|
|
|
| 9 |
videoPlayer.muted = true;
|
| 10 |
videoPlayer.defaultMuted = true;
|
| 11 |
|
|
|
|
| 12 |
function escapeHtml(value) {
|
| 13 |
return String(value)
|
| 14 |
.replaceAll('&', '&')
|
|
|
|
| 18 |
.replaceAll("'", ''');
|
| 19 |
}
|
| 20 |
|
|
|
|
| 21 |
async function sendPlayback(action, options = {}) {
|
| 22 |
const projectName = options.projectName || currentLoadedProject;
|
| 23 |
if (!projectName) return;
|
|
|
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
|
|
|
| 50 |
function startSyncLoop() {
|
| 51 |
stopSyncLoop();
|
| 52 |
syncTimer = setInterval(() => {
|
|
|
|
| 56 |
}, SYNC_INTERVAL_MS);
|
| 57 |
}
|
| 58 |
|
|
|
|
| 59 |
function stopSyncLoop() {
|
| 60 |
if (syncTimer) {
|
| 61 |
clearInterval(syncTimer);
|
|
|
|
| 63 |
}
|
| 64 |
}
|
| 65 |
|
|
|
|
| 66 |
document.addEventListener('DOMContentLoaded', () => {
|
| 67 |
loadLibrary();
|
| 68 |
setInterval(loadLibrary, 3000);
|
|
|
|
| 92 |
});
|
| 93 |
});
|
| 94 |
|
|
|
|
| 95 |
document.getElementById('import-btn').onclick = function() {
|
| 96 |
const name = document.getElementById('video-name').value.trim();
|
| 97 |
const file = document.getElementById('video-file').files[0];
|
|
|
|
| 110 |
const reader = new FileReader();
|
| 111 |
reader.readAsDataURL(file);
|
| 112 |
|
|
|
|
| 113 |
reader.onload = async function () {
|
| 114 |
const base64data = reader.result;
|
| 115 |
|
|
|
|
| 140 |
};
|
| 141 |
};
|
| 142 |
|
|
|
|
| 143 |
document.getElementById('yt-import-btn').onclick = async function() {
|
| 144 |
const name = document.getElementById('yt-name').value.trim();
|
| 145 |
const url = document.getElementById('yt-url').value.trim();
|
|
|
|
| 181 |
}
|
| 182 |
};
|
| 183 |
|
|
|
|
| 184 |
window.deleteProject = async function(event, name) {
|
| 185 |
event.stopPropagation();
|
| 186 |
|
|
|
|
| 212 |
}
|
| 213 |
};
|
| 214 |
|
|
|
|
| 215 |
async function loadLibrary() {
|
| 216 |
try {
|
| 217 |
const response = await fetch('/api/library');
|
|
|
|
| 276 |
}
|
| 277 |
}
|
| 278 |
|
|
|
|
| 279 |
window.loadVideoPlayer = async function(name, url) {
|
| 280 |
if (currentLoadedProject && currentLoadedProject !== name) {
|
| 281 |
await sendPlayback('stop', { projectName: currentLoadedProject, playing: false });
|
|
|
|
| 311 |
}
|
| 312 |
};
|
| 313 |
|
|
|
|
| 314 |
videoPlayer.addEventListener('play', () => {
|
| 315 |
if (!currentLoadedProject) return;
|
| 316 |
robotStatus.textContent = 'Lecture synchronisée — audio robot';
|
|
|
|
| 318 |
startSyncLoop();
|
| 319 |
});
|
| 320 |
|
|
|
|
| 321 |
videoPlayer.addEventListener('pause', () => {
|
| 322 |
if (!currentLoadedProject || videoPlayer.ended) return;
|
| 323 |
robotStatus.textContent = 'En pause';
|
|
|
|
| 325 |
stopSyncLoop();
|
| 326 |
});
|
| 327 |
|
|
|
|
| 328 |
videoPlayer.addEventListener('seeking', () => {
|
| 329 |
if (!currentLoadedProject) return;
|
| 330 |
sendPlayback('seek', { playing: !videoPlayer.paused && !videoPlayer.ended });
|
| 331 |
});
|
| 332 |
|
|
|
|
| 333 |
videoPlayer.addEventListener('seeked', () => {
|
| 334 |
if (!currentLoadedProject) return;
|
| 335 |
sendPlayback('seek', { playing: !videoPlayer.paused && !videoPlayer.ended });
|
| 336 |
});
|
| 337 |
|
|
|
|
| 338 |
videoPlayer.addEventListener('timeupdate', () => {
|
| 339 |
// synchro geree par startSyncLoop(), ici pas de double envoi
|
| 340 |
});
|
| 341 |
|
|
|
|
| 342 |
videoPlayer.addEventListener('ended', () => {
|
| 343 |
robotStatus.textContent = 'Terminé';
|
| 344 |
sendPlayback('stop', { playing: false });
|
| 345 |
stopSyncLoop();
|
| 346 |
});
|
| 347 |
|
|
|
|
| 348 |
function createEmotionRow() {
|
| 349 |
const row = document.createElement('div');
|
| 350 |
row.className = 'emotion-row';
|
|
|
|
| 390 |
document.getElementById('sequence-list').appendChild(row);
|
| 391 |
}
|
| 392 |
|
|
|
|
| 393 |
document.addEventListener('DOMContentLoaded', () => {
|
| 394 |
const addEmotionBtn = document.getElementById('add-emotion-btn');
|
| 395 |
if(addEmotionBtn) {
|
|
|
|
| 408 |
}
|
| 409 |
});
|
| 410 |
|
|
|
|
| 411 |
document.getElementById('save-seq-btn').onclick = async function() {
|
| 412 |
const name = document.getElementById('seq-name').value.trim();
|
| 413 |
const msg = document.getElementById('seq-msg');
|