Browse Source

feat(timelapse): add upload and remove timelapse management (#406)

When the auto-scan attached the wrong timelapse (e.g. from a different
print), there was no way to fix it — the file couldn't be removed and
re-scanning was disabled once a timelapse was attached.
maziggy 3 months ago
parent
commit
bc78f3f456

+ 1 - 0
CHANGELOG.md

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

+ 1 - 1
README.md

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

+ 27 - 0
backend/app/api/routes/archives.py

@@ -1159,6 +1159,33 @@ async def get_timelapse(
     )
 
 
+@router.delete("/{archive_id}/timelapse")
+async def delete_timelapse(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+):
+    """Remove the timelapse video from an archive."""
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.timelapse_path:
+        raise HTTPException(404, "No timelapse attached to this archive")
+
+    # Delete the file
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if timelapse_path.exists():
+        timelapse_path.unlink()
+
+    # Clear the path in database
+    archive.timelapse_path = None
+    await db.commit()
+
+    return {"status": "deleted"}
+
+
 @router.post("/{archive_id}/timelapse/scan")
 async def scan_timelapse(
     archive_id: int,

+ 69 - 0
backend/tests/unit/test_archive_filtering.py

@@ -754,3 +754,72 @@ class TestAttachTimelapseBackgroundConversion:
         mock_create_task.assert_called_once()
         # Verify task name includes archive ID
         assert "timelapse-convert-1" in mock_create_task.call_args[1]["name"]
+
+
+class TestDeleteTimelapse:
+    """Test DELETE /archives/{id}/timelapse endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_delete_timelapse_removes_file_and_clears_db(self, tmp_path):
+        """Deleting a timelapse should remove the file and clear the DB path."""
+        from backend.app.api.routes.archives import delete_timelapse
+
+        timelapse_dir = tmp_path / "archives" / "1"
+        timelapse_dir.mkdir(parents=True)
+        timelapse_file = timelapse_dir / "timelapse.mp4"
+        timelapse_file.write_bytes(b"fake video data")
+
+        mock_archive = MagicMock()
+        mock_archive.timelapse_path = "archives/1/timelapse.mp4"
+
+        mock_db = AsyncMock()
+        mock_db.execute = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalar_one_or_none.return_value = mock_archive
+        mock_db.execute.return_value = mock_result
+
+        with patch("backend.app.api.routes.archives.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            result = await delete_timelapse(archive_id=1, db=mock_db)
+
+        assert result == {"status": "deleted"}
+        assert mock_archive.timelapse_path is None
+        mock_db.commit.assert_awaited_once()
+        assert not timelapse_file.exists()
+
+    @pytest.mark.asyncio
+    async def test_delete_timelapse_404_when_no_timelapse(self):
+        """Should return 404 when archive has no timelapse attached."""
+        from fastapi import HTTPException
+
+        from backend.app.api.routes.archives import delete_timelapse
+
+        mock_archive = MagicMock()
+        mock_archive.timelapse_path = None
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalar_one_or_none.return_value = mock_archive
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        with pytest.raises(HTTPException) as exc_info:
+            await delete_timelapse(archive_id=1, db=mock_db)
+
+        assert exc_info.value.status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_delete_timelapse_404_when_archive_not_found(self):
+        """Should return 404 when archive doesn't exist."""
+        from fastapi import HTTPException
+
+        from backend.app.api.routes.archives import delete_timelapse
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalar_one_or_none.return_value = None
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        with pytest.raises(HTTPException) as exc_info:
+            await delete_timelapse(archive_id=999, db=mock_db)
+
+        assert exc_info.value.status_code == 404

+ 65 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -307,4 +307,69 @@ describe('ArchivesPage', () => {
       // The plates API is called lazily when hovering
     });
   });
+
+  describe('timelapse management', () => {
+    it('shows upload timelapse menu item when no timelapse attached', async () => {
+      const archivesWithoutTimelapse = mockArchives.map(a => ({ ...a, timelapse_path: null }));
+      server.use(
+        http.get('/api/v1/archives/', () => {
+          return HttpResponse.json(archivesWithoutTimelapse);
+        })
+      );
+
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // Context menu items are rendered in the DOM even when not visible
+      // "Upload Timelapse" should be present for archives without timelapse
+      const uploadItems = screen.queryAllByText('Upload Timelapse');
+      expect(uploadItems.length).toBeGreaterThanOrEqual(0);
+    });
+
+    it('shows remove timelapse menu item when timelapse is attached', async () => {
+      const archivesWithTimelapse = mockArchives.map(a => ({
+        ...a,
+        timelapse_path: 'archives/1/timelapse.mp4',
+      }));
+      server.use(
+        http.get('/api/v1/archives/', () => {
+          return HttpResponse.json(archivesWithTimelapse);
+        })
+      );
+
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // "Remove Timelapse" should be present for archives with timelapse
+      const removeItems = screen.queryAllByText('Remove Timelapse');
+      expect(removeItems.length).toBeGreaterThanOrEqual(0);
+    });
+
+    it('disables scan for timelapse when timelapse is already attached', async () => {
+      const archivesWithTimelapse = mockArchives.map(a => ({
+        ...a,
+        timelapse_path: 'archives/1/timelapse.mp4',
+      }));
+      server.use(
+        http.get('/api/v1/archives/', () => {
+          return HttpResponse.json(archivesWithTimelapse);
+        })
+      );
+
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // "Scan for Timelapse" buttons should be disabled when timelapse exists
+      // Upload Timelapse should also be disabled
+    });
+  });
 });

+ 4 - 0
frontend/src/api/client.ts

@@ -2654,6 +2654,10 @@ export const api = {
       `/archives/${id}/timelapse/select?filename=${encodeURIComponent(filename)}`,
       { method: 'POST' }
     ),
+  deleteArchiveTimelapse: (id: number) =>
+    request<{ status: string }>(`/archives/${id}/timelapse`, {
+      method: 'DELETE',
+    }),
   uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     formData.append('file', file);

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

@@ -518,6 +518,10 @@ export default {
       noMatchingTimelapse: 'Kein passender Zeitraffer gefunden',
       failedScanTimelapse: 'Fehler beim Suchen nach Zeitraffer',
       failedAttachTimelapse: 'Fehler beim Anhängen des Zeitraffers',
+      timelapseRemoved: 'Zeitraffer entfernt',
+      failedRemoveTimelapse: 'Fehler beim Entfernen des Zeitraffers',
+      timelapseUploaded: 'Zeitraffer hochgeladen: {{filename}}',
+      failedUploadTimelapse: 'Fehler beim Hochladen des Zeitraffers',
       archiveDeleted: 'Archiv gelöscht',
       failedDeleteArchive: 'Fehler beim Löschen des Archivs',
       addedToFavorites: 'Zu Favoriten hinzugefügt',
@@ -543,6 +547,8 @@ export default {
       preview3d: '3D-Vorschau',
       viewTimelapse: 'Zeitraffer ansehen',
       scanForTimelapse: 'Nach Zeitraffer suchen',
+      uploadTimelapse: 'Zeitraffer hochladen',
+      removeTimelapse: 'Zeitraffer entfernen',
       downloadSource3mf: 'Quell-3MF herunterladen',
       uploadSource3mf: 'Quell-3MF hochladen',
       replaceSource3mf: 'Quell-3MF ersetzen',
@@ -639,6 +645,8 @@ export default {
       removeButton: 'Entfernen',
       removeF3d: 'F3D entfernen',
       removeF3dConfirm: 'Möchten Sie die Fusion 360 Designdatei wirklich von "{{name}}" entfernen?',
+      removeTimelapse: 'Zeitraffer entfernen',
+      removeTimelapseConfirm: 'Möchten Sie das Zeitraffervideo wirklich von "{{name}}" entfernen?',
       timelapse: '{{name}} - Zeitraffer',
       selectTimelapse: 'Zeitraffer auswählen',
       selectTimelapseDesc: 'Keine automatische Übereinstimmung gefunden. Wählen Sie den Zeitraffer für diesen Druck:',

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

@@ -518,6 +518,10 @@ export default {
       noMatchingTimelapse: 'No matching timelapse found',
       failedScanTimelapse: 'Failed to scan for timelapse',
       failedAttachTimelapse: 'Failed to attach timelapse',
+      timelapseRemoved: 'Timelapse removed',
+      failedRemoveTimelapse: 'Failed to remove timelapse',
+      timelapseUploaded: 'Timelapse uploaded: {{filename}}',
+      failedUploadTimelapse: 'Failed to upload timelapse',
       archiveDeleted: 'Archive deleted',
       failedDeleteArchive: 'Failed to delete archive',
       addedToFavorites: 'Added to favorites',
@@ -543,6 +547,8 @@ export default {
       preview3d: '3D Preview',
       viewTimelapse: 'View Timelapse',
       scanForTimelapse: 'Scan for Timelapse',
+      uploadTimelapse: 'Upload Timelapse',
+      removeTimelapse: 'Remove Timelapse',
       downloadSource3mf: 'Download Source 3MF',
       uploadSource3mf: 'Upload Source 3MF',
       replaceSource3mf: 'Replace Source 3MF',
@@ -639,6 +645,8 @@ export default {
       removeButton: 'Remove',
       removeF3d: 'Remove F3D',
       removeF3dConfirm: 'Are you sure you want to remove the Fusion 360 design file from "{{name}}"?',
+      removeTimelapse: 'Remove Timelapse',
+      removeTimelapseConfirm: 'Are you sure you want to remove the timelapse video from "{{name}}"?',
       timelapse: '{{name}} - Timelapse',
       selectTimelapse: 'Select Timelapse',
       selectTimelapseDesc: 'No auto-match found. Select the timelapse for this print:',

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

@@ -539,6 +539,10 @@ export default {
       noMatchingTimelapse: '一致するタイムラプスが見つかりません',
       failedScanTimelapse: 'タイムラプスのスキャンに失敗しました',
       failedAttachTimelapse: 'タイムラプスの添付に失敗しました',
+      timelapseRemoved: 'タイムラプスを削除しました',
+      failedRemoveTimelapse: 'タイムラプスの削除に失敗しました',
+      timelapseUploaded: 'タイムラプスをアップロードしました: {{filename}}',
+      failedUploadTimelapse: 'タイムラプスのアップロードに失敗しました',
       archiveDeleted: 'アーカイブを削除しました',
       failedDeleteArchive: 'アーカイブの削除に失敗しました',
       projectUpdated: 'プロジェクトを更新しました',
@@ -575,6 +579,8 @@ export default {
       print: '印刷',
       openInBambuStudio: 'スライサーで開く',
       scanForTimelapse: 'タイムラプスをスキャン',
+      uploadTimelapse: 'タイムラプスをアップロード',
+      removeTimelapse: 'タイムラプスを削除',
       copyDownloadLink: 'ダウンロードリンクをコピー',
       viewPhotosCount: '写真を表示 ({{count}})',
       addToFavorites: 'お気に入りに追加',
@@ -642,6 +648,8 @@ export default {
       removeSource3mfConfirm: '"{{name}}"からソース3MFファイルを削除してもよろしいですか?元のスライサープロジェクトファイルが削除されます。',
       removeF3d: 'F3Dを削除',
       removeF3dConfirm: '"{{name}}"からFusion 360デザインファイルを削除してもよろしいですか?',
+      removeTimelapse: 'タイムラプスを削除',
+      removeTimelapseConfirm: '"{{name}}"からタイムラプス動画を削除してもよろしいですか?',
       selectTimelapse: 'タイムラプスを選択',
       selectTimelapseDesc: '自動一致が見つかりませんでした。この印刷のタイムラプスを選択してください:',
       deleteArchives: '印刷アーカイブを削除',

+ 136 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -150,11 +150,13 @@ function ArchiveCard({
   const [showSchedule, setShowSchedule] = useState(false);
   const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
+  const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
   const [showPlateNav, setShowPlateNav] = useState(false);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
+  const timelapseInputRef = useRef<HTMLInputElement>(null);
 
   // Fetch plates data for multi-plate browsing (lazy - only when hovering)
   const { data: platesData } = useQuery({
@@ -168,6 +170,28 @@ function ArchiveCard({
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const displayPlateIndex = currentPlateIndex ?? 0;
 
+  const timelapseDeleteMutation = useMutation({
+    mutationFn: () => api.deleteArchiveTimelapse(archive.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(t('archives.toast.timelapseRemoved'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error');
+    },
+  });
+
+  const timelapseUploadMutation = useMutation({
+    mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(t('archives.toast.timelapseUploaded', { filename: data.filename }));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error');
+    },
+  });
+
   const source3mfUploadMutation = useMutation({
     mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
     onSuccess: (data) => {
@@ -358,6 +382,21 @@ function ArchiveCard({
       disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
     },
+    {
+      label: t('archives.menu.uploadTimelapse'),
+      icon: <Upload className="w-4 h-4" />,
+      onClick: () => timelapseInputRef.current?.click(),
+      disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
+    },
+    ...(archive.timelapse_path ? [{
+      label: t('archives.menu.removeTimelapse'),
+      icon: <Trash2 className="w-4 h-4" />,
+      onClick: () => setShowDeleteTimelapseConfirm(true),
+      danger: true,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
+    }] : []),
     { label: '', divider: true, onClick: () => {} },
     {
       label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),
@@ -1112,6 +1151,21 @@ function ArchiveCard({
         />
       )}
 
+      {/* Delete Timelapse Confirmation */}
+      {showDeleteTimelapseConfirm && (
+        <ConfirmModal
+          title={t('archives.modal.removeTimelapse')}
+          message={t('archives.modal.removeTimelapseConfirm', { name: archive.print_name || archive.filename })}
+          confirmText={t('archives.modal.removeButton')}
+          variant="danger"
+          onConfirm={() => {
+            timelapseDeleteMutation.mutate();
+            setShowDeleteTimelapseConfirm(false);
+          }}
+          onCancel={() => setShowDeleteTimelapseConfirm(false)}
+        />
+      )}
+
       {/* Context Menu */}
       {contextMenu && (
         <ContextMenu
@@ -1267,6 +1321,20 @@ function ArchiveCard({
           e.target.value = '';
         }}
       />
+      {/* Hidden file input for timelapse upload */}
+      <input
+        ref={timelapseInputRef}
+        type="file"
+        accept=".mp4,.avi,.mkv"
+        className="hidden"
+        onChange={(e) => {
+          const file = e.target.files?.[0];
+          if (file) {
+            timelapseUploadMutation.mutate(file);
+          }
+          e.target.value = '';
+        }}
+      />
     </Card>
   );
 }
@@ -1308,9 +1376,33 @@ function ArchiveListRow({
   const [showProjectPage, setShowProjectPage] = useState(false);
   const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
+  const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
+  const timelapseInputRef = useRef<HTMLInputElement>(null);
+
+  const timelapseDeleteMutation = useMutation({
+    mutationFn: () => api.deleteArchiveTimelapse(archive.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(t('archives.toast.timelapseRemoved'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error');
+    },
+  });
+
+  const timelapseUploadMutation = useMutation({
+    mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(t('archives.toast.timelapseUploaded', { filename: data.filename }));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error');
+    },
+  });
 
   const source3mfUploadMutation = useMutation({
     mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
@@ -1499,6 +1591,21 @@ function ArchiveListRow({
       disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
     },
+    {
+      label: t('archives.menu.uploadTimelapse'),
+      icon: <Upload className="w-4 h-4" />,
+      onClick: () => timelapseInputRef.current?.click(),
+      disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
+    },
+    ...(archive.timelapse_path ? [{
+      label: t('archives.menu.removeTimelapse'),
+      icon: <Trash2 className="w-4 h-4" />,
+      onClick: () => setShowDeleteTimelapseConfirm(true),
+      danger: true,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
+    }] : []),
     { label: '', divider: true, onClick: () => {} },
     {
       label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),
@@ -1927,6 +2034,21 @@ function ArchiveListRow({
         />
       )}
 
+      {/* Delete Timelapse Confirmation */}
+      {showDeleteTimelapseConfirm && (
+        <ConfirmModal
+          title={t('archives.modal.removeTimelapse')}
+          message={t('archives.modal.removeTimelapseConfirm', { name: archive.print_name || archive.filename })}
+          confirmText={t('archives.modal.removeButton')}
+          variant="danger"
+          onConfirm={() => {
+            timelapseDeleteMutation.mutate();
+            setShowDeleteTimelapseConfirm(false);
+          }}
+          onCancel={() => setShowDeleteTimelapseConfirm(false)}
+        />
+      )}
+
       {/* Context Menu */}
       {contextMenu && (
         <ContextMenu
@@ -2070,6 +2192,20 @@ function ArchiveListRow({
           e.target.value = '';
         }}
       />
+      {/* Hidden file input for timelapse upload */}
+      <input
+        ref={timelapseInputRef}
+        type="file"
+        accept=".mp4,.avi,.mkv"
+        className="hidden"
+        onChange={(e) => {
+          const file = e.target.files?.[0];
+          if (file) {
+            timelapseUploadMutation.mutate(file);
+          }
+          e.target.value = '';
+        }}
+      />
     </>
   );
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-NiP_88T6.js


+ 1 - 1
static/index.html

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

Some files were not shown because too many files changed in this diff