Selaa lähdekoodia

feat: USB cameras are picked from a list of detected devices (refresh, not-found marking, manual fallback, Docker hint; device listing off the event loop, /dev/video0-99 only); fix: timelapse editor saves as a background job with progress and cancel (ffmpeg stopped on cancel, no overwrite of a timelapse removed or replaced meanwhile, ffprobe/thumbnail timeouts), and the music volume is applied

maziggy 1 päivä sitten
vanhempi
sitoutus
2443cea174
32 muutettua tiedostoa jossa 1587 lisäystä ja 92 poistoa
  1. 5 0
      CHANGELOG.md
  2. 129 54
      backend/app/api/routes/archives.py
  3. 3 2
      backend/app/api/routes/printers.py
  4. 4 0
      backend/app/main.py
  5. 18 6
      backend/app/schemas/timelapse.py
  6. 17 10
      backend/app/services/external_camera.py
  7. 89 0
      backend/app/services/timelapse_jobs.py
  8. 82 3
      backend/app/services/timelapse_processor.py
  9. 239 0
      backend/tests/integration/test_timelapse_editor_job.py
  10. 197 0
      backend/tests/unit/services/test_timelapse_jobs.py
  11. 56 0
      backend/tests/unit/services/test_usb_camera_listing.py
  12. 155 0
      frontend/src/__tests__/components/TimelapseEditorModalJob.test.tsx
  13. 153 0
      frontend/src/__tests__/pages/SettingsPageUsbCameraWiring.test.tsx
  14. 37 1
      frontend/src/api/client.ts
  15. 58 8
      frontend/src/components/TimelapseEditorModal.tsx
  16. 74 0
      frontend/src/components/UsbCameraDevicePicker.tsx
  17. 16 0
      frontend/src/i18n/locales/de.ts
  18. 16 0
      frontend/src/i18n/locales/en.ts
  19. 16 0
      frontend/src/i18n/locales/es.ts
  20. 16 0
      frontend/src/i18n/locales/fr.ts
  21. 16 0
      frontend/src/i18n/locales/it.ts
  22. 16 0
      frontend/src/i18n/locales/ja.ts
  23. 16 0
      frontend/src/i18n/locales/ko.ts
  24. 16 0
      frontend/src/i18n/locales/nl.ts
  25. 16 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 16 0
      frontend/src/i18n/locales/ru.ts
  27. 16 0
      frontend/src/i18n/locales/sv.ts
  28. 16 0
      frontend/src/i18n/locales/tr.ts
  29. 16 0
      frontend/src/i18n/locales/uk.ts
  30. 16 0
      frontend/src/i18n/locales/zh-CN.ts
  31. 16 0
      frontend/src/i18n/locales/zh-TW.ts
  32. 31 8
      frontend/src/pages/SettingsPage.tsx

+ 5 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **USB cameras are picked from a list** — With an external camera set to **USB Camera (V4L2)**, Settings > Camera now lists the video devices the Bambuddy server can see, by name and path (`HD Pro Webcam C920 (/dev/video0)`), instead of asking for the device path. A refresh button looks again after a camera is plugged in, and a saved camera that is unplugged stays selected, marked **not found**. When no device is visible, the list says how to pass a camera to the Docker container. **Enter path manually** still takes a typed path. Looking for devices no longer holds up the server while it runs.
 - **The MQTT relay publishes printer offline/online and low filament** — Three new topics for Home Assistant, Node-RED and other subscribers. `printers/{serial}/offline` is sent once a printer has been disconnected for a minute, and `printers/{serial}/online` when it comes back after that, so a short blip sends neither. `printers/{serial}/filament/low` is sent once when the spool in an AMS slot drops below its low-stock threshold, at the same moment as the Low Filament notification, and works even when no notification provider has that event switched on. Wiki updated with the payloads.
 - **Notify! notification provider with iOS Live Activities and Lock Screen widgets (#2919, requested and contributed by @simplytoast1 in #3265)** — Notify! used to need the separate notify-bambuddy companion container. Bambuddy now talks to it directly: add a provider of type Notify! with the device ID and token.
   - **Alerts:** the usual events, printer filter, quiet hours and daily digest apply as for every provider, with the camera photo attached. Each printer gets its own notification thread. **Time Sensitive problem alerts** sends failures, stops, printer errors and failure detection as time-sensitive notifications.
@@ -167,6 +168,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Saving in the timelapse editor could fail on long videos, and ignored the music volume** — Save re-encoded the video while the browser waited, which for a long timelapse took minutes. A browser or reverse proxy that gave up showed an error while FFmpeg went on running on the server.
+  - **Runs in the background:** Save now starts the work on the server and returns at once. The button shows how far it has got, **Cancel** stops FFmpeg and keeps the original, and an editor closed and opened again shows the progress. While one edit of a timelapse runs, Save is unavailable for it. If the timelapse is removed or replaced while the edit runs, the edit is not saved over it.
+  - **Music volume:** the volume slider only changed the preview; the saved video always had the music at full volume. It is now mixed in at the volume set, and muted means silent.
+  - On a broken file, reading the video's details now gives up after a minute and each timeline frame after 30 seconds, instead of waiting forever.
 - **Firmware could be uploaded to a printer in the middle of a print** — The firmware update dialog offered **Upload Firmware** whatever the printer was doing, and the upload wrote to the SD card the print runs from. It is now only offered while the printer is idle (a finished or failed print counts), and the server refuses it otherwise. If a print starts while the firmware is still downloading, nothing is written to the card, and the queue holds its next print for a printer until its firmware upload is over.
 - **Reset Slot cleared an AMS slot on a single click, even mid-print** — It now asks first, and is unavailable while a print is running, paused or starting, since the print may be feeding from that slot.
 - **Some destructive buttons acted without asking** — **Clear all** on the shopping list, **Clear** in the Git backup history and removing a linked SSO account now ask for confirmation first. An account created through SSO, which has no password, can no longer remove its last SSO link, which was its only way to sign in.

+ 129 - 54
backend/app/api/routes/archives.py

@@ -3366,12 +3366,13 @@ async def get_timelapse_thumbnails(
         raise HTTPException(500, f"Failed to generate thumbnails: {str(e)}")
 
 
-@router.post("/{archive_id}/timelapse/process")
+@router.post("/{archive_id}/timelapse/process", status_code=202)
 async def process_timelapse(
     archive_id: int,
     trim_start: float = Form(0),
     trim_end: float = Form(None),
     speed: float = Form(1.0),
+    audio_volume: float = Form(1.0),
     save_mode: str = Form("new"),
     output_filename: str = Form(None),
     audio: UploadFile = File(None),
@@ -3379,17 +3380,26 @@ async def process_timelapse(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Process timelapse with trim, speed, and optional audio overlay."""
+    """Start re-encoding the timelapse with trim, speed and optional audio.
+
+    Runs as a background job: poll ``GET .../timelapse/job`` for progress and
+    the result, ``DELETE .../timelapse/job`` cancels it.
+    """
     import shutil
     import tempfile
+    import uuid
 
-    from backend.app.schemas.timelapse import ProcessResponse
+    from backend.app.schemas.timelapse import TimelapseJobResponse
+    from backend.app.services import timelapse_jobs
     from backend.app.services.timelapse_processor import TimelapseProcessor
 
     # Validate speed
     if not 0.25 <= speed <= 4.0:
         raise HTTPException(400, "Speed must be between 0.25 and 4.0")
 
+    if not 0.0 <= audio_volume <= 1.0:
+        raise HTTPException(400, "audio_volume must be between 0 and 1")
+
     if save_mode not in ("replace", "new"):
         raise HTTPException(400, "save_mode must be 'replace' or 'new'")
 
@@ -3402,7 +3412,37 @@ async def process_timelapse(
     if not timelapse_path.exists():
         raise HTTPException(404, "Timelapse file not found")
 
+    current = timelapse_jobs.get_job(archive_id)
+    if current is not None and current.status == timelapse_jobs.RUNNING:
+        raise HTTPException(409, "This timelapse is already being processed")
+
+    try:
+        processor = TimelapseProcessor(timelapse_path)
+    except RuntimeError as e:
+        raise HTTPException(500, f"Processing failed: {e}") from None
+
     archive_dir = timelapse_path.parent
+    stored_timelapse_path = archive.timelapse_path
+    # What is being edited, so a replace does not overwrite a timelapse removed
+    # or swapped for another one while the job ran.
+    source = timelapse_path.stat()
+    source_signature = (source.st_size, source.st_mtime_ns, source.st_ino)
+
+    # Determine output path. Unique temp names: two archives' jobs run at once.
+    if save_mode == "replace":
+        # Process to temp file first, then replace
+        output_path = Path(tempfile.gettempdir()) / f"processed_{archive_id}_{uuid.uuid4().hex}.mp4"
+    else:
+        # Save as new file alongside original
+        filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
+        # Sanitize filename - remove path separators and traversal sequences
+        filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
+        # Prevent path traversal
+        if ".." in filename or not filename or filename.startswith("."):
+            filename = f"timelapse_{archive_id}_edited"
+        if not filename.endswith(".mp4"):
+            filename += ".mp4"
+        output_path = archive_dir / filename  # SEC-PATH-OK: filename alnum-filtered + .. rejected above
 
     # Handle audio file
     audio_temp_path = None
@@ -3416,65 +3456,100 @@ async def process_timelapse(
         suffix = Path(audio.filename).suffix.lower()
         if suffix not in (".mp3", ".wav", ".m4a", ".aac", ".ogg"):
             raise HTTPException(400, "Invalid audio file extension")
-        audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
+        audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}_{uuid.uuid4().hex}{suffix}"
         audio_temp_path.write_bytes(audio_content)
 
+    async def run(job: timelapse_jobs.TimelapseJob) -> tuple[str, str]:
+        try:
+            success = await processor.process(
+                output_path=output_path,
+                trim_start=trim_start,
+                trim_end=trim_end,
+                speed=speed,
+                audio_path=audio_temp_path,
+                audio_volume=audio_volume,
+                on_progress=job.set_progress,
+            )
+            if not success:
+                raise RuntimeError("Video processing failed")
+
+            if save_mode == "replace":
+                await _ensure_timelapse_unchanged(archive_id, stored_timelapse_path, timelapse_path, source_signature)
+                shutil.move(str(output_path), str(timelapse_path))
+                return stored_timelapse_path, "Timelapse replaced successfully"
+            return str(output_path.relative_to(settings.base_dir)), f"Saved as {output_path.name}"
+        finally:
+            if save_mode == "replace":
+                output_path.unlink(missing_ok=True)
+            if audio_temp_path:
+                audio_temp_path.unlink(missing_ok=True)
+
     try:
-        processor = TimelapseProcessor(timelapse_path)
+        job = timelapse_jobs.start_job(archive_id, run)
+    except timelapse_jobs.JobAlreadyRunning:
+        # Started by another request while this one read its audio upload.
+        if audio_temp_path:
+            audio_temp_path.unlink(missing_ok=True)
+        raise HTTPException(409, "This timelapse is already being processed") from None
+    return TimelapseJobResponse.from_job(job)
+
+
+async def _ensure_timelapse_unchanged(
+    archive_id: int, stored_path: str, timelapse_path: Path, signature: tuple[int, int, int]
+) -> None:
+    async with database.async_session() as db:
+        current = (
+            await db.execute(select(PrintArchive.timelapse_path).where(PrintArchive.id == archive_id))
+        ).scalar_one_or_none()
+    try:
+        now = timelapse_path.stat()
+    except FileNotFoundError:
+        now = None
+    if current != stored_path or now is None or (now.st_size, now.st_mtime_ns, now.st_ino) != signature:
+        raise RuntimeError("The timelapse was removed or replaced while it was being edited; the edit was not saved")
 
-        # Determine output path
-        if save_mode == "replace":
-            # Process to temp file first, then replace
-            temp_output = Path(tempfile.gettempdir()) / f"processed_{archive_id}.mp4"
-            output_path = temp_output
-        else:
-            # Save as new file alongside original
-            filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
-            # Sanitize filename - remove path separators and traversal sequences
-            filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
-            # Prevent path traversal
-            if ".." in filename or not filename or filename.startswith("."):
-                filename = f"timelapse_{archive_id}_edited"
-            if not filename.endswith(".mp4"):
-                filename += ".mp4"
-            output_path = archive_dir / filename  # SEC-PATH-OK: filename alnum-filtered + .. rejected above
-
-        success = await processor.process(
-            output_path=output_path,
-            trim_start=trim_start,
-            trim_end=trim_end,
-            speed=speed,
-            audio_path=audio_temp_path,
-        )
 
-        if not success:
-            raise HTTPException(500, "Video processing failed")
+async def _timelapse_job_archive(archive_id: int, db: AsyncSession, printer_scope: PrinterScope) -> None:
+    archive = await ArchiveService(db).get_archive(archive_id)
+    if not archive or not printer_scope.allows(archive.printer_id):
+        raise HTTPException(404, "Timelapse not found")
+
 
-        # Handle save mode
-        if save_mode == "replace":
-            # Replace original file
-            shutil.move(str(output_path), str(timelapse_path))
-            final_path = archive.timelapse_path
-            message = "Timelapse replaced successfully"
-        else:
-            final_path = str(output_path.relative_to(settings.base_dir))
-            message = f"Saved as {output_path.name}"
+@router.get("/{archive_id}/timelapse/job")
+async def get_timelapse_job(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """The archive's running or last timelapse job: status, progress, result."""
+    from backend.app.schemas.timelapse import TimelapseJobResponse
+    from backend.app.services import timelapse_jobs
 
-        return ProcessResponse(
-            status="completed",
-            output_path=final_path,
-            message=message,
-        )
+    await _timelapse_job_archive(archive_id, db, printer_scope)
+    job = timelapse_jobs.get_job(archive_id)
+    if job is None:
+        raise HTTPException(404, "No timelapse job")
+    return TimelapseJobResponse.from_job(job)
 
-    except HTTPException:
-        raise
-    except Exception as e:
-        logger.error("Timelapse processing failed: %s", e)
-        raise HTTPException(500, f"Processing failed: {str(e)}")
-    finally:
-        # Cleanup temp audio file
-        if audio_temp_path and audio_temp_path.exists():
-            audio_temp_path.unlink()
+
+@router.delete("/{archive_id}/timelapse/job")
+async def cancel_timelapse_job(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Cancel the running timelapse job; stops ffmpeg and drops its output."""
+    from backend.app.schemas.timelapse import TimelapseJobResponse
+    from backend.app.services import timelapse_jobs
+
+    await _timelapse_job_archive(archive_id, db, printer_scope)
+    if not await timelapse_jobs.cancel_job(archive_id):
+        raise HTTPException(404, "No timelapse job is running")
+    job = timelapse_jobs.get_job(archive_id)
+    assert job is not None
+    return TimelapseJobResponse.from_job(job)
 
 
 # ============================================

+ 3 - 2
backend/app/api/routes/printers.py

@@ -243,9 +243,10 @@ async def list_usb_cameras(
     Returns:
         List of dicts with {device: str, name: str, capabilities: list, formats?: list}
     """
-    from backend.app.services.external_camera import list_usb_cameras
+    from backend.app.services import external_camera
 
-    cameras = list_usb_cameras()
+    # v4l2-ctl runs twice per device, with a 5 s timeout each.
+    cameras = await asyncio.to_thread(external_camera.list_usb_cameras)
     return {"cameras": cameras}
 
 

+ 4 - 0
backend/app/main.py

@@ -11222,6 +11222,10 @@ async def lifespan(app: FastAPI):
     from backend.app.services.printer_media import stop_printer_download_cleanup
 
     await stop_printer_download_cleanup()
+    # Stops a timelapse editor's ffmpeg rather than leaving it to the OS.
+    from backend.app.services import timelapse_jobs
+
+    await timelapse_jobs.cancel_all()
     printer_manager.disconnect_all()
     slot_unlink_grace.reset()
     await close_spoolman_client()

+ 18 - 6
backend/app/schemas/timelapse.py

@@ -22,9 +22,21 @@ class ThumbnailResponse(BaseModel):
     timestamps: list[float] = Field(description="Timestamp for each thumbnail in seconds")
 
 
-class ProcessResponse(BaseModel):
-    """Processing result response."""
-
-    status: str = Field(description="Processing status: completed, error")
-    output_path: str | None = Field(default=None, description="Relative path to output file")
-    message: str = Field(description="Status message")
+class TimelapseJobResponse(BaseModel):
+    """A timelapse re-encode job."""
+
+    status: str = Field(description="running, completed, failed or cancelled")
+    progress: float | None = Field(default=None, description="Share done, 0.0 to 1.0; null before the first report")
+    output_path: str | None = Field(default=None, description="Relative path to the output file, once completed")
+    message: str | None = Field(default=None, description="Result message, once completed")
+    error: str | None = Field(default=None, description="Why it failed")
+
+    @classmethod
+    def from_job(cls, job) -> "TimelapseJobResponse":
+        return cls(
+            status=job.status,
+            progress=job.progress,
+            output_path=job.output_path,
+            message=job.message,
+            error=job.error,
+        )

+ 17 - 10
backend/app/services/external_camera.py

@@ -171,17 +171,27 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
         return None
 
 
-def list_usb_cameras() -> list[dict]:
+# The device names a USB camera can stream from: _safe_usb_device_path
+# accepts /dev/video0 to /dev/video99, written without a leading zero.
+_USB_DEVICE_NAME = re.compile(r"^video([0-9]|[1-9][0-9])$")
+
+
+def list_usb_cameras(dev_dir: Path = Path("/dev")) -> list[dict]:
     """List available USB cameras (V4L2 devices on Linux).
 
+    Blocking: runs v4l2-ctl for each device, so call it from a thread.
+
     Returns:
         List of dicts with {device: str, name: str, capabilities: list}
     """
     cameras = []
-    video_devices = sorted(Path("/dev").glob("video*"))
+    video_devices = sorted(
+        (device for device in dev_dir.glob("video*") if _USB_DEVICE_NAME.match(device.name)),
+        key=lambda device: int(device.name.removeprefix("video")),
+    )
 
     for device in video_devices:
-        device_path = str(device)
+        device_path = f"/dev/{device.name}"
         info = {"device": device_path, "name": device.name, "capabilities": []}
 
         # Try to get device info via v4l2-ctl
@@ -222,13 +232,10 @@ def list_usb_cameras() -> list[dict]:
 
         # Only include devices that look like video capture devices
         # Skip metadata devices (typically odd numbered like video1, video3)
-        try:
-            device_num = int(device.name.replace("video", ""))
-            # Even numbered devices are usually capture, odd are metadata
-            # But also check if we got capabilities
-            if info.get("capabilities") or device_num % 2 == 0:
-                cameras.append(info)
-        except ValueError:
+        device_num = int(device.name.removeprefix("video"))
+        # Even numbered devices are usually capture, odd are metadata
+        # But also check if we got capabilities
+        if info.get("capabilities") or device_num % 2 == 0:
             cameras.append(info)
 
     return cameras

+ 89 - 0
backend/app/services/timelapse_jobs.py

@@ -0,0 +1,89 @@
+"""Timelapse editor re-encodes, run as background jobs.
+
+Re-encoding a long timelapse takes minutes. Run inside the request, a browser
+or proxy that gave up left ffmpeg running with nobody waiting for the result.
+A job runs on its own instead: the editor polls its progress, and cancelling
+it stops ffmpeg. One job per archive at a time; the last one is kept so a
+reopened editor can show how it ended.
+"""
+
+import asyncio
+import contextlib
+import logging
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+
+logger = logging.getLogger(__name__)
+
+RUNNING = "running"
+COMPLETED = "completed"
+FAILED = "failed"
+CANCELLED = "cancelled"
+
+
+@dataclass
+class TimelapseJob:
+    archive_id: int
+    status: str = RUNNING
+    # 0.0 to 1.0; None until ffmpeg reports its first position.
+    progress: float | None = None
+    output_path: str | None = None
+    message: str | None = None
+    error: str | None = None
+    task: asyncio.Task | None = field(default=None, repr=False)
+
+    def set_progress(self, progress: float) -> None:
+        self.progress = min(max(progress, 0.0), 1.0)
+
+
+class JobAlreadyRunning(Exception):
+    pass
+
+
+# Runs the re-encode; returns (output path relative to the data dir, message).
+JobRunner = Callable[[TimelapseJob], Awaitable[tuple[str, str]]]
+
+_jobs: dict[int, TimelapseJob] = {}
+
+
+def get_job(archive_id: int) -> TimelapseJob | None:
+    return _jobs.get(archive_id)
+
+
+def start_job(archive_id: int, run: JobRunner) -> TimelapseJob:
+    current = _jobs.get(archive_id)
+    if current is not None and current.status == RUNNING:
+        raise JobAlreadyRunning
+    job = TimelapseJob(archive_id=archive_id)
+    _jobs[archive_id] = job
+    job.task = asyncio.create_task(_run(job, run))
+    return job
+
+
+async def _run(job: TimelapseJob, run: JobRunner) -> None:
+    try:
+        job.output_path, job.message = await run(job)
+        job.progress = 1.0
+        job.status = COMPLETED
+    except asyncio.CancelledError:
+        job.status = CANCELLED
+    except Exception as e:
+        logger.error("Timelapse processing failed for archive %s: %s", job.archive_id, e)
+        job.error = str(e)
+        job.status = FAILED
+
+
+async def cancel_job(archive_id: int) -> bool:
+    """Cancel the archive's running job; False when none is running."""
+    job = _jobs.get(archive_id)
+    if job is None or job.status != RUNNING or job.task is None:
+        return False
+    job.task.cancel()
+    with contextlib.suppress(asyncio.CancelledError):
+        await job.task
+    return True
+
+
+async def cancel_all() -> None:
+    for archive_id in list(_jobs):
+        await cancel_job(archive_id)

+ 82 - 3
backend/app/services/timelapse_processor.py

@@ -1,9 +1,11 @@
 """Timelapse video processing service using FFmpeg."""
 
 import asyncio
+import contextlib
 import json
 import logging
 import tempfile
+from collections.abc import Callable
 from pathlib import Path
 
 from backend.app.services.camera import get_ffmpeg_path
@@ -11,6 +13,26 @@ from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_s
 
 logger = logging.getLogger(__name__)
 
+# ffprobe and a single thumbnail frame take well under a second on a healthy
+# file; these bound a broken or unreadable one, which runs inside a request.
+PROBE_TIMEOUT_SECONDS = 60
+THUMBNAIL_TIMEOUT_SECONDS = 30
+
+
+async def _communicate(process: asyncio.subprocess.Process, timeout: float) -> tuple[bytes, bytes]:
+    """``process.communicate()`` that kills the process when it takes too long."""
+    try:
+        return await asyncio.wait_for(process.communicate(), timeout)
+    except (TimeoutError, asyncio.CancelledError):
+        _kill(process)
+        await process.wait()
+        raise
+
+
+def _kill(process: asyncio.subprocess.Process) -> None:
+    with contextlib.suppress(ProcessLookupError):
+        process.kill()
+
 
 class TimelapseProcessor:
     """Service for processing timelapse videos with FFmpeg."""
@@ -41,7 +63,10 @@ class TimelapseProcessor:
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
-        stdout, stderr = await process.communicate()
+        try:
+            stdout, stderr = await _communicate(process, PROBE_TIMEOUT_SECONDS)
+        except TimeoutError:
+            raise RuntimeError(f"ffprobe took longer than {PROBE_TIMEOUT_SECONDS} s") from None
 
         if process.returncode != 0:
             # Summarised once and used for both: the raise carried a second,
@@ -124,7 +149,11 @@ class TimelapseProcessor:
                     stdout=asyncio.subprocess.PIPE,
                     stderr=asyncio.subprocess.PIPE,
                 )
-                await process.communicate()
+                try:
+                    await _communicate(process, THUMBNAIL_TIMEOUT_SECONDS)
+                except TimeoutError:
+                    logger.warning("Timelapse thumbnail at %.1f s took too long; skipped", timestamp)
+                    continue
 
                 if output_path.exists():
                     thumbnails.append((timestamp, output_path.read_bytes()))
@@ -139,6 +168,7 @@ class TimelapseProcessor:
         speed: float = 1.0,
         audio_path: Path | None = None,
         audio_volume: float = 1.0,
+        on_progress: Callable[[float], None] | None = None,
     ) -> bool:
         """Process video with trim, speed, and optional audio overlay.
 
@@ -149,10 +179,28 @@ class TimelapseProcessor:
             speed: Speed multiplier (0.25 to 4.0)
             audio_path: Optional music file to overlay
             audio_volume: Volume for audio overlay (0.0 to 1.0)
+            on_progress: Called with the share done (0.0 to 1.0) as ffmpeg
+                reports its position
 
         Returns:
             True if processing succeeded, False otherwise
+
+        Cancelling the call stops ffmpeg and removes the partial output.
         """
+        # The output's length, for progress: what is kept of the input, at
+        # the new speed. An audio overlay with -shortest may end it earlier.
+        expected_seconds = None
+        if on_progress is not None:
+            end = trim_end if trim_end is not None and trim_end > trim_start else None
+            if end is None:
+                try:
+                    end = (await self.get_info())["duration"]
+                except Exception as e:
+                    # Progress is a nicety: encode without it.
+                    logger.warning("Timelapse length unknown, no progress: %s", e)
+            if end is not None:
+                expected_seconds = max(end - trim_start, 0) / speed
+
         # Build FFmpeg command
         cmd = [self.ffmpeg, "-y"]
 
@@ -219,6 +267,10 @@ class TimelapseProcessor:
                 "128k",
                 "-movflags",
                 "+faststart",  # Enable streaming
+                # Position reports on stdout, for progress.
+                "-progress",
+                "pipe:1",
+                "-nostats",
                 str(output_path),
             ]
         )
@@ -232,7 +284,17 @@ class TimelapseProcessor:
             stderr=asyncio.subprocess.PIPE,
         )
 
-        _, stderr = await process.communicate()
+        try:
+            _, stderr = await asyncio.gather(
+                self._read_progress(process, expected_seconds, on_progress),
+                process.stderr.read(),  # type: ignore[union-attr]
+            )
+            await process.wait()
+        except asyncio.CancelledError:
+            _kill(process)
+            await process.wait()
+            output_path.unlink(missing_ok=True)
+            raise
 
         if process.returncode != 0:
             logger.error("FFmpeg processing failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
@@ -240,6 +302,23 @@ class TimelapseProcessor:
 
         return output_path.exists()
 
+    @staticmethod
+    async def _read_progress(
+        process: asyncio.subprocess.Process,
+        expected_seconds: float | None,
+        on_progress: Callable[[float], None] | None,
+    ) -> None:
+        """Read ffmpeg's ``-progress`` lines, passing on the share done."""
+        assert process.stdout is not None
+        async for raw in process.stdout:
+            key, _, value = raw.decode(errors="replace").strip().partition("=")
+            if on_progress is None or not expected_seconds or key != "out_time_us":
+                continue
+            try:
+                on_progress(int(value) / 1_000_000 / expected_seconds)
+            except ValueError:
+                pass  # "N/A" before the first frame
+
     def _build_atempo_chain(self, speed: float) -> str:
         """Build atempo filter chain.
 

+ 239 - 0
backend/tests/integration/test_timelapse_editor_job.py

@@ -0,0 +1,239 @@
+"""The timelapse editor's re-encode runs as a job: started, polled, cancelled."""
+
+import asyncio
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services import timelapse_jobs
+from backend.app.services.timelapse_processor import TimelapseProcessor
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.fixture(autouse=True)
+def _no_jobs():
+    timelapse_jobs._jobs.clear()
+    yield
+    timelapse_jobs._jobs.clear()
+
+
+@pytest.fixture
+def data_dir(tmp_path):
+    from backend.app.api.routes import archives
+
+    with (
+        patch.object(archives.settings, "base_dir", tmp_path),
+        patch("backend.app.services.timelapse_processor.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+    ):
+        yield tmp_path
+
+
+async def _archive(printer_factory, archive_factory, data_dir: Path):
+    printer = await printer_factory()
+    video = data_dir / "archive" / "1" / "timelapse.mp4"
+    video.parent.mkdir(parents=True)
+    video.write_bytes(b"original")
+    return await archive_factory(printer.id, timelapse_path="archive/1/timelapse.mp4")
+
+
+async def _settled(client: AsyncClient, archive_id: int) -> dict:
+    for _ in range(100):
+        body = (await client.get(f"/api/v1/archives/{archive_id}/timelapse/job")).json()
+        if body["status"] != "running":
+            return body
+        await asyncio.sleep(0.02)
+    raise AssertionError("job still running")
+
+
+def _encoder(gate: asyncio.Event | None = None, ok: bool = True):
+    async def process(self, output_path, on_progress=None, **kwargs):
+        on_progress(0.4)
+        if gate is not None:
+            await gate.wait()
+        output_path.write_bytes(b"edited")
+        return ok
+
+    return patch.object(TimelapseProcessor, "process", process)
+
+
+async def test_the_request_returns_before_the_encode_and_the_job_reports_how_it_went(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    gate = asyncio.Event()
+
+    with _encoder(gate):
+        started = await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace", "speed": "2"}
+        )
+        assert started.status_code == 202, started.text
+        assert started.json()["status"] == "running"
+        await asyncio.sleep(0.05)
+        running = (await async_client.get(f"/api/v1/archives/{archive.id}/timelapse/job")).json()
+        gate.set()
+        done = await _settled(async_client, archive.id)
+
+    assert (running["status"], running["progress"]) == ("running", 0.4)
+    assert done["status"] == "completed"
+    assert done["message"] == "Timelapse replaced successfully"
+    assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"edited"
+
+
+async def test_a_second_start_while_one_runs_is_refused(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    gate = asyncio.Event()
+
+    with _encoder(gate):
+        first = await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"}
+        )
+        second = await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"}
+        )
+        gate.set()
+        await _settled(async_client, archive.id)
+
+    assert (first.status_code, second.status_code) == (202, 409)
+
+
+async def test_cancelling_keeps_the_original_and_removes_the_temporary_files(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    import tempfile
+
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    before = set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*"))
+
+    with _encoder(asyncio.Event()):
+        await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process",
+            data={"save_mode": "replace"},
+            files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
+        )
+        cancelled = await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse/job")
+
+    assert cancelled.status_code == 200, cancelled.text
+    assert cancelled.json()["status"] == "cancelled"
+    assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"original"
+    assert set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*")) == before
+    assert (await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse/job")).status_code == 404
+
+
+async def test_a_failed_encode_is_reported(async_client: AsyncClient, printer_factory, archive_factory, data_dir):
+    import tempfile
+
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    before = set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*"))
+
+    with _encoder(ok=False):
+        await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"})
+        done = await _settled(async_client, archive.id)
+
+    assert (done["status"], done["error"]) == ("failed", "Video processing failed")
+    assert (data_dir / "archive/1/timelapse.mp4").read_bytes() == b"original"
+    # The half-written output goes too.
+    assert set(Path(tempfile.gettempdir()).glob(f"*_{archive.id}_*")) == before
+
+
+async def test_no_job_yet(async_client: AsyncClient, printer_factory, archive_factory, data_dir):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+
+    assert (await async_client.get(f"/api/v1/archives/{archive.id}/timelapse/job")).status_code == 404
+    assert (await async_client.get("/api/v1/archives/999999/timelapse/job")).status_code == 404
+
+
+async def test_the_music_volume_reaches_the_encode(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    seen = {}
+
+    async def process(self, output_path, on_progress=None, **kwargs):
+        seen.update(kwargs)
+        output_path.write_bytes(b"edited")
+        return True
+
+    with patch.object(TimelapseProcessor, "process", process):
+        await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process",
+            data={"save_mode": "replace", "audio_volume": "0.3"},
+            files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
+        )
+        await _settled(async_client, archive.id)
+
+    assert seen["audio_volume"] == 0.3
+
+
+@pytest.mark.parametrize("volume", ["-0.1", "1.5"])
+async def test_a_volume_out_of_range_is_refused(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir, volume
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+
+    response = await async_client.post(
+        f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace", "audio_volume": volume}
+    )
+
+    assert response.status_code == 400
+    assert timelapse_jobs.get_job(archive.id) is None
+
+
+@pytest.mark.parametrize("change", ["removed", "replaced"])
+async def test_a_timelapse_changed_during_the_edit_is_not_overwritten(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir, db_session, change
+):
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    video = data_dir / "archive/1/timelapse.mp4"
+    gate = asyncio.Event()
+
+    with _encoder(gate):
+        await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/process", data={"save_mode": "replace"})
+        if change == "removed":
+            assert (await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse")).status_code == 200
+        else:
+            video.unlink()
+            video.write_bytes(b"a newer video")
+        gate.set()
+        done = await _settled(async_client, archive.id)
+
+    assert done["status"] == "failed"
+    assert "removed or replaced" in done["error"]
+    if change == "removed":
+        assert not video.exists()
+    else:
+        assert video.read_bytes() == b"a newer video"
+
+
+async def test_two_saves_racing_get_one_job_and_a_409(
+    async_client: AsyncClient, printer_factory, archive_factory, data_dir
+):
+    """The second passed the early check while the first one's audio upload was read."""
+    import tempfile
+
+    archive = await _archive(printer_factory, archive_factory, data_dir)
+    before = set(Path(tempfile.gettempdir()).glob(f"audio_{archive.id}_*"))
+    real_start = timelapse_jobs.start_job
+
+    async def other_request(job):
+        await asyncio.Event().wait()
+        return "", ""
+
+    def raced(archive_id, run):
+        real_start(archive_id, other_request)
+        return real_start(archive_id, run)
+
+    with _encoder(asyncio.Event()), patch.object(timelapse_jobs, "start_job", raced):
+        response = await async_client.post(
+            f"/api/v1/archives/{archive.id}/timelapse/process",
+            data={"save_mode": "replace"},
+            files={"audio": ("music.mp3", b"ID3", "audio/mpeg")},
+        )
+    await timelapse_jobs.cancel_all()
+
+    assert response.status_code == 409
+    assert set(Path(tempfile.gettempdir()).glob(f"audio_{archive.id}_*")) == before

+ 197 - 0
backend/tests/unit/services/test_timelapse_jobs.py

@@ -0,0 +1,197 @@
+"""Timelapse editor re-encodes run as background jobs that report progress and can be cancelled."""
+
+import asyncio
+import shutil
+import stat
+import sys
+from pathlib import Path
+
+import pytest
+
+from backend.app.services import timelapse_jobs
+from backend.app.services.timelapse_processor import TimelapseProcessor, _communicate
+
+
+@pytest.fixture(autouse=True)
+def _no_jobs():
+    timelapse_jobs._jobs.clear()
+    yield
+    timelapse_jobs._jobs.clear()
+
+
+async def _finished(job: timelapse_jobs.TimelapseJob) -> None:
+    assert job.task is not None
+    await asyncio.wait_for(asyncio.shield(job.task), 5)
+
+
+class TestJobs:
+    @pytest.mark.asyncio
+    async def test_a_finished_job_keeps_its_result(self):
+        async def run(job):
+            job.set_progress(0.5)
+            return "archive/1/out.mp4", "Saved as out.mp4"
+
+        job = timelapse_jobs.start_job(1, run)
+        assert job.status == timelapse_jobs.RUNNING
+        await _finished(job)
+
+        assert timelapse_jobs.get_job(1) is job
+        assert (job.status, job.progress, job.output_path, job.message) == (
+            timelapse_jobs.COMPLETED,
+            1.0,
+            "archive/1/out.mp4",
+            "Saved as out.mp4",
+        )
+
+    @pytest.mark.asyncio
+    async def test_a_failed_job_says_why(self):
+        async def run(job):
+            raise RuntimeError("Video processing failed")
+
+        job = timelapse_jobs.start_job(1, run)
+        await _finished(job)
+
+        assert (job.status, job.error) == (timelapse_jobs.FAILED, "Video processing failed")
+
+    @pytest.mark.asyncio
+    async def test_one_job_per_archive_at_a_time(self):
+        release = asyncio.Event()
+
+        async def run(job):
+            await release.wait()
+            return "a", "b"
+
+        job = timelapse_jobs.start_job(1, run)
+        with pytest.raises(timelapse_jobs.JobAlreadyRunning):
+            timelapse_jobs.start_job(1, run)
+        other = timelapse_jobs.start_job(2, run)
+
+        release.set()
+        await _finished(job)
+        await _finished(other)
+        again = timelapse_jobs.start_job(1, run)
+        await _finished(again)
+        assert again.status == timelapse_jobs.COMPLETED
+
+    @pytest.mark.asyncio
+    async def test_cancelling_stops_the_job(self):
+        started = asyncio.Event()
+        stopped = []
+
+        async def run(job):
+            started.set()
+            try:
+                await asyncio.sleep(30)
+            except asyncio.CancelledError:
+                stopped.append(True)
+                raise
+            return "a", "b"
+
+        job = timelapse_jobs.start_job(1, run)
+        await started.wait()
+
+        assert await timelapse_jobs.cancel_job(1) is True
+        assert job.status == timelapse_jobs.CANCELLED
+        assert stopped == [True]
+        assert await timelapse_jobs.cancel_job(1) is False
+
+    @pytest.mark.asyncio
+    async def test_shutdown_cancels_every_running_job(self):
+        async def run(job):
+            await asyncio.sleep(30)
+            return "a", "b"
+
+        jobs = [timelapse_jobs.start_job(i, run) for i in (1, 2)]
+        await asyncio.sleep(0)
+
+        await timelapse_jobs.cancel_all()
+
+        assert [j.status for j in jobs] == [timelapse_jobs.CANCELLED] * 2
+
+    @pytest.mark.asyncio
+    async def test_no_job_to_cancel(self):
+        assert await timelapse_jobs.cancel_job(7) is False
+
+
+def _fake_ffmpeg(tmp_path: Path, body: str) -> str:
+    """A stand-in ffmpeg: a Python script; the output path is its last argument."""
+    script = tmp_path / "ffmpeg"
+    script.write_text(f"#!{sys.executable}\nimport sys, time\nout = sys.argv[-1]\n{body}\n")
+    script.chmod(script.stat().st_mode | stat.S_IEXEC)
+    return str(script)
+
+
+def _processor(tmp_path: Path, ffmpeg: str) -> TimelapseProcessor:
+    processor = TimelapseProcessor.__new__(TimelapseProcessor)
+    processor.input_path = tmp_path / "in.mp4"
+    processor.ffmpeg = ffmpeg
+    processor.ffprobe = ffmpeg
+    return processor
+
+
+class TestProcessor:
+    @pytest.mark.asyncio
+    async def test_progress_is_the_share_of_the_output_written(self, tmp_path):
+        ffmpeg = _fake_ffmpeg(
+            tmp_path,
+            "open(out, 'wb').write(b'x')\n"
+            "for us in ('N/A', '1000000', '2000000'):\n"
+            "    print(f'out_time_us={us}', flush=True)\n"
+            "    print('progress=continue', flush=True)",
+        )
+        seen: list[float] = []
+
+        # 8 s of input at double speed: 4 s of output.
+        ok = await _processor(tmp_path, ffmpeg).process(
+            tmp_path / "out.mp4", trim_start=2, trim_end=10, speed=2.0, on_progress=seen.append
+        )
+
+        assert ok is True
+        assert seen == [0.25, 0.5]
+
+    @pytest.mark.asyncio
+    async def test_cancelling_stops_ffmpeg_and_drops_the_partial_file(self, tmp_path):
+        ffmpeg = _fake_ffmpeg(
+            tmp_path,
+            "open(out, 'wb').write(b'partial')\nprint('out_time_us=1000000', flush=True)\ntime.sleep(60)",
+        )
+        output = tmp_path / "out.mp4"
+        reported = asyncio.Event()
+
+        task = asyncio.create_task(
+            _processor(tmp_path, ffmpeg).process(output, trim_end=10, on_progress=lambda _: reported.set())
+        )
+        await asyncio.wait_for(reported.wait(), 10)
+        task.cancel()
+
+        with pytest.raises(asyncio.CancelledError):
+            await asyncio.wait_for(task, 5)
+        assert not output.exists()
+
+    @pytest.mark.asyncio
+    async def test_a_probe_that_hangs_is_killed(self):
+        sleep = shutil.which("sleep")
+        if sleep is None:
+            pytest.skip("no sleep binary")
+        process = await asyncio.create_subprocess_exec(
+            sleep, "30", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
+        )
+
+        # Killed, not waited out: well inside the 30 s the process would run.
+        with pytest.raises(TimeoutError):
+            await asyncio.wait_for(_communicate(process, 0.2), 5)
+
+        assert process.returncode is not None
+
+
+@pytest.mark.asyncio
+async def test_without_a_known_length_it_encodes_without_progress(tmp_path):
+    ffmpeg = _fake_ffmpeg(tmp_path, "open(out, 'wb').write(b'x')\nprint('out_time_us=1000000', flush=True)")
+    processor = _processor(tmp_path, ffmpeg)
+    processor.ffprobe = "/nonexistent/ffprobe"
+    seen: list[float] = []
+
+    ok = await processor.process(tmp_path / "out.mp4", on_progress=seen.append)
+
+    assert ok is True
+    assert seen == []

+ 56 - 0
backend/tests/unit/services/test_usb_camera_listing.py

@@ -0,0 +1,56 @@
+"""The USB camera list the camera settings pick a device from.
+
+Only devices a USB camera can stream from are listed (streaming accepts
+/dev/video0 to /dev/video99), and the listing, which runs v4l2-ctl for each
+device, does not hold up the server while it runs.
+"""
+
+import threading
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.external_camera import list_usb_cameras
+
+
+def _devices(tmp_path: Path, *names: str) -> Path:
+    for name in names:
+        (tmp_path / name).touch()
+    return tmp_path
+
+
+def test_only_device_paths_streaming_accepts_are_listed(tmp_path):
+    dev = _devices(tmp_path, "video0", "video12", "video100", "video05", "video-meta", "videoX")
+
+    with patch("backend.app.services.external_camera.shutil.which", return_value=None):
+        cameras = list_usb_cameras(dev_dir=dev)
+
+    assert [camera["device"] for camera in cameras] == ["/dev/video0", "/dev/video12"]
+
+
+def test_paths_are_listed_as_dev_paths(tmp_path):
+    """The entry names the device as the camera settings store it."""
+    dev = _devices(tmp_path, "video2")
+
+    with patch("backend.app.services.external_camera.shutil.which", return_value=None):
+        cameras = list_usb_cameras(dev_dir=dev)
+
+    assert cameras == [{"device": "/dev/video2", "name": "video2", "capabilities": []}]
+
+
+@pytest.mark.asyncio
+async def test_the_listing_runs_off_the_event_loop(async_client: AsyncClient):
+    seen: list[threading.Thread] = []
+
+    def listing():
+        seen.append(threading.current_thread())
+        return [{"device": "/dev/video0", "name": "Webcam", "capabilities": ["capture"]}]
+
+    with patch("backend.app.services.external_camera.list_usb_cameras", side_effect=listing):
+        response = await async_client.get("/api/v1/printers/usb-cameras")
+
+    assert response.status_code == 200
+    assert response.json()["cameras"][0]["device"] == "/dev/video0"
+    assert seen and seen[0] is not threading.main_thread()

+ 155 - 0
frontend/src/__tests__/components/TimelapseEditorModalJob.test.tsx

@@ -0,0 +1,155 @@
+/**
+ * The timelapse editor's Save starts a re-encode job on the server; the editor
+ * shows its progress, can cancel it, and reports how it ended.
+ */
+import { useState } from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { TimelapseEditorModal } from '../../components/TimelapseEditorModal';
+import { api } from '../../api/client';
+import type { TimelapseJob } from '../../api/client';
+
+vi.mock('../../api/client', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../api/client')>();
+  return {
+    ...actual,
+    api: {
+      ...actual.api,
+      getTimelapseInfo: vi.fn(),
+      getTimelapseThumbnails: vi.fn(),
+      getTimelapseJob: vi.fn(),
+      processTimelapse: vi.fn(),
+      cancelTimelapseJob: vi.fn(),
+    },
+  };
+});
+
+const job = (overrides: Partial<TimelapseJob>): TimelapseJob => ({
+  status: 'running',
+  progress: null,
+  output_path: null,
+  message: null,
+  error: null,
+  ...overrides,
+});
+
+function open() {
+  const onClose = vi.fn();
+  const onSave = vi.fn();
+  render(<TimelapseEditorModal archiveId={5} timelapseSrc="/t.mp4" onClose={onClose} onSave={onSave} />);
+  return { onClose, onSave };
+}
+
+const POLL = { timeout: 3000 };
+
+describe('TimelapseEditorModal re-encode job', () => {
+  beforeEach(() => {
+    vi.mocked(api.getTimelapseInfo).mockResolvedValue({
+      duration: 10,
+      width: 320,
+      height: 240,
+      fps: 30,
+      codec: 'h264',
+      file_size: 1000,
+      has_audio: false,
+    });
+    vi.mocked(api.getTimelapseThumbnails).mockResolvedValue({ thumbnails: [], timestamps: [] });
+    vi.mocked(api.getTimelapseJob).mockReset().mockResolvedValue(null);
+    vi.mocked(api.processTimelapse).mockReset().mockResolvedValue(job({}));
+    vi.mocked(api.cancelTimelapseJob).mockReset().mockResolvedValue(job({ status: 'cancelled' }));
+  });
+
+  it('Save starts the job, shows its progress and closes with the result', async () => {
+    vi.mocked(api.getTimelapseJob)
+      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce(job({ progress: 0.4 }))
+      .mockResolvedValue(job({ status: 'completed', progress: 1, message: 'Timelapse replaced successfully' }));
+    const { onClose, onSave } = open();
+
+    await userEvent.click(await screen.findByRole('button', { name: /save/i }));
+
+    expect(api.processTimelapse).toHaveBeenCalledWith(
+      5,
+      expect.objectContaining({ saveMode: 'replace', audioVolume: 0.8 }),
+      undefined
+    );
+    expect(await screen.findByText('Processing… 40%', {}, POLL)).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /processing/i })).toBeDisabled();
+    await waitFor(() => expect(onClose).toHaveBeenCalled(), POLL);
+    expect(onSave).toHaveBeenCalled();
+    expect(screen.getByText('Timelapse replaced successfully')).toBeInTheDocument();
+  });
+
+  it('Cancel stops the job and keeps the editor open', async () => {
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ progress: 0.2 }));
+    const { onClose, onSave } = open();
+
+    await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
+
+    expect(api.cancelTimelapseJob).toHaveBeenCalledWith(5);
+    expect(await screen.findByText('Processing cancelled')).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
+    expect(onClose).not.toHaveBeenCalled();
+    expect(onSave).not.toHaveBeenCalled();
+  });
+
+  it('a failed job says why and keeps the editor open', async () => {
+    vi.mocked(api.getTimelapseJob)
+      .mockResolvedValueOnce(job({}))
+      .mockResolvedValue(job({ status: 'failed', error: 'Video processing failed' }));
+    const { onClose } = open();
+
+    expect(await screen.findByText('Video processing failed', {}, POLL)).toBeInTheDocument();
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('a reopened editor shows a job still running', async () => {
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ progress: 0.75 }));
+    open();
+
+    expect(await screen.findByText('Processing… 75%')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /processing/i })).toBeDisabled();
+    expect(api.processTimelapse).not.toHaveBeenCalled();
+  });
+
+  it('an earlier finished job is not reported again', async () => {
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ status: 'completed', message: 'Saved as old.mp4' }));
+    const { onClose } = open();
+
+    await screen.findByRole('button', { name: /save/i });
+    await waitFor(() => expect(api.getTimelapseJob).toHaveBeenCalled());
+    expect(screen.queryByText('Saved as old.mp4')).not.toBeInTheDocument();
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('reopened after the job ended meanwhile, the editor stays open and quiet', async () => {
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ progress: 0.5 }));
+    const onClose = vi.fn();
+    // Keeps the cache across the reopening, like the app's client (the test
+    // client drops it at once).
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    function Viewer() {
+      const [editing, setEditing] = useState(true);
+      return (
+        <QueryClientProvider client={client}>
+          <button onClick={() => setEditing((e) => !e)}>toggle editor</button>
+          {editing && <TimelapseEditorModal archiveId={5} timelapseSrc="/t.mp4" onClose={onClose} />}
+        </QueryClientProvider>
+      );
+    }
+    render(<Viewer />);
+    expect(await screen.findByText('Processing… 50%')).toBeInTheDocument();
+
+    await userEvent.click(screen.getByRole('button', { name: 'toggle editor' }));
+    vi.mocked(api.getTimelapseJob).mockResolvedValue(job({ status: 'completed', message: 'Timelapse replaced successfully' }));
+    await userEvent.click(screen.getByRole('button', { name: 'toggle editor' }));
+
+    await waitFor(() => expect(screen.getByRole('button', { name: /save/i })).toBeEnabled());
+    expect(onClose).not.toHaveBeenCalled();
+    expect(screen.queryByText('Timelapse replaced successfully')).not.toBeInTheDocument();
+  });
+});

+ 153 - 0
frontend/src/__tests__/pages/SettingsPageUsbCameraWiring.test.tsx

@@ -0,0 +1,153 @@
+/**
+ * Settings > Camera: a USB camera is picked from the video devices the server
+ * lists, a saved device that is unplugged stays selected, and the path can
+ * still be typed by hand.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
+import { SettingsPage } from '../../pages/SettingsPage';
+
+const PRINTER = {
+  id: 7,
+  name: 'Bench',
+  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: '/dev/video0',
+  external_camera_type: 'usb',
+  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',
+};
+
+const WEBCAM = { device: '/dev/video0', name: 'HD Pro Webcam C920', capabilities: ['capture'] };
+const SECOND = { device: '/dev/video2', name: 'USB Camera', capabilities: ['capture'] };
+
+let listCalls: number;
+let cameras: typeof WEBCAM[];
+let patches: Record<string, unknown>[];
+
+function setup(printer: Record<string, unknown> = PRINTER) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
+    http.get('/api/v1/printers/usb-cameras', () => {
+      listCalls += 1;
+      return HttpResponse.json({ cameras });
+    }),
+    http.patch('/api/v1/printers/7', async ({ request }) => {
+      const body = (await request.json()) as Record<string, unknown>;
+      patches.push(body);
+      return HttpResponse.json({ ...printer, ...body });
+    }),
+  );
+}
+
+async function openCamera() {
+  render(<SettingsPage />);
+  await userEvent.setup().click(await screen.findByLabelText('Camera settings: Bench'));
+}
+
+const picker = () => screen.findByLabelText('USB device: Bench') as Promise<HTMLSelectElement>;
+
+beforeEach(() => {
+  window.history.replaceState({}, '', '/?tab=camera');
+  setAuthToken(null);
+  listCalls = 0;
+  cameras = [WEBCAM, SECOND];
+  patches = [];
+});
+
+describe('USB camera device picker', () => {
+  it('lists the server\'s video devices for a USB camera', async () => {
+    setup();
+    await openCamera();
+
+    const select = await picker();
+    await waitFor(() => expect(within(select).getByRole('option', { name: 'HD Pro Webcam C920 (/dev/video0)' })).toBeInTheDocument());
+    expect(within(select).getByRole('option', { name: 'USB Camera (/dev/video2)' })).toBeInTheDocument();
+    expect(select.value).toBe('/dev/video0');
+    expect(screen.queryByPlaceholderText('Device path (/dev/video0)')).not.toBeInTheDocument();
+  });
+
+  it('saves the picked device as the camera path', async () => {
+    setup();
+    await openCamera();
+
+    const select = await picker();
+    await screen.findByRole('option', { name: 'USB Camera (/dev/video2)' });
+    await userEvent.setup().selectOptions(select, '/dev/video2');
+
+    await waitFor(() => expect(patches).toContainEqual({ external_camera_url: '/dev/video2' }), { timeout: 3000 });
+  });
+
+  it('keeps a saved device that is not connected, marked not found', async () => {
+    cameras = [SECOND];
+    setup();
+    await openCamera();
+
+    const select = await picker();
+    await screen.findByRole('option', { name: '/dev/video0 (not found)' });
+    expect(select.value).toBe('/dev/video0');
+    expect(patches).toEqual([]);
+  });
+
+  it('says how to pass a camera to Docker when no device is visible', async () => {
+    cameras = [];
+    setup();
+    await openCamera();
+
+    expect(await screen.findByText(/No video devices are visible to Bambuddy/)).toBeInTheDocument();
+    expect(screen.getByText('devices: - /dev/video0:/dev/video0')).toBeInTheDocument();
+  });
+
+  it('lets the path be typed by hand, and goes back to the list', async () => {
+    setup();
+    await openCamera();
+    await picker();
+    const user = userEvent.setup();
+
+    await user.click(screen.getByRole('button', { name: 'Enter path manually' }));
+    const input = screen.getByPlaceholderText('Device path (/dev/video0)') as HTMLInputElement;
+    expect(input.value).toBe('/dev/video0');
+    expect(screen.queryByLabelText('USB device: Bench')).not.toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: 'Choose a detected device' }));
+    expect(await picker()).toBeInTheDocument();
+  });
+
+  it('looks for devices again on refresh', async () => {
+    setup();
+    await openCamera();
+    await screen.findByRole('option', { name: 'USB Camera (/dev/video2)' });
+    const before = listCalls;
+
+    cameras = [WEBCAM, SECOND, { device: '/dev/video4', name: 'Plugged in later', capabilities: ['capture'] }];
+    await userEvent.setup().click(screen.getByRole('button', { name: 'Look for devices again' }));
+
+    expect(await screen.findByRole('option', { name: 'Plugged in later (/dev/video4)' })).toBeInTheDocument();
+    expect(listCalls).toBeGreaterThan(before);
+  });
+
+  it('does not list devices for a camera that is not USB', async () => {
+    setup({ ...PRINTER, external_camera_type: 'mjpeg', external_camera_url: 'http://192.168.1.61/stream' });
+    await openCamera();
+
+    expect(await screen.findByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
+    expect(screen.queryByLabelText('USB device: Bench')).not.toBeInTheDocument();
+    expect(listCalls).toBe(0);
+  });
+});

+ 37 - 1
frontend/src/api/client.ts

@@ -622,6 +622,15 @@ export interface ExtruderSlot {
 // Which FTS inlet an AMS is plumbed into: 'A' | 'B'.
 export type FtsInlet = 'A' | 'B';
 
+// A video device the server can see, for a USB camera (GET /printers/usb-cameras).
+export interface UsbCamera {
+  device: string;
+  name: string;
+  capabilities: string[];
+  driver?: string;
+  formats?: string[];
+}
+
 export interface PrinterStatus {
   id: number;
   name: string;
@@ -840,6 +849,16 @@ export interface ArchivePrinterMedia {
   warnings: Array<'printer_missing' | 'timelapse_unavailable' | 'ipcam_unavailable' | 'printer_files_forbidden'>;
 }
 
+/** A timelapse editor re-encode, run in the background. */
+export interface TimelapseJob {
+  status: 'running' | 'completed' | 'failed' | 'cancelled';
+  /** Share done, 0 to 1; null before ffmpeg's first report. */
+  progress: number | null;
+  output_path: string | null;
+  message: string | null;
+  error: string | null;
+}
+
 /** How a post-print outcome verdict reached the archive (#1898). */
 export type VerdictSource = 'dialog' | 'link' | 'plate_clear' | 'printer_card' | 'api' | 'reaction';
 
@@ -5105,6 +5124,7 @@ export const api = {
     request<{ connected: boolean }>(`/printers/${id}/connect`, {
       method: 'POST',
     }),
+  getUsbCameras: () => request<{ cameras: UsbCamera[] }>('/printers/usb-cameras'),
   testExternalCamera: (printerId: number, url: string, cameraType: string) =>
     // `coalesced` is true when the frame came from a capture that was already
     // running (Obico polling, a snapshot) rather than a connection this test
@@ -5767,17 +5787,22 @@ export const api = {
       trimStart?: number;
       trimEnd?: number;
       speed?: number;
+      /** Music volume, 0 to 1. */
+      audioVolume?: number;
       saveMode: 'replace' | 'new';
       outputFilename?: string;
     },
     audioFile?: File
-  ): Promise<{ status: string; output_path: string | null; message: string }> => {
+  ): Promise<TimelapseJob> => {
     const formData = new FormData();
     formData.append('trim_start', String(params.trimStart ?? 0));
     if (params.trimEnd !== undefined) {
       formData.append('trim_end', String(params.trimEnd));
     }
     formData.append('speed', String(params.speed ?? 1));
+    if (params.audioVolume !== undefined) {
+      formData.append('audio_volume', String(params.audioVolume));
+    }
     formData.append('save_mode', params.saveMode);
     if (params.outputFilename) {
       formData.append('output_filename', params.outputFilename);
@@ -5800,6 +5825,17 @@ export const api = {
     }
     return response.json();
   },
+  // The archive's running or last re-encode; null when there has been none.
+  getTimelapseJob: async (archiveId: number): Promise<TimelapseJob | null> => {
+    try {
+      return await request<TimelapseJob>(`/archives/${archiveId}/timelapse/job`);
+    } catch (error) {
+      if (error instanceof ApiError && error.status === 404) return null;
+      throw error;
+    }
+  },
+  cancelTimelapseJob: (archiveId: number) =>
+    request<TimelapseJob>(`/archives/${archiveId}/timelapse/job`, { method: 'DELETE' }),
   // Photos
   getArchivePhotoUrl: (archiveId: number, filename: string) =>
     withMediaToken(`${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`),

+ 58 - 8
frontend/src/components/TimelapseEditorModal.tsx

@@ -1,5 +1,6 @@
 import { useState, useRef, useEffect, useCallback } from 'react';
-import { useQuery, useMutation } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import {
   X,
   Save,
@@ -35,7 +36,9 @@ export function TimelapseEditorModal({
   onClose,
   onSave,
 }: TimelapseEditorModalProps) {
+  const { t } = useTranslation();
   const { showToast } = useToast();
+  const queryClient = useQueryClient();
   const videoRef = useRef<HTMLVideoElement>(null);
   const audioRef = useRef<HTMLAudioElement>(null);
 
@@ -66,6 +69,41 @@ export function TimelapseEditorModal({
     queryFn: () => api.getTimelapseThumbnails(archiveId, 15),
   });
 
+  // The re-encode runs on the server as a job; this polls it while it runs,
+  // also one started before the editor was (re)opened.
+  const jobKey = ['timelapse-job', archiveId];
+  const [openedAt] = useState(() => Date.now());
+  const { data: job, dataUpdatedAt } = useQuery({
+    queryKey: jobKey,
+    queryFn: () => api.getTimelapseJob(archiveId),
+    refetchInterval: (query) => (query.state.data?.status === 'running' ? 1000 : false),
+  });
+  const jobRunning = job?.status === 'running';
+  // Only a job seen running since the editor opened reports how it ended: a
+  // cached "running" from an earlier opening may have ended long ago.
+  const watchingJobRef = useRef(false);
+  if (jobRunning && dataUpdatedAt >= openedAt) watchingJobRef.current = true;
+
+  useEffect(() => {
+    if (!job || job.status === 'running' || !watchingJobRef.current) return;
+    watchingJobRef.current = false;
+    if (job.status === 'completed') {
+      showToast(job.message ?? '', 'success');
+      onSave?.();
+      onClose();
+    } else if (job.status === 'failed') {
+      showToast(job.error || t('timelapseEditor.failed'), 'error');
+    } else {
+      showToast(t('timelapseEditor.cancelled'), 'info');
+    }
+  }, [job, onSave, onClose, showToast, t]);
+
+  const cancelMutation = useMutation({
+    mutationFn: () => api.cancelTimelapseJob(archiveId),
+    onSuccess: (data) => queryClient.setQueryData(jobKey, data),
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
   // Process mutation
   const processMutation = useMutation({
     mutationFn: () =>
@@ -75,17 +113,17 @@ export function TimelapseEditorModal({
           trimStart,
           trimEnd,
           speed,
+          audioVolume: audioMuted ? 0 : audioVolume,
           saveMode: 'replace',
         },
         audioFile || undefined
       ),
     onSuccess: (data) => {
-      showToast(data.message, 'success');
-      onSave?.();
-      onClose();
+      watchingJobRef.current = true;
+      queryClient.setQueryData(jobKey, data);
     },
     onError: (error: Error) => {
-      showToast(error.message || 'Processing failed', 'error');
+      showToast(error.message || t('timelapseEditor.failed'), 'error');
     },
   });
 
@@ -253,16 +291,28 @@ export function TimelapseEditorModal({
             Edit Timelapse
           </h3>
           <div className="flex items-center gap-2">
+            {jobRunning && (
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => cancelMutation.mutate()}
+                disabled={cancelMutation.isPending}
+              >
+                {t('common.cancel')}
+              </Button>
+            )}
             <Button
               variant="primary"
               size="sm"
               onClick={() => processMutation.mutate()}
-              disabled={processMutation.isPending}
+              disabled={processMutation.isPending || jobRunning}
             >
-              {processMutation.isPending ? (
+              {processMutation.isPending || jobRunning ? (
                 <>
                   <Loader2 className="w-4 h-4 animate-spin" />
-                  Processing...
+                  {job?.progress != null && jobRunning
+                    ? t('timelapseEditor.processingPercent', { percent: Math.round(job.progress * 100) })
+                    : t('timelapseEditor.processing')}
                 </>
               ) : (
                 <>

+ 74 - 0
frontend/src/components/UsbCameraDevicePicker.tsx

@@ -0,0 +1,74 @@
+import { useQuery } from '@tanstack/react-query';
+import { RefreshCw } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { api } from '../api/client';
+
+interface UsbCameraDevicePickerProps {
+  printerName: string;
+  /** The saved device path, '' when none is set. */
+  value: string;
+  onSelect: (device: string) => void;
+  onEnterManually: () => void;
+}
+
+/** Picks a USB camera from the video devices the server can see. */
+export function UsbCameraDevicePicker({ printerName, value, onSelect, onEnterManually }: UsbCameraDevicePickerProps) {
+  const { t } = useTranslation();
+  const { data, isLoading, isError, isFetching, refetch } = useQuery({
+    queryKey: ['usb-cameras'],
+    queryFn: api.getUsbCameras,
+    // A camera plugged in since the last look should show up on the next one.
+    staleTime: 0,
+  });
+  const cameras = data?.cameras ?? [];
+  // A saved camera that is unplugged stays selected rather than being cleared.
+  const missing = value !== '' && !isLoading && !cameras.some((camera) => camera.device === value);
+
+  return (
+    <div className="space-y-1">
+      <div className="flex items-center gap-2">
+        <select
+          aria-label={`${t('settings.usbCameraDevice')}: ${printerName}`}
+          value={value}
+          onChange={(e) => onSelect(e.target.value)}
+          disabled={isLoading}
+          className="flex-1 min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+        >
+          {isLoading ? (
+            <option value={value}>{t('settings.usbCameraSearching')}</option>
+          ) : (
+            <>
+              {value === '' && <option value="" disabled>{t('settings.usbCameraSelect')}</option>}
+              {cameras.map((camera) => (
+                <option key={camera.device} value={camera.device}>
+                  {camera.name} ({camera.device})
+                </option>
+              ))}
+              {missing && <option value={value}>{t('settings.usbCameraNotFound', { device: value })}</option>}
+            </>
+          )}
+        </select>
+        <button
+          type="button"
+          onClick={() => refetch()}
+          disabled={isFetching}
+          aria-label={t('settings.usbCameraRefresh')}
+          title={t('settings.usbCameraRefresh')}
+          className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-50"
+        >
+          <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
+        </button>
+      </div>
+      {isError && <p className="text-xs text-red-400">{t('settings.usbCameraListFailed')}</p>}
+      {data && cameras.length === 0 && (
+        <p className="text-xs text-bambu-gray">
+          {t('settings.usbCameraNoneFound')}{' '}
+          <code className="text-bambu-green">devices: - /dev/video0:/dev/video0</code>
+        </p>
+      )}
+      <button type="button" onClick={onEnterManually} className="text-xs text-bambu-green hover:underline">
+        {t('settings.usbCameraEnterManually')}
+      </button>
+    </div>
+  );
+}

+ 16 - 0
frontend/src/i18n/locales/de.ts

@@ -2867,6 +2867,15 @@ export default {
     cameraWindowDescription: 'Kamera öffnet sich in einem separaten Browserfenster',
     externalCamerasDescription: 'Externe Kameras konfigurieren, um die eingebaute Druckerkamera zu ersetzen. Unterstützt MJPEG-Streams, RTSP, HTTP-Snapshots und USB-Kameras (V4L2). Wenn aktiviert, wird die externe Kamera für Live-Ansicht und Abschlussfotos verwendet.',
     cameraPlaceholderUsb: 'Gerätepfad (/dev/video0)',
+    usbCameraDevice: 'USB-Gerät',
+    usbCameraSelect: 'Gerät auswählen',
+    usbCameraSearching: 'Suche nach Geräten…',
+    usbCameraNotFound: '{{device}} (nicht gefunden)',
+    usbCameraRefresh: 'Erneut nach Geräten suchen',
+    usbCameraListFailed: 'Die Videogeräte konnten nicht aufgelistet werden.',
+    usbCameraNoneFound: 'Bambuddy sieht keine Videogeräte. Unter Docker die Kamera an den Container durchreichen:',
+    usbCameraEnterManually: 'Pfad manuell eingeben',
+    usbCameraPickFromList: 'Erkanntes Gerät auswählen',
     cameraPlaceholderUrl: 'Kamera-URL (rtsp://... oder http://...)',
     cameraTypeMjpeg: 'MJPEG-Stream',
     cameraTypeRtsp: 'RTSP-Stream',
@@ -5829,6 +5838,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: 'Wird verarbeitet…',
+    processingPercent: 'Wird verarbeitet… {{percent}} %',
+    failed: 'Verarbeitung fehlgeschlagen',
+    cancelled: 'Verarbeitung abgebrochen',
+  },
   // Tags
   tags: {
     title: 'Tags',

+ 16 - 0
frontend/src/i18n/locales/en.ts

@@ -2889,6 +2889,15 @@ export default {
     cameraWindowDescription: 'Camera opens in a separate browser window',
     externalCamerasDescription: 'Configure external cameras to replace the built-in printer camera. Supports MJPEG streams, RTSP, HTTP snapshots, and USB cameras (V4L2). When enabled, the external camera is used for live view and finish photos.',
     cameraPlaceholderUsb: 'Device path (/dev/video0)',
+    usbCameraDevice: 'USB device',
+    usbCameraSelect: 'Select a device',
+    usbCameraSearching: 'Looking for devices…',
+    usbCameraNotFound: '{{device}} (not found)',
+    usbCameraRefresh: 'Look for devices again',
+    usbCameraListFailed: 'Could not list the video devices.',
+    usbCameraNoneFound: 'No video devices are visible to Bambuddy. In Docker, pass the camera to the container:',
+    usbCameraEnterManually: 'Enter path manually',
+    usbCameraPickFromList: 'Choose a detected device',
     cameraPlaceholderUrl: 'Camera URL (rtsp://... or http://...)',
     cameraTypeMjpeg: 'MJPEG Stream',
     cameraTypeRtsp: 'RTSP Stream',
@@ -5881,6 +5890,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: 'Processing…',
+    processingPercent: 'Processing… {{percent}}%',
+    failed: 'Processing failed',
+    cancelled: 'Processing cancelled',
+  },
   // Tags
   tags: {
     title: 'Tags',

+ 16 - 0
frontend/src/i18n/locales/es.ts

@@ -2869,6 +2869,15 @@ export default {
     cameraWindowDescription: 'La cámara se abre en una ventana de navegador independiente',
     externalCamerasDescription: 'Configure cámaras externas para sustituir la cámara integrada de la impresora. Admite transmisiones MJPEG, RTSP, capturas HTTP y cámaras USB (V4L2). Cuando está activada, la cámara externa se usa para la vista en directo y las fotos de finalización.',
     cameraPlaceholderUsb: 'Ruta del dispositivo (/dev/video0)',
+    usbCameraDevice: 'Dispositivo USB',
+    usbCameraSelect: 'Selecciona un dispositivo',
+    usbCameraSearching: 'Buscando dispositivos…',
+    usbCameraNotFound: '{{device}} (no encontrado)',
+    usbCameraRefresh: 'Buscar dispositivos de nuevo',
+    usbCameraListFailed: 'No se pudieron listar los dispositivos de vídeo.',
+    usbCameraNoneFound: 'Bambuddy no ve ningún dispositivo de vídeo. En Docker, pasa la cámara al contenedor:',
+    usbCameraEnterManually: 'Introducir la ruta manualmente',
+    usbCameraPickFromList: 'Elegir un dispositivo detectado',
     cameraPlaceholderUrl: 'URL de la cámara (rtsp://... o http://...)',
     cameraTypeMjpeg: 'Transmisión MJPEG',
     cameraTypeRtsp: 'Transmisión RTSP',
@@ -5836,6 +5845,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: 'Procesando…',
+    processingPercent: 'Procesando… {{percent}} %',
+    failed: 'Error al procesar',
+    cancelled: 'Procesamiento cancelado',
+  },
   // Tags
   tags: {
     title: 'Etiquetas',

+ 16 - 0
frontend/src/i18n/locales/fr.ts

@@ -2819,6 +2819,15 @@ export default {
     cameraWindowDescription: 'La caméra s\'ouvre dans une fenêtre de navigateur séparée',
     externalCamerasDescription: 'Configurer des caméras externes pour remplacer la caméra intégrée. Supporte les flux MJPEG, RTSP, snapshots HTTP et caméras USB (V4L2). Lorsqu\'activée, la caméra externe est utilisée pour la vue en direct et les photos de fin.',
     cameraPlaceholderUsb: 'Chemin du périphérique (/dev/video0)',
+    usbCameraDevice: 'Périphérique USB',
+    usbCameraSelect: 'Choisir un périphérique',
+    usbCameraSearching: 'Recherche des périphériques…',
+    usbCameraNotFound: '{{device}} (introuvable)',
+    usbCameraRefresh: 'Rechercher à nouveau les périphériques',
+    usbCameraListFailed: 'Impossible de lister les périphériques vidéo.',
+    usbCameraNoneFound: 'Bambuddy ne voit aucun périphérique vidéo. Sous Docker, transmettez la caméra au conteneur :',
+    usbCameraEnterManually: 'Saisir le chemin manuellement',
+    usbCameraPickFromList: 'Choisir un périphérique détecté',
     cameraPlaceholderUrl: 'URL caméra (rtsp://... ou http://...)',
     cameraTypeMjpeg: 'Flux MJPEG',
     cameraTypeRtsp: 'Flux RTSP',
@@ -5816,6 +5825,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: 'Traitement…',
+    processingPercent: 'Traitement… {{percent}} %',
+    failed: 'Échec du traitement',
+    cancelled: 'Traitement annulé',
+  },
   // Tags
   tags: {
     title: 'Étiquettes',

+ 16 - 0
frontend/src/i18n/locales/it.ts

@@ -2818,6 +2818,15 @@ export default {
     cameraWindowDescription: 'La fotocamera si apre in una finestra separata del browser',
     externalCamerasDescription: 'Configura fotocamere esterne per sostituire la fotocamera integrata della stampante. Supporta stream MJPEG, RTSP, snapshot HTTP e fotocamere USB (V4L2). Quando abilitata, la fotocamera esterna viene usata per la vista in diretta e le foto di completamento.',
     cameraPlaceholderUsb: 'Percorso dispositivo (/dev/video0)',
+    usbCameraDevice: 'Dispositivo USB',
+    usbCameraSelect: 'Seleziona un dispositivo',
+    usbCameraSearching: 'Ricerca dispositivi…',
+    usbCameraNotFound: '{{device}} (non trovato)',
+    usbCameraRefresh: 'Cerca di nuovo i dispositivi',
+    usbCameraListFailed: 'Impossibile elencare i dispositivi video.',
+    usbCameraNoneFound: 'Bambuddy non vede alcun dispositivo video. In Docker, passa la fotocamera al container:',
+    usbCameraEnterManually: 'Inserisci il percorso manualmente',
+    usbCameraPickFromList: 'Scegli un dispositivo rilevato',
     cameraPlaceholderUrl: 'URL fotocamera (rtsp://... o http://...)',
     cameraTypeMjpeg: 'Stream MJPEG',
     cameraTypeRtsp: 'Stream RTSP',
@@ -5815,6 +5824,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: 'Elaborazione…',
+    processingPercent: 'Elaborazione… {{percent}}%',
+    failed: 'Elaborazione non riuscita',
+    cancelled: 'Elaborazione annullata',
+  },
   // Tags
   tags: {
     title: 'Tag',

+ 16 - 0
frontend/src/i18n/locales/ja.ts

@@ -2866,6 +2866,15 @@ export default {
     cameraWindowDescription: '別のブラウザウィンドウでカメラを表示',
     externalCamerasDescription: '内蔵プリンターカメラの代わりに外部カメラを設定。MJPEGストリーム、RTSP、HTTPスナップショット、USBカメラ(V4L2)をサポート。有効にすると、ライブビューと完了写真に外部カメラが使用されます。',
     cameraPlaceholderUsb: 'デバイスパス (/dev/video0)',
+    usbCameraDevice: 'USBデバイス',
+    usbCameraSelect: 'デバイスを選択',
+    usbCameraSearching: 'デバイスを検索中…',
+    usbCameraNotFound: '{{device}}(見つかりません)',
+    usbCameraRefresh: 'デバイスを再検索',
+    usbCameraListFailed: 'ビデオデバイスを一覧表示できませんでした。',
+    usbCameraNoneFound: 'Bambuddy からビデオデバイスが見えません。Docker ではカメラをコンテナに渡してください:',
+    usbCameraEnterManually: 'パスを手動で入力',
+    usbCameraPickFromList: '検出されたデバイスを選択',
     cameraPlaceholderUrl: 'カメラURL (rtsp://... または http://...)',
     cameraTypeMjpeg: 'MJPEGストリーム',
     cameraTypeRtsp: 'RTSPストリーム',
@@ -5829,6 +5838,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: '処理中…',
+    processingPercent: '処理中… {{percent}}%',
+    failed: '処理に失敗しました',
+    cancelled: '処理をキャンセルしました',
+  },
   // Tags
   tags: {
     title: 'タグ',

+ 16 - 0
frontend/src/i18n/locales/ko.ts

@@ -2730,6 +2730,15 @@ export default {
     cameraWindowDescription: '카메라가 별도의 브라우저 창에서 열립니다',
     externalCamerasDescription: '내장 프린터 카메라를 교체할 외부 카메라를 설정하세요. MJPEG 스트림, RTSP, HTTP 스냅샷, USB 카메라(V4L2)를 지원합니다.',
     cameraPlaceholderUsb: '장치 경로 (/dev/video0)',
+    usbCameraDevice: 'USB 장치',
+    usbCameraSelect: '장치 선택',
+    usbCameraSearching: '장치 검색 중…',
+    usbCameraNotFound: '{{device}} (찾을 수 없음)',
+    usbCameraRefresh: '장치 다시 검색',
+    usbCameraListFailed: '비디오 장치 목록을 가져올 수 없습니다.',
+    usbCameraNoneFound: 'Bambuddy에서 비디오 장치가 보이지 않습니다. Docker에서는 카메라를 컨테이너에 전달하세요:',
+    usbCameraEnterManually: '경로 직접 입력',
+    usbCameraPickFromList: '감지된 장치 선택',
     cameraPlaceholderUrl: '카메라 URL (rtsp://... 또는 http://...)',
     cameraTypeMjpeg: 'MJPEG 스트림',
     cameraTypeRtsp: 'RTSP 스트림',
@@ -5586,6 +5595,13 @@ export default {
     repoIsPublicWarning: '저장소가 공개입니다. Bambuddy 백업에는 MQTT 자격증명, Home Assistant 토큰, Prometheus 토큰, Bambu Cloud 이메일, K-프로파일을 통한 프린터 액세스 코드가 포함됩니다. 공급자 설정에서 저장소를 비공개로 변경할 때까지 저장이 차단됩니다.',
     repoVisibilityUnknown: '저장소 공개 여부를 확인할 수 없습니다. Bambuddy는 비공개로 확인된 저장소에만 백업하며, 저장이 차단됩니다.'
   },
+  // Timelapse editor
+  timelapseEditor: {
+    processing: '처리 중…',
+    processingPercent: '처리 중… {{percent}}%',
+    failed: '처리에 실패했습니다',
+    cancelled: '처리가 취소되었습니다',
+  },
   tags: {
     title: '태그',
     addTag: '태그 추가',

+ 16 - 0
frontend/src/i18n/locales/nl.ts

@@ -2887,6 +2887,15 @@ export default {
     cameraWindowDescription: 'Camera opent in een apart browservenster',
     externalCamerasDescription: 'Configureer externe camera\'s ter vervanging van de ingebouwde printercamera. Ondersteunt MJPEG-streams, RTSP, HTTP-momentopnamen en USB-camera\'s (V4L2). Wanneer ingeschakeld wordt de externe camera gebruikt voor liveweergave en eindfoto\'s.',
     cameraPlaceholderUsb: 'Apparaatpad (/dev/video0)',
+    usbCameraDevice: 'USB-apparaat',
+    usbCameraSelect: 'Kies een apparaat',
+    usbCameraSearching: 'Apparaten zoeken…',
+    usbCameraNotFound: '{{device}} (niet gevonden)',
+    usbCameraRefresh: 'Opnieuw naar apparaten zoeken',
+    usbCameraListFailed: 'De videoapparaten konden niet worden opgehaald.',
+    usbCameraNoneFound: 'Bambuddy ziet geen videoapparaten. Geef in Docker de camera door aan de container:',
+    usbCameraEnterManually: 'Pad handmatig invoeren',
+    usbCameraPickFromList: 'Een gevonden apparaat kiezen',
     cameraPlaceholderUrl: 'Camera-URL (rtsp://... of http://...)',
     cameraTypeMjpeg: 'MJPEG-stream',
     cameraTypeRtsp: 'RTSP-stream',
@@ -5879,6 +5888,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: 'Bezig met verwerken…',
+    processingPercent: 'Bezig met verwerken… {{percent}}%',
+    failed: 'Verwerken mislukt',
+    cancelled: 'Verwerken geannuleerd',
+  },
   // Tags
   tags: {
     title: 'Tags',

+ 16 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2818,6 +2818,15 @@ export default {
     cameraWindowDescription: 'A câmera abre em uma janela separada do navegador',
     externalCamerasDescription: 'Configure câmeras externas para substituir a câmera integrada da impressora. Suporta streams MJPEG, RTSP, snapshots HTTP e câmeras USB (V4L2). Quando habilitada, a câmera externa é usada para visualização ao vivo e fotos de conclusão.',
     cameraPlaceholderUsb: 'Caminho do dispositivo (/dev/video0)',
+    usbCameraDevice: 'Dispositivo USB',
+    usbCameraSelect: 'Selecione um dispositivo',
+    usbCameraSearching: 'Procurando dispositivos…',
+    usbCameraNotFound: '{{device}} (não encontrado)',
+    usbCameraRefresh: 'Procurar dispositivos novamente',
+    usbCameraListFailed: 'Não foi possível listar os dispositivos de vídeo.',
+    usbCameraNoneFound: 'O Bambuddy não vê nenhum dispositivo de vídeo. No Docker, passe a câmera para o contêiner:',
+    usbCameraEnterManually: 'Digitar o caminho manualmente',
+    usbCameraPickFromList: 'Escolher um dispositivo detectado',
     cameraPlaceholderUrl: 'URL da câmera (rtsp://... ou http://...)',
     cameraTypeMjpeg: 'Stream MJPEG',
     cameraTypeRtsp: 'Stream RTSP',
@@ -5815,6 +5824,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: 'Processando…',
+    processingPercent: 'Processando… {{percent}}%',
+    failed: 'Falha no processamento',
+    cancelled: 'Processamento cancelado',
+  },
   // Tags
   tags: {
     title: 'Etiquetas',

+ 16 - 0
frontend/src/i18n/locales/ru.ts

@@ -2734,6 +2734,15 @@ export default {
     cameraWindowDescription: "Камера открывается в отдельном окне браузера",
     externalCamerasDescription: "Настройка внешних камер вместо встроенной камеры принтера. Поддерживаются потоки MJPEG, RTSP, HTTP-снимки и USB-камеры V4L2. Внешняя камера используется для просмотра в реальном времени и снимков после завершения печати.",
     cameraPlaceholderUsb: "Путь к устройству (/dev/video0)",
+    usbCameraDevice: "USB-устройство",
+    usbCameraSelect: "Выберите устройство",
+    usbCameraSearching: "Поиск устройств…",
+    usbCameraNotFound: "{{device}} (не найдено)",
+    usbCameraRefresh: "Искать устройства снова",
+    usbCameraListFailed: "Не удалось получить список видеоустройств.",
+    usbCameraNoneFound: "Bambuddy не видит ни одного видеоустройства. В Docker передайте камеру в контейнер:",
+    usbCameraEnterManually: "Ввести путь вручную",
+    usbCameraPickFromList: "Выбрать найденное устройство",
     cameraPlaceholderUrl: "URL камеры (rtsp://… или http://…)",
     cameraTypeMjpeg: "Поток MJPEG",
     cameraTypeRtsp: "Поток RTSP",
@@ -5574,6 +5583,13 @@ export default {
       container_ephemeral: "{{path}} находится внутри контейнера Bambuddy, а не на хосте. Записанные туда резервные копии будут потеряны при пересоздании контейнера. Подключите каталог с хоста:",
     },
   },
+  // Timelapse editor
+  timelapseEditor: {
+    processing: "Обработка…",
+    processingPercent: "Обработка… {{percent}}%",
+    failed: "Не удалось обработать",
+    cancelled: "Обработка отменена",
+  },
   tags: {
     title: "Метки",
     addTag: "Добавить метку",

+ 16 - 0
frontend/src/i18n/locales/sv.ts

@@ -2886,6 +2886,15 @@ export default {
     cameraWindowDescription: 'Kameran öppnas i ett separat webbläsarfönster',
     externalCamerasDescription: 'Konfigurera externa kameror för att ersätta den inbyggda skrivarkameran. Stöder MJPEG-strömmar, RTSP, HTTP-ögonblicksbilder och USB-kameror (V4L2). När aktiverad används den externa kameran för live-vy och färdigbilder.',
     cameraPlaceholderUsb: 'Enhetssökväg (/dev/video0)',
+    usbCameraDevice: 'USB-enhet',
+    usbCameraSelect: 'Välj en enhet',
+    usbCameraSearching: 'Söker efter enheter…',
+    usbCameraNotFound: '{{device}} (hittades inte)',
+    usbCameraRefresh: 'Sök efter enheter igen',
+    usbCameraListFailed: 'Det gick inte att lista videoenheterna.',
+    usbCameraNoneFound: 'Bambuddy ser inga videoenheter. I Docker skickar du kameran vidare till containern:',
+    usbCameraEnterManually: 'Ange sökvägen manuellt',
+    usbCameraPickFromList: 'Välj en hittad enhet',
     cameraPlaceholderUrl: 'Kamera-URL (rtsp://... eller http://...)',
     cameraTypeMjpeg: 'MJPEG-ström',
     cameraTypeRtsp: 'RTSP-ström',
@@ -5877,6 +5886,13 @@ errors: {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: 'Bearbetar…',
+    processingPercent: 'Bearbetar… {{percent}} %',
+    failed: 'Bearbetningen misslyckades',
+    cancelled: 'Bearbetningen avbröts',
+  },
   // Tags
   tags: {
     title: 'Taggar',

+ 16 - 0
frontend/src/i18n/locales/tr.ts

@@ -2870,6 +2870,15 @@ export default {
     cameraWindowDescription: 'Kamera ayrı bir tarayıcı penceresinde açılır',
     externalCamerasDescription: 'Yerleşik yazıcı kamerasını değiştirmek için harici kameraları yapılandırın. MJPEG akışlarını, RTSP, HTTP anlık görüntüleri ve USB kameralarını (V4L2) destekler. Etkinleştirildiğinde, harici kamera canlı görüntüleme ve bitiş fotoğrafları için kullanılır.',
     cameraPlaceholderUsb: 'Cihaz yolu (/dev/video0)',
+    usbCameraDevice: 'USB cihazı',
+    usbCameraSelect: 'Bir cihaz seçin',
+    usbCameraSearching: 'Cihazlar aranıyor…',
+    usbCameraNotFound: '{{device}} (bulunamadı)',
+    usbCameraRefresh: 'Cihazları yeniden ara',
+    usbCameraListFailed: 'Video cihazları listelenemedi.',
+    usbCameraNoneFound: 'Bambuddy hiçbir video cihazı göremiyor. Docker\'da kamerayı konteynere aktarın:',
+    usbCameraEnterManually: 'Yolu elle girin',
+    usbCameraPickFromList: 'Algılanan bir cihaz seçin',
     cameraPlaceholderUrl: 'Kamera URL\'si (rtsp://... veya http://...)',
     cameraTypeMjpeg: 'MJPEG Akışı',
     cameraTypeRtsp: 'RTSP Akışı',
@@ -5803,6 +5812,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: 'İşleniyor…',
+    processingPercent: 'İşleniyor… %{{percent}}',
+    failed: 'İşleme başarısız oldu',
+    cancelled: 'İşleme iptal edildi',
+  },
   // Etiketler
   tags: {
     title: 'Etiketler',

+ 16 - 0
frontend/src/i18n/locales/uk.ts

@@ -2884,6 +2884,15 @@ export default {
     cameraWindowDescription: "Камера відкривається в окремому вікні браузера",
     externalCamerasDescription: "Налаштуйте зовнішні камери замість вбудованої камери принтера. Підтримує потоки MJPEG, знімки RTSP, HTTP і камери USB (V4L2). Якщо ввімкнено, зовнішня камера використовується для перегляду в реальному часі та готових фотографій.",
     cameraPlaceholderUsb: "Шлях пристрою (/dev/video0)",
+    usbCameraDevice: "USB-пристрій",
+    usbCameraSelect: "Виберіть пристрій",
+    usbCameraSearching: "Пошук пристроїв…",
+    usbCameraNotFound: "{{device}} (не знайдено)",
+    usbCameraRefresh: "Шукати пристрої знову",
+    usbCameraListFailed: "Не вдалося отримати список відеопристроїв.",
+    usbCameraNoneFound: "Bambuddy не бачить жодного відеопристрою. У Docker передайте камеру в контейнер:",
+    usbCameraEnterManually: "Ввести шлях вручну",
+    usbCameraPickFromList: "Вибрати знайдений пристрій",
     cameraPlaceholderUrl: "Камера URL (rtsp://... або http://...)",
     cameraTypeMjpeg: "Потік MJPEG",
     cameraTypeRtsp: "RTSP Потік",
@@ -5869,6 +5878,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: "Обробка…",
+    processingPercent: "Обробка… {{percent}}%",
+    failed: "Не вдалося обробити",
+    cancelled: "Обробку скасовано",
+  },
   // Tags
   tags: {
     title: "Теги",

+ 16 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2863,6 +2863,15 @@ export default {
     cameraWindowDescription: '摄像头在单独的浏览器窗口中打开',
     externalCamerasDescription: '配置外部摄像头以替换内置打印机摄像头。支持MJPEG流、RTSP、HTTP快照和USB摄像头(V4L2)。启用后,外部摄像头将用于实时查看和完成照片。',
     cameraPlaceholderUsb: '设备路径 (/dev/video0)',
+    usbCameraDevice: 'USB 设备',
+    usbCameraSelect: '选择设备',
+    usbCameraSearching: '正在查找设备…',
+    usbCameraNotFound: '{{device}}(未找到)',
+    usbCameraRefresh: '重新查找设备',
+    usbCameraListFailed: '无法列出视频设备。',
+    usbCameraNoneFound: 'Bambuddy 看不到任何视频设备。在 Docker 中,请将摄像头传给容器:',
+    usbCameraEnterManually: '手动输入路径',
+    usbCameraPickFromList: '选择检测到的设备',
     cameraPlaceholderUrl: '摄像头URL (rtsp://... 或 http://...)',
     cameraTypeMjpeg: 'MJPEG 流',
     cameraTypeRtsp: 'RTSP 流',
@@ -5815,6 +5824,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: '处理中…',
+    processingPercent: '处理中… {{percent}}%',
+    failed: '处理失败',
+    cancelled: '已取消处理',
+  },
   // Tags
   tags: {
     title: '标签',

+ 16 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2863,6 +2863,15 @@ export default {
     cameraWindowDescription: '攝影機在單獨的瀏覽器視窗中開啟',
     externalCamerasDescription: '設定外部攝影機以替換內建印表機攝影機。支援MJPEG流、RTSP、HTTP快照和USB攝影機(V4L2)。啟用後,外部攝影機將用於即時檢視和完成照片。',
     cameraPlaceholderUsb: '裝置路徑 (/dev/video0)',
+    usbCameraDevice: 'USB 裝置',
+    usbCameraSelect: '選擇裝置',
+    usbCameraSearching: '正在尋找裝置…',
+    usbCameraNotFound: '{{device}}(找不到)',
+    usbCameraRefresh: '重新尋找裝置',
+    usbCameraListFailed: '無法列出視訊裝置。',
+    usbCameraNoneFound: 'Bambuddy 看不到任何視訊裝置。在 Docker 中,請將攝影機傳給容器:',
+    usbCameraEnterManually: '手動輸入路徑',
+    usbCameraPickFromList: '選擇偵測到的裝置',
     cameraPlaceholderUrl: '攝影機URL (rtsp://... 或 http://...)',
     cameraTypeMjpeg: 'MJPEG 流',
     cameraTypeRtsp: 'RTSP 流',
@@ -5815,6 +5824,13 @@ export default {
     },
   },
 
+  // Timelapse editor
+  timelapseEditor: {
+    processing: '處理中…',
+    processingPercent: '處理中… {{percent}}%',
+    failed: '處理失敗',
+    cancelled: '已取消處理',
+  },
   // Tags
   tags: {
     title: '標籤',

+ 31 - 8
frontend/src/pages/SettingsPage.tsx

@@ -70,6 +70,7 @@ import type { UsersSubTab } from '../lib/settingsSearch';
 import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
 import { NumberInput } from '../components/NumberInput';
 import { PrinterSearchPicker } from '../components/PrinterSearchPicker';
+import { UsbCameraDevicePicker } from '../components/UsbCameraDevicePicker';
 
 const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'camera', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
@@ -1535,6 +1536,8 @@ export function SettingsPage() {
   const [localSnapshotUrls, setLocalSnapshotUrls] = useState<Record<number, string>>({});
   const snapshotUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
   const [openCameraPrinterId, setOpenCameraPrinterId] = useState<number | null>(null);
+  // USB cameras whose device path is typed instead of picked from the list.
+  const [usbManualEntry, setUsbManualEntry] = useState<Record<number, boolean>>({});
   const initializedPrinterSnapshotUrlsRef = useRef<Set<number>>(new Set());
 
   // Initialize local camera URLs from printer data
@@ -4434,14 +4437,34 @@ export function SettingsPage() {
 
                             {isOpen && (
                           <div className="space-y-2 px-2 pb-3 pt-1 bg-bambu-dark/40">
-                            <input
-                              type="text"
-                              aria-label={`${t('settings.cameraPlaceholderUrl')}: ${printer.name}`}
-                              placeholder={printer.external_camera_type === 'usb' ? t('settings.cameraPlaceholderUsb') : t('settings.cameraPlaceholderUrl')}
-                              value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
-                              onChange={(e) => handleCameraUrlChange(printer.id, e.target.value)}
-                              className="w-full px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                            />
+                            {printer.external_camera_type === 'usb' && !usbManualEntry[printer.id] ? (
+                              <UsbCameraDevicePicker
+                                printerName={printer.name}
+                                value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
+                                onSelect={(device) => handleCameraUrlChange(printer.id, device)}
+                                onEnterManually={() => setUsbManualEntry(prev => ({ ...prev, [printer.id]: true }))}
+                              />
+                            ) : (
+                              <>
+                                <input
+                                  type="text"
+                                  aria-label={`${t('settings.cameraPlaceholderUrl')}: ${printer.name}`}
+                                  placeholder={printer.external_camera_type === 'usb' ? t('settings.cameraPlaceholderUsb') : t('settings.cameraPlaceholderUrl')}
+                                  value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
+                                  onChange={(e) => handleCameraUrlChange(printer.id, e.target.value)}
+                                  className="w-full px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                                />
+                                {printer.external_camera_type === 'usb' && (
+                                  <button
+                                    type="button"
+                                    onClick={() => setUsbManualEntry(prev => ({ ...prev, [printer.id]: false }))}
+                                    className="text-xs text-bambu-green hover:underline"
+                                  >
+                                    {t('settings.usbCameraPickFromList')}
+                                  </button>
+                                )}
+                              </>
+                            )}
                             <div className="flex flex-wrap items-center gap-2">
                               <select
                                 value={printer.external_camera_type || 'mjpeg'}