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 },
);
});
});
});
+2
View File
@@ -152,6 +152,7 @@ export interface Printer {
external_camera_url: string | null;
external_camera_type: string | null; // "mjpeg", "rtsp", "snapshot"
external_camera_enabled: boolean;
external_camera_snapshot_url: string | null; // optional single-frame override (#1177)
camera_rotation: number; // 0, 90, 180, 270 degrees
plate_detection_enabled: boolean; // Check plate before print
plate_detection_roi?: PlateDetectionROI; // ROI for plate detection
@@ -351,6 +352,7 @@ export interface PrinterCreate {
external_camera_url?: string | null;
external_camera_type?: string | null;
external_camera_enabled?: boolean;
external_camera_snapshot_url?: string | null;
camera_rotation?: number;
plate_detection_enabled?: boolean;
plate_detection_roi?: PlateDetectionROI;
+3
View File
@@ -2056,6 +2056,9 @@ export default {
cameraTypeRtsp: 'RTSP-Stream',
cameraTypeSnapshot: 'HTTP-Snapshot',
cameraTypeUsb: 'USB-Kamera (V4L2)',
cameraSnapshotUrl: 'Snapshot-URL (optional)',
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
cameraSnapshotUrlHelp: 'URL für Einzelbildaufnahmen — wird für Benachrichtigungs-Vorschaubilder, Abschlussfotos, Zeitraffer und Plattenerkennung verwendet. Leer lassen, um Bilder aus dem oben konfigurierten Live-Stream zu verwenden. Nützlich für go2rtc (/api/frame.jpeg) und IP-Kameras mit dediziertem Snapshot-Endpunkt.',
cameraRotation: 'Drehung',
test: 'Testen',
connected: 'Verbunden',
+3
View File
@@ -2059,6 +2059,9 @@ export default {
cameraTypeRtsp: 'RTSP Stream',
cameraTypeSnapshot: 'HTTP Snapshot',
cameraTypeUsb: 'USB Camera (V4L2)',
cameraSnapshotUrl: 'Snapshot URL (optional)',
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
cameraRotation: 'Rotation',
test: 'Test',
connected: 'Connected',
+3
View File
@@ -2010,6 +2010,9 @@ export default {
cameraTypeRtsp: 'Flux RTSP',
cameraTypeSnapshot: 'Snapshot HTTP',
cameraTypeUsb: 'Caméra USB (V4L2)',
cameraSnapshotUrl: 'Snapshot URL (optional)',
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
cameraRotation: 'Rotation',
test: 'Tester',
connected: 'Connecté',
+3
View File
@@ -2009,6 +2009,9 @@ export default {
cameraTypeRtsp: 'Stream RTSP',
cameraTypeSnapshot: 'Snapshot HTTP',
cameraTypeUsb: 'Fotocamera USB (V4L2)',
cameraSnapshotUrl: 'Snapshot URL (optional)',
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
cameraRotation: 'Rotazione',
test: 'Test',
connected: 'Connesso',
+3
View File
@@ -2055,6 +2055,9 @@ export default {
cameraTypeRtsp: 'RTSPストリーム',
cameraTypeSnapshot: 'HTTPスナップショット',
cameraTypeUsb: 'USBカメラ (V4L2)',
cameraSnapshotUrl: 'Snapshot URL (optional)',
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
cameraRotation: '回転',
test: 'テスト',
connected: '接続済み',
+3
View File
@@ -2009,6 +2009,9 @@ export default {
cameraTypeRtsp: 'Stream RTSP',
cameraTypeSnapshot: 'Snapshot HTTP',
cameraTypeUsb: 'Câmera USB (V4L2)',
cameraSnapshotUrl: 'Snapshot URL (optional)',
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
cameraRotation: 'Rotação',
test: 'Testar',
connected: 'Conectado',
+3
View File
@@ -2053,6 +2053,9 @@ export default {
cameraTypeRtsp: 'RTSP 流',
cameraTypeSnapshot: 'HTTP 快照',
cameraTypeUsb: 'USB 摄像头 (V4L2)',
cameraSnapshotUrl: 'Snapshot URL (optional)',
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
cameraRotation: '旋转',
test: '测试',
connected: '已连接',
+3
View File
@@ -2053,6 +2053,9 @@ export default {
cameraTypeRtsp: 'RTSP 流',
cameraTypeSnapshot: 'HTTP 快照',
cameraTypeUsb: 'USB 攝影機 (V4L2)',
cameraSnapshotUrl: 'Snapshot URL (optional)',
cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
cameraRotation: '旋轉',
test: '測試',
connected: '已連線',
+56 -1
View File
@@ -893,7 +893,7 @@ export function SettingsPage() {
});
const updatePrinterMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> }) =>
mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; external_camera_snapshot_url: string | null; camera_rotation: number }> }) =>
api.updatePrinter(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['printers'] });
@@ -1116,20 +1116,31 @@ export function SettingsPage() {
const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
const [localSnapshotUrls, setLocalSnapshotUrls] = useState<Record<number, string>>({});
const snapshotUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
const initializedPrinterSnapshotUrlsRef = useRef<Set<number>>(new Set());
// Initialize local camera URLs from printer data
useEffect(() => {
if (printers) {
const urls: Record<number, string> = {};
const snapUrls: Record<number, string> = {};
printers.forEach(p => {
if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {
urls[p.id] = p.external_camera_url;
initializedPrinterUrlsRef.current.add(p.id);
}
if (p.external_camera_snapshot_url && !initializedPrinterSnapshotUrlsRef.current.has(p.id)) {
snapUrls[p.id] = p.external_camera_snapshot_url;
initializedPrinterSnapshotUrlsRef.current.add(p.id);
}
});
if (Object.keys(urls).length > 0) {
setLocalCameraUrls(prev => ({ ...prev, ...urls }));
}
if (Object.keys(snapUrls).length > 0) {
setLocalSnapshotUrls(prev => ({ ...prev, ...snapUrls }));
}
}
}, [printers]);
@@ -1151,6 +1162,21 @@ export function SettingsPage() {
}, 800);
};
const handleSnapshotUrlChange = (printerId: number, url: string) => {
setLocalSnapshotUrls(prev => ({ ...prev, [printerId]: url }));
if (snapshotUrlSaveTimeoutRef.current[printerId]) {
clearTimeout(snapshotUrlSaveTimeoutRef.current[printerId]);
}
snapshotUrlSaveTimeoutRef.current[printerId] = setTimeout(() => {
updatePrinterMutation.mutate({
id: printerId,
data: { external_camera_snapshot_url: url || null }
});
}, 800);
};
const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number }) => {
const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> = {};
if (updates.type !== undefined) data.external_camera_type = updates.type || null;
@@ -1912,6 +1938,35 @@ export function SettingsPage() {
)}
</div>
)}
{(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && (
<div className="space-y-1">
<label className="text-xs text-bambu-gray">{t('settings.cameraSnapshotUrl', 'Snapshot URL (optional)')}</label>
<div className="flex gap-2">
<input
type="text"
placeholder={t('settings.cameraSnapshotUrlPlaceholder', 'http://192.168.1.61:1984/api/frame.jpeg?src=printer')}
value={localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? ''}
onChange={(e) => handleSnapshotUrlChange(printer.id, e.target.value)}
className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
/>
<Button
size="sm"
variant="secondary"
onClick={() => handleTestExternalCamera(printer.id, localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? '', 'snapshot')}
disabled={extCameraTestLoading[printer.id] || !(localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url)}
>
{extCameraTestLoading[printer.id] ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t('settings.test')
)}
</Button>
</div>
<p className="text-xs text-bambu-gray opacity-75">
{t('settings.cameraSnapshotUrlHelp', 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.')}
</p>
</div>
)}
<div className="flex items-center gap-2">
<label className="text-xs text-bambu-gray">{t('settings.cameraRotation')}</label>
<select