mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 13:11:35 +02:00
Review follow-ups on applying camera_rotation to finish photos and
layer-timelapse frames.
Rotating the frame popped from _stage22_finish_frames rotated one of its
sources twice. The cache has two kinds of feeder: live grabs, which are raw,
and the #1867 in-print bank, whose bytes come from
_capture_snapshot_for_notification and have already been rotated on the way
in. The consumer cannot tell them apart, so on the finish_state trigger - the
path the bank exists to serve, on firmware that never emits stg_cur=22 - a 180
degree rotation cancelled itself out and the photo was upside-down again,
which is the reported symptom exactly; 90 and 270 landed 180 out. Rotation now
happens where each frame is captured, so every entry in the cache carries one
rotation whatever produced it, and the invariant is stated both where the
cache is declared and where it is consumed.
Two finish-photo sources were still writing unrotated files: the built-in
camera's own capture_finish_photo, and the still extracted from a
printer-recorded timelapse - which is the *preferred* source for a built-in
camera print, so a user with a rotation set got a correctly oriented photo or
not depending on which source happened to win. Neither ever holds the frame as
bytes; ffmpeg writes the file and they return a filename. apply_camera_rotation_to_file
handles that case and is best-effort - a failed rotate leaves the unrotated
file rather than losing a delivered photo. The archived video itself is the
printer's own file and is not re-encoded, so it still plays at the camera's
native orientation; the CHANGELOG says so rather than leaving it to be
discovered.
apply_camera_rotation logs at debug, not info. It was on a path that runs once
per layer, where a tall print would have put hundreds of lines in the log for
something the surrounding capture already reports at debug.
The moved rotation logic had no test of its own - every existing test patches
it out and asserts the call, so a flipped sign or a dropped expand=True would
have shipped green. test_camera_rotation.py drives the real round trip: a
corner marker pins which way it turns, the dimensions pin that the frame is
not cropped, and an undecodable frame comes back by identity because a capture
path must not lose a frame to a failed rotate.
Tests for the fix itself sit on both sides of the cache. The producer half is
driven directly; the consumer half is a closure nested inside on_print_complete
with nothing able to reach it, so it is pinned by an AST guard - checked
against the source because the alternative is no check at all. Reverting
main.py to the pre-fix shape fails three of the five, the guard among them.
The three new tests used Path("/tmp/test") for a patched base_dir, which Bandit
flagged (B108); they take tmp_path now.
421 lines
16 KiB
Python
421 lines
16 KiB
Python
"""Layer-based timelapse for external cameras.
|
|
|
|
Captures a frame on each layer change and stitches them into a video on print completion.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import shutil
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from backend.app.core.config import settings
|
|
from backend.app.services.camera import apply_camera_rotation
|
|
from backend.app.services.external_camera import capture_frame
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Active timelapse sessions: {printer_id: TimelapseSession}
|
|
_active_sessions: dict[int, "TimelapseSession"] = {}
|
|
|
|
# Sessions whose frames are being stitched right now: {printer_id: session_id}.
|
|
# on_print_complete removes the session from _active_sessions *before* handing
|
|
# frames_dir to ffmpeg, so for the length of a stitch (up to 300s) nothing in
|
|
# _active_sessions marks that directory as in use. Without this second registry
|
|
# the only thing standing between an in-progress stitch and
|
|
# cleanup_orphaned_timelapse_sessions() is the age margin — whose default is
|
|
# exactly the stitch timeout, so there is no headroom at all.
|
|
_finalizing_sessions: dict[int, str] = {}
|
|
|
|
|
|
def get_ffmpeg_path() -> str | None:
|
|
"""Get the path to ffmpeg executable."""
|
|
# Try shutil.which first
|
|
path = shutil.which("ffmpeg")
|
|
if path:
|
|
return path
|
|
# Check common locations (systemd services may have limited PATH)
|
|
for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
|
|
if Path(common_path).exists():
|
|
return common_path
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class TimelapseSession:
|
|
"""Active timelapse recording session."""
|
|
|
|
printer_id: int
|
|
archive_id: int | None
|
|
camera_url: str
|
|
camera_type: str
|
|
snapshot_url: str | None = None # Optional single-frame override; #1177
|
|
rotation: int = 0 # Printer's configured camera_rotation, degrees clockwise
|
|
last_layer: int = -1
|
|
frame_count: int = 0
|
|
session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
|
|
frames_dir: Path = field(init=False)
|
|
|
|
def __post_init__(self):
|
|
self.frames_dir = settings.base_dir / "timelapse_frames" / str(self.printer_id) / self.session_id
|
|
self.frames_dir.mkdir(parents=True, exist_ok=True)
|
|
logger.info("Created timelapse session %s for printer %s", self.session_id, self.printer_id)
|
|
|
|
async def capture_layer(self, layer_num: int) -> bool:
|
|
"""Capture frame if layer changed.
|
|
|
|
Args:
|
|
layer_num: Current layer number from printer
|
|
|
|
Returns:
|
|
True if frame was captured, False otherwise
|
|
"""
|
|
# Only capture if layer increased
|
|
if layer_num <= self.last_layer:
|
|
return False
|
|
|
|
self.last_layer = layer_num
|
|
|
|
try:
|
|
# Reuse the live view's frame instead of opening a second handle on
|
|
# a single-reader device (#2707). Unguarded, a print watched from
|
|
# start to finish recorded zero successful layer captures, and the
|
|
# stitched video came out empty or badly truncated.
|
|
from backend.app.api.routes.camera import live_frame_for_capture
|
|
|
|
defer, buffered = live_frame_for_capture(self.printer_id)
|
|
if defer:
|
|
if not buffered:
|
|
# Viewer attached but nothing buffered yet: skip this layer
|
|
# rather than compete and kick them off (#1348).
|
|
logger.debug(
|
|
"Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
|
|
layer_num,
|
|
self.printer_id,
|
|
)
|
|
return False
|
|
frame_data = buffered
|
|
else:
|
|
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
|
|
if frame_data:
|
|
if self.rotation:
|
|
frame_data = await asyncio.to_thread(apply_camera_rotation, frame_data, self.rotation, logger)
|
|
frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
|
|
await asyncio.to_thread(frame_path.write_bytes, frame_data)
|
|
self.frame_count += 1
|
|
logger.debug(
|
|
"Captured layer %s for printer %s (frame %s)", layer_num, self.printer_id, self.frame_count
|
|
)
|
|
return True
|
|
else:
|
|
logger.warning("Failed to capture frame for layer %s", layer_num)
|
|
return False
|
|
except Exception as e:
|
|
logger.error("Error capturing timelapse frame: %s", e)
|
|
return False
|
|
|
|
async def stitch(self, output_path: Path, fps: int = 30) -> bool:
|
|
"""Create MP4 from captured frames using ffmpeg.
|
|
|
|
Args:
|
|
output_path: Path for output video file
|
|
fps: Frames per second for output video
|
|
|
|
Returns:
|
|
True if stitching succeeded, False otherwise
|
|
"""
|
|
if self.frame_count == 0:
|
|
logger.warning("No frames to stitch")
|
|
return False
|
|
|
|
ffmpeg = get_ffmpeg_path()
|
|
if not ffmpeg:
|
|
logger.error("ffmpeg not found - required for timelapse stitching")
|
|
return False
|
|
|
|
# Find all frame files and create a sequential list
|
|
# This handles gaps in layer numbers (e.g., if some captures failed)
|
|
frame_files = sorted(self.frames_dir.glob("layer_*.jpg"))
|
|
if not frame_files:
|
|
logger.warning("No frame files found in timelapse directory")
|
|
return False
|
|
|
|
# Create a concat file listing all frames
|
|
concat_file = self.frames_dir / "frames.txt"
|
|
try:
|
|
with open(concat_file, "w") as f:
|
|
for frame in frame_files:
|
|
# Each frame shown for 1/fps duration
|
|
f.write(f"file '{frame.name}'\n")
|
|
f.write(f"duration {1.0 / fps}\n")
|
|
# Add last frame again (required by concat demuxer)
|
|
if frame_files:
|
|
f.write(f"file '{frame_files[-1].name}'\n")
|
|
except Exception as e:
|
|
logger.error("Failed to create concat file: %s", e)
|
|
return False
|
|
|
|
# Use ffmpeg concat demuxer for variable-gap frame sequences
|
|
cmd = [
|
|
ffmpeg,
|
|
"-y", # Overwrite output
|
|
"-f",
|
|
"concat",
|
|
"-safe",
|
|
"0",
|
|
"-i",
|
|
str(concat_file),
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-preset",
|
|
"medium",
|
|
"-crf",
|
|
"23",
|
|
str(output_path),
|
|
]
|
|
|
|
try:
|
|
process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
cwd=str(self.frames_dir), # Run in frames dir so relative paths work
|
|
)
|
|
|
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
|
|
|
|
if process.returncode != 0:
|
|
logger.error("ffmpeg timelapse stitch failed: %s", stderr.decode()[:500])
|
|
return False
|
|
|
|
logger.info("Created timelapse video: %s (%s frames)", output_path, self.frame_count)
|
|
return True
|
|
|
|
except TimeoutError:
|
|
logger.error("Timelapse stitching timed out")
|
|
if process:
|
|
process.kill()
|
|
return False
|
|
except Exception as e:
|
|
logger.error("Timelapse stitch failed: %s", e)
|
|
return False
|
|
|
|
def cleanup(self):
|
|
"""Remove temporary frames directory."""
|
|
try:
|
|
if self.frames_dir.exists():
|
|
shutil.rmtree(self.frames_dir, ignore_errors=True)
|
|
logger.info("Cleaned up timelapse frames for session %s", self.session_id)
|
|
except Exception as e:
|
|
logger.warning("Failed to cleanup timelapse frames: %s", e)
|
|
|
|
|
|
def start_session(
|
|
printer_id: int,
|
|
archive_id: int | None,
|
|
url: str,
|
|
cam_type: str,
|
|
snapshot_url: str | None = None,
|
|
rotation: int = 0,
|
|
) -> TimelapseSession:
|
|
"""Start new timelapse session for a printer.
|
|
|
|
Args:
|
|
printer_id: The printer ID
|
|
archive_id: Associated print archive ID (optional)
|
|
url: External camera URL
|
|
cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
|
|
snapshot_url: Optional single-frame URL override; when set, layer captures
|
|
fetch from it directly instead of opening the live stream. #1177.
|
|
rotation: Printer's configured camera_rotation (degrees clockwise),
|
|
applied to every captured frame before it's saved.
|
|
|
|
Returns:
|
|
The new TimelapseSession
|
|
"""
|
|
# Cancel any existing session
|
|
cancel_session(printer_id)
|
|
|
|
session = TimelapseSession(
|
|
printer_id=printer_id,
|
|
archive_id=archive_id,
|
|
camera_url=url,
|
|
camera_type=cam_type,
|
|
snapshot_url=snapshot_url,
|
|
rotation=rotation,
|
|
)
|
|
_active_sessions[printer_id] = session
|
|
logger.info("Started timelapse session for printer %s", printer_id)
|
|
return session
|
|
|
|
|
|
def get_session(printer_id: int) -> TimelapseSession | None:
|
|
"""Get active timelapse session for a printer."""
|
|
return _active_sessions.get(printer_id)
|
|
|
|
|
|
async def on_layer_change(printer_id: int, layer_num: int):
|
|
"""Called on layer change - captures frame if session active.
|
|
|
|
Args:
|
|
printer_id: The printer ID
|
|
layer_num: Current layer number
|
|
"""
|
|
session = get_session(printer_id)
|
|
if session:
|
|
await session.capture_layer(layer_num)
|
|
|
|
|
|
async def on_print_complete(printer_id: int) -> Path | None:
|
|
"""Stitch timelapse and return path. Cleans up session.
|
|
|
|
Args:
|
|
printer_id: The printer ID
|
|
|
|
Returns:
|
|
Path to stitched video, or None if no session or stitching failed
|
|
"""
|
|
session = _active_sessions.pop(printer_id, None)
|
|
if not session:
|
|
return None
|
|
|
|
if session.frame_count == 0:
|
|
logger.info("No timelapse frames captured for printer %s", printer_id)
|
|
session.cleanup()
|
|
return None
|
|
|
|
# Create output path in parent of frames dir
|
|
output_path = session.frames_dir.parent / f"timelapse_{session.session_id}.mp4"
|
|
|
|
# The session is already out of _active_sessions, so mark it finalizing for
|
|
# the length of the stitch — otherwise a sweep running now sees a frames
|
|
# directory that matches no session and whose mtime is the last layer's
|
|
# write, which on a tall print's final layer is easily older than the age
|
|
# margin, and deletes ffmpeg's input from under it.
|
|
_finalizing_sessions[printer_id] = session.session_id
|
|
try:
|
|
success = await session.stitch(output_path)
|
|
if success:
|
|
# Cleanup frames after successful stitch
|
|
session.cleanup()
|
|
return output_path
|
|
else:
|
|
session.cleanup()
|
|
return None
|
|
except Exception as e:
|
|
logger.error("Timelapse completion failed: %s", e)
|
|
session.cleanup()
|
|
return None
|
|
finally:
|
|
_finalizing_sessions.pop(printer_id, None)
|
|
|
|
|
|
def cancel_session(printer_id: int):
|
|
"""Cancel and cleanup timelapse session (on print fail/cancel).
|
|
|
|
Args:
|
|
printer_id: The printer ID
|
|
"""
|
|
session = _active_sessions.pop(printer_id, None)
|
|
if session:
|
|
session.cleanup()
|
|
logger.info("Cancelled timelapse session for printer %s", printer_id)
|
|
|
|
|
|
def get_active_sessions() -> dict[int, TimelapseSession]:
|
|
"""Get all active timelapse sessions."""
|
|
return _active_sessions.copy()
|
|
|
|
|
|
def cleanup_orphaned_timelapse_sessions(min_age_seconds: float = 300) -> int:
|
|
"""Remove timelapse_frames/<printer_id>/* left behind by a crash or
|
|
restart that happened while a session was active.
|
|
|
|
_active_sessions is in-memory only, so a process restart loses track of
|
|
any in-flight session without ever calling cancel_session()/cleanup() -
|
|
the frames directory (and, if stitching had already produced output
|
|
before the restart, a stray `timelapse_<session_id>.mp4`) are then
|
|
orphaned on disk with nothing else to reap them (unlike the ffmpeg
|
|
orphan janitor in routes/camera.py, there was no equivalent here).
|
|
|
|
Safe to call once at startup: normal operation always cleans up via
|
|
on_print_complete/cancel_session, so anything found here predates this
|
|
process - and a restart-recovered print doesn't get a new timelapse
|
|
session either (`_maybe_start_layer_timelapse` is only wired into fresh
|
|
PRINT_START events, see #1353), so an orphaned directory can never be
|
|
resumed.
|
|
|
|
Also safe to call mid-run, which needs all three guards rather than the
|
|
age margin alone:
|
|
|
|
* `_active_sessions` covers a session that is still capturing.
|
|
* `_finalizing_sessions` covers the stitch window. on_print_complete drops
|
|
the session from `_active_sessions` before handing frames_dir to ffmpeg,
|
|
so without this the directory matches no session for up to 300s while
|
|
being actively read.
|
|
* `min_age_seconds` covers the remaining gap - a session in the middle of
|
|
being created, and the stitched `.mp4` between ffmpeg finishing it and
|
|
the caller attaching and unlinking it. Both are freshly written, so the
|
|
margin has real headroom there; it did NOT have any for the stitch
|
|
window, whose length is bounded by the same 300s.
|
|
|
|
Returns the number of orphaned directories/files removed.
|
|
"""
|
|
base_dir = settings.base_dir / "timelapse_frames"
|
|
if not base_dir.exists():
|
|
return 0
|
|
|
|
now = time.time()
|
|
removed = 0
|
|
for printer_dir in base_dir.iterdir():
|
|
if not printer_dir.is_dir():
|
|
continue
|
|
try:
|
|
printer_id = int(printer_dir.name)
|
|
except ValueError:
|
|
continue
|
|
|
|
active_session = _active_sessions.get(printer_id)
|
|
in_use_session_ids = {
|
|
active_session.session_id if active_session else None,
|
|
_finalizing_sessions.get(printer_id),
|
|
} - {None}
|
|
|
|
for entry in printer_dir.iterdir():
|
|
# Frame dirs are named "<session_id>/"; stitched-but-not-yet-
|
|
# attached output files are "timelapse_<session_id>.mp4" (see
|
|
# on_print_complete's output_path). Anything else under here was
|
|
# not written by this module, so leave it alone rather than
|
|
# deleting a file on the strength of its age.
|
|
if entry.is_dir():
|
|
entry_session_id = entry.name
|
|
elif entry.name.startswith("timelapse_") and entry.name.endswith(".mp4"):
|
|
entry_session_id = entry.name[len("timelapse_") : -len(".mp4")]
|
|
else:
|
|
continue
|
|
if entry_session_id in in_use_session_ids:
|
|
continue
|
|
try:
|
|
if now - entry.stat().st_mtime < min_age_seconds:
|
|
continue
|
|
except OSError:
|
|
continue
|
|
try:
|
|
# No ignore_errors: it would swallow a failed removal while the
|
|
# count and the log line below still claimed success, and that
|
|
# log is the only evidence an operator has of what was deleted.
|
|
if entry.is_dir():
|
|
shutil.rmtree(entry)
|
|
else:
|
|
entry.unlink(missing_ok=True)
|
|
removed += 1
|
|
logger.info("Removed orphaned timelapse artifact: %s", entry)
|
|
except OSError as e:
|
|
logger.warning("Failed to remove orphaned timelapse artifact %s: %s", entry, e)
|
|
|
|
return removed
|