فهرست منبع

Merge origin/0.2.0b, resolve conflict keeping SUPPORTED_CURRENCIES.map()

maziggy 3 ماه پیش
والد
کامیت
71560b7634

+ 10 - 0
CHANGELOG.md

@@ -2,6 +2,15 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.2.0b] - Not released
+
+### Fixed
+- **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
+- **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
+
+### Improved
+- **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329)) — Added 16 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR.
+
 ## [0.1.9] - 2026-02-10
 ## [0.1.9] - 2026-02-10
 
 
 ### New Features
 ### New Features
@@ -37,6 +46,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Virtual Printer IP Override for Server Mode** ([#52](https://github.com/maziggy/bambuddy/issues/52)) — The `remote_interface_ip` setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from `_get_local_ip()` followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).
 - **Virtual Printer IP Override for Server Mode** ([#52](https://github.com/maziggy/bambuddy/issues/52)) — The `remote_interface_ip` setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from `_get_local_ip()` followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).
 - **Wrong Thumbnail When Reprinting Same Project** ([#314](https://github.com/maziggy/bambuddy/issues/314)) — Reprinting a project with the same name but a different bed layout showed the old thumbnail during printing. The cover image cache was keyed by `subtask_name` and never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start.
 - **Wrong Thumbnail When Reprinting Same Project** ([#314](https://github.com/maziggy/bambuddy/issues/314)) — Reprinting a project with the same name but a different bed layout showed the old thumbnail during printing. The cover image cache was keyed by `subtask_name` and never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start.
 - **Wrong Timelapse Attached to Archive** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — After a print, the archive could receive a timelapse from a previous print instead of the just-completed one. The auto-scan sorted MP4 files by mtime and grabbed the "most recent," but in LAN-only mode (no NTP) the printer's clock is wrong, making mtime unreliable. Replaced with a snapshot-diff approach: baseline existing files before waiting, then detect the new file that appears after encoding. Falls back to print-name matching if no new file is found after retries.
 - **Wrong Timelapse Attached to Archive** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — After a print, the archive could receive a timelapse from a previous print instead of the just-completed one. The auto-scan sorted MP4 files by mtime and grabbed the "most recent," but in LAN-only mode (no NTP) the printer's clock is wrong, making mtime unreliable. Replaced with a snapshot-diff approach: baseline existing files before waiting, then detect the new file that appears after encoding. Falls back to print-name matching if no new file is found after retries.
+- **Timelapse Not Attached — Baseline Race Condition** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Follow-up to the snapshot-diff timelapse fix: the baseline of existing MP4 files was captured at print completion time inside a background task, but fast-encoding printers could finish writing the timelapse before the baseline was taken, causing the new file to appear in the baseline and never be detected as "new." Moved baseline capture to print start time, when the timelapse file cannot possibly exist yet. Falls back to completion-time baseline if the app was restarted mid-print.
 - **Calibration Prints Archived** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Standalone calibration prints (flow, vibration, bed leveling) were being archived as regular prints. The calibration gcode (`/usr/etc/print/auto_cali_for_user.gcode`) and other internal printer files under `/usr/` are now detected and skipped during print start.
 - **Calibration Prints Archived** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Standalone calibration prints (flow, vibration, bed leveling) were being archived as regular prints. The calibration gcode (`/usr/etc/print/auto_cali_for_user.gcode`) and other internal printer files under `/usr/` are now detected and skipped during print start.
 - **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
 - **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
 - **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.
 - **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.

+ 19 - 0
backend/app/api/routes/camera.py

@@ -547,6 +547,25 @@ async def camera_snapshot(
 
 
     printer = await get_printer_or_404(printer_id, db)
     printer = await get_printer_or_404(printer_id, db)
 
 
+    # Check for external camera first
+    if printer.external_camera_enabled and printer.external_camera_url:
+        from backend.app.services.external_camera import capture_frame
+
+        frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type, timeout=15)
+        if not frame_data:
+            raise HTTPException(
+                status_code=503,
+                detail="Failed to capture frame from external camera.",
+            )
+        return Response(
+            content=frame_data,
+            media_type="image/jpeg",
+            headers={
+                "Cache-Control": "no-cache, no-store, must-revalidate",
+                "Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"',
+            },
+        )
+
     # Create temporary file for the snapshot
     # Create temporary file for the snapshot
     with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
     with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
         temp_path = Path(f.name)
         temp_path = Path(f.name)

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.9"
+APP_VERSION = "0.2.0b"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # App directory - where the application is installed (for static files)
 # App directory - where the application is installed (for static files)

+ 44 - 13
backend/app/main.py

@@ -253,6 +253,10 @@ _last_progress_milestone: dict[int, int] = {}
 # This prevents sending duplicate notifications for the same error
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
 _notified_hms_errors: dict[int, set[str]] = {}
 
 
+# Track timelapse file baselines at print start: {printer_id: set of MP4 filenames}
+# Used for snapshot-diff detection at print completion
+_timelapse_baselines: dict[int, set[str]] = {}
+
 
 
 async def _get_plug_energy(plug, db) -> dict | None:
 async def _get_plug_energy(plug, db) -> dict | None:
     """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
     """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
@@ -1404,6 +1408,18 @@ async def on_print_start(printer_id: int, data: dict):
                     await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, printer_manager)
                     await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, printer_manager)
                 except Exception as e:
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
+
+                # Capture timelapse file baseline for snapshot-diff on completion
+                try:
+                    baseline_files, _ = await _list_timelapse_mp4s(printer)
+                    _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
+                    logger.info(
+                        "[TIMELAPSE] Baseline at print start: %s MP4 files for printer %s",
+                        len(_timelapse_baselines[printer_id]),
+                        printer_id,
+                    )
+                except Exception as e:
+                    logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
         finally:
         finally:
             if temp_path and temp_path.exists():
             if temp_path and temp_path.exists():
                 temp_path.unlink()
                 temp_path.unlink()
@@ -1435,7 +1451,7 @@ async def _list_timelapse_mp4s(printer) -> tuple[list[dict], str | None]:
     return [], None
     return [], None
 
 
 
 
-async def _scan_for_timelapse_with_retries(archive_id: int):
+async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None):
     """
     """
     Scan for timelapse with retries using a snapshot-diff approach.
     Scan for timelapse with retries using a snapshot-diff approach.
 
 
@@ -1443,6 +1459,10 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
     clock is wrong in LAN-only mode), we snapshot existing MP4 filenames BEFORE
     clock is wrong in LAN-only mode), we snapshot existing MP4 filenames BEFORE
     waiting, then look for any NEW filename that appears after each delay.
     waiting, then look for any NEW filename that appears after each delay.
 
 
+    If baseline_names is provided (captured at print start), it is used directly.
+    Otherwise falls back to taking a baseline at completion time (best-effort
+    for prints started before app restart).
+
     Falls back to name-matching (print name contained in MP4 filename) if no
     Falls back to name-matching (print name contained in MP4 filename) if no
     new file appears after all retries.
     new file appears after all retries.
     """
     """
@@ -1468,18 +1488,28 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
                 logger.warning("[TIMELAPSE] Archive %s has no printer, aborting", archive_id)
                 logger.warning("[TIMELAPSE] Archive %s has no printer, aborting", archive_id)
                 return
                 return
 
 
-            result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
-            printer = result.scalar_one_or_none()
-            if not printer:
-                logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
-                return
+            if baseline_names is not None:
+                # Use pre-captured baseline from print start (no race condition)
+                logger.info(
+                    "[TIMELAPSE] Using print-start baseline: %s existing MP4 files for archive %s",
+                    len(baseline_names),
+                    archive_id,
+                )
+            else:
+                # Fallback: take baseline now (e.g. app restarted mid-print)
+                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+                printer = result.scalar_one_or_none()
+                if not printer:
+                    logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
+                    return
 
 
-            # Snapshot current MP4 filenames as baseline
-            baseline_files, _ = await _list_timelapse_mp4s(printer)
-            baseline_names: set[str] = {f.get("name", "") for f in baseline_files}
-            logger.info(
-                "[TIMELAPSE] Baseline snapshot: %s existing MP4 files for archive %s", len(baseline_names), archive_id
-            )
+                baseline_files, _ = await _list_timelapse_mp4s(printer)
+                baseline_names = {f.get("name", "") for f in baseline_files}
+                logger.info(
+                    "[TIMELAPSE] Baseline snapshot (fallback): %s existing MP4 files for archive %s",
+                    len(baseline_names),
+                    archive_id,
+                )
 
 
             # Derive base_name for name-matching fallback
             # Derive base_name for name-matching fallback
             base_name = Path(archive.filename).stem if archive.filename else ""
             base_name = Path(archive.filename).stem if archive.filename else ""
@@ -2179,7 +2209,8 @@ async def on_print_complete(printer_id: int, data: dict):
         logger.info("[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive %s", archive_id)
         logger.info("[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive %s", archive_id)
         # Schedule timelapse scan as background task with retries
         # Schedule timelapse scan as background task with retries
         # The printer needs time to encode the video after print completion
         # The printer needs time to encode the video after print completion
-        asyncio.create_task(_scan_for_timelapse_with_retries(archive_id))
+        baseline = _timelapse_baselines.pop(printer_id, None)
+        asyncio.create_task(_scan_for_timelapse_with_retries(archive_id, baseline))
         log_timing("Timelapse scan scheduled")
         log_timing("Timelapse scan scheduled")
 
 
     # Update queue item if this was a scheduled print
     # Update queue item if this was a scheduled print

+ 36 - 15
backend/app/services/external_camera.py

@@ -362,7 +362,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     ]
     ]
 
 
     try:
     try:
-        print(f"[EXT-CAM] Running ffmpeg command: {' '.join(cmd[:6])}...")
+        logger.debug(f"Running ffmpeg command: {' '.join(cmd[:6])}...")
         process = await asyncio.create_subprocess_exec(
         process = await asyncio.create_subprocess_exec(
             *cmd,
             *cmd,
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
@@ -370,13 +370,12 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
         )
         )
 
 
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
-        print(
-            f"[EXT-CAM] ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
+        logger.debug(
+            f"ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
         )
         )
 
 
         if process.returncode != 0:
         if process.returncode != 0:
             logger.error("ffmpeg RTSP capture failed: %s", stderr.decode()[:200])
             logger.error("ffmpeg RTSP capture failed: %s", stderr.decode()[:200])
-            print(f"[EXT-CAM] ffmpeg error: {stderr.decode()[:300]}")
             return None
             return None
 
 
         if not stdout or len(stdout) < 100:
         if not stdout or len(stdout) < 100:
@@ -440,11 +439,9 @@ async def test_connection(url: str, camera_type: str) -> dict:
     Returns:
     Returns:
         Dict with {success: bool, error?: str, resolution?: str}
         Dict with {success: bool, error?: str, resolution?: str}
     """
     """
-    print(f"[EXT-CAM] Testing camera connection: type={camera_type}, url={url[:50]}...")
     logger.info("Testing camera connection: type=%s, url=%s...", camera_type, url[:50])
     logger.info("Testing camera connection: type=%s, url=%s...", camera_type, url[:50])
     try:
     try:
         frame = await capture_frame(url, camera_type, timeout=10)
         frame = await capture_frame(url, camera_type, timeout=10)
-        print(f"[EXT-CAM] Capture result: {len(frame) if frame else 0} bytes")
         logger.info("Capture result: %s bytes", len(frame) if frame else 0)
         logger.info("Capture result: %s bytes", len(frame) if frame else 0)
 
 
         if frame:
         if frame:
@@ -490,17 +487,41 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
     last_frame_time = 0.0
     last_frame_time = 0.0
 
 
     if camera_type == "mjpeg":
     if camera_type == "mjpeg":
-        # Proxy MJPEG stream directly
-        async for frame in _stream_mjpeg(url):
-            current_time = asyncio.get_event_loop().time()
-            if current_time - last_frame_time >= frame_interval:
-                last_frame_time = current_time
-                yield _format_mjpeg_frame(frame)
+        # Proxy MJPEG stream directly, with reconnect on timeout
+        max_retries = 3
+        for attempt in range(max_retries + 1):
+            frame_yielded = False
+            async for frame in _stream_mjpeg(url):
+                frame_yielded = True
+                current_time = asyncio.get_event_loop().time()
+                if current_time - last_frame_time >= frame_interval:
+                    last_frame_time = current_time
+                    yield _format_mjpeg_frame(frame)
+            if not frame_yielded or attempt == max_retries:
+                break
+            logger.warning(
+                "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
+                attempt + 1,
+                max_retries,
+            )
+            await asyncio.sleep(2)
 
 
     elif camera_type == "rtsp":
     elif camera_type == "rtsp":
-        # Use ffmpeg to convert RTSP to MJPEG
-        async for frame in _stream_rtsp(url, fps):
-            yield _format_mjpeg_frame(frame)
+        # Use ffmpeg to convert RTSP to MJPEG, with reconnect on timeout
+        max_retries = 3
+        for attempt in range(max_retries + 1):
+            frame_yielded = False
+            async for frame in _stream_rtsp(url, fps):
+                frame_yielded = True
+                yield _format_mjpeg_frame(frame)
+            if not frame_yielded or attempt == max_retries:
+                break
+            logger.warning(
+                "External RTSP stream ended, reconnecting (attempt %d/%d)...",
+                attempt + 1,
+                max_retries,
+            )
+            await asyncio.sleep(2)
 
 
     elif camera_type == "usb":
     elif camera_type == "usb":
         # Use ffmpeg to stream from USB camera
         # Use ffmpeg to stream from USB camera

+ 43 - 0
backend/tests/integration/test_camera_api.py

@@ -192,6 +192,49 @@ class TestCameraAPI:
         assert response.status_code == 503
         assert response.status_code == 503
         assert "Failed to capture" in response.json()["detail"]
         assert "Failed to capture" in response.json()["detail"]
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_snapshot_external_camera_success(self, async_client: AsyncClient, printer_factory):
+        """Verify snapshot uses external camera when configured."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
+
+        with patch(
+            "backend.app.services.external_camera.capture_frame",
+            new_callable=AsyncMock,
+            return_value=fake_jpeg,
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+        assert response.status_code == 200
+        assert response.headers["content-type"] == "image/jpeg"
+        assert response.content == fake_jpeg
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_snapshot_external_camera_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify 503 when external camera capture fails."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        with patch(
+            "backend.app.services.external_camera.capture_frame",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+        assert response.status_code == 503
+        assert "external camera" in response.json()["detail"].lower()
+
     # ========================================================================
     # ========================================================================
     # Camera Stream Endpoint
     # Camera Stream Endpoint
     # ========================================================================
     # ========================================================================

+ 11 - 2
frontend/src/pages/PrintersPage.tsx

@@ -421,6 +421,14 @@ function parseFilamentColor(rgba: string): string | null {
   return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
   return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
 }
 }
 
 
+function isLightFilamentColor(rgba: string): boolean {
+  if (!rgba || rgba.length < 6) return false;
+  const r = parseInt(rgba.slice(0, 2), 16);
+  const g = parseInt(rgba.slice(2, 4), 16);
+  const b = parseInt(rgba.slice(4, 6), 16);
+  return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
+}
+
 // Expand nozzle type codes to material names
 // Expand nozzle type codes to material names
 // Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01")
 // Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01")
 // Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide
 // Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide
@@ -798,6 +806,7 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
         {rackSlots.map((slot, i) => {
         {rackSlots.map((slot, i) => {
           const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
           const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
           const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
           const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
+          const lightBg = filamentBg ? isLightFilamentColor(slot.filament_color) : false;
 
 
           return (
           return (
             <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
             <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
@@ -809,8 +818,8 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
                 }`}
                 }`}
                 style={filamentBg ? { backgroundColor: filamentBg } : undefined}
                 style={filamentBg ? { backgroundColor: filamentBg } : undefined}
               >
               >
-                <span className={`text-[10px] font-semibold ${isEmpty ? 'text-bambu-gray/30' : 'text-white'}`}
-                      style={filamentBg ? { textShadow: '0 1px 3px rgba(0,0,0,0.9)' } : undefined}
+                <span className={`text-[10px] font-semibold ${isEmpty ? 'text-bambu-gray/30' : lightBg ? 'text-black/80' : 'text-white'}`}
+                      style={filamentBg && !lightBg ? { textShadow: '0 1px 3px rgba(0,0,0,0.9)' } : undefined}
                 >
                 >
                   {isEmpty ? '—' : (slot.nozzle_diameter || '?')}
                   {isEmpty ? '—' : (slot.nozzle_diameter || '?')}
                 </span>
                 </span>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-Co9UeRtt.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-GkrFU7v8.js"></script>
+    <script type="module" crossorigin src="/assets/index-Co9UeRtt.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DLgJjh2G.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DLgJjh2G.css">
   </head>
   </head>
   <body>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است