Files
bambuddy/backend/app/services/camera_profiles.py
maziggy 6d7a92c024 fix(camera): P2S RTSP stream dropped every frame after the first (#1395)
A fresh P2S support bundle showed ffmpeg's reason for the stall:
  `frame=1 time=00:00:00.06 dup=0 drop=526 speed=0.0037x`. ffmpeg
  connects and frames arrive (drop counter climbs ~15/s), but it emits
  one output frame and the output clock freezes.

  The streaming command ends with `-r 15`, putting ffmpeg in CFR mode:
  it drops/dupes input frames to hit 15 fps based on the source's
  timestamps. P2S firmware 01.02.00.00 sends an RTSP stream whose RTP
  timestamps don't advance, so CFR treats every frame after the first
  as a same-timestamp duplicate and drops it. Snapshot capture works on
  the same printer because that path has no `-r` (no CFR conversion);
  X1/H2 are unaffected because their firmware timestamps are correct.
  The earlier probesize fix was masking this second bug.

  Add `-use_wallclock_as_timestamps 1` to the P2S camera profile via
  the existing extra_ffmpeg_input_args hook. ffmpeg rebuilds each
  packet's PTS from arrival wall-clock time, the output clock advances,
  and CFR conversion works. No dataclass change, no other model touched.

  Tests: 2 new in test_camera_profiles.py (P2S splices the flag+value
  pair; default profile keeps extra_ffmpeg_input_args empty so the
  override never leaks to X1/H2).
2026-05-22 13:55:16 +02:00

123 lines
5.3 KiB
Python

"""Per-printer-model camera tuning knobs.
Bambuddy talks to multiple Bambu Lab printer models that all expose a
camera but in subtly different ways:
- **Chamber image** (port 6000, proprietary binary protocol) — A1, A1
Mini, P1P, P1S. Frame pacing and TLS quirks are firmware-driven and
don't go through ffmpeg.
- **RTSPS** (port 322) — X1 series, X2D, H2 series, P2S. Wrapped by a
local TLS proxy + ffmpeg to MJPEG.
The RTSPS path used to live with hard-coded module constants in
``camera.py``: a single ``-probesize 32 -analyzeduration 0`` tuned for
X1/H2 fast startup. That breaks the P2S on firmware 01.02.00.00, whose
RTSP keyframe pacing is slow enough that ffmpeg can't lock onto the
stream within 32 bytes and gives up with "not enough frames to estimate
rate" (#1395 follow-up — Tschipel's reproduction).
This module replaces those module constants with per-model
:class:`CameraProfile` entries. Defaults match the historical pre-fix
behaviour, so existing models (X1, H2, X2D, X1E) keep their fast-
startup tuning unchanged. Quirky models override the relevant fields
only — the P2S entry below is the first example.
Adding a new model's quirk is a config edit (an entry in ``_PROFILES``
plus the alias for its internal SSDP code if needed), not another
hard-coded global constant.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class CameraProfile:
"""Tuning knobs for one printer model's camera path.
All defaults reflect the historical X1/H2 behaviour (fast startup,
minimal probing). Models with quirky firmware override individual
fields rather than re-defining the whole profile.
"""
# --- RTSPS / ffmpeg path -------------------------------------------------
# ffmpeg's `-probesize` (bytes). Smaller = lower startup latency but
# less margin to lock onto a stream whose first keyframe is delayed
# or whose container metadata is incomplete. P2S 01.02.00.00 needs a
# full MB to lock; X1/H2 lock within ~32 bytes.
probesize: int = 32
# ffmpeg's `-analyzeduration` (microseconds). 0 = skip format
# analysis entirely. Same trade-off as probesize.
analyzeduration: int = 0
# Max consecutive ffmpeg respawns when the printer drops the RTSP
# session mid-stream. Some firmwares cut the stream after a few
# seconds (originally noted on P2S), so we transparently respawn
# to keep the MJPEG client alive.
rtsp_reconnect_max: int = 30
# Seconds between ffmpeg respawn attempts.
rtsp_reconnect_delay: float = 0.2
# --- Extra ffmpeg input args ---------------------------------------------
# Hook for future per-model knobs (e.g. `-fflags` overrides) without
# changing the dataclass shape. Tuple, not list, so the dataclass
# stays hashable / frozen-friendly.
extra_ffmpeg_input_args: tuple[str, ...] = field(default_factory=tuple)
# ---------------------------------------------------------------------------
# Profile registry
# ---------------------------------------------------------------------------
# Default profile = historical X1/H2 fast-startup behaviour. Used for
# every RTSP-capable model that doesn't have an entry in ``_PROFILES``.
DEFAULT_PROFILE = CameraProfile()
# Per-model overrides. Keys are uppercase display names (e.g. "P2S")
# AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
# ``_MODEL_ALIASES`` below.
_PROFILES: dict[str, CameraProfile] = {
# P2S firmware 01.02.00.00 has two RTSP quirks, both surfaced by #1395:
#
# 1. Slow keyframe pacing — ffmpeg's "32-byte probe + zero analyze"
# combo can't estimate the frame rate ("consider increasing
# probesize"). Fixed by the relaxed probesize/analyzeduration below.
#
# 2. Non-advancing RTP timestamps — every frame is stamped at ~t=0.06s.
# With ffmpeg's default CFR rate conversion (`-r 15`), this freezes
# the output clock after the first frame and drops every subsequent
# frame as a same-timestamp duplicate (ffmpeg stderr: `frame=1
# time=00:00:00.06 dup=0 drop=526`). `-use_wallclock_as_timestamps 1`
# regenerates each packet's PTS from arrival wall-clock time, so the
# output clock advances and CFR conversion works. X1/H2 send correct
# timestamps and need no override.
"P2S": CameraProfile(
probesize=1_000_000,
analyzeduration=500_000,
extra_ffmpeg_input_args=("-use_wallclock_as_timestamps", "1"),
),
}
# SSDP internal codes that should resolve to a display-name profile.
# Display-name lookup is the canonical path; this just lets the camera
# code pass through whatever ``Printer.model`` carries without each
# call site needing to know the code→name map.
_MODEL_ALIASES: dict[str, str] = {
"N7": "P2S", # P2S internal SSDP code
}
def get_camera_profile(model: str | None) -> CameraProfile:
"""Return the :class:`CameraProfile` for *model*, or the default.
``model`` can be either a display name (e.g. ``"P2S"``) or an
internal SSDP code (e.g. ``"N7"``). Unknown models fall back to
:data:`DEFAULT_PROFILE` so the camera path is never blocked on a
missing entry.
"""
if not model:
return DEFAULT_PROFILE
key = model.upper().strip()
key = _MODEL_ALIASES.get(key, key)
return _PROFILES.get(key, DEFAULT_PROFILE)