add services
This commit is contained in:
@@ -1 +1,2 @@
|
||||
/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.
|
||||
+13
-16
@@ -9,11 +9,13 @@ 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +28,7 @@ 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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<footer class="flex">
|
||||
<div 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>
|
||||
</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>
|
||||
|
||||
@@ -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