Parcourir la source

fix(timelapse): support AVI format from P1-series printers (#405)

P1S/P1P save timelapses as AVI (MJPEG), but the scanner only looked
for MP4 files — so P1-series timelapses were never found or attached.
maziggy il y a 3 mois
Parent
commit
c82521efc9

+ 1 - 0
CHANGELOG.md

@@ -50,6 +50,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Inventory Usage Not Tracked for Remapped AMS Slots** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When reprinting an archive with a different AMS slot mapping (e.g. changing from slot A1 to C4 in the mapping modal), the usage tracker used the default 3MF slot-to-tray mapping instead of the actual mapping from the print command. The `ams_mapping` from reprint, library print, and queue print commands is now stored and used as the highest-priority mapping source for usage tracking.
 - **Inventory Usage Not Tracked for Slicer-Initiated Prints on H2D** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2D printers, the AMS `tray_now` field is always 255 in MQTT data. The actual tray is resolved via the snow field ~44 seconds after print start, but reverts to "unloaded" when the AMS retracts filament at completion. The usage tracker now tracks `last_loaded_tray` — the last valid tray seen during printing — as a fallback when both `tray_now` at start and at completion are invalid. Also captures `tray_now` at print start for printers that report a valid value before the RUNNING state.
 - **Inventory Usage Wrong Tray for Slicer-Initiated Prints** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When a print was started from an external slicer (BambuStudio, OrcaSlicer, Bambu Handy), Bambuddy never saw the `ams_mapping` the slicer sent, because it only subscribed to the printer's report topic. The usage tracker fell back to `tray_now` which could resolve to the wrong AMS tray (e.g., Black PLA at A2 instead of Green PLA at A4 on H2D Pro). Now subscribes to the MQTT request topic to intercept print commands from any source, capturing the `ams_mapping` universally — regardless of who starts the print.
+- **P1S Timelapse Not Detected — AVI Format Support** ([#405](https://github.com/maziggy/bambuddy/issues/405)) — P1-series printers save timelapse videos as `.avi` (MJPEG), but the timelapse scanner only looked for `.mp4` files — so P1S timelapses were never found or attached to archives. Now discovers both `.mp4` and `.avi` timelapse files across all FTP directories (`/timelapse`, `/timelapse/video`, `/record`, `/recording`). AVI files are saved immediately and converted to MP4 in a non-blocking background task using FFmpeg with `-threads 1` and `nice -n 19` to minimize CPU impact on Raspberry Pi. If FFmpeg is unavailable, the AVI is served as-is with the correct MIME type. The manual "Scan for Timelapse" route also searches the additional directories used by P1-series printers.
 - **Spool Assignments Falsely Unlinked After Print Due to Color Variation** — The auto-unlink logic compared AMS tray colors against saved fingerprints using exact hex match. RFID sensors report slightly different color values across reads (e.g. `7CC4D5FF` vs `56B7E6FF` for the same spool, Euclidean distance ~43.6). Now uses a color similarity function with a tolerance threshold of 50, preventing false unlinks from minor RFID/firmware color variations while still detecting genuinely different spools.
 
 ### Improved

+ 1 - 1
README.md

@@ -71,7 +71,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - 3D model preview (Three.js)
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
-- Timelapse editor (trim, speed, music)
+- Timelapse editor (trim, speed, music) with automatic AVI-to-MP4 conversion for P1-series printers
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)

+ 20 - 13
backend/app/api/routes/archives.py

@@ -1143,10 +1143,15 @@ async def get_timelapse(
     # Use file modification time as ETag to bust cache after processing
     mtime = int(timelapse_path.stat().st_mtime)
 
+    # Detect media type from file extension (AVI from P1S before background conversion)
+    suffix = timelapse_path.suffix.lower()
+    media_type = {".mp4": "video/mp4", ".avi": "video/x-msvideo", ".mkv": "video/x-matroska"}.get(suffix, "video/mp4")
+    ext = suffix if suffix in (".mp4", ".avi", ".mkv") else ".mp4"
+
     return FileResponse(
         path=timelapse_path,
-        media_type="video/mp4",
-        filename=f"{archive.print_name or 'timelapse'}.mp4",
+        media_type=media_type,
+        filename=f"{archive.print_name or 'timelapse'}{ext}",
         headers={
             "Cache-Control": "no-cache, must-revalidate",
             "ETag": f'"{mtime}"',
@@ -1190,9 +1195,9 @@ async def scan_timelapse(
     base_name = Path(archive.filename).stem
 
     # Scan timelapse directory on printer
-    # Try both /timelapse and /timelapse/video (different printer models use different paths)
+    # Different printer models use different paths
     files = []
-    for timelapse_path in ["/timelapse", "/timelapse/video"]:
+    for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
         try:
             files = await list_files_async(
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
@@ -1206,10 +1211,12 @@ async def scan_timelapse(
 
     # Look for matching timelapse
     matching_file = None
-    mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
+    video_files = [
+        f for f in files if not f.get("is_directory") and f.get("name", "").lower().endswith((".mp4", ".avi"))
+    ]
 
     # Strategy 1: Match by print name in filename
-    for f in mp4_files:
+    for f in video_files:
         fname = f.get("name", "")
         if base_name.lower() in fname.lower():
             matching_file = f
@@ -1228,7 +1235,7 @@ async def scan_timelapse(
         best_match = None
         best_diff = timedelta(hours=24)  # Max 24 hour difference
 
-        for f in mp4_files:
+        for f in video_files:
             fname = f.get("name", "")
             # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4"
             match = re.search(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", fname)
@@ -1285,7 +1292,7 @@ async def scan_timelapse(
         best_match = None
         best_diff = timedelta(hours=24)
 
-        for f in mp4_files:
+        for f in video_files:
             mtime = f.get("mtime")
             if mtime:
                 # Timelapse file should be modified during or shortly after the print
@@ -1305,7 +1312,7 @@ async def scan_timelapse(
 
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # This handles cases where printer clock is wrong or timezone issues exist
-    if not matching_file and len(mp4_files) == 1:
+    if not matching_file and len(video_files) == 1:
         from datetime import datetime, timedelta
 
         archive_completed = archive.completed_at or archive.created_at
@@ -1313,8 +1320,8 @@ async def scan_timelapse(
             time_since_completion = datetime.now() - archive_completed
             # If archive was completed within the last hour, assume the single timelapse is for it
             if time_since_completion < timedelta(hours=1):
-                matching_file = mp4_files[0]
-                logger.info("Using single timelapse file as fallback: %s", mp4_files[0].get("name"))
+                matching_file = video_files[0]
+                logger.info("Using single timelapse file as fallback: %s", video_files[0].get("name"))
 
     # Note: We intentionally don't use a "most recent file" fallback because
     # we can't verify if timelapse was actually enabled for this print.
@@ -1329,7 +1336,7 @@ async def scan_timelapse(
                 "size": f.get("size"),
                 "mtime": f.get("mtime").isoformat() if f.get("mtime") else None,
             }
-            for f in mp4_files
+            for f in video_files
         ]
         # Sort by mtime descending (most recent first)
         available_files.sort(key=lambda x: x.get("mtime") or "", reverse=True)
@@ -1414,7 +1421,7 @@ async def select_timelapse(
     # Find the file on the printer
     files = []
     remote_path = None
-    for timelapse_dir in ["/timelapse", "/timelapse/video"]:
+    for timelapse_dir in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
         try:
             files = await list_files_async(
                 printer.ip_address, printer.access_code, timelapse_dir, printer_model=printer.model

+ 28 - 20
backend/app/main.py

@@ -261,7 +261,7 @@ _last_progress_milestone: dict[int, int] = {}
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
 
-# Track timelapse file baselines at print start: {printer_id: set of MP4 filenames}
+# Track timelapse file baselines at print start: {printer_id: set of video filenames}
 # Used for snapshot-diff detection at print completion
 _timelapse_baselines: dict[int, set[str]] = {}
 
@@ -1712,10 +1712,10 @@ async def on_print_start(printer_id: int, data: dict):
 
                 # Capture timelapse file baseline for snapshot-diff on completion
                 try:
-                    baseline_files, _ = await _list_timelapse_mp4s(printer)
+                    baseline_files, _ = await _list_timelapse_videos(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",
+                        "[TIMELAPSE] Baseline at print start: %s video files for printer %s",
                         len(_timelapse_baselines[printer_id]),
                         printer_id,
                     )
@@ -1726,10 +1726,14 @@ async def on_print_start(printer_id: int, data: dict):
                 temp_path.unlink()
 
 
-async def _list_timelapse_mp4s(printer) -> tuple[list[dict], str | None]:
-    """List MP4 files from printer's timelapse directory.
+_TIMELAPSE_VIDEO_EXTENSIONS = (".mp4", ".avi")
 
-    Returns (mp4_files, found_path) where mp4_files is a list of file dicts
+
+async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
+    """List video files from printer's timelapse directory.
+
+    Finds MP4 (X1/A1 series) and AVI (P1 series) timelapse files.
+    Returns (video_files, found_path) where video_files is a list of file dicts
     and found_path is the directory where they were found, or ([], None).
     """
     from backend.app.services.bambu_ftp import list_files_async
@@ -1742,9 +1746,13 @@ async def _list_timelapse_mp4s(printer) -> tuple[list[dict], str | None]:
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
             )
             if found_files:
-                mp4_files = [f for f in found_files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
-                if mp4_files:
-                    return mp4_files, timelapse_path
+                video_files = [
+                    f
+                    for f in found_files
+                    if not f.get("is_directory") and f.get("name", "").lower().endswith(_TIMELAPSE_VIDEO_EXTENSIONS)
+                ]
+                if video_files:
+                    return video_files, timelapse_path
         except Exception as e:
             logger.debug("[TIMELAPSE] Path %s failed: %s", timelapse_path, e)
             continue
@@ -1792,7 +1800,7 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
             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",
+                    "[TIMELAPSE] Using print-start baseline: %s existing video files for archive %s",
                     len(baseline_names),
                     archive_id,
                 )
@@ -1804,10 +1812,10 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
                     return
 
-                baseline_files, _ = await _list_timelapse_mp4s(printer)
+                baseline_files, _ = await _list_timelapse_videos(printer)
                 baseline_names = {f.get("name", "") for f in baseline_files}
                 logger.info(
-                    "[TIMELAPSE] Baseline snapshot (fallback): %s existing MP4 files for archive %s",
+                    "[TIMELAPSE] Baseline snapshot (fallback): %s existing video files for archive %s",
                     len(baseline_names),
                     archive_id,
                 )
@@ -1855,18 +1863,18 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping retries", archive_id)
                     return
 
-                mp4_files, found_path = await _list_timelapse_mp4s(printer)
+                video_files, found_path = await _list_timelapse_videos(printer)
 
-                if not mp4_files:
-                    logger.info("[TIMELAPSE] Attempt %s: No MP4 files found, will retry", attempt)
+                if not video_files:
+                    logger.info("[TIMELAPSE] Attempt %s: No video files found, will retry", attempt)
                     continue
 
-                logger.info("[TIMELAPSE] Attempt %s: Found %s MP4 files in %s", attempt, len(mp4_files), found_path)
-                for f in mp4_files[:5]:
+                logger.info("[TIMELAPSE] Attempt %s: Found %s video files in %s", attempt, len(video_files), found_path)
+                for f in video_files[:5]:
                     logger.info("[TIMELAPSE]   - %s", f.get("name"))
 
                 # Find files that are NEW (not in baseline snapshot)
-                new_files = [f for f in mp4_files if f.get("name", "") not in baseline_names]
+                new_files = [f for f in video_files if f.get("name", "") not in baseline_names]
 
                 if new_files:
                     # Pick the first new file (there should typically be exactly one)
@@ -1917,8 +1925,8 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
                 if not printer:
                     return
 
-                mp4_files, found_path = await _list_timelapse_mp4s(printer)
-                for f in mp4_files:
+                video_files, found_path = await _list_timelapse_videos(printer)
+                for f in video_files:
                     fname = f.get("name", "")
                     if base_name.lower() in fname.lower():
                         remote_path = f.get("path") or f"/timelapse/{fname}"

+ 112 - 1
backend/app/services/archive.py

@@ -1115,7 +1115,11 @@ class ArchiveService:
         timelapse_data: bytes,
         filename: str = "timelapse.mp4",
     ) -> bool:
-        """Attach a timelapse video to an archive."""
+        """Attach a timelapse video to an archive.
+
+        Non-MP4 videos (e.g. AVI from P1S) are saved as-is and a background
+        task converts them to MP4 for browser compatibility.
+        """
         import asyncio
 
         archive = await self.get_archive(archive_id)
@@ -1135,4 +1139,111 @@ class ArchiveService:
         archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
         await self.db.commit()
 
+        # For non-MP4 videos (e.g. AVI from P1S), kick off background conversion
+        if not filename.lower().endswith(".mp4"):
+            asyncio.create_task(
+                _convert_timelapse_to_mp4(archive_id, timelapse_file),
+                name=f"timelapse-convert-{archive_id}",
+            )
+
         return True
+
+
+async def _convert_timelapse_to_mp4(archive_id: int, source_path: Path) -> None:
+    """Background task: convert non-MP4 timelapse (e.g. AVI from P1S) to MP4.
+
+    Runs with low CPU priority (-threads 1, nice) so it doesn't starve
+    other processes on resource-constrained devices like Raspberry Pi.
+    """
+    import asyncio
+
+    from backend.app.core.database import async_session
+    from backend.app.services.camera import get_ffmpeg_path
+
+    logger = logging.getLogger(__name__)
+
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.info(
+            "FFmpeg not available, skipping timelapse conversion for archive %s (file saved as %s)",
+            archive_id,
+            source_path.suffix,
+        )
+        return
+
+    mp4_path = source_path.with_suffix(".mp4")
+
+    try:
+        cmd = [
+            ffmpeg,
+            "-y",
+            "-i",
+            str(source_path),
+            "-c:v",
+            "libx264",
+            "-preset",
+            "fast",
+            "-crf",
+            "23",
+            "-threads",
+            "1",
+            "-movflags",
+            "+faststart",
+            str(mp4_path),
+        ]
+
+        # Try with nice for lower CPU priority (standard on Linux/macOS)
+        try:
+            process = await asyncio.create_subprocess_exec(
+                "nice",
+                "-n",
+                "19",
+                *cmd,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+        except FileNotFoundError:
+            # nice not available (e.g. Windows), run without
+            process = await asyncio.create_subprocess_exec(
+                *cmd,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+
+        _, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            logger.warning(
+                "Timelapse conversion failed for archive %s: %s",
+                archive_id,
+                stderr.decode()[-500:],
+            )
+            if mp4_path.exists():
+                mp4_path.unlink()
+            return
+
+        # Update DB path to the new MP4 file
+        async with async_session() as db:
+            from backend.app.models.archive import PrintArchive
+
+            result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+            archive = result.scalar_one_or_none()
+            if archive:
+                archive.timelapse_path = str(mp4_path.relative_to(settings.base_dir))
+                await db.commit()
+
+        # Remove original non-MP4 file
+        if source_path.exists():
+            source_path.unlink()
+
+        logger.info(
+            "Converted timelapse to MP4 for archive %s (%s → %s)",
+            archive_id,
+            source_path.name,
+            mp4_path.name,
+        )
+
+    except Exception as e:
+        logger.warning("Timelapse conversion error for archive %s: %s", archive_id, e)
+        if mp4_path.exists():
+            mp4_path.unlink()

+ 291 - 21
backend/tests/unit/test_archive_filtering.py

@@ -3,7 +3,7 @@ Unit tests for archive filtering and timelapse snapshot-diff logic.
 
 Tests:
 1. Calibration print filtering — /usr/ prefix skips archive creation
-2. Timelapse snapshot-diff — _list_timelapse_mp4s and _scan_for_timelapse_with_retries
+2. Timelapse snapshot-diff — _list_timelapse_videos and _scan_for_timelapse_with_retries
 """
 
 from unittest.mock import AsyncMock, MagicMock, patch
@@ -156,12 +156,12 @@ class TestCalibrationPrintFiltering:
         assert not skip_msgs, "User gcode should not be skipped"
 
 
-class TestListTimelapseMp4s:
-    """Test the _list_timelapse_mp4s helper function."""
+class TestListTimelapseVideos:
+    """Test the _list_timelapse_videos helper function."""
 
     @pytest.mark.asyncio
-    async def test_finds_mp4_files_in_timelapse_dir(self):
-        """Should return MP4 files found in /timelapse directory."""
+    async def test_finds_video_files_in_timelapse_dir(self):
+        """Should return MP4 and AVI files found in /timelapse directory."""
         mock_printer = MagicMock()
         mock_printer.ip_address = "192.168.1.100"
         mock_printer.access_code = "12345678"
@@ -177,13 +177,13 @@ class TestListTimelapseMp4s:
         with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
             mock_list.return_value = mock_files
 
-            from backend.app.main import _list_timelapse_mp4s
+            from backend.app.main import _list_timelapse_videos
 
-            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+            videos, path = await _list_timelapse_videos(mock_printer)
 
-        assert len(mp4s) == 2
+        assert len(videos) == 3
         assert path == "/timelapse"
-        assert all(f["name"].endswith(".mp4") for f in mp4s)
+        assert all(f["name"].endswith((".mp4", ".avi")) for f in videos)
 
     @pytest.mark.asyncio
     async def test_tries_multiple_directories(self):
@@ -199,9 +199,9 @@ class TestListTimelapseMp4s:
             return []
 
         with patch(f"{_FTP_MODULE}.list_files_async", side_effect=mock_list_files):
-            from backend.app.main import _list_timelapse_mp4s
+            from backend.app.main import _list_timelapse_videos
 
-            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+            mp4s, path = await _list_timelapse_videos(mock_printer)
 
         assert len(mp4s) == 1
         assert path == "/record"
@@ -218,9 +218,9 @@ class TestListTimelapseMp4s:
         with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
             mock_list.return_value = []
 
-            from backend.app.main import _list_timelapse_mp4s
+            from backend.app.main import _list_timelapse_videos
 
-            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+            mp4s, path = await _list_timelapse_videos(mock_printer)
 
         assert mp4s == []
         assert path is None
@@ -241,9 +241,9 @@ class TestListTimelapseMp4s:
         with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
             mock_list.return_value = mock_files
 
-            from backend.app.main import _list_timelapse_mp4s
+            from backend.app.main import _list_timelapse_videos
 
-            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+            mp4s, path = await _list_timelapse_videos(mock_printer)
 
         assert len(mp4s) == 1
         assert mp4s[0]["name"] == "real.mp4"
@@ -306,7 +306,7 @@ class TestScanForTimelapseWithRetries:
 
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -346,7 +346,7 @@ class TestScanForTimelapseWithRetries:
 
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -387,7 +387,7 @@ class TestScanForTimelapseWithRetries:
 
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -419,7 +419,7 @@ class TestScanForTimelapseWithRetries:
 
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", new_callable=AsyncMock) as mock_list,
+            patch("backend.app.main._list_timelapse_videos", new_callable=AsyncMock) as mock_list,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
             patch("backend.app.main.ArchiveService", return_value=mock_service),
         ):
@@ -443,7 +443,7 @@ class TestScanForTimelapseWithRetries:
 
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", new_callable=AsyncMock) as mock_list,
+            patch("backend.app.main._list_timelapse_videos", new_callable=AsyncMock) as mock_list,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
             patch("backend.app.main.ArchiveService", return_value=mock_service),
         ):
@@ -469,7 +469,7 @@ class TestScanForTimelapseWithRetries:
 
         with (
             patch("backend.app.main.async_session", return_value=mock_session),
-            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_mp4s),
             patch("backend.app.main.ws_manager") as mock_ws,
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
             patch("backend.app.main.ArchiveService", return_value=mock_service),
@@ -484,3 +484,273 @@ class TestScanForTimelapseWithRetries:
         assert mock_sleep.call_count == 4
         sleep_args = [call.args[0] for call in mock_sleep.call_args_list]
         assert sleep_args == [5, 10, 20, 30]
+
+
+class TestListTimelapseVideosAvi:
+    """Test that _list_timelapse_videos finds AVI files (P1S format)."""
+
+    @pytest.mark.asyncio
+    async def test_finds_avi_files(self):
+        """Should return AVI files alongside MP4 files."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "P1S"
+
+        mock_files = [
+            {
+                "name": "video_2026-02-17_10-00-00.avi",
+                "is_directory": False,
+                "size": 50000,
+                "path": "/timelapse/video_2026-02-17_10-00-00.avi",
+            },
+        ]
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = mock_files
+
+            from backend.app.main import _list_timelapse_videos
+
+            videos, path = await _list_timelapse_videos(mock_printer)
+
+        assert len(videos) == 1
+        assert videos[0]["name"].endswith(".avi")
+        assert path == "/timelapse"
+
+    @pytest.mark.asyncio
+    async def test_finds_avi_case_insensitive(self):
+        """Should match .AVI (uppercase) extensions."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "P1S"
+
+        mock_files = [
+            {"name": "VIDEO.AVI", "is_directory": False, "size": 1000, "path": "/timelapse/VIDEO.AVI"},
+        ]
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = mock_files
+
+            from backend.app.main import _list_timelapse_videos
+
+            videos, path = await _list_timelapse_videos(mock_printer)
+
+        assert len(videos) == 1
+
+    @pytest.mark.asyncio
+    async def test_scan_detects_new_avi_file(self):
+        """Snapshot-diff should detect new AVI files just like MP4."""
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.timelapse_path = None
+        mock_archive.printer_id = 1
+        mock_archive.filename = "benchy.gcode.3mf"
+
+        mock_printer = MagicMock()
+        mock_printer.id = 1
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "P1S"
+
+        baseline_files = []
+        new_files = [
+            {
+                "name": "video_2026-02-17.avi",
+                "is_directory": False,
+                "size": 50000,
+                "path": "/timelapse/video_2026-02-17.avi",
+            },
+        ]
+
+        call_count = 0
+
+        async def mock_list_videos(printer):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return baseline_files, "/timelapse"
+            return new_files, "/timelapse"
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_service.attach_timelapse = AsyncMock(return_value=True)
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+        mock_session.execute = AsyncMock(
+            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+        )
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_videos", side_effect=mock_list_videos),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+            patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+            mock_download.return_value = b"fake avi data"
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        mock_service.attach_timelapse.assert_called_once()
+        attached_filename = mock_service.attach_timelapse.call_args[0][2]
+        assert attached_filename == "video_2026-02-17.avi"
+
+
+class TestConvertTimelapseToMp4:
+    """Test the background AVI-to-MP4 conversion."""
+
+    @pytest.mark.asyncio
+    async def test_converts_avi_to_mp4(self, tmp_path):
+        """Should call FFmpeg to convert and update the DB path."""
+        source = tmp_path / "video.avi"
+        source.write_bytes(b"fake avi")
+        mp4_path = tmp_path / "video.mp4"
+
+        mock_process = AsyncMock()
+        mock_process.communicate = AsyncMock(return_value=(b"", b""))
+        mock_process.returncode = 0
+
+        mock_archive = MagicMock()
+        mock_archive.id = 42
+        mock_archive.timelapse_path = "archives/42/video.avi"
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalar_one_or_none.return_value = mock_archive
+        mock_session.execute = AsyncMock(return_value=mock_result)
+        mock_session.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.core.database.async_session", return_value=mock_session),
+            patch("backend.app.services.archive.settings") as mock_settings,
+            patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
+        ):
+            mock_settings.base_dir = tmp_path
+            mock_exec.return_value = mock_process
+            # Create the expected output file (as FFmpeg would)
+            mp4_path.write_bytes(b"fake mp4 output")
+
+            from backend.app.services.archive import _convert_timelapse_to_mp4
+
+            await _convert_timelapse_to_mp4(42, source)
+
+        # FFmpeg should have been called
+        mock_exec.assert_called_once()
+        cmd_args = mock_exec.call_args[0]
+        assert "/usr/bin/ffmpeg" in cmd_args
+        assert "-threads" in cmd_args
+        assert "1" in cmd_args
+
+        # DB should have been updated to .mp4 path
+        mock_session.commit.assert_called_once()
+        assert mock_archive.timelapse_path == "video.mp4"
+
+    @pytest.mark.asyncio
+    async def test_skips_when_no_ffmpeg(self, tmp_path):
+        """Should log and return without converting when FFmpeg is unavailable."""
+        source = tmp_path / "video.avi"
+        source.write_bytes(b"fake avi")
+
+        with patch("backend.app.services.camera.get_ffmpeg_path", return_value=None):
+            from backend.app.services.archive import _convert_timelapse_to_mp4
+
+            await _convert_timelapse_to_mp4(1, source)
+
+        # Source file should still exist (not deleted)
+        assert source.exists()
+
+    @pytest.mark.asyncio
+    async def test_cleans_up_on_ffmpeg_failure(self, tmp_path):
+        """Should remove partial MP4 and keep source on conversion failure."""
+        source = tmp_path / "video.avi"
+        source.write_bytes(b"fake avi")
+        mp4_path = tmp_path / "video.mp4"
+
+        mock_process = AsyncMock()
+        mock_process.communicate = AsyncMock(return_value=(b"", b"conversion error"))
+        mock_process.returncode = 1
+
+        with (
+            patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
+        ):
+            mock_exec.return_value = mock_process
+            # Simulate partial output file
+            mp4_path.write_bytes(b"partial")
+
+            from backend.app.services.archive import _convert_timelapse_to_mp4
+
+            await _convert_timelapse_to_mp4(1, source)
+
+        # Partial MP4 should be cleaned up
+        assert not mp4_path.exists()
+        # Source should still exist
+        assert source.exists()
+
+
+class TestAttachTimelapseBackgroundConversion:
+    """Test that attach_timelapse spawns background conversion for non-MP4."""
+
+    @pytest.mark.asyncio
+    async def test_mp4_does_not_spawn_conversion(self, tmp_path):
+        """MP4 files should not trigger background conversion."""
+        from backend.app.services.archive import ArchiveService
+
+        mock_archive = MagicMock()
+        mock_archive.file_path = "archives/1/file.3mf"
+
+        mock_db = AsyncMock()
+        service = ArchiveService(mock_db)
+        service.get_archive = AsyncMock(return_value=mock_archive)
+
+        archive_dir = tmp_path / "archives" / "1"
+        archive_dir.mkdir(parents=True)
+
+        with (
+            patch("backend.app.services.archive.settings") as mock_settings,
+            patch("asyncio.create_task") as mock_create_task,
+        ):
+            mock_settings.base_dir = tmp_path
+
+            result = await service.attach_timelapse(1, b"fake mp4 data", "video.mp4")
+
+        assert result is True
+        mock_create_task.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_avi_spawns_background_conversion(self, tmp_path):
+        """AVI files should trigger background conversion task."""
+        from backend.app.services.archive import ArchiveService
+
+        mock_archive = MagicMock()
+        mock_archive.file_path = "archives/1/file.3mf"
+
+        mock_db = AsyncMock()
+        service = ArchiveService(mock_db)
+        service.get_archive = AsyncMock(return_value=mock_archive)
+
+        archive_dir = tmp_path / "archives" / "1"
+        archive_dir.mkdir(parents=True)
+
+        with (
+            patch("backend.app.services.archive.settings") as mock_settings,
+            patch("asyncio.create_task") as mock_create_task,
+        ):
+            mock_settings.base_dir = tmp_path
+
+            result = await service.attach_timelapse(1, b"fake avi data", "video.avi")
+
+        assert result is True
+        mock_create_task.assert_called_once()
+        # Verify task name includes archive ID
+        assert "timelapse-convert-1" in mock_create_task.call_args[1]["name"]

+ 4 - 4
frontend/src/pages/ArchivesPage.tsx

@@ -1143,9 +1143,9 @@ function ArchiveCard({
           <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
             <div className="flex items-center justify-between p-4 border-b border-gray-700">
               <div>
-                <h3 className="text-lg font-semibold text-white">Select Timelapse</h3>
+                <h3 className="text-lg font-semibold text-white">{t('archives.modal.selectTimelapse')}</h3>
                 <p className="text-sm text-gray-400 mt-1">
-                  No auto-match found. Select the timelapse for this print:
+                  {t('archives.modal.selectTimelapseDesc')}
                 </p>
               </div>
               <button
@@ -1958,9 +1958,9 @@ function ArchiveListRow({
           <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
             <div className="flex items-center justify-between p-4 border-b border-gray-700">
               <div>
-                <h3 className="text-lg font-semibold text-white">Select Timelapse</h3>
+                <h3 className="text-lg font-semibold text-white">{t('archives.modal.selectTimelapse')}</h3>
                 <p className="text-sm text-gray-400 mt-1">
-                  No auto-match found. Select the timelapse for this print:
+                  {t('archives.modal.selectTimelapseDesc')}
                 </p>
               </div>
               <button

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-BMMGffpP.js


+ 1 - 1
static/index.html

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff