add services

This commit is contained in:
2026-06-02 22:54:55 +02:00
parent f83e01380c
commit 28bcb6232f
9 changed files with 160 additions and 37 deletions
+1
View File
@@ -1 +1,2 @@
/camera/photos
.env
+66
View File
@@ -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/<timestamp>.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.
+18 -21
View File
@@ -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"
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+11 -1
View File
@@ -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();
});
+40 -7
View File
@@ -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;
}
+6 -8
View File
@@ -14,18 +14,16 @@
<div class="stream-container">
<img id="stream" src="/video_feed" alt="Camera stream">
<div class="flash-overlay" id="flash"></div>
</div>
<section class="gallery" id="gallery">
<p class="gallery-empty">Aucune photo pour l'instant</p>
</section>
<footer>
Résolution <span>{{ width }}×{{ height }}</span>
Qualité <span>{{ quality }}%</span>
<footer class="flex">
<div class="gallery" id="gallery">
<p class="gallery-empty">Aucune photo pour l'instant</p>
</div>
<img class="qrcode" src="{{ url_for('static', filename='qrcode.png') }}" alt="QR Code">
</footer>
<script src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Photo-booth queue
[Service]
Type=simple
ExecStart=php /home/pi/Scripts/photo_booth/ImmichUploader/artisan queue:work --tries=2 --timeout=60
[Install]
WantedBy=default.target
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Photo-booth queue
[Service]
Type=simple
ExecStart=php /home/pi/Scripts/photo_booth/ImmichUploader/artisan immich:watch-folder
[Install]
WantedBy=default.target