initial POC
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
timelapses/*
|
||||||
|
!timelapses/.gitkeep
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
+12
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user