initial POC

This commit is contained in:
Dark98
2026-09-06 15:14:21 +00:00
commit f9c52293bb
9 changed files with 564 additions and 0 deletions
+21
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
timelapses/*
!timelapses/.gitkeep
__pycache__/
*.pyc
+12
View File
@@ -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"]
+80
View File
@@ -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.
+321
View File
@@ -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()
Executable
+15
View File
@@ -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"
+16
View File
@@ -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"
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Layerlight | Timelapse</title>
<style>
:root { color-scheme: dark; --ink: #f4f1e8; --muted: #a8aaa2; --line: #303531; --panel: #171b19; --accent: #e2b36d; --good: #8fca98; --bad: #e47f6d; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #0d100f; color: var(--ink); font: 15px/1.5 Georgia, serif; }
main { width: min(1100px, calc(100% - 40px)); margin: 0 auto; padding: 42px 0 64px; }
header { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 38px; border-bottom: 1px solid var(--line); padding-bottom: 24px; }
h1, h2, p { margin: 0; } h1 { font-size: clamp(2.3rem, 6vw, 4.5rem); line-height: .95; font-weight: 400; letter-spacing: -.04em; }
h1 span { color: var(--accent); } h2 { font: 600 12px/1.2 monospace; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.eyebrow { color: var(--accent); font: 11px monospace; letter-spacing: .18em; text-transform: uppercase; margin-bottom: 12px; }
.connection { color: var(--muted); font: 12px monospace; text-align: right; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--bad); margin-right: 7px; } .dot.good { background: var(--good); }
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); margin-bottom: 36px; }
.metric { padding: 22px; border-right: 1px solid var(--line); } .metric:last-child { border-right: 0; }
.value { display: block; margin-top: 7px; font: 28px monospace; color: var(--ink); } .unit { color: var(--muted); font-size: 12px; }
section { margin-top: 32px; } .section-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
.videos { display: grid; gap: 10px; } .video { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 17px 20px; background: var(--panel); border: 1px solid var(--line); }
.video strong { font: 14px monospace; } .video small { display: block; margin-top: 5px; color: var(--muted); } a { color: var(--accent); font: 12px monospace; text-decoration: none; border-bottom: 1px solid var(--accent); padding-bottom: 2px; }
.preview { position: relative; min-height: 180px; background: var(--panel); border: 1px solid var(--line); overflow: hidden; } .preview img { display: block; width: 100%; max-height: 560px; object-fit: contain; } .preview p { padding: 70px 20px; color: var(--muted); text-align: center; font: 12px monospace; }
.live { background: #050706; border: 1px solid var(--line); overflow: hidden; } .live img { display: block; width: 100%; min-height: 220px; max-height: 620px; object-fit: contain; }
.empty { color: var(--muted); padding: 22px 0; } .error { color: var(--bad); font: 12px monospace; margin-top: 16px; min-height: 18px; }
@media (max-width: 700px) { main { width: min(100% - 24px, 560px); padding-top: 28px; } header { display: block; } .connection { text-align: left; margin-top: 20px; } .metrics { grid-template-columns: repeat(2, 1fr); } .metric:nth-child(2) { border-right: 0; } .metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); } .video { align-items: start; flex-direction: column; } }
</style>
</head>
<body>
<main>
<header>
<div><p class="eyebrow">Moonraker / layer capture</p><h1>Layer<span>light</span></h1></div>
<p class="connection"><i class="dot" id="dot"></i><span id="connection">Connecting...</span><br><span id="updated">Waiting for worker</span></p>
</header>
<div class="metrics">
<div class="metric"><h2>Printer</h2><span class="value" id="state">--</span></div>
<div class="metric"><h2>Current Z</h2><span class="value" id="z">-- <span class="unit">mm</span></span></div>
<div class="metric"><h2>Frames</h2><span class="value" id="frames">0</span></div>
<div class="metric"><h2>Print</h2><span class="value" id="session">--</span></div>
</div>
<section><div class="section-head"><h2>Latest snapshot</h2><h2 id="snapshot-time">Waiting for first layer</h2></div><div class="preview"><img id="snapshot" alt="Latest captured layer snapshot" hidden><p id="snapshot-empty">No snapshot captured yet.</p></div></section>
<section><div class="section-head"><h2>Live printer view</h2><h2>Camera stream</h2></div><div class="live"><img src="/api/camera" alt="Live printer camera view" onerror="this.alt='Camera stream unavailable';"></div></section>
<section><div class="section-head"><h2>Rendered timelapses</h2><h2 id="count">0 files</h2></div><div class="videos" id="videos"><p class="empty">No completed prints yet.</p></div><p class="error" id="error"></p></section>
</main>
<script>
const $ = (id) => document.getElementById(id);
const formatBytes = (bytes) => bytes > 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
async function refresh() {
try {
const [status, videos] = await Promise.all([fetch('/api/status').then(r => r.json()), fetch('/api/videos').then(r => r.json())]);
$('dot').className = `dot ${status.connected ? 'good' : ''}`;
$('connection').textContent = status.connected ? 'Moonraker connected' : 'Moonraker offline';
$('updated').textContent = status.updated_at ? new Date(status.updated_at).toLocaleTimeString() : 'Waiting for worker';
$('state').textContent = status.state || '--'; $('z').firstChild.textContent = status.z_height == null ? '-- ' : `${status.z_height.toFixed(2)} `;
$('frames').textContent = status.frames || 0; $('session').textContent = status.print_name || status.session || '--'; $('error').textContent = status.last_error || '';
if (status.frames > 0) { $('snapshot').src = `/api/snapshot?frame=${status.frames}`; $('snapshot').hidden = false; $('snapshot-empty').hidden = true; $('snapshot-time').textContent = `Layer ${status.frames}`; }
$('count').textContent = `${videos.length} file${videos.length === 1 ? '' : 's'}`;
$('videos').innerHTML = videos.length ? videos.map(video => `<article class="video"><div><strong>${video.name}</strong><small>${formatBytes(video.size_bytes)} · ${new Date(video.created_at).toLocaleString()}</small></div><a href="${video.url}" download>Download MP4</a></article>`).join('') : '<p class="empty">No completed prints yet.</p>';
} catch (error) { $('connection').textContent = 'Panel unavailable'; }
}
refresh(); setInterval(refresh, 3000);
</script>
</body>
</html>
Executable
+30
View File
@@ -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