Files
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

109 lines
5.1 KiB
Python

"""Unit tests for camera profile registry.
The registry decouples per-model camera tuning (probesize, analyzeduration,
reconnect cadence) from the hard-coded constants that lived in
``camera.py`` until #1395 follow-up. Adding a new model's quirk should
be a config edit, not a code change.
"""
from dataclasses import FrozenInstanceError
import pytest
from backend.app.services.camera_profiles import (
DEFAULT_PROFILE,
CameraProfile,
get_camera_profile,
)
class TestGetCameraProfile:
def test_unknown_model_returns_default(self):
"""Models with no override fall through to DEFAULT_PROFILE so the
camera path is never blocked on a missing entry."""
assert get_camera_profile("UNKNOWN_MODEL") is DEFAULT_PROFILE
assert get_camera_profile("Future_Bambu_Model_X42") is DEFAULT_PROFILE
def test_none_model_returns_default(self):
"""`None` / empty model (very early in connect handshake) must not
crash; the default profile is safe for any RTSP-capable printer."""
assert get_camera_profile(None) is DEFAULT_PROFILE
assert get_camera_profile("") is DEFAULT_PROFILE
def test_default_profile_preserves_historical_fast_startup(self):
"""X1/H2 fast-startup tuning is the historical baseline. The first
refactor must not regress it for the printers that already worked.
"""
assert DEFAULT_PROFILE.probesize == 32
assert DEFAULT_PROFILE.analyzeduration == 0
assert DEFAULT_PROFILE.rtsp_reconnect_max == 30
assert DEFAULT_PROFILE.rtsp_reconnect_delay == 0.2
def test_p2s_has_relaxed_probe(self):
"""P2S firmware 01.02.00.00 needs more probe room — ffmpeg's own
diagnostic says so. This is the first per-model override and the
regression to guard."""
profile = get_camera_profile("P2S")
assert profile is not DEFAULT_PROFILE
# Order of magnitude up from the default — enough to lock onto a
# slow-keyframe stream without adding multi-second startup.
assert profile.probesize >= 1_000_000
assert profile.analyzeduration >= 500_000
def test_p2s_regenerates_timestamps_from_wallclock(self):
"""P2S firmware 01.02.00.00 sends non-advancing RTP timestamps;
ffmpeg's default CFR conversion (`-r 15`) then freezes the output
clock after frame 1 and drops everything else (#1395). The profile
must splice `-use_wallclock_as_timestamps 1` into the input args so
ffmpeg rebuilds PTS from arrival time. Order matters — the flag and
its value must be adjacent so they reach ffmpeg as a pair."""
args = get_camera_profile("P2S").extra_ffmpeg_input_args
assert "-use_wallclock_as_timestamps" in args
idx = args.index("-use_wallclock_as_timestamps")
assert args[idx + 1] == "1"
def test_default_profile_has_no_timestamp_override(self):
"""X1/H2 send correct, advancing RTP timestamps — they must NOT get
the wallclock override, which would needlessly re-stamp a healthy
stream. Guards against the P2S fix leaking into the default."""
assert DEFAULT_PROFILE.extra_ffmpeg_input_args == ()
def test_p2s_internal_code_resolves_to_p2s_profile(self):
"""SSDP internal codes (e.g. `N7` for P2S) must resolve to the
same profile as their display name. Otherwise printers freshly
connected (before display-name lookup completes) would use the
default profile and hit the same #1395 bug."""
assert get_camera_profile("N7") is get_camera_profile("P2S")
def test_lookup_is_case_insensitive(self):
"""Display-name capitalisation should not matter — callers may
carry lowercase or mixed-case values straight from MQTT."""
assert get_camera_profile("p2s") is get_camera_profile("P2S")
assert get_camera_profile("P2s") is get_camera_profile("P2S")
def test_known_rtsp_models_keep_default_unchanged(self):
"""X1, X1C, X1E, H2D, H2S, X2D — every other RTSP-capable model
must use the default profile until proven otherwise. Anything
else means we silently changed behaviour for a model the user
hasn't reported a problem on."""
for model in ("X1", "X1C", "X1E", "X2D", "H2C", "H2D", "H2D PRO", "H2S"):
assert get_camera_profile(model) is DEFAULT_PROFILE, (
f"{model} unexpectedly has a non-default profile — review "
"whether the change is intentional before shipping."
)
class TestCameraProfileShape:
def test_profile_is_frozen(self):
"""Profiles are immutable; mutating them at runtime would
introduce action-at-a-distance for the camera generator."""
with pytest.raises(FrozenInstanceError):
DEFAULT_PROFILE.probesize = 999 # type: ignore[misc]
def test_extra_ffmpeg_input_args_defaults_to_empty_tuple(self):
"""Profiles can declare extra `-flag value` pairs to splice into
the ffmpeg input args without changing the dataclass shape.
Default is empty so the historical command is unchanged."""
p = CameraProfile()
assert p.extra_ffmpeg_input_args == ()