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 0000000..30e53ee Binary files /dev/null and b/camera/static/qrcode.png differ diff --git a/camera/static/script.js b/camera/static/script.js index f67ebc8..a790a8b 100644 --- a/camera/static/script.js +++ b/camera/static/script.js @@ -11,8 +11,18 @@ async function refreshGallery() { } document.addEventListener("keydown", async (e) => { - 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 +
- - -