From 28bcb6232f337f4d7020dcc7959310aac1af860a Mon Sep 17 00:00:00 2001 From: Romulus21 Date: Tue, 2 Jun 2026 22:54:55 +0200 Subject: [PATCH] add services --- .gitignore | 1 + CLAUDE.md | 66 +++++++++++++++++++++++++++++++++ camera/main.py | 39 +++++++++---------- camera/static/qrcode.png | Bin 0 -> 6890 bytes camera/static/script.js | 12 +++++- camera/static/style.css | 47 +++++++++++++++++++---- camera/templates/index.html | 14 +++---- services/booth-queue.service | 9 +++++ services/booth-watcher.service | 9 +++++ 9 files changed, 160 insertions(+), 37 deletions(-) create mode 100644 CLAUDE.md create mode 100644 camera/static/qrcode.png create mode 100644 services/booth-queue.service create mode 100644 services/booth-watcher.service diff --git a/.gitignore b/.gitignore index 64640cc..234a639 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /camera/photos +.env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d4954d9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +A two-component photo booth system running on Raspberry Pi: + +1. **`camera/`** — Python/Flask camera server using `picamera2` (Raspberry Pi HQ Camera M12). Streams live MJPEG video and exposes a `/capture` endpoint that saves JPEG snapshots to `camera/photos/`. +2. **`ImmichUploader/`** — Laravel 13 PHP application that watches `camera/photos/` via `inotify`, and uploads new photos to a self-hosted [Immich](https://immich.app) instance via its HTTP API, then adds each asset to a specific album. + +## Running the camera server + +```bash +cd camera +python main.py +``` + +The server listens on port 5000 on all interfaces. Endpoints: +- `GET /` — live MJPEG stream viewer +- `GET /video_feed` — raw MJPEG stream +- `GET /capture` — saves current frame to `camera/photos/` and returns JSON + +## Running the ImmichUploader + +```bash +cd ImmichUploader +composer run setup # first-time: installs deps, generates key, runs migrations +composer run dev # starts all services: web server + queue worker + log viewer + vite +``` + +Or run the folder watcher and queue worker individually: +```bash +php artisan immich:watch-folder # watches FOLDER_PATH via inotify, dispatches jobs +php artisan queue:listen --tries=1 --timeout=0 # processes SendToImmichJob +``` + +Run tests: +```bash +composer run test +``` + +## Environment variables + +**`ImmichUploader/.env`** (not the root `.env`): +- `IMMICH_URL` — base URL of the Immich instance (e.g. `https://photos.example.com`) +- `IMMICH_KEY` — Immich API key +- `FOLDER_PATH` — absolute path to the photos directory (matches `camera/photos/`) + +The root `.env` is gitignored and only excluded `camera/photos` from tracking. + +## Architecture notes + +**Camera → Uploader flow:** +1. `camera/main.py` writes a JPEG to `camera/photos/.jpg` on `/capture` +2. `WatchFolder` artisan command (`inotify_add_watch` on `IN_CLOSE_WRITE | IN_MOVED_TO`) detects the new file +3. Dispatches `SendToImmichJob` to the database queue +4. The queue worker runs `SendToImmichJob::handle()` which POSTs the image to `/api/assets` then adds it to the hardcoded album via `PUT /api/albums/{albumId}/assets` + +**Hardcoded values in `SendToImmichJob.php`:** +- `$albumId` — Immich album ID where photos are added +- `$id` (deviceAssetId) — fixed UUID used as the Immich device asset identifier + +**`camera/` directory structure:** The `camera/` directory is itself a Python venv (has `pyvenv.cfg`), but the actual venv used by the app is `camera/myenv/` (contains Flask). `picamera2` is available from the system Python installation. + +**ImmichUploader** uses SQLite (default, `database/database.sqlite`) for the queue, cache, and sessions — no separate database process needed. diff --git a/camera/main.py b/camera/main.py index 446fc33..6287126 100644 --- a/camera/main.py +++ b/camera/main.py @@ -9,23 +9,26 @@ import os import threading import time from datetime import datetime -from flask import Flask, Response, jsonify, render_template, send_from_directory +from flask import Flask, Response, jsonify, render_template, send_from_directory +from libcamera import Transform from picamera2 import Picamera2 from picamera2.encoders import JpegEncoder from picamera2.outputs import FileOutput +from PIL import Image # ─── Configuration ──────────────────────────────────────────────────────────── -RESOLUTION = (1280, 720) # Résolution du stream (largeur, hauteur) -FRAMERATE = 30 # Images par seconde -QUALITY = 85 # Qualité JPEG (1-100) -HOST = "0.0.0.0" # Ecoute sur toutes les interfaces réseau -PORT = 5000 +RESOLUTION = (1280, 720) # Résolution du stream (largeur, hauteur) +FRAMERATE = 30 # Images par seconde +QUALITY = 85 # Qualité JPEG (1-100) +HOST = "0.0.0.0" # Ecoute sur toutes les interfaces réseau +PORT = 5000 PHOTOS_DIR = os.path.join(os.path.dirname(__file__), "photos") # ─── Buffer de frames thread-safe ───────────────────────────────────────────── + class StreamOutput(io.BufferedIOBase): def __init__(self): self.frame = None @@ -43,7 +46,8 @@ picam2 = Picamera2() video_config = picam2.create_video_configuration( main={"size": RESOLUTION, "format": "RGB888"}, - controls={"FrameRate": FRAMERATE} + transform=Transform(rotation=90), + controls={"FrameRate": FRAMERATE}, ) picam2.configure(video_config) @@ -58,16 +62,14 @@ os.makedirs(PHOTOS_DIR, exist_ok=True) # ─── Générateur MJPEG ───────────────────────────────────────────────────────── + def generate_frames(): """Génère un flux MJPEG frame par frame.""" while True: with output.condition: output.condition.wait() frame = output.frame - yield ( - b"--frame\r\n" - b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n" - ) + yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n") # ─── Application Flask ──────────────────────────────────────────────────────── @@ -78,10 +80,7 @@ app = Flask(__name__) @app.route("/") def index(): return render_template( - "index.html", - width=RESOLUTION[0], - height=RESOLUTION[1], - quality=QUALITY + "index.html", width=RESOLUTION[0], height=RESOLUTION[1], quality=QUALITY ) @@ -94,8 +93,8 @@ def capture(): filename = datetime.now().strftime("%Y%m%d_%H%M%S.jpg") filepath = os.path.join(PHOTOS_DIR, filename) - with open(filepath, "wb") as f: - f.write(frame) + image = Image.open(io.BytesIO(frame)).rotate(-90, expand=True) + image.save(filepath, "JPEG", quality=QUALITY) return jsonify({"status": "ok", "file": filename}) @@ -103,8 +102,7 @@ def capture(): @app.route("/photos") def photos(): files = sorted( - [f for f in os.listdir(PHOTOS_DIR) if f.endswith(".jpg")], - reverse=True + [f for f in os.listdir(PHOTOS_DIR) if f.endswith(".jpg")], reverse=True )[:5] return jsonify(files) @@ -118,8 +116,7 @@ def photo(filename): def video_feed(): """Endpoint du flux MJPEG.""" return Response( - generate_frames(), - mimetype="multipart/x-mixed-replace; boundary=frame" + generate_frames(), mimetype="multipart/x-mixed-replace; boundary=frame" ) diff --git a/camera/static/qrcode.png b/camera/static/qrcode.png new file mode 100644 index 0000000000000000000000000000000000000000..30e53eef3aeb2ba1c273ca2d326645a508c9bacc GIT binary patch literal 6890 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 { - if (e.key !== "c" && e.key !== "C") return; + if (e.key !== ">") return; + + const flash = document.getElementById("flash"); + flash.classList.remove("fade"); + flash.classList.add("active"); + + await new Promise((r) => setTimeout(r, 300)); await fetch("/capture"); + + flash.classList.remove("active"); + flash.classList.add("fade"); + refreshGallery(); }); diff --git a/camera/static/style.css b/camera/static/style.css index 48cd08b..5c58014 100644 --- a/camera/static/style.css +++ b/camera/static/style.css @@ -12,7 +12,7 @@ body { display: flex; flex-direction: column; align-items: center; - justify-content: center; + justify-content: space-between; gap: 20px; } @@ -38,6 +38,7 @@ h1 { position: relative; border: 1px solid #1a1a1a; box-shadow: 0 0 40px rgba(0, 255, 136, 0.05); + transform: rotate(90deg) scale(1.5); } .stream-container::before { @@ -49,7 +50,25 @@ h1 { color: #ff4444; letter-spacing: 0.1em; z-index: 10; - animation: blink 1.5s infinite; + animation: blink 0.2s infinite; +} + +.flash-overlay { + position: absolute; + inset: 0; + background: white; + opacity: 0; + pointer-events: none; + transition: opacity 0.08s ease-in; +} + +.flash-overlay.active { + opacity: 0.6; +} + +.flash-overlay.fade { + opacity: 0; + transition: opacity 0.2s ease-out; } @keyframes blink { @@ -58,18 +77,21 @@ h1 { opacity: 1; } 50% { - opacity: 0.2; + opacity: 0; } } img#stream { display: block; - max-width: 75vh; - max-height: 90vw; + max-height: 95vh; + max-width: 100vw; } .gallery { display: flex; + justify-content: center; + flex: 1; + margin-bottom: 5px; gap: 8px; } @@ -80,8 +102,8 @@ img#stream { } .gallery img { - width: 120px; - height: 80px; + width: 200px; + height: 300px; object-fit: cover; border: 1px solid #1a1a1a; opacity: 0.75; @@ -93,11 +115,22 @@ img#stream { } footer { + display: flex; + align-items: end; font-size: 0.7rem; color: #333; letter-spacing: 0.1em; + width: 100%; } footer span { color: #00ff88; } + +.qrcode { + display: inline-block; + right: 16px; + width: 200px; + height: 200px; + margin-bottom: 30px; +} diff --git a/camera/templates/index.html b/camera/templates/index.html index c0205c1..e18af26 100644 --- a/camera/templates/index.html +++ b/camera/templates/index.html @@ -14,18 +14,16 @@
Camera stream +
- - -