Skip to content

Instantly share code, notes, and snippets.

@MrQuallzin
Last active July 10, 2026 02:03
Show Gist options
  • Select an option

  • Save MrQuallzin/7fc19d08a27c59c8bd9e94978a1bfeaa to your computer and use it in GitHub Desktop.

Select an option

Save MrQuallzin/7fc19d08a27c59c8bd9e94978a1bfeaa to your computer and use it in GitHub Desktop.
Cosmos timelapse with external camera (via gphoto2)

Cosmos timelapse with an external USB camera

Trigger a USB-tethered camera for Cosmos timelapses instead of using the printer's built-in camera. Rendering is offloaded to a Linux desktop so the printer's R528 SoC doesn't have to do video encoding.

How it works

The printer fires a small HTTP call per layer to a Flask shim running on a Linux desktop. The shim calls gphoto2 to capture from the camera and saves the JPEG locally. At print end, ffmpeg renders the frames into an MP4.

printer (layer change) ──HTTP──▶ Flask shim ──▶ gphoto2 ──▶ camera (USB)
                                      │
                                      ▼
                              JPEGs on desktop
                                      │
                                      ▼
                          ffmpeg (at print end)
                                      │
                                      ▼
                                    MP4

Requirements

  • A printer running Cosmos
  • A Debian (or Debian-based) Linux desktop on the same LAN (Probably works on others, Debian is all I know)
  • A camera with USB control — anything supported by gphoto2 (most modern Sonys, Canons, Nikons, Fujis). Built and tested with a Sony a6000 in PC Remote mode.
  • A dummy battery for the camera (long prints will drain a real battery)

Setup

Desktop

sudo apt install python3-flask gphoto2 ffmpeg
mkdir -p ~/timelapses ~/tools/cosmosnap
  • Put app.py in ~/tools/cosmosnap/
  • Put cosmosnap.service in /etc/systemd/system/ (Edit cosmosnap.service and replace USERNAME on the User= and Group= lines with your Linux username)
  • sudo systemctl daemon-reload && sudo systemctl enable --now cosmosnap.service
  • On the camera: set USB Connection to PC Remote, switch to manual focus, set Auto Power Off to maximum

Smoke test:

curl "http://localhost:8090/snap?job=test"
ls ~/timelapses/test/

A JPEG should land in that directory.

Printer

Append the contents of printer.cfg.snippet to the top of your printer.cfg (edit the hostname/IP to point at your desktop), then FIRMWARE_RESTART. Verify by running COSMOSNAP in the Mainsail console — it should produce another frame on the desktop under ~/timelapses/untitled/.

Slicer

In your slicer's machine G-code:

  • Change layer G-code: COSMOSNAP
  • Machine end G-code: RENDER_COSMOSNAP

Using it

Print normally. Frames accumulate in ~/timelapses/<job_name>/ on the desktop. When the print finishes, ffmpeg renders <job_name>.mp4 next to them — 1080p, ~10–20 MB for a typical 2-hour print, with a 3-second freeze on the last frame.

Notes

  • GVFS will steal the camera on Debian with a desktop environment running — KDE/GNOME auto-mounts cameras as PTP storage. The systemd unit kills the gvfs camera processes on start, but if you plug the camera in after starting the service, run sudo systemctl restart cosmosnap.
  • Manual focus is required. Autofocus will hunt on every shot and miss frames.
  • Ensure your camera is set to take JPEG, not RAW photos
  • MP4s land on the desktop, not in Mainsail's gallery.

Files

  • app.py — Flask shim
  • cosmosnap.service — systemd unit
  • printer.cfg.snippet — printer config blocks

AI Disclaimer

The code was generated by Claude, but was reviewed and edited (to the best of my ability) by hand by me

from flask import Flask, request
from pathlib import Path
import subprocess, threading, sys, time
FRAMES_ROOT = Path("~/timelapses")
app = Flask(__name__)
# Non-reentrant lock: if a capture is in progress, the next /snap drops the frame.
_gphoto_lock = threading.Lock()
def _log(label, msg):
print(f"[{label}] {msg}", file=sys.stderr, flush=True)
@app.route("/snap")
def snap():
job = request.args.get("job", "untitled")
frame_dir = FRAMES_ROOT / job
frame_dir.mkdir(parents=True, exist_ok=True)
if not _gphoto_lock.acquire(blocking=False):
_log("snap", f"busy, dropped frame for job={job}")
return ("busy\n", 202)
def _capture():
try:
ts = int(time.time() * 1000)
out = frame_dir / f"frame_{ts}.jpg"
r = subprocess.run(
["gphoto2",
"--capture-image-and-download",
"--filename", str(out), "--force-overwrite"],
capture_output=True, timeout=30)
if r.returncode != 0:
_log("snap", f"rc={r.returncode}: "
f"{r.stderr.decode(errors='replace').strip()}")
except subprocess.TimeoutExpired:
_log("snap", "timed out after 30s")
finally:
_gphoto_lock.release()
threading.Thread(target=_capture, daemon=True).start()
return ("triggered\n", 202)
@app.route("/render")
def render():
job = request.args.get("job")
frame_dir = FRAMES_ROOT / job
out = FRAMES_ROOT / f"{job}.mp4"
subprocess.Popen([
"ffmpeg", "-framerate", "30", "-pattern_type", "glob",
"-i", f"{frame_dir}/*.jpg",
"-vf", "scale=1920:-2,tpad=stop_mode=clone:stop_duration=3",
"-c:v", "libx264", "-preset", "slow",
"-crf", "23", "-pix_fmt", "yuv420p",
"-movflags", "+faststart",
str(out)])
return ("rendering\n", 202)
[Unit]
Description=Cosmosnap — USB camera timelapse shim (gphoto2 → HTTP)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=USERNAME
Group=USERNAME
WorkingDirectory=%h/tools/cosmosnap
# Kill GVFS's camera handler so it doesn't claim the device before gphoto2 can
ExecStartPre=-/usr/bin/pkill -u %u -f gvfs-gphoto2-volume-monitor
ExecStartPre=-/usr/bin/pkill -u %u -f gvfsd-gphoto2
ExecStart=/usr/bin/python3 -m flask --app app run --host 0.0.0.0 --port 8090
Restart=on-failure
RestartSec=5
# Light hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=%h/timelapses
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=cosmosnap
[Install]
WantedBy=multi-user.target
[gcode_shell_command cosmosnap]
command: sh -c 'curl -sf -m 2 "http://192.168.86.181:8090/snap?job=$0" >/dev/null 2>&1 &'
timeout: 3.
verbose: False
[gcode_shell_command cosmorender]
command: sh -c 'curl -sf -m 5 "http://192.168.86.181:8090/render?job=$0" >/dev/null 2>&1 &'
timeout: 5.
verbose: False
[gcode_macro COSMOSNAP]
gcode:
{% set fn = printer.print_stats.filename|default('untitled')
|replace('.gcode','')|replace(' ','_') %}
RUN_SHELL_COMMAND CMD=cosmosnap PARAMS={fn}
[gcode_macro COSMORENDER]
gcode:
{% set fn = printer.print_stats.filename|default('untitled')
|replace('.gcode','')|replace(' ','_') %}
RUN_SHELL_COMMAND CMD=cosmorender PARAMS={fn}
@bniggemyer

Copy link
Copy Markdown

where is printer.cfg.snippet?

@MrQuallzin

Copy link
Copy Markdown
Author

where is printer.cfg.snippet?

Sorry about that, I've updated the gist

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment