From 6aae4ef40c9fda7069e15354b2f323be2883add3 Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 2 Jun 2026 16:37:44 +0200 Subject: [PATCH] =?UTF-8?q?Toutes=20les=20frames,=20clip=20web-compatible,?= =?UTF-8?q?=20lightbox=20plein=20=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Garde toutes les frames (plus de limite TOP_FRAMES), triées score LPR desc - Clip remuxé avec -movflags +faststart pour lecture directe navigateur + lien téléchargement - Lightbox : double-clic sur frame → plein écran, navigation ←/→, Échap pour fermer - Grille 8 colonnes pour voir toutes les frames Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 3 +- app/templates/event_detail.html | 109 ++++++++++++++++++++++++++------ app/watcher.py | 31 +++++---- 3 files changed, 108 insertions(+), 35 deletions(-) diff --git a/app/main.py b/app/main.py index 53ce746..324d1de 100644 --- a/app/main.py +++ b/app/main.py @@ -80,7 +80,6 @@ async def index( CAPTURE_DURATION = int(os.environ.get("CAPTURE_DURATION", "15")) CAPTURE_FPS = int(os.environ.get("CAPTURE_FPS", "5")) -TOP_FRAMES = int(os.environ.get("TOP_FRAMES", "10")) @app.get("/event/{event_id}", response_class=HTMLResponse) @@ -109,8 +108,8 @@ async def event_detail(request: Request, event_id: str): "has_clip": has_clip, "clip_size": clip_size, "capture_duration": CAPTURE_DURATION, + "capture_fps": CAPTURE_FPS, "capture_total": CAPTURE_DURATION * CAPTURE_FPS, - "top_frames": TOP_FRAMES, }) diff --git a/app/templates/event_detail.html b/app/templates/event_detail.html index be86736..a70d82a 100644 --- a/app/templates/event_detail.html +++ b/app/templates/event_detail.html @@ -12,10 +12,21 @@ .plate { font-family: monospace; letter-spacing: 0.15em; } .btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; text-decoration: none; display: inline-block; } .btn-ghost:hover { background: #334155; color: #e2e8f0; } - .frame-thumb { cursor: pointer; border: 2px solid #334155; border-radius: 6px; overflow: hidden; transition: border-color 0.15s; } - .frame-thumb:hover { border-color: #3b82f6; } + .btn-dl { background: #166534; border: 1px solid #16a34a; color: #4ade80; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; text-decoration: none; display: inline-block; } + .btn-dl:hover { background: #14532d; } + .frame-thumb { cursor: zoom-in; border: 2px solid #334155; border-radius: 6px; overflow: hidden; transition: border-color 0.12s; } + .frame-thumb:hover { border-color: #60a5fa; } .frame-thumb.active { border-color: #3b82f6; } label { color: #64748b; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; } + + /* Lightbox */ + #lightbox { display:none; position:fixed; inset:0; z-index:50; background:rgba(0,0,0,0.92); align-items:center; justify-content:center; } + #lightbox.open { display:flex; } + #lightbox img { max-width:95vw; max-height:92vh; object-fit:contain; border-radius:4px; } + #lb-nav { position:absolute; bottom:16px; left:50%; transform:translateX(-50%); display:flex; gap:12px; align-items:center; } + #lb-close { position:absolute; top:12px; right:16px; font-size:1.8rem; color:#94a3b8; cursor:pointer; line-height:1; } + #lb-close:hover { color:#fff; } + #lb-label { color:#94a3b8; font-size:0.85rem; } @@ -28,10 +39,9 @@
- +
-
@@ -45,7 +55,7 @@
- +
{{ ev.color_name or '—' }} @@ -53,35 +63,43 @@
-
-
+
id: {{ ev.id[:8] }}…
-
frames: {{ frames|length }} / {{ capture_total }}
+
frames: {{ frames|length }} (~{{ capture_total }} capturées)
clip: {% if ev.clip_path %}✓ {{ clip_size }}{% else %}—{% endif %}
-
snapshot: {% if ev.snapshot_path %}✓{% else %}—{% endif %}
+ + {% if has_clip %} + ⬇ Télécharger le clip + {% endif %}
-
-
+
+ 🔍 cliquer pour agrandir
-
Meilleure frame (★)
+
Meilleure frame ★
{% if has_clip %}
- +
+ + ⬇ Télécharger +
{% else %} @@ -91,38 +109,91 @@
{% endif %} - + {% if frames %}
- -
+ +
{% for frame_path in frames %}
+ onclick="selectFrame(this, '/{{ frame_path }}', 'Frame #{{ loop.index }}{% if loop.first %} ★{% endif %}')" + ondblclick="openLightbox('/{{ frame_path }}', 'Frame #{{ loop.index }}')"> -
+
#{{ loop.index }}{% if loop.first %} ★{% endif %}
{% endfor %}
+

Clic simple → aperçu | Double-clic → plein écran

{% else %}
- Événement antérieur à la fonctionnalité frames (pas de dossier /data/events/{{ ev.id[:8] }}…) + Événement antérieur à la fonctionnalité frames
{% endif %}
+ + + diff --git a/app/watcher.py b/app/watcher.py index 91e607c..e3f4720 100644 --- a/app/watcher.py +++ b/app/watcher.py @@ -26,7 +26,6 @@ EVENTS_DIR = os.environ.get("EVENTS_DIR", "/data/events") POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "2")) CAPTURE_DURATION = int(os.environ.get("CAPTURE_DURATION", "15")) CAPTURE_FPS = int(os.environ.get("CAPTURE_FPS", "5")) -TOP_FRAMES = int(os.environ.get("TOP_FRAMES", "10")) COOLDOWN = int(os.environ.get("COOLDOWN", "60")) _token: str | None = None @@ -133,12 +132,21 @@ def _process_event(): shutil.rmtree(event_dir, ignore_errors=True) return - # Step 2: extract frames + # Step 2: extract all frames subprocess.run([ "ffmpeg", "-y", "-i", clip_path, "-vf", f"fps={CAPTURE_FPS}", "-q:v", "2", frames_pattern, ], capture_output=True, timeout=30) + # Step 2b: remux clip for browser compatibility (moov atom at start) + web_clip = os.path.join(event_dir, "clip_web.mp4") + subprocess.run([ + "ffmpeg", "-y", "-i", clip_path, + "-c", "copy", "-movflags", "+faststart", web_clip, + ], capture_output=True, timeout=20) + if os.path.exists(web_clip): + os.replace(web_clip, clip_path) + frame_files = sorted(glob.glob(os.path.join(event_dir, "frame_*.jpg"))) log.info(f"Extracted {len(frame_files)} frames") @@ -146,7 +154,7 @@ def _process_event(): shutil.rmtree(event_dir, ignore_errors=True) return - # Step 3: score and select top frames + # Step 3: score ALL frames, rename sorted (best = frame_0001) scored: list[tuple[float, np.ndarray, str]] = [] for path in frame_files: frame = cv2.imread(path) @@ -158,18 +166,13 @@ def _process_event(): scored.sort(key=lambda x: -x[0]) - # Delete frames below TOP_FRAMES - for _, _, path in scored[TOP_FRAMES:]: - os.remove(path) + # Rename to sorted order (temp names to avoid collisions) + for i, (_, _, old_path) in enumerate(scored): + os.rename(old_path, old_path + ".tmp") + for i, (_, _, old_path) in enumerate(scored): + os.rename(old_path + ".tmp", os.path.join(event_dir, f"frame_{i+1:04d}.jpg")) - # Rename kept frames to sorted order (best first = frame_0001) - kept = scored[:TOP_FRAMES] - for i, (_, _, old_path) in enumerate(kept): - new_path = os.path.join(event_dir, f"frame_{i+1:04d}.jpg") - if old_path != new_path: - os.rename(old_path, new_path) - - best_frame = kept[0][1] if kept else None + best_frame = scored[0][1] if scored else None if best_frame is None: shutil.rmtree(event_dir, ignore_errors=True) return