From f9c52293bb0f32795f47923277e097c1e27c26f6 Mon Sep 17 00:00:00 2001 From: Dark98 Date: Sun, 6 Sep 2026 15:14:21 +0000 Subject: [PATCH] initial POC --- .env.example | 21 +++ .gitignore | 5 + Dockerfile | 12 ++ README.md | 80 +++++++++++ app.py | 321 +++++++++++++++++++++++++++++++++++++++++++++ build.sh | 15 +++ docker-compose.yml | 16 +++ panel.html | 64 +++++++++ run.sh | 30 +++++ 9 files changed, 564 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100755 build.sh create mode 100644 docker-compose.yml create mode 100644 panel.html create mode 100755 run.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..629e286 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Moonraker's HTTP API, usually exposed beside Mainsail on port 7125. +MOONRAKER_URL=http://host.docker.internal:7125 + +# Snapshot endpoint for the camera configured in Mainsail. +CAMERA_SNAPSHOT_URL=http://host.docker.internal/webcam/?action=snapshot +CAMERA_STREAM_URL=http://host.docker.internal/webcam/?action=stream + +# Polling and capture behavior. +POLL_INTERVAL_SECONDS=0.5 +MIN_LAYER_HEIGHT_MM=0.05 +HTTP_TIMEOUT_SECONDS=10 + +# Storage and rendering. +OUTPUT_DIR=/timelapses +VIDEO_FPS=12 +VIDEO_CODEC=libx264 +VIDEO_CRF=18 +VIDEO_PRESET=medium +VIDEO_PIXEL_FORMAT=yuv420p +# Number of copies of the final snapshot to append. 0 disables the hold. +VIDEO_DUPLICATE_END_FRAMES=0 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..011d7f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +timelapses/* +!timelapses/.gitkeep +__pycache__/ +*.pyc \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..54276af --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY app.py /app/app.py +COPY panel.html /app/panel.html + +ENV PYTHONUNBUFFERED=1 +CMD ["python", "/app/app.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6014a6 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Moonraker Timelapse Docker Template + +This service watches a Klipper/Moonraker printer, captures a JPEG whenever the reported toolhead Z position increases by a configured layer height, and renders the captured frames to an H.264 MP4 after a successful print. Snapshot directories and videos are named with the print filename and UTC start date/time, for example `benchy_20260905-210500` and `benchy_20260905-210500.mp4`. + +## Quick start + +1. Copy `.env.example` to `.env`. +2. Set `MOONRAKER_URL` and `CAMERA_SNAPSHOT_URL` to the endpoints used by your printer and Mainsail. +3. Start the service: + + ```sh + docker compose up --build -d + ``` + +4. Open the panel at `http://localhost:8080`. +5. Find rendered videos under `./timelapses`. + +Storage is organized with videos in the root timelapse folder and snapshots in a matching print folder: + +```text +timelapses/ +├── benchy_20260905-210500.mp4 +└── benchy_20260905-210500/ + ├── frame-000000.jpg + └── frame-000001.jpg +``` + +The panel is also available when running locally with `./run.sh` at `http://localhost:8080`. Set `WEB_PORT` to use another port. It shows Moonraker connectivity, printer state, current Z height, the latest captured snapshot, and downloadable completed videos. The JSON endpoints are `/api/status` and `/api/videos`; the latest JPEG is available at `/api/snapshot`. + +To build the image directly instead of using Compose: + +```sh +./build.sh +``` + +The default image is `moonraker-timelapse:latest`. Override it when publishing or testing a specific version: + +```sh +IMAGE_NAME=registry.example.com/you/timelapse IMAGE_TAG=0.1.0 ./build.sh +``` + +For local development without Docker, install Python 3 and `ffmpeg`, then run: + +```sh +./run.sh +``` + +The script loads `.env` when present, defaults `OUTPUT_DIR` to `./timelapses`, and uses `127.0.0.1:7125` as the local Moonraker default. Set `MOONRAKER_URL` and `CAMERA_SNAPSHOT_URL` in `.env` when the printer or camera is on another host. + +The default camera URL is compatible with common MJPEG camera configurations using `?action=snapshot`. For a camera that exposes a different still-image endpoint, set `CAMERA_SNAPSHOT_URL` accordingly. + +### Render settings + +Render behavior can be configured in `.env`: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `VIDEO_FPS` | `12` | Playback frame rate. | +| `VIDEO_DUPLICATE_END_FRAMES` | `0` | Copies of the final snapshot appended to hold the end of the video. | +| `VIDEO_CODEC` | `libx264` | FFmpeg video encoder. | +| `VIDEO_PRESET` | `medium` | Encoder speed/compression preset. | +| `VIDEO_CRF` | `18` | H.264 quality; lower values produce larger, higher-quality files. | +| `VIDEO_PIXEL_FORMAT` | `yuv420p` | Output pixel format for broad player compatibility. | + +For example, `VIDEO_FPS=24` and `VIDEO_DUPLICATE_END_FRAMES=48` creates a two-second hold on the final snapshot. + +## How layer detection works + +The worker polls Moonraker's `print_stats` and `gcode_move` objects. When slicer layer metadata (`print_stats.info.current_layer`) is available, it captures once per reported layer. Otherwise it falls back to detecting Z increases using `MIN_LAYER_HEIGHT_MM`; the default poll interval is `0.5` seconds, but layer metadata is recommended because a slow poll can miss a short Z move. + +## Development smoke test + +The Python worker uses only the standard library. Validate syntax locally with: + +```sh +python3 -m py_compile app.py +docker compose config +``` + +The service intentionally treats Moonraker and camera failures as recoverable polling errors, so a printer or camera reboot does not stop the container. \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..3793314 --- /dev/null +++ b/app.py @@ -0,0 +1,321 @@ +"""Capture one image per detected Z increase and render completed prints.""" + +from __future__ import annotations + +import datetime as dt +import http.server +import json +import logging +import os +import re +import subprocess +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import unquote, urlparse + + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(message)s", +) +LOGGER = logging.getLogger("timelapse") + + +def env_float(name: str, default: float) -> float: + return float(os.getenv(name, str(default))) + + +MOONRAKER_URL = os.getenv("MOONRAKER_URL", "http://host.docker.internal:7125").rstrip("/") +CAMERA_SNAPSHOT_URL = os.getenv( + "CAMERA_SNAPSHOT_URL", + "http://host.docker.internal/webcam/?action=snapshot", +) +CAMERA_STREAM_URL = os.getenv( + "CAMERA_STREAM_URL", + "http://host.docker.internal/webcam/?action=stream", +) +POLL_INTERVAL = env_float("POLL_INTERVAL_SECONDS", 0.5) +MIN_LAYER_HEIGHT = env_float("MIN_LAYER_HEIGHT_MM", 0.05) +HTTP_TIMEOUT = env_float("HTTP_TIMEOUT_SECONDS", 10) +OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/timelapses")) +VIDEO_FPS = os.getenv("VIDEO_FPS", "12") +VIDEO_CODEC = os.getenv("VIDEO_CODEC", "libx264") +VIDEO_CRF = os.getenv("VIDEO_CRF", "18") +VIDEO_PRESET = os.getenv("VIDEO_PRESET", "medium") +VIDEO_PIXEL_FORMAT = os.getenv("VIDEO_PIXEL_FORMAT", "yuv420p") +VIDEO_DUPLICATE_END_FRAMES = int(os.getenv("VIDEO_DUPLICATE_END_FRAMES", "0")) +WEB_HOST = os.getenv("WEB_HOST", "0.0.0.0") +WEB_PORT = int(os.getenv("WEB_PORT", "8080")) + +STATUS_LOCK = threading.Lock() +STATUS = { + "connected": False, + "state": "unknown", + "z_height": None, + "layer": None, + "print_name": None, + "frames": 0, + "session": None, + "last_error": None, + "updated_at": None, +} +LATEST_SNAPSHOT: Path | None = None + + +def update_status(**changes: object) -> None: + with STATUS_LOCK: + STATUS.update(changes) + STATUS["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat() + + +def status_snapshot() -> dict: + with STATUS_LOCK: + return dict(STATUS) + + +class PanelHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + request_path = urlparse(self.path).path + if request_path == "/": + self.send_file(Path(__file__).with_name("panel.html"), "text/html; charset=utf-8") + elif request_path == "/api/status": + self.send_json(status_snapshot()) + elif request_path == "/api/videos": + self.send_json(list_videos()) + elif request_path == "/api/snapshot": + self.send_snapshot() + elif request_path == "/api/camera": + self.send_camera_stream() + elif request_path.startswith("/media/"): + self.send_media(unquote(request_path.removeprefix("/media/"))) + else: + self.send_error(404) + + def send_json(self, payload: object) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def send_file(self, path: Path, content_type: str) -> None: + try: + body = path.read_bytes() + except OSError: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def send_media(self, relative_path: str) -> None: + path = (OUTPUT_DIR / relative_path).resolve() + try: + path.relative_to(OUTPUT_DIR.resolve()) + except ValueError: + self.send_error(403) + return + if path.suffix != ".mp4" or not path.is_file(): + self.send_error(404) + return + self.send_file(path, "video/mp4") + + def send_snapshot(self) -> None: + if LATEST_SNAPSHOT is None or not LATEST_SNAPSHOT.is_file(): + self.send_error(404) + return + self.send_file(LATEST_SNAPSHOT, "image/jpeg") + + def send_camera_stream(self) -> None: + request = urllib.request.Request(CAMERA_STREAM_URL) + try: + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response: + content_type = response.headers.get("Content-Type", "multipart/x-mixed-replace") + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + while True: + chunk = response.read(64 * 1024) + if not chunk: + break + self.wfile.write(chunk) + self.wfile.flush() + except (OSError, urllib.error.URLError): + LOGGER.warning("Camera stream unavailable at %s", CAMERA_STREAM_URL) + + def log_message(self, format: str, *args: object) -> None: + LOGGER.info("panel: " + format, *args) + + +def list_videos() -> list[dict[str, object]]: + videos = [] + for path in sorted(OUTPUT_DIR.glob("*.mp4"), reverse=True): + stat = path.stat() + videos.append( + { + "name": path.name, + "url": f"/media/{path.name}", + "size_bytes": stat.st_size, + "created_at": dt.datetime.fromtimestamp(stat.st_mtime, dt.timezone.utc).isoformat(), + } + ) + return videos + + +def start_panel() -> None: + server = http.server.ThreadingHTTPServer((WEB_HOST, WEB_PORT), PanelHandler) + threading.Thread(target=server.serve_forever, name="web-panel", daemon=True).start() + LOGGER.info("Web panel listening at http://%s:%s", WEB_HOST, WEB_PORT) + + +def get_json(path: str) -> dict: + request = urllib.request.Request(f"{MOONRAKER_URL}{path}") + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response: + return json.loads(response.read()) + + +def moonraker_status() -> tuple[str, float, str | None, int | None]: + result = get_json("/printer/objects/query?print_stats&gcode_move") + status = result["result"]["status"] + state = status["print_stats"]["state"] + z_height = float(status["gcode_move"]["gcode_position"][2]) + filename = status["print_stats"].get("filename") + current_layer = status["print_stats"].get("info", {}).get("current_layer") + if current_layer is None: + current_layer = status["print_stats"].get("current_layer") + try: + current_layer = int(current_layer) if current_layer is not None else None + except (TypeError, ValueError): + current_layer = None + return state, z_height, filename or None, current_layer + + +def capture_snapshot(path: Path) -> None: + request = urllib.request.Request(CAMERA_SNAPSHOT_URL) + with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response: + path.write_bytes(response.read()) + + +def render(session_dir: Path) -> None: + frames = session_dir / "frame-%06d.jpg" + output = OUTPUT_DIR / f"{session_dir.name}.mp4" + command = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-framerate", + VIDEO_FPS, + "-i", + str(frames), + ] + if VIDEO_DUPLICATE_END_FRAMES > 0: + command.extend( + [ + "-vf", + f"tpad=stop_mode=clone:stop_frames={VIDEO_DUPLICATE_END_FRAMES}", + ] + ) + command.extend( + [ + "-c:v", + VIDEO_CODEC, + "-preset", + VIDEO_PRESET, + "-crf", + VIDEO_CRF, + "-pix_fmt", + VIDEO_PIXEL_FORMAT, + str(output), + ] + ) + subprocess.run(command, check=True) + LOGGER.info("Rendered %s", output) + + +def safe_print_name(filename: str | None) -> str: + name = Path(filename or "unnamed-print").stem + name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-_") + return name[:100] or "unnamed-print" + + +def new_session(filename: str | None) -> tuple[Path, float]: + timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d-%H%M%S") + session_name = f"{safe_print_name(filename)}_{timestamp}" + session_dir = OUTPUT_DIR / session_name + session_dir.mkdir(parents=True, exist_ok=True) + LOGGER.info("Started print session %s", session_name) + return session_dir, -1.0 + + +def main() -> None: + global LATEST_SNAPSHOT + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + start_panel() + session_dir: Path | None = None + last_state = "unknown" + last_z = -1.0 + last_layer: int | None = None + frame_number = 0 + rendered = False + + while True: + try: + state, z_height, print_name, current_layer = moonraker_status() + update_status( + connected=True, + state=state, + z_height=z_height, + layer=current_layer, + print_name=print_name, + last_error=None, + ) + + if state == "printing" and last_state != "printing": + session_dir, last_z = new_session(print_name) + frame_number = 0 + last_layer = None + rendered = False + update_status(session=session_dir.name, frames=0) + + layer_changed = ( + current_layer is not None + and (last_layer is None or current_layer > last_layer) + ) + z_changed = current_layer is None and z_height - last_z >= MIN_LAYER_HEIGHT + if state == "printing" and session_dir and (layer_changed or z_changed): + frame_path = session_dir / f"frame-{frame_number:06d}.jpg" + capture_snapshot(frame_path) + layer_label = f"layer={current_layer}" if current_layer is not None else f"Z={z_height:.3f}" + LOGGER.info("Captured %s (%s)", layer_label, frame_path.name) + frame_number += 1 + last_z = z_height + last_layer = current_layer + LATEST_SNAPSHOT = frame_path + update_status(frames=frame_number) + + if state == "complete" and last_state == "printing" and session_dir and not rendered: + render(session_dir) + rendered = True + + if state in {"cancelled", "error"}: + LOGGER.warning("Print ended in state %s; no video rendered", state) + + last_state = state + except (OSError, KeyError, TypeError, ValueError, urllib.error.URLError, subprocess.CalledProcessError) as error: + LOGGER.warning("Poll/capture failed: %s", error) + update_status(connected=False, last_error=str(error)) + + time.sleep(POLL_INTERVAL) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..0db4a9e --- /dev/null +++ b/build.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +IMAGE_NAME=${IMAGE_NAME:-moonraker-timelapse} +IMAGE_TAG=${IMAGE_TAG:-latest} + +cd "$SCRIPT_DIR" + +docker build \ + --tag "${IMAGE_NAME}:${IMAGE_TAG}" \ + . + +printf 'Built %s:%s\n' "$IMAGE_NAME" "$IMAGE_TAG" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c09ff85 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + timelapse: + build: . + container_name: klipper-timelapse + restart: unless-stopped + ports: + - "8080:8080" + env_file: + - path: .env + required: false + volumes: + - ./timelapses:/timelapses + # The default Moonraker and camera URLs target services reachable on the + # Docker host. Replace these with container/service names when applicable. + extra_hosts: + - "host.docker.internal:host-gateway" \ No newline at end of file diff --git a/panel.html b/panel.html new file mode 100644 index 0000000..b7ed527 --- /dev/null +++ b/panel.html @@ -0,0 +1,64 @@ + + + + + + Layerlight | Timelapse + + + +
+
+

Moonraker / layer capture

Layerlight

+

Connecting...
Waiting for worker

+
+
+

Printer

--
+

Current Z

-- mm
+

Frames

0
+

Print

--
+
+

Latest snapshot

Waiting for first layer

No snapshot captured yet.

+

Live printer view

Camera stream

Live printer camera view
+

Rendered timelapses

0 files

No completed prints yet.

+
+ + + \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..d01260e --- /dev/null +++ b/run.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +if ! command -v python3 >/dev/null 2>&1; then + printf 'Error: python3 is required.\n' >&2 + exit 1 +fi + +if ! command -v ffmpeg >/dev/null 2>&1; then + printf 'Error: ffmpeg is required for rendering.\n' >&2 + exit 1 +fi + +if [ -f "$SCRIPT_DIR/.env" ]; then + set -a + # shellcheck disable=SC1091 + . "$SCRIPT_DIR/.env" + set +a +fi + +export MOONRAKER_URL=${MOONRAKER_URL:-http://192.168.1.13} +export CAMERA_SNAPSHOT_URL=${CAMERA_SNAPSHOT_URL:-http://192.168.1.13/webcam/?action=snapshot} +export CAMERA_STREAM_URL=${CAMERA_STREAM_URL:-http://192.168.1.13/webcam/?action=stream} +export OUTPUT_DIR=${OUTPUT_DIR:-"$SCRIPT_DIR/timelapses"} + +cd "$SCRIPT_DIR" +exec python3 app.py \ No newline at end of file