feat(camera): optional snapshot URL override for external cameras (#1177)

go2rtc and several IP cameras still emit a warm-up / black frame on every
  fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
  slipped through intermittently for @nkm8's setup. His own bisect named
  the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
  single-frame endpoint that never returns the encoder's stale keyframe.

  Adds an optional external_camera_snapshot_url column on printers. When
  set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
  notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
  Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
  on the override URL via plain HTTP GET, bypassing the warm-up dance.

  Live view stays on the configured stream URL — only single-frame
  captures use the override. Override is camera-type-agnostic. SSRF guard
  applies (existing _sanitize_camera_url allowlist). Empty string treated
  as unset.

  Settings UI: new "Snapshot URL (optional)" input + Test button under
  External Cameras, hidden for camera_type=snapshot since the live URL is
  already a single-frame source. en + de fully translated; 6 other locales
  seeded with English copy.

  5 backend tests pin the routing contract; 3 frontend tests pin the
  input + debounced PATCH. Documented in
  bambuddy-wiki/docs/features/camera.md with the go2rtc example.
This commit is contained in:
maziggy
2026-05-03 07:59:02 +02:00
parent d6a3139397
commit abc8e97050
24 changed files with 517 additions and 133 deletions
@@ -717,4 +717,95 @@ describe('SettingsPage', () => {
});
});
});
describe('external camera snapshot URL override (#1177)', () => {
/**
* The snapshot URL input only appears for stream camera types where the
* MJPEG warm-up problem can occur (mjpeg / rtsp / usb). Pure HTTP
* snapshot sources don't need an override since their stream URL is
* already a single-frame endpoint.
*/
const mjpegPrinter = {
id: 7,
name: 'go2rtc Cam',
serial_number: 'TEST123',
ip_address: '192.168.1.100',
access_code: 'XXXX',
model: 'P1S',
location: null,
nozzle_count: 1,
is_active: true,
auto_archive: true,
external_camera_url: 'http://192.168.1.61:1984/api/stream.mjpeg?src=printer',
external_camera_type: 'mjpeg',
external_camera_enabled: true,
external_camera_snapshot_url: null,
camera_rotation: 0,
plate_detection_enabled: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
it('renders the snapshot URL input when camera_type is mjpeg', async () => {
server.use(
http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
);
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
});
});
it('hides the snapshot URL input when camera_type is snapshot (already a single-frame source)', async () => {
server.use(
http.get('/api/v1/printers/', () =>
HttpResponse.json([{ ...mjpegPrinter, external_camera_type: 'snapshot' }]),
),
);
render(<SettingsPage />);
// Wait for the live-stream URL placeholder to render so we know the
// camera section finished mounting before asserting absence of the
// snapshot input below.
await waitFor(() => {
expect(screen.getByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
});
expect(screen.queryByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).not.toBeInTheDocument();
});
it('PATCHes the printer with external_camera_snapshot_url when the user types into the input', async () => {
let receivedBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
http.patch('/api/v1/printers/7', async ({ request }) => {
receivedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ...mjpegPrinter, ...receivedBody });
}),
);
render(<SettingsPage />);
const input = await waitFor(() =>
screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
);
const user = userEvent.setup();
await user.type(input, 'http://192.168.1.61:1984/api/frame.jpeg?src=printer');
// Save is debounced by 800ms; assert the PATCH eventually fires with
// the typed snapshot URL.
await waitFor(
() => {
expect(receivedBody).not.toBeNull();
expect(receivedBody!.external_camera_snapshot_url).toBe(
'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
);
},
{ timeout: 3000 },
);
});
});
});