add services
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
/camera/photos
|
/camera/photos
|
||||||
|
.env
|
||||||
|
|||||||
@@ -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
@@ -9,23 +9,26 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
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 import Picamera2
|
||||||
from picamera2.encoders import JpegEncoder
|
from picamera2.encoders import JpegEncoder
|
||||||
from picamera2.outputs import FileOutput
|
from picamera2.outputs import FileOutput
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
RESOLUTION = (1280, 720) # Résolution du stream (largeur, hauteur)
|
RESOLUTION = (1280, 720) # Résolution du stream (largeur, hauteur)
|
||||||
FRAMERATE = 30 # Images par seconde
|
FRAMERATE = 30 # Images par seconde
|
||||||
QUALITY = 85 # Qualité JPEG (1-100)
|
QUALITY = 85 # Qualité JPEG (1-100)
|
||||||
HOST = "0.0.0.0" # Ecoute sur toutes les interfaces réseau
|
HOST = "0.0.0.0" # Ecoute sur toutes les interfaces réseau
|
||||||
PORT = 5000
|
PORT = 5000
|
||||||
PHOTOS_DIR = os.path.join(os.path.dirname(__file__), "photos")
|
PHOTOS_DIR = os.path.join(os.path.dirname(__file__), "photos")
|
||||||
|
|
||||||
# ─── Buffer de frames thread-safe ─────────────────────────────────────────────
|
# ─── Buffer de frames thread-safe ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class StreamOutput(io.BufferedIOBase):
|
class StreamOutput(io.BufferedIOBase):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.frame = None
|
self.frame = None
|
||||||
@@ -43,7 +46,8 @@ picam2 = Picamera2()
|
|||||||
|
|
||||||
video_config = picam2.create_video_configuration(
|
video_config = picam2.create_video_configuration(
|
||||||
main={"size": RESOLUTION, "format": "RGB888"},
|
main={"size": RESOLUTION, "format": "RGB888"},
|
||||||
controls={"FrameRate": FRAMERATE}
|
transform=Transform(rotation=90),
|
||||||
|
controls={"FrameRate": FRAMERATE},
|
||||||
)
|
)
|
||||||
picam2.configure(video_config)
|
picam2.configure(video_config)
|
||||||
|
|
||||||
@@ -58,16 +62,14 @@ os.makedirs(PHOTOS_DIR, exist_ok=True)
|
|||||||
|
|
||||||
# ─── Générateur MJPEG ─────────────────────────────────────────────────────────
|
# ─── Générateur MJPEG ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def generate_frames():
|
def generate_frames():
|
||||||
"""Génère un flux MJPEG frame par frame."""
|
"""Génère un flux MJPEG frame par frame."""
|
||||||
while True:
|
while True:
|
||||||
with output.condition:
|
with output.condition:
|
||||||
output.condition.wait()
|
output.condition.wait()
|
||||||
frame = output.frame
|
frame = output.frame
|
||||||
yield (
|
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
|
||||||
b"--frame\r\n"
|
|
||||||
b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Application Flask ────────────────────────────────────────────────────────
|
# ─── Application Flask ────────────────────────────────────────────────────────
|
||||||
@@ -78,10 +80,7 @@ app = Flask(__name__)
|
|||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html", width=RESOLUTION[0], height=RESOLUTION[1], quality=QUALITY
|
||||||
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")
|
filename = datetime.now().strftime("%Y%m%d_%H%M%S.jpg")
|
||||||
filepath = os.path.join(PHOTOS_DIR, filename)
|
filepath = os.path.join(PHOTOS_DIR, filename)
|
||||||
with open(filepath, "wb") as f:
|
image = Image.open(io.BytesIO(frame)).rotate(-90, expand=True)
|
||||||
f.write(frame)
|
image.save(filepath, "JPEG", quality=QUALITY)
|
||||||
|
|
||||||
return jsonify({"status": "ok", "file": filename})
|
return jsonify({"status": "ok", "file": filename})
|
||||||
|
|
||||||
@@ -103,8 +102,7 @@ def capture():
|
|||||||
@app.route("/photos")
|
@app.route("/photos")
|
||||||
def photos():
|
def photos():
|
||||||
files = sorted(
|
files = sorted(
|
||||||
[f for f in os.listdir(PHOTOS_DIR) if f.endswith(".jpg")],
|
[f for f in os.listdir(PHOTOS_DIR) if f.endswith(".jpg")], reverse=True
|
||||||
reverse=True
|
|
||||||
)[:5]
|
)[:5]
|
||||||
return jsonify(files)
|
return jsonify(files)
|
||||||
|
|
||||||
@@ -118,8 +116,7 @@ def photo(filename):
|
|||||||
def video_feed():
|
def video_feed():
|
||||||
"""Endpoint du flux MJPEG."""
|
"""Endpoint du flux MJPEG."""
|
||||||
return Response(
|
return Response(
|
||||||
generate_frames(),
|
generate_frames(), mimetype="multipart/x-mixed-replace; boundary=frame"
|
||||||
mimetype="multipart/x-mixed-replace; boundary=frame"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
+11
-1
@@ -11,8 +11,18 @@ async function refreshGallery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", async (e) => {
|
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");
|
await fetch("/capture");
|
||||||
|
|
||||||
|
flash.classList.remove("active");
|
||||||
|
flash.classList.add("fade");
|
||||||
|
|
||||||
refreshGallery();
|
refreshGallery();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+40
-7
@@ -12,7 +12,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ h1 {
|
|||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid #1a1a1a;
|
border: 1px solid #1a1a1a;
|
||||||
box-shadow: 0 0 40px rgba(0, 255, 136, 0.05);
|
box-shadow: 0 0 40px rgba(0, 255, 136, 0.05);
|
||||||
|
transform: rotate(90deg) scale(1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-container::before {
|
.stream-container::before {
|
||||||
@@ -49,7 +50,25 @@ h1 {
|
|||||||
color: #ff4444;
|
color: #ff4444;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
z-index: 10;
|
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 {
|
@keyframes blink {
|
||||||
@@ -58,18 +77,21 @@ h1 {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.2;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
img#stream {
|
img#stream {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 75vh;
|
max-height: 95vh;
|
||||||
max-height: 90vw;
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery {
|
.gallery {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 5px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +102,8 @@ img#stream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gallery img {
|
.gallery img {
|
||||||
width: 120px;
|
width: 200px;
|
||||||
height: 80px;
|
height: 300px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 1px solid #1a1a1a;
|
border: 1px solid #1a1a1a;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
@@ -93,11 +115,22 @@ img#stream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: #333;
|
color: #333;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer span {
|
footer span {
|
||||||
color: #00ff88;
|
color: #00ff88;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qrcode {
|
||||||
|
display: inline-block;
|
||||||
|
right: 16px;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,18 +14,16 @@
|
|||||||
|
|
||||||
<div class="stream-container">
|
<div class="stream-container">
|
||||||
<img id="stream" src="/video_feed" alt="Camera stream">
|
<img id="stream" src="/video_feed" alt="Camera stream">
|
||||||
|
<div class="flash-overlay" id="flash"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="gallery" id="gallery">
|
<footer class="flex">
|
||||||
<p class="gallery-empty">Aucune photo pour l'instant</p>
|
<div class="gallery" id="gallery">
|
||||||
</section>
|
<p class="gallery-empty">Aucune photo pour l'instant</p>
|
||||||
|
</div>
|
||||||
<footer>
|
<img class="qrcode" src="{{ url_for('static', filename='qrcode.png') }}" alt="QR Code">
|
||||||
Résolution <span>{{ width }}×{{ height }}</span> —
|
|
||||||
Qualité <span>{{ quality }}%</span>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user