- Add AMS filament preview for reprints and file type badges

- AMS Filament Preview:
    - Add GET /archives/{id}/filament-requirements endpoint to extract
      filament data from archived 3MF slice_info.config
    - Show filament comparison in reprint modal when printer is selected
    - Compare both filament type AND color with visual indicators:
      - Green checkmark: Type and color match
      - Yellow warning: Same type, different color
      - Orange warning: Different type or empty slot
    - Handle AMS-HT units (id >= 128) and virtual tray (slot 254)
    - Normalize color formats between API (RRGGBBAA) and 3MF (#RRGGBB)

- File Type Badge:
  - Archive cards show GCODE (green) or SOURCE (orange) badge
  - Helps users identify which files have print data vs source-only
  - Badge appears next to printer name in card content area

- Camera Stream Stability:
  - Increase ffmpeg read timeout from 10s to 30s
  - Add ffmpeg RTSP options: -timeout, -buffer_size, -max_delay
  - Add frontend auto-reconnection with exponential backoff (2s-30s)
  - Show reconnection UI with countdown and attempt counter
This commit is contained in:
maziggy
2025-12-28 13:50:29 +01:00
parent 530a7a4608
commit b3ef6ccb32
8 changed files with 720 additions and 419 deletions
+12
View File
@@ -1576,6 +1576,18 @@ export const api = {
`${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
getArchiveForSlicer: (id: number, filename: string) =>
`${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
getArchiveFilamentRequirements: (archiveId: number) =>
request<{
archive_id: number;
filename: string;
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}>(`/archives/${archiveId}/filament-requirements`),
reprintArchive: (archiveId: number, printerId: number) =>
request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
`/archives/${archiveId}/reprint?printer_id=${printerId}`,
+205 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { X, Printer, Loader2 } from 'lucide-react';
import { X, Printer, Loader2, AlertTriangle, Check, Circle } from 'lucide-react';
import { api } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
@@ -29,6 +29,19 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
queryFn: api.getPrinters,
});
// Fetch filament requirements from the archived 3MF
const { data: filamentReqs } = useQuery({
queryKey: ['archive-filaments', archiveId],
queryFn: () => api.getArchiveFilamentRequirements(archiveId),
});
// Fetch printer status when a printer is selected
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', selectedPrinter],
queryFn: () => api.getPrinterStatus(selectedPrinter!),
enabled: !!selectedPrinter,
});
const reprintMutation = useMutation({
mutationFn: () => {
if (!selectedPrinter) throw new Error('No printer selected');
@@ -42,6 +55,109 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
const activePrinters = printers?.filter((p) => p.is_active) || [];
// Build a map of AMS slot positions to loaded filaments
// Bambu Lab slot numbering: slot = amsId * 4 + trayId + 1 (for regular AMS)
// AMS-HT (id >= 128) is special - uses its position in the array
// External spool: slot 254, No filament: slot 255
const loadedFilaments = useMemo(() => {
if (!printerStatus?.ams) return new Map<number, { type: string; color: string }>();
const map = new Map<number, { type: string; color: string }>();
// Sort AMS units by ID to get consistent ordering, filter out AMS-HT for now
const regularAms = printerStatus.ams
.filter((ams) => ams.id < 128)
.sort((a, b) => a.id - b.id);
// Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
const normalizeColor = (color: string | null | undefined): string => {
if (!color) return '#808080';
// Remove alpha channel if present (8-char hex to 6-char)
const hex = color.replace('#', '').substring(0, 6);
return `#${hex}`;
};
regularAms.forEach((amsUnit) => {
amsUnit.tray.forEach((tray) => {
// Calculate global slot ID (1-based to match 3MF)
// AMS 0 tray 0 = slot 1, AMS 0 tray 1 = slot 2, etc.
const globalSlotId = amsUnit.id * 4 + tray.id + 1;
if (tray.tray_type) {
map.set(globalSlotId, {
type: tray.tray_type,
color: normalizeColor(tray.tray_color),
});
}
});
});
// AMS-HT units get slots after regular AMS slots
const amsHtUnits = printerStatus.ams.filter((ams) => ams.id >= 128);
let htSlotBase = regularAms.length * 4 + 1;
amsHtUnits.forEach((amsUnit) => {
amsUnit.tray.forEach((tray) => {
if (tray.tray_type) {
map.set(htSlotBase + tray.id, {
type: tray.tray_type,
color: normalizeColor(tray.tray_color),
});
}
});
htSlotBase += amsUnit.tray.length;
});
// Add virtual tray (external spool) as slot 254 (Bambu standard)
if (printerStatus.vt_tray?.tray_type) {
map.set(254, {
type: printerStatus.vt_tray.tray_type,
color: normalizeColor(printerStatus.vt_tray.tray_color),
});
}
return map;
}, [printerStatus]);
// Compare required filaments with loaded filaments
const filamentComparison = useMemo(() => {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
// Helper to normalize color for comparison (case-insensitive, strip #)
const normalizeColorForCompare = (color: string | undefined): string => {
if (!color) return '';
return color.replace('#', '').toLowerCase();
};
return filamentReqs.filaments.map((req) => {
const loaded = loadedFilaments.get(req.slot_id);
const hasFilament = !!loaded;
const typeMatch = hasFilament && loaded?.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch = hasFilament && normalizeColorForCompare(loaded?.color) === normalizeColorForCompare(req.color);
// Status: match (both), type_only (type ok, color different), mismatch (type wrong), empty
let status: 'match' | 'type_only' | 'mismatch' | 'empty';
if (!hasFilament) {
status = 'empty';
} else if (typeMatch && colorMatch) {
status = 'match';
} else if (typeMatch) {
status = 'type_only'; // Same type, different color
} else {
status = 'mismatch'; // Different type
}
return {
...req,
loaded,
hasFilament,
typeMatch,
colorMatch,
status,
};
});
}, [filamentReqs, loadedFilaments]);
const hasAnyMismatch = filamentComparison.some((f) => f.status !== 'match');
const hasEmptySlots = filamentComparison.some((f) => f.status === 'empty');
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
<Card className="w-full max-w-md">
@@ -105,6 +221,93 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
</div>
)}
{/* Filament comparison - show when printer selected and has filament requirements */}
{selectedPrinter && filamentComparison.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm text-bambu-gray">Filament Check</span>
{hasEmptySlots ? (
<span className="text-xs text-orange-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Empty slots
</span>
) : filamentComparison.some((f) => f.status === 'mismatch') ? (
<span className="text-xs text-orange-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Type mismatch
</span>
) : filamentComparison.some((f) => f.status === 'type_only') ? (
<span className="text-xs text-yellow-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Color mismatch
</span>
) : (
<span className="text-xs text-bambu-green flex items-center gap-1">
<Check className="w-3 h-3" />
Ready
</span>
)}
</div>
<div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
{filamentComparison.map((item) => (
<div
key={item.slot_id}
className="grid items-center gap-2"
style={{ gridTemplateColumns: '48px 16px 1fr auto 16px 56px 16px' }}
>
{/* Slot label */}
<span className="text-bambu-gray">Slot {item.slot_id}</span>
{/* Required color */}
<Circle
className="w-3 h-3 flex-shrink-0"
fill={item.color}
stroke={item.color}
/>
{/* Required type + grams */}
<span className="text-white truncate">
{item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
</span>
{/* Arrow */}
<span className="text-bambu-gray">→</span>
{/* Loaded color */}
{item.loaded ? (
<Circle
className="w-3 h-3 flex-shrink-0"
fill={item.loaded.color}
stroke={item.loaded.color}
/>
) : (
<span />
)}
{/* Loaded type */}
<span className={
item.status === 'match' ? 'text-bambu-green' :
item.status === 'type_only' ? 'text-yellow-400' :
'text-orange-400'
}>
{item.loaded?.type || 'Empty'}
</span>
{/* Status icon */}
{item.status === 'match' ? (
<Check className="w-3 h-3 text-bambu-green" />
) : item.status === 'type_only' ? (
<span title="Color mismatch">
<AlertTriangle className="w-3 h-3 text-yellow-400" />
</span>
) : (
<AlertTriangle className="w-3 h-3 text-orange-400" />
)}
</div>
))}
</div>
{(hasAnyMismatch || hasEmptySlots) && (
<p className="text-xs text-orange-400 mt-2">
The printer may load different filaments than expected.
</p>
)}
</div>
)}
{/* Error message */}
{reprintMutation.isError && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+16 -1
View File
@@ -526,8 +526,23 @@ function ArchiveCard({
<h3 className="font-medium text-white mb-1 truncate">
{archive.print_name || archive.filename}
</h3>
<div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<p className="text-xs text-bambu-gray">{printerName}</p>
{/* File type badge */}
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
archive.filename?.toLowerCase().includes('.gcode.')
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-orange-500/20 text-orange-400'
}`}
title={
archive.filename?.toLowerCase().includes('.gcode.')
? 'Sliced file - ready to print'
: 'Source file only - no AMS mapping available'
}
>
{archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'}
</span>
{archive.project_name && (
<span
className="text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]"