Videos didactiques multi-etapes : wizard operateur + admin dynamique
Backend : 8 endpoints API (upload/delete/reorder/toggle/label/stream/creer etape),
stockage data/videos/{situation}/etape_XX.mp4, config.json toggle+labels,
6 situations par defaut, migration auto anciens fichiers flat.
Admin : panneau dynamique avec toggle, etapes reordonnables, labels editables.
Wizard booth : overlay plein ecran, video loop par etape, bouton validation sequentielle.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
221
backend/main.py
221
backend/main.py
@@ -1427,47 +1427,212 @@ async def _lancer_photostation():
|
|||||||
FLAG_PHOTOSTATION.unlink(missing_ok=True)
|
FLAG_PHOTOSTATION.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
# --- API Videos de situation ---
|
# --- API Videos didactiques (multi-etapes) ---
|
||||||
|
|
||||||
DOSSIER_VIDEOS = RACINE / "data" / "videos"
|
DOSSIER_VIDEOS = RACINE / "data" / "videos"
|
||||||
DOSSIER_VIDEOS.mkdir(parents=True, exist_ok=True)
|
DOSSIER_VIDEOS.mkdir(parents=True, exist_ok=True)
|
||||||
|
FICHIER_VIDEOS_CONFIG = DOSSIER_VIDEOS / "config.json"
|
||||||
|
|
||||||
|
SITUATIONS_DEFAUT = [
|
||||||
|
{"id": "montage", "label": "Montage / Mise en service"},
|
||||||
|
{"id": "demontage", "label": "Demontage"},
|
||||||
|
{"id": "bourrage", "label": "Bourrage imprimante"},
|
||||||
|
{"id": "changement_rouleau", "label": "Changement rouleau / ruban"},
|
||||||
|
{"id": "wifi", "label": "Probleme WiFi"},
|
||||||
|
{"id": "depannage_imprimante", "label": "Depannage imprimante"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def charger_videos_config():
|
||||||
|
if FICHIER_VIDEOS_CONFIG.exists():
|
||||||
|
with open(FICHIER_VIDEOS_CONFIG) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def sauvegarder_videos_config(cfg):
|
||||||
|
with open(FICHIER_VIDEOS_CONFIG, "w") as f:
|
||||||
|
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
def _lister_etapes(situation: str):
|
||||||
|
dossier = DOSSIER_VIDEOS / situation
|
||||||
|
if not dossier.is_dir():
|
||||||
|
return []
|
||||||
|
fichiers = sorted(
|
||||||
|
[f for f in dossier.iterdir() if f.is_file() and f.suffix.lower() in ('.mp4', '.webm', '.mov')],
|
||||||
|
key=lambda f: f.name
|
||||||
|
)
|
||||||
|
return [{"index": i, "fichier": f.name} for i, f in enumerate(fichiers)]
|
||||||
|
|
||||||
|
def _migrer_anciens_videos():
|
||||||
|
for f in DOSSIER_VIDEOS.iterdir():
|
||||||
|
if f.is_file() and f.suffix.lower() in ('.mp4', '.webm', '.mov'):
|
||||||
|
situation = f.stem
|
||||||
|
dossier = DOSSIER_VIDEOS / situation
|
||||||
|
dossier.mkdir(exist_ok=True)
|
||||||
|
dest = dossier / f"etape_00{f.suffix}"
|
||||||
|
f.rename(dest)
|
||||||
|
|
||||||
|
_migrer_anciens_videos()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/videos")
|
@app.get("/api/videos")
|
||||||
async def api_videos():
|
async def api_videos():
|
||||||
videos = {}
|
cfg = charger_videos_config()
|
||||||
for f in DOSSIER_VIDEOS.iterdir():
|
result = {}
|
||||||
if f.is_file() and f.suffix.lower() in ('.mp4', '.webm', '.mov'):
|
for sit in SITUATIONS_DEFAUT:
|
||||||
videos[f.stem] = f.name
|
sid = sit["id"]
|
||||||
return {"videos": videos}
|
etapes = _lister_etapes(sid)
|
||||||
|
sit_cfg = cfg.get(sid, {})
|
||||||
|
result[sid] = {
|
||||||
|
"label": sit.get("label", sid),
|
||||||
|
"actif": sit_cfg.get("actif", False),
|
||||||
|
"etapes": etapes,
|
||||||
|
"labels_etapes": sit_cfg.get("labels_etapes", {}),
|
||||||
|
}
|
||||||
|
for sid in sorted(cfg.keys()):
|
||||||
|
if sid not in result:
|
||||||
|
etapes = _lister_etapes(sid)
|
||||||
|
result[sid] = {
|
||||||
|
"label": cfg[sid].get("label", sid),
|
||||||
|
"actif": cfg[sid].get("actif", False),
|
||||||
|
"etapes": etapes,
|
||||||
|
"labels_etapes": cfg[sid].get("labels_etapes", {}),
|
||||||
|
}
|
||||||
|
return {"situations": result}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/videos/upload")
|
@app.post("/api/videos/{situation}/toggle")
|
||||||
async def api_upload_video(video: UploadFile = File(...), situation: str = ""):
|
async def api_toggle_situation(situation: str, req: Request):
|
||||||
if not situation:
|
body = await req.json()
|
||||||
return JSONResponse({"erreur": "situation requise"}, 400)
|
cfg = charger_videos_config()
|
||||||
ext = Path(video.filename).suffix.lower() or '.mp4'
|
if situation not in cfg:
|
||||||
dest = DOSSIER_VIDEOS / f"{situation}{ext}"
|
cfg[situation] = {}
|
||||||
for old in DOSSIER_VIDEOS.glob(f"{situation}.*"):
|
cfg[situation]["actif"] = bool(body.get("actif", False))
|
||||||
old.unlink()
|
sauvegarder_videos_config(cfg)
|
||||||
with open(dest, "wb") as f:
|
|
||||||
while chunk := await video.read(1024 * 1024):
|
|
||||||
f.write(chunk)
|
|
||||||
return {"ok": True, "fichier": dest.name}
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/videos/{situation}")
|
|
||||||
async def api_delete_video(situation: str):
|
|
||||||
for f in DOSSIER_VIDEOS.glob(f"{situation}.*"):
|
|
||||||
f.unlink()
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/videos/{situation}/stream")
|
@app.post("/api/videos/{situation}/upload")
|
||||||
async def api_stream_video(situation: str):
|
async def api_upload_etape(situation: str, video: UploadFile = File(...)):
|
||||||
for f in DOSSIER_VIDEOS.glob(f"{situation}.*"):
|
dossier = DOSSIER_VIDEOS / situation
|
||||||
|
dossier.mkdir(parents=True, exist_ok=True)
|
||||||
|
existantes = sorted(dossier.glob("etape_*"))
|
||||||
|
prochain = len(existantes)
|
||||||
|
ext = Path(video.filename).suffix.lower() or '.mp4'
|
||||||
|
dest = dossier / f"etape_{prochain:02d}{ext}"
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
while chunk := await video.read(1024 * 1024):
|
||||||
|
f.write(chunk)
|
||||||
|
return {"ok": True, "fichier": dest.name, "index": prochain}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/videos/{situation}/{index}")
|
||||||
|
async def api_delete_etape(situation: str, index: int):
|
||||||
|
dossier = DOSSIER_VIDEOS / situation
|
||||||
|
if not dossier.is_dir():
|
||||||
|
return JSONResponse({"erreur": "situation introuvable"}, 404)
|
||||||
|
fichiers = sorted(
|
||||||
|
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
|
||||||
|
key=lambda f: f.name
|
||||||
|
)
|
||||||
|
if index < 0 or index >= len(fichiers):
|
||||||
|
return JSONResponse({"erreur": "index invalide"}, 400)
|
||||||
|
fichiers[index].unlink()
|
||||||
|
for i, f in enumerate(sorted(
|
||||||
|
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
|
||||||
|
key=lambda f: f.name
|
||||||
|
)):
|
||||||
|
nouveau = dossier / f"etape_{i:02d}{f.suffix}"
|
||||||
|
if f != nouveau:
|
||||||
|
f.rename(nouveau)
|
||||||
|
cfg = charger_videos_config()
|
||||||
|
sit_cfg = cfg.get(situation, {})
|
||||||
|
old_labels = sit_cfg.get("labels_etapes", {})
|
||||||
|
new_labels = {}
|
||||||
|
for k, v in old_labels.items():
|
||||||
|
ki = int(k)
|
||||||
|
if ki < index:
|
||||||
|
new_labels[str(ki)] = v
|
||||||
|
elif ki > index:
|
||||||
|
new_labels[str(ki - 1)] = v
|
||||||
|
if old_labels != new_labels:
|
||||||
|
sit_cfg["labels_etapes"] = new_labels
|
||||||
|
cfg[situation] = sit_cfg
|
||||||
|
sauvegarder_videos_config(cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/videos/{situation}/reorder")
|
||||||
|
async def api_reorder_etapes(situation: str, req: Request):
|
||||||
|
body = await req.json()
|
||||||
|
old_idx = body.get("de")
|
||||||
|
new_idx = body.get("vers")
|
||||||
|
dossier = DOSSIER_VIDEOS / situation
|
||||||
|
if not dossier.is_dir():
|
||||||
|
return JSONResponse({"erreur": "situation introuvable"}, 404)
|
||||||
|
fichiers = sorted(
|
||||||
|
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
|
||||||
|
key=lambda f: f.name
|
||||||
|
)
|
||||||
|
if old_idx is None or new_idx is None or old_idx < 0 or old_idx >= len(fichiers):
|
||||||
|
return JSONResponse({"erreur": "index invalide"}, 400)
|
||||||
|
moved = fichiers.pop(old_idx)
|
||||||
|
fichiers.insert(new_idx, moved)
|
||||||
|
tmp_names = []
|
||||||
|
for i, f in enumerate(fichiers):
|
||||||
|
tmp = dossier / f"_tmp_{i:02d}{f.suffix}"
|
||||||
|
f.rename(tmp)
|
||||||
|
tmp_names.append(tmp)
|
||||||
|
for i, tmp in enumerate(tmp_names):
|
||||||
|
ext = tmp.suffix
|
||||||
|
final = dossier / f"etape_{i:02d}{ext}"
|
||||||
|
tmp.rename(final)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/videos/{situation}/label")
|
||||||
|
async def api_label_etape(situation: str, req: Request):
|
||||||
|
body = await req.json()
|
||||||
|
index = body.get("index")
|
||||||
|
label = body.get("label", "")
|
||||||
|
cfg = charger_videos_config()
|
||||||
|
if situation not in cfg:
|
||||||
|
cfg[situation] = {}
|
||||||
|
labels = cfg[situation].setdefault("labels_etapes", {})
|
||||||
|
labels[str(index)] = label
|
||||||
|
sauvegarder_videos_config(cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/videos/{situation}/{index}/stream")
|
||||||
|
async def api_stream_etape(situation: str, index: int):
|
||||||
|
dossier = DOSSIER_VIDEOS / situation
|
||||||
|
if not dossier.is_dir():
|
||||||
|
return JSONResponse({"erreur": "situation introuvable"}, 404)
|
||||||
|
fichiers = sorted(
|
||||||
|
[f for f in dossier.iterdir() if f.is_file() and f.name.startswith("etape_")],
|
||||||
|
key=lambda f: f.name
|
||||||
|
)
|
||||||
|
if index < 0 or index >= len(fichiers):
|
||||||
|
return JSONResponse({"erreur": "index invalide"}, 404)
|
||||||
|
f = fichiers[index]
|
||||||
media = "video/mp4" if f.suffix == ".mp4" else "video/webm"
|
media = "video/mp4" if f.suffix == ".mp4" else "video/webm"
|
||||||
return FileResponse(f, media_type=media)
|
return FileResponse(f, media_type=media)
|
||||||
return JSONResponse({"erreur": "video introuvable"}, 404)
|
|
||||||
|
|
||||||
|
@app.post("/api/videos/situation/creer")
|
||||||
|
async def api_creer_situation(req: Request):
|
||||||
|
body = await req.json()
|
||||||
|
sid = body.get("id", "").strip().lower().replace(" ", "_")
|
||||||
|
label = body.get("label", sid)
|
||||||
|
if not sid:
|
||||||
|
return JSONResponse({"erreur": "id requis"}, 400)
|
||||||
|
cfg = charger_videos_config()
|
||||||
|
if sid not in cfg:
|
||||||
|
cfg[sid] = {"label": label, "actif": False, "labels_etapes": {}}
|
||||||
|
sauvegarder_videos_config(cfg)
|
||||||
|
dossier = DOSSIER_VIDEOS / sid
|
||||||
|
dossier.mkdir(parents=True, exist_ok=True)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# --- API Surprise ---
|
# --- API Surprise ---
|
||||||
|
|||||||
@@ -908,40 +908,21 @@
|
|||||||
Appuyer sur Tester...
|
Appuyer sur Tester...
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-panneau" id="panneau-videos-admin">
|
<div class="admin-panneau" id="panneau-videos-admin">
|
||||||
<h3>Videos de situation</h3>
|
<h3>Videos didactiques</h3>
|
||||||
<p class="aide">Uploadez des videos pour chaque situation. Elles seront jouees automatiquement sur l'ecran du booth.</p>
|
<p class="aide">Chaque situation peut contenir plusieurs etapes video. L'operateur verra les videos en sequence : chaque etape boucle jusqu'a validation avant de passer a la suivante.</p>
|
||||||
<div class="video-situations" id="video-situations-liste">
|
<div id="video-situations-liste"></div>
|
||||||
<div class="video-situation-bloc" data-situation="montage">
|
<div style="margin-top:1rem;padding:0.75rem;background:#1a1a2e;border-radius:8px;border:1px dashed #444">
|
||||||
<h4>Montage / Installation</h4>
|
<h4 style="margin:0 0 0.5rem">Ajouter une situation</h4>
|
||||||
<div class="video-situation-current" id="video-sit-montage">Aucune video</div>
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">
|
||||||
<input type="file" id="input-video-montage" accept=".mp4,.webm,.mov" class="input-fichier">
|
<input type="text" id="nouvelle-situation-label" placeholder="Nom de la situation" style="flex:1;min-width:150px;padding:0.4rem 0.6rem;border-radius:6px;border:1px solid #555;background:#111;color:#fff">
|
||||||
<button class="btn-secondaire btn-petit" onclick="uploaderVideoSituation('montage')">Importer</button>
|
<button class="btn-action btn-petit" onclick="creerSituation()">+ Ajouter</button>
|
||||||
<button class="btn-danger btn-petit" onclick="supprimerVideoSituation('montage')">Supprimer</button>
|
|
||||||
</div>
|
|
||||||
<div class="video-situation-bloc" data-situation="depannage_imprimante">
|
|
||||||
<h4>Depannage imprimante</h4>
|
|
||||||
<div class="video-situation-current" id="video-sit-depannage_imprimante">Aucune video</div>
|
|
||||||
<input type="file" id="input-video-depannage_imprimante" accept=".mp4,.webm,.mov" class="input-fichier">
|
|
||||||
<button class="btn-secondaire btn-petit" onclick="uploaderVideoSituation('depannage_imprimante')">Importer</button>
|
|
||||||
<button class="btn-danger btn-petit" onclick="supprimerVideoSituation('depannage_imprimante')">Supprimer</button>
|
|
||||||
</div>
|
|
||||||
<div class="video-situation-bloc" data-situation="bourrage">
|
|
||||||
<h4>Bourrage papier</h4>
|
|
||||||
<div class="video-situation-current" id="video-sit-bourrage">Aucune video</div>
|
|
||||||
<input type="file" id="input-video-bourrage" accept=".mp4,.webm,.mov" class="input-fichier">
|
|
||||||
<button class="btn-secondaire btn-petit" onclick="uploaderVideoSituation('bourrage')">Importer</button>
|
|
||||||
<button class="btn-danger btn-petit" onclick="supprimerVideoSituation('bourrage')">Supprimer</button>
|
|
||||||
</div>
|
|
||||||
<div class="video-situation-bloc" data-situation="rechargement">
|
|
||||||
<h4>Rechargement papier/ruban</h4>
|
|
||||||
<div class="video-situation-current" id="video-sit-rechargement">Aucune video</div>
|
|
||||||
<input type="file" id="input-video-rechargement" accept=".mp4,.webm,.mov" class="input-fichier">
|
|
||||||
<button class="btn-secondaire btn-petit" onclick="uploaderVideoSituation('rechargement')">Importer</button>
|
|
||||||
<button class="btn-danger btn-petit" onclick="supprimerVideoSituation('rechargement')">Supprimer</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h4 style="margin-top:1.5rem">Apercu</h4>
|
<h4 style="margin-top:1.5rem">Apercu</h4>
|
||||||
<video id="video-situation-preview" style="width:100%;max-height:300px;border-radius:8px;background:#111" controls></video>
|
<video id="video-situation-preview" style="width:100%;max-height:300px;border-radius:8px;background:#111" controls></video>
|
||||||
|
<div style="margin-top:1rem">
|
||||||
|
<button class="btn-action" onclick="lancerWizardDepuisAdmin()">▶ Tester le wizard sur le booth</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-panneau" id="panneau-surprise-admin">
|
<div class="admin-panneau" id="panneau-surprise-admin">
|
||||||
@@ -981,6 +962,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Wizard video didactique plein ecran -->
|
||||||
|
<div id="wizard-video" class="popup-overlay cache" style="z-index:9999;background:rgba(0,0,0,0.97);display:flex;flex-direction:column;align-items:center;justify-content:center">
|
||||||
|
<div id="wizard-titre" style="color:#fff;font-size:1.8rem;font-weight:700;margin-bottom:0.3rem;text-align:center"></div>
|
||||||
|
<div id="wizard-etape-info" style="color:#aaa;font-size:1rem;margin-bottom:1rem"></div>
|
||||||
|
<video id="wizard-video-player" muted playsinline loop style="width:90%;max-height:65vh;border-radius:12px;background:#000"></video>
|
||||||
|
<div id="wizard-etape-label" style="color:#ccc;font-size:1.1rem;margin-top:0.8rem;text-align:center;min-height:1.5rem"></div>
|
||||||
|
<button id="wizard-btn-ok" onclick="wizardEtapeSuivante()" style="margin-top:1.5rem;padding:1.2rem 3rem;font-size:1.5rem;font-weight:700;border:none;border-radius:16px;background:#4caf50;color:#fff;cursor:pointer;min-width:280px">OK, c'est bon !</button>
|
||||||
|
<button onclick="fermerWizard()" style="margin-top:0.8rem;padding:0.6rem 1.5rem;font-size:0.9rem;border:none;border-radius:8px;background:#333;color:#aaa;cursor:pointer">Quitter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Popup sélection cadre impression -->
|
<!-- Popup sélection cadre impression -->
|
||||||
<div id="popup-cadre-impression" class="popup-overlay cache">
|
<div id="popup-cadre-impression" class="popup-overlay cache">
|
||||||
<div class="popup-box popup-box-cadre">
|
<div class="popup-box popup-box-cadre">
|
||||||
@@ -1066,6 +1057,6 @@
|
|||||||
<script src="/js/effects.js?v=4"></script>
|
<script src="/js/effects.js?v=4"></script>
|
||||||
<script src="/js/gallery.js?v=4"></script>
|
<script src="/js/gallery.js?v=4"></script>
|
||||||
<script src="/js/share.js?v=8"></script>
|
<script src="/js/share.js?v=8"></script>
|
||||||
<script src="/js/admin.js?v=11"></script>
|
<script src="/js/admin.js?v=12"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1487,63 +1487,273 @@ async function sauvegarderCapacites() {
|
|||||||
afficherStatut('Capacites sauvegardees', 'succes');
|
afficherStatut('Capacites sauvegardees', 'succes');
|
||||||
}
|
}
|
||||||
|
|
||||||
// === VIDEOS DE SITUATION ===
|
// === VIDEOS DIDACTIQUES (multi-etapes) ===
|
||||||
|
|
||||||
|
let _videosData = {};
|
||||||
|
|
||||||
async function chargerVideosAdmin() {
|
async function chargerVideosAdmin() {
|
||||||
try {
|
try {
|
||||||
const data = await apiGet('/api/videos');
|
const data = await apiGet('/api/videos');
|
||||||
const situations = ['montage', 'depannage_imprimante', 'bourrage', 'rechargement'];
|
_videosData = data.situations || {};
|
||||||
for (const sit of situations) {
|
const container = document.getElementById('video-situations-liste');
|
||||||
const el = document.getElementById('video-sit-' + sit);
|
if (!container) return;
|
||||||
if (!el) continue;
|
container.innerHTML = '';
|
||||||
const v = data.videos && data.videos[sit];
|
for (const [sid, sit] of Object.entries(_videosData)) {
|
||||||
if (v) {
|
container.appendChild(_creerBlocSituation(sid, sit));
|
||||||
el.innerHTML = `<a href="#" onclick="previewVideoSituation('${sit}');return false">${_escHtml(v)}</a>`;
|
|
||||||
} else {
|
|
||||||
el.textContent = 'Aucune video';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Erreur chargement videos:', e);
|
console.warn('Erreur chargement videos:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploaderVideoSituation(situation) {
|
function _creerBlocSituation(sid, sit) {
|
||||||
const input = document.getElementById('input-video-' + situation);
|
const bloc = document.createElement('div');
|
||||||
|
bloc.className = 'video-situation-bloc';
|
||||||
|
bloc.style.cssText = 'margin-bottom:1rem;padding:0.75rem;background:#1a1a2e;border-radius:8px;border:1px solid #333';
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.style.cssText = 'display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;flex-wrap:wrap';
|
||||||
|
const toggle = document.createElement('label');
|
||||||
|
toggle.className = 'toggle';
|
||||||
|
toggle.style.cssText = 'margin:0;flex-shrink:0';
|
||||||
|
const cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.checked = !!sit.actif;
|
||||||
|
cb.onchange = () => toggleSituation(sid, cb.checked);
|
||||||
|
const slider = document.createElement('span');
|
||||||
|
slider.className = 'toggle-slider';
|
||||||
|
toggle.appendChild(cb);
|
||||||
|
toggle.appendChild(slider);
|
||||||
|
const titre = document.createElement('h4');
|
||||||
|
titre.style.cssText = 'margin:0;flex:1;font-size:0.95rem';
|
||||||
|
titre.textContent = sit.label || sid;
|
||||||
|
const nbEtapes = document.createElement('span');
|
||||||
|
nbEtapes.style.cssText = 'color:#888;font-size:0.8rem';
|
||||||
|
nbEtapes.textContent = `${(sit.etapes || []).length} etape(s)`;
|
||||||
|
header.appendChild(toggle);
|
||||||
|
header.appendChild(titre);
|
||||||
|
header.appendChild(nbEtapes);
|
||||||
|
bloc.appendChild(header);
|
||||||
|
|
||||||
|
const etapesDiv = document.createElement('div');
|
||||||
|
etapesDiv.style.cssText = 'margin-left:0.5rem';
|
||||||
|
(sit.etapes || []).forEach((etape, i) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.cssText = 'display:flex;align-items:center;gap:0.5rem;margin-bottom:0.4rem;padding:0.3rem 0.5rem;background:#111;border-radius:6px';
|
||||||
|
const num = document.createElement('span');
|
||||||
|
num.style.cssText = 'color:#888;font-size:0.8rem;min-width:1.5rem';
|
||||||
|
num.textContent = `${i + 1}.`;
|
||||||
|
const labelEtape = document.createElement('input');
|
||||||
|
labelEtape.type = 'text';
|
||||||
|
labelEtape.placeholder = `Etape ${i + 1}`;
|
||||||
|
labelEtape.value = (sit.labels_etapes && sit.labels_etapes[String(i)]) || '';
|
||||||
|
labelEtape.style.cssText = 'flex:1;padding:0.2rem 0.4rem;border-radius:4px;border:1px solid #444;background:#1a1a2e;color:#fff;font-size:0.8rem';
|
||||||
|
labelEtape.onchange = () => labeliserEtape(sid, i, labelEtape.value);
|
||||||
|
const btnPlay = document.createElement('button');
|
||||||
|
btnPlay.className = 'btn-secondaire btn-petit';
|
||||||
|
btnPlay.textContent = '▶';
|
||||||
|
btnPlay.title = 'Apercu';
|
||||||
|
btnPlay.onclick = () => previewEtape(sid, i);
|
||||||
|
const btnUp = document.createElement('button');
|
||||||
|
btnUp.className = 'btn-secondaire btn-petit';
|
||||||
|
btnUp.textContent = '↑';
|
||||||
|
btnUp.disabled = i === 0;
|
||||||
|
btnUp.onclick = () => reordonnerEtape(sid, i, i - 1);
|
||||||
|
const btnDown = document.createElement('button');
|
||||||
|
btnDown.className = 'btn-secondaire btn-petit';
|
||||||
|
btnDown.textContent = '↓';
|
||||||
|
btnDown.disabled = i === (sit.etapes || []).length - 1;
|
||||||
|
btnDown.onclick = () => reordonnerEtape(sid, i, i + 1);
|
||||||
|
const btnDel = document.createElement('button');
|
||||||
|
btnDel.className = 'btn-danger btn-petit';
|
||||||
|
btnDel.textContent = '✕';
|
||||||
|
btnDel.onclick = () => supprimerEtape(sid, i);
|
||||||
|
row.appendChild(num);
|
||||||
|
row.appendChild(labelEtape);
|
||||||
|
row.appendChild(btnPlay);
|
||||||
|
row.appendChild(btnUp);
|
||||||
|
row.appendChild(btnDown);
|
||||||
|
row.appendChild(btnDel);
|
||||||
|
etapesDiv.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ajoutRow = document.createElement('div');
|
||||||
|
ajoutRow.style.cssText = 'display:flex;align-items:center;gap:0.5rem;margin-top:0.4rem';
|
||||||
|
const inputFile = document.createElement('input');
|
||||||
|
inputFile.type = 'file';
|
||||||
|
inputFile.accept = '.mp4,.webm,.mov';
|
||||||
|
inputFile.className = 'input-fichier';
|
||||||
|
inputFile.id = 'input-etape-' + sid;
|
||||||
|
inputFile.style.cssText = 'flex:1;font-size:0.75rem';
|
||||||
|
const btnAjout = document.createElement('button');
|
||||||
|
btnAjout.className = 'btn-secondaire btn-petit';
|
||||||
|
btnAjout.textContent = '+ Etape';
|
||||||
|
btnAjout.onclick = () => uploaderEtape(sid);
|
||||||
|
ajoutRow.appendChild(inputFile);
|
||||||
|
ajoutRow.appendChild(btnAjout);
|
||||||
|
etapesDiv.appendChild(ajoutRow);
|
||||||
|
|
||||||
|
bloc.appendChild(etapesDiv);
|
||||||
|
return bloc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSituation(sid, actif) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/videos/${sid}/toggle`, {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ actif })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
afficherStatut('Erreur toggle', 'erreur');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploaderEtape(sid) {
|
||||||
|
const input = document.getElementById('input-etape-' + sid);
|
||||||
if (!input || !input.files.length) return;
|
if (!input || !input.files.length) return;
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('video', input.files[0]);
|
fd.append('video', input.files[0]);
|
||||||
fd.append('situation', situation);
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/videos/upload', { method: 'POST', body: fd });
|
const resp = await fetch(`/api/videos/${sid}/upload`, { method: 'POST', body: fd });
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
chargerVideosAdmin();
|
chargerVideosAdmin();
|
||||||
afficherStatut('Video importee', 'succes');
|
afficherStatut('Etape ajoutee', 'succes');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
afficherStatut('Erreur upload video', 'erreur');
|
afficherStatut('Erreur upload etape', 'erreur');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function supprimerVideoSituation(situation) {
|
async function supprimerEtape(sid, index) {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/videos/' + situation, { method: 'DELETE' });
|
await fetch(`/api/videos/${sid}/${index}`, { method: 'DELETE' });
|
||||||
chargerVideosAdmin();
|
chargerVideosAdmin();
|
||||||
afficherStatut('Video supprimee', 'succes');
|
afficherStatut('Etape supprimee', 'succes');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
afficherStatut('Erreur suppression video', 'erreur');
|
afficherStatut('Erreur suppression', 'erreur');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewVideoSituation(situation) {
|
async function reordonnerEtape(sid, de, vers) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/videos/${sid}/reorder`, {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ de, vers })
|
||||||
|
});
|
||||||
|
chargerVideosAdmin();
|
||||||
|
} catch (e) {
|
||||||
|
afficherStatut('Erreur reorder', 'erreur');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function labeliserEtape(sid, index, label) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/videos/${sid}/label`, {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ index, label })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Erreur label etape:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewEtape(sid, index) {
|
||||||
const preview = document.getElementById('video-situation-preview');
|
const preview = document.getElementById('video-situation-preview');
|
||||||
if (preview) {
|
if (preview) {
|
||||||
preview.src = '/api/videos/' + situation + '/stream';
|
preview.src = `/api/videos/${sid}/${index}/stream`;
|
||||||
preview.load();
|
preview.load();
|
||||||
|
preview.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function creerSituation() {
|
||||||
|
const input = document.getElementById('nouvelle-situation-label');
|
||||||
|
const label = (input && input.value || '').trim();
|
||||||
|
if (!label) return;
|
||||||
|
const id = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||||
|
try {
|
||||||
|
await fetch('/api/videos/situation/creer', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ id, label })
|
||||||
|
});
|
||||||
|
input.value = '';
|
||||||
|
chargerVideosAdmin();
|
||||||
|
afficherStatut('Situation ajoutee', 'succes');
|
||||||
|
} catch (e) {
|
||||||
|
afficherStatut('Erreur creation situation', 'erreur');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === WIZARD VIDEO DIDACTIQUE ===
|
||||||
|
|
||||||
|
let _wizardSituation = null;
|
||||||
|
let _wizardEtapes = [];
|
||||||
|
let _wizardIndex = 0;
|
||||||
|
|
||||||
|
async function lancerWizard(situationId) {
|
||||||
|
const data = await apiGet('/api/videos');
|
||||||
|
const sit = (data.situations || {})[situationId];
|
||||||
|
if (!sit || !sit.etapes || sit.etapes.length === 0) {
|
||||||
|
afficherStatut('Aucune etape pour cette situation', 'erreur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_wizardSituation = { id: situationId, ...sit };
|
||||||
|
_wizardEtapes = sit.etapes;
|
||||||
|
_wizardIndex = 0;
|
||||||
|
_afficherEtapeWizard();
|
||||||
|
document.getElementById('wizard-video').classList.remove('cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _afficherEtapeWizard() {
|
||||||
|
const titre = document.getElementById('wizard-titre');
|
||||||
|
const info = document.getElementById('wizard-etape-info');
|
||||||
|
const video = document.getElementById('wizard-video-player');
|
||||||
|
const label = document.getElementById('wizard-etape-label');
|
||||||
|
const btn = document.getElementById('wizard-btn-ok');
|
||||||
|
titre.textContent = _wizardSituation.label || _wizardSituation.id;
|
||||||
|
info.textContent = `Etape ${_wizardIndex + 1} / ${_wizardEtapes.length}`;
|
||||||
|
const etapeLabel = (_wizardSituation.labels_etapes || {})[String(_wizardIndex)] || '';
|
||||||
|
label.textContent = etapeLabel;
|
||||||
|
video.src = `/api/videos/${_wizardSituation.id}/${_wizardIndex}/stream`;
|
||||||
|
video.load();
|
||||||
|
video.play();
|
||||||
|
if (_wizardIndex === _wizardEtapes.length - 1) {
|
||||||
|
btn.textContent = 'Terminer ✓';
|
||||||
|
btn.style.background = '#2196f3';
|
||||||
|
} else {
|
||||||
|
btn.textContent = "OK, c'est bon !";
|
||||||
|
btn.style.background = '#4caf50';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wizardEtapeSuivante() {
|
||||||
|
_wizardIndex++;
|
||||||
|
if (_wizardIndex >= _wizardEtapes.length) {
|
||||||
|
fermerWizard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_afficherEtapeWizard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fermerWizard() {
|
||||||
|
const overlay = document.getElementById('wizard-video');
|
||||||
|
const video = document.getElementById('wizard-video-player');
|
||||||
|
if (video) { video.pause(); video.src = ''; }
|
||||||
|
overlay.classList.add('cache');
|
||||||
|
_wizardSituation = null;
|
||||||
|
_wizardEtapes = [];
|
||||||
|
_wizardIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lancerWizardDepuisAdmin() {
|
||||||
|
const actives = Object.entries(_videosData).filter(([_, s]) => s.actif && s.etapes && s.etapes.length > 0);
|
||||||
|
if (actives.length === 0) {
|
||||||
|
afficherStatut('Aucune situation active avec des etapes', 'erreur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lancerWizard(actives[0][0]);
|
||||||
|
}
|
||||||
|
|
||||||
// === SURPRISE (photo/video avant capture) ===
|
// === SURPRISE (photo/video avant capture) ===
|
||||||
|
|
||||||
async function chargerSurpriseConfig() {
|
async function chargerSurpriseConfig() {
|
||||||
|
|||||||
109
memoire.md
109
memoire.md
@@ -99,10 +99,9 @@ Utilisateurs finaux = novices (événementiel). Erreurs imprimante : prévoir gu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## URGENT — VPN booth en panne (2026-07-04)
|
## VPN booth (2026-07-04)
|
||||||
|
|
||||||
Booth sur WiFi externe, tunnel WG ne connecte pas même après reboot. Dernier handshake : 6+ jours.
|
Tunnel WG a eu une panne le 04/07. Cause probable : wg-quick@wg0 pas enabled au boot, ou Endpoint IP locale.
|
||||||
Cause probable : wg-quick@wg0 pas enabled au boot, ou Endpoint pointe vers IP locale.
|
|
||||||
|
|
||||||
### Procédure fix (clavier USB temporaire)
|
### Procédure fix (clavier USB temporaire)
|
||||||
|
|
||||||
@@ -111,10 +110,6 @@ Cause probable : wg-quick@wg0 pas enabled au boot, ou Endpoint pointe vers IP lo
|
|||||||
3. `sudo systemctl enable wg-quick@wg0 && sudo systemctl restart wg-quick@wg0`
|
3. `sudo systemctl enable wg-quick@wg0 && sudo systemctl restart wg-quick@wg0`
|
||||||
4. Installer watchdog + reverse SSH — voir PROCEDURE_VPN_FIX.md
|
4. Installer watchdog + reverse SSH — voir PROCEDURE_VPN_FIX.md
|
||||||
|
|
||||||
### Commits non déployés sur Surface
|
|
||||||
- Flash blanc capture + printer toast CSS + admin galerie + compteur copies
|
|
||||||
- Déployer : `cd ~/photobooth && git pull && kill $(pgrep -f 'python -m backend.main')`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## LXC 111 — booth.copydev.fr (état au 2026-07-04)
|
## LXC 111 — booth.copydev.fr (état au 2026-07-04)
|
||||||
@@ -160,6 +155,106 @@ Backup local des fichiers modifiés : `lxc111_backup/` dans ce dossier projet.
|
|||||||
### Compteur photos
|
### Compteur photos
|
||||||
- `distribuer_photo()` accepte paramètre `copies` et incrémente par ce nombre (pas toujours +1)
|
- `distribuer_photo()` accepte paramètre `copies` et incrémente par ce nombre (pas toujours +1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Historique développement (juin-juillet 2026)
|
||||||
|
|
||||||
|
### Kiosk & production (3-4 juin)
|
||||||
|
- Kiosk boucle infinie : backend + Chromium relancés automatiquement, plus d'écran login
|
||||||
|
- Impression Mitsubishi K60 + cadres + interface admin distante
|
||||||
|
- Chromium : désactivation Translate (--lang=fr + prefs FR), meta notranslate
|
||||||
|
- Verrou instance unique kiosk (anti-clignotement)
|
||||||
|
- Intégration station d'impression photo (photostation) — popup accueil
|
||||||
|
- USB automount avec udiskie dans kiosk-session
|
||||||
|
- Détection USB 2 niveaux /media/<user>/<label> + filtre fstype
|
||||||
|
- Cadres : choix avec zoom 2.5x + fond photo simulé
|
||||||
|
|
||||||
|
### Caméra & preview (5-10 juin)
|
||||||
|
- Viewfinder permanent + preview live pendant compte à rebours
|
||||||
|
- Fix preview + miroir : thread continu, grace period, lock gphoto2
|
||||||
|
- Fix caméra : kill gvfsd-gphoto2, race condition viewfinder, exif_transpose
|
||||||
|
- Fix strip K60 coupe auto, diagnostic preview
|
||||||
|
- Caméra déconnectée : retour accueil automatique + abort capture
|
||||||
|
- Bullet-proof caméra : reconnexion auto + fix crash capture + AF LiveView
|
||||||
|
- Capture : viewfinder=0 avant prise (AF phase-détection), timeout 20s, spinner UI
|
||||||
|
- surveiller_dslr : reset USB auto après 3 échecs → retiré (contre-productif)
|
||||||
|
- Fix preview live : position:absolute sur img
|
||||||
|
- Remote debugging Chromium port 9222
|
||||||
|
- Kiosk : git pull auto + diagnostic caméra dans admin
|
||||||
|
|
||||||
|
### Imprimante & impression (4-23 juin)
|
||||||
|
- Statut détaillé imprimante + message erreur CUPS traduit
|
||||||
|
- Bandeau erreur imprimante tous écrans + reset USB
|
||||||
|
- Bouton Couper papier (micro-job blanc déclenche le cutter K60)
|
||||||
|
- Cadres : preview coin haut-gauche + compteur bloque impression pas capture
|
||||||
|
- Pellicule : marge de coupe plus large + warmup LiveView après capture
|
||||||
|
- Fix impression pellicule (portrait vs paysage, taille pleine, rotation)
|
||||||
|
- Cadre de visée live correspondant au recadrage final pellicule/collage
|
||||||
|
- Calibration impression : offsets coupe (mm) + rotation 180° configurable
|
||||||
|
- Fix impression paysage : inverser largeur/hauteur
|
||||||
|
|
||||||
|
### DSLR robustesse (21-28 juin)
|
||||||
|
- Flash Canon configurable dans admin
|
||||||
|
- DSLR anti-veille : autopoweroff Canon + udev no-autosuspend
|
||||||
|
- Keepalive Canon : get_config() toutes les 10s quand preview KO
|
||||||
|
- DSLR auto-recovery : USB reset + cadre overlay non-mirroré
|
||||||
|
- USB rebind Canon : unbind/rebind sysfs pour recovery hard PTP freeze
|
||||||
|
- Escalade recovery : uhubctl power cycle + backoff 60s après 10 échecs
|
||||||
|
- Viewfinder off avant capture pour AF phase-detect
|
||||||
|
- Viewfinder warmup en arrière-plan (accélération capture)
|
||||||
|
|
||||||
|
### Email (22 juin)
|
||||||
|
- Email plein écran avec suggestions d'adresses précédentes
|
||||||
|
- Fermer formulaire avant envoi + raccourcis domaines FR
|
||||||
|
- Email spool : file d'attente quand envoi échoue, retry 60s
|
||||||
|
- Spool envoi au démarrage uniquement, message "dans la semaine"
|
||||||
|
- Rapport email à contact@copydev.fr après vidage du spool
|
||||||
|
|
||||||
|
### Événements & galerie (22-23 juin)
|
||||||
|
- Galerie booth : QR avec auto-login, event_id aléatoire, flyer imprimable
|
||||||
|
- Admin : galerie en ligne dans Destinations + booth envoie toutes les photos
|
||||||
|
- Gestion événements + cadres par event + fix cadre paysage + dossier non-imprimées
|
||||||
|
- Thème Viking Celte : bois sculpté, bronze, entrelacs
|
||||||
|
- Cadre imposé visible en overlay sur live + preview + partage
|
||||||
|
- Formats activables par événement + cadre imposé exclut strips
|
||||||
|
- Lien galerie événement dans admin
|
||||||
|
|
||||||
|
### Résilience & réseau (26-27 juin)
|
||||||
|
- Écran pause, reconnexion WS auto, watchdog systemd, WiFi monitor
|
||||||
|
- Admin WiFi : scan réseaux, connexion, mots de passe mémorisés
|
||||||
|
- Kiosk : flag maintenance + anti-doublon backend
|
||||||
|
- Watchdog systemd + fix stabilité kiosk/backend
|
||||||
|
- QR code par photo : popup QR dynamique + page vue photo sur booth
|
||||||
|
- Fix QR code URL (routes publiques /g/)
|
||||||
|
- Fix QR après photo : corriger share.js qui écrasait afficherQR()
|
||||||
|
- Masquer popup QR à chaque changement d'écran
|
||||||
|
- Supprimer le timeout auto-retour du menu admin
|
||||||
|
|
||||||
|
### Admin galerie & copies (30 juin)
|
||||||
|
- Admin galerie + compteur copies + flash capture + erreur imprimante toast
|
||||||
|
- Bump cache versions (style v12, camera v17, gallery v4)
|
||||||
|
|
||||||
|
### Admin backoffice (9 juillet)
|
||||||
|
- Rubriques Général / Événement dans admin
|
||||||
|
- Vidéos situation (tutoriels visuels)
|
||||||
|
- Mode surprise
|
||||||
|
- Rembobinage ruban (instruction Mitsubishi)
|
||||||
|
|
||||||
|
### Suivi consommables (12 juillet)
|
||||||
|
- Compteur papier, ruban et photos avec diagnostic ratio
|
||||||
|
- Compteur poses perdues + ratio corrigé 0.5 (10 poses par feuille, 20 feuilles par rouleau)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commits non pushés (au 12 juillet 2026)
|
||||||
|
|
||||||
|
3 commits en avance sur origin/main :
|
||||||
|
- `5e482a8` Consommables : poses perdues + ratio 0.5
|
||||||
|
- `3b6f376` Suivi consommables : compteur papier/ruban/photos + diagnostic
|
||||||
|
- `44a9992` Admin backoffice : rubriques + vidéos + surprise + rembobinage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tâches Trello du projet
|
## Tâches Trello du projet
|
||||||
|
|
||||||
- [📋 À faire] faire essaie avec mon dslr et imprimante ricoh et installer sur ecran tactile terra
|
- [📋 À faire] faire essaie avec mon dslr et imprimante ricoh et installer sur ecran tactile terra
|
||||||
|
|||||||
Reference in New Issue
Block a user