Browse Source

Some small improvements for timelapse videos; Added new video player with speed controls

Martin Ziegler 6 months ago
parent
commit
526de63acd

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

@@ -1,8 +1,11 @@
 from pathlib import Path
 import zipfile
 import io
+import logging
 
 from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
+
+logger = logging.getLogger(__name__)
 from fastapi.responses import FileResponse, Response
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select, func
@@ -596,6 +599,18 @@ async def scan_timelapse(
         if best_match and best_diff < timedelta(hours=2):  # Within 2 hours
             matching_file = best_match
 
+    # Strategy 3: If only one timelapse exists and archive was recently completed, use it
+    # This handles cases where printer clock is wrong or timezone issues exist
+    if not matching_file and len(mp4_files) == 1:
+        from datetime import datetime, timedelta
+        archive_completed = archive.completed_at or archive.created_at
+        if archive_completed:
+            time_since_completion = datetime.now() - archive_completed
+            # If archive was completed within the last hour, assume the single timelapse is for it
+            if time_since_completion < timedelta(hours=1):
+                matching_file = mp4_files[0]
+                logger.info(f"Using single timelapse file as fallback: {mp4_files[0].get('name')}")
+
     if not matching_file:
         return {"status": "not_found", "message": "No matching timelapse found on printer"}
 

+ 202 - 0
frontend/src/components/TimelapseViewer.tsx

@@ -0,0 +1,202 @@
+import { useState, useRef, useEffect } from 'react';
+import { X, Download, Film, Play, Pause, SkipBack, SkipForward } from 'lucide-react';
+import { Button } from './Button';
+
+interface TimelapseViewerProps {
+  src: string;
+  title: string;
+  downloadFilename: string;
+  onClose: () => void;
+}
+
+const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
+
+export function TimelapseViewer({ src, title, downloadFilename, onClose }: TimelapseViewerProps) {
+  const videoRef = useRef<HTMLVideoElement>(null);
+  const [isPlaying, setIsPlaying] = useState(true);
+  const [playbackRate, setPlaybackRate] = useState(0.5); // Default to 0.5x for timelapse
+  const [currentTime, setCurrentTime] = useState(0);
+  const [duration, setDuration] = useState(0);
+
+  useEffect(() => {
+    const video = videoRef.current;
+    if (video) {
+      video.playbackRate = playbackRate;
+    }
+  }, [playbackRate]);
+
+  useEffect(() => {
+    const video = videoRef.current;
+    if (!video) return;
+
+    const handleTimeUpdate = () => setCurrentTime(video.currentTime);
+    const handleDurationChange = () => setDuration(video.duration);
+    const handlePlay = () => setIsPlaying(true);
+    const handlePause = () => setIsPlaying(false);
+
+    video.addEventListener('timeupdate', handleTimeUpdate);
+    video.addEventListener('durationchange', handleDurationChange);
+    video.addEventListener('play', handlePlay);
+    video.addEventListener('pause', handlePause);
+
+    return () => {
+      video.removeEventListener('timeupdate', handleTimeUpdate);
+      video.removeEventListener('durationchange', handleDurationChange);
+      video.removeEventListener('play', handlePlay);
+      video.removeEventListener('pause', handlePause);
+    };
+  }, []);
+
+  const togglePlay = () => {
+    const video = videoRef.current;
+    if (!video) return;
+    if (isPlaying) {
+      video.pause();
+    } else {
+      video.play();
+    }
+  };
+
+  const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = parseFloat(e.target.value);
+  };
+
+  const skipBackward = () => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = Math.max(0, video.currentTime - 5);
+  };
+
+  const skipForward = () => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = Math.min(duration, video.currentTime + 5);
+  };
+
+  const formatTime = (time: number) => {
+    const minutes = Math.floor(time / 60);
+    const seconds = Math.floor(time % 60);
+    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
+  };
+
+  const handleDownload = () => {
+    const link = document.createElement('a');
+    link.href = src;
+    link.download = downloadFilename;
+    link.click();
+  };
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
+      <div className="relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <h3 className="text-lg font-semibold text-white flex items-center gap-2">
+            <Film className="w-5 h-5 text-bambu-green" />
+            {title}
+          </h3>
+          <div className="flex items-center gap-2">
+            <Button variant="secondary" size="sm" onClick={handleDownload}>
+              <Download className="w-4 h-4" />
+              Download
+            </Button>
+            <button
+              onClick={onClose}
+              className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
+            >
+              <X className="w-5 h-5 text-bambu-gray" />
+            </button>
+          </div>
+        </div>
+
+        {/* Video */}
+        <div className="p-4">
+          <video
+            ref={videoRef}
+            src={src}
+            autoPlay
+            className="w-full rounded-lg"
+            onClick={togglePlay}
+          />
+
+          {/* Custom Controls */}
+          <div className="mt-4 space-y-3">
+            {/* Progress bar */}
+            <div className="flex items-center gap-3">
+              <span className="text-xs text-bambu-gray w-12 text-right">
+                {formatTime(currentTime)}
+              </span>
+              <input
+                type="range"
+                min={0}
+                max={duration || 100}
+                value={currentTime}
+                onChange={handleSeek}
+                className="flex-1 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer
+                  [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
+                  [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full
+                  [&::-webkit-slider-thumb]:cursor-pointer"
+              />
+              <span className="text-xs text-bambu-gray w-12">
+                {formatTime(duration)}
+              </span>
+            </div>
+
+            {/* Playback controls */}
+            <div className="flex items-center justify-between">
+              {/* Left: Play controls */}
+              <div className="flex items-center gap-2">
+                <button
+                  onClick={skipBackward}
+                  className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+                  title="Skip back 5s"
+                >
+                  <SkipBack className="w-5 h-5 text-bambu-gray" />
+                </button>
+                <button
+                  onClick={togglePlay}
+                  className="p-2 bg-bambu-green hover:bg-bambu-green-dark rounded-lg transition-colors"
+                >
+                  {isPlaying ? (
+                    <Pause className="w-5 h-5 text-white" />
+                  ) : (
+                    <Play className="w-5 h-5 text-white" />
+                  )}
+                </button>
+                <button
+                  onClick={skipForward}
+                  className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+                  title="Skip forward 5s"
+                >
+                  <SkipForward className="w-5 h-5 text-bambu-gray" />
+                </button>
+              </div>
+
+              {/* Right: Speed control */}
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-bambu-gray">Speed:</span>
+                <div className="flex gap-1">
+                  {SPEED_OPTIONS.map((speed) => (
+                    <button
+                      key={speed}
+                      onClick={() => setPlaybackRate(speed)}
+                      className={`px-2 py-1 text-xs rounded transition-colors ${
+                        playbackRate === speed
+                          ? 'bg-bambu-green text-white'
+                          : 'bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark-tertiary/80'
+                      }`}
+                    >
+                      {speed}x
+                    </button>
+                  ))}
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 7 - 39
frontend/src/pages/ArchivesPage.tsx

@@ -50,6 +50,7 @@ import { CalendarView } from '../components/CalendarView';
 import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
+import { TimelapseViewer } from '../components/TimelapseViewer';
 import { useToast } from '../contexts/ToastContext';
 
 function formatFileSize(bytes: number): string {
@@ -565,45 +566,12 @@ function ArchiveCard({
 
       {/* Timelapse Viewer Modal */}
       {showTimelapse && archive.timelapse_path && (
-        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
-          <div className="relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden">
-            <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-              <h3 className="text-lg font-semibold text-white flex items-center gap-2">
-                <Film className="w-5 h-5 text-bambu-green" />
-                {archive.print_name || archive.filename} - Timelapse
-              </h3>
-              <div className="flex items-center gap-2">
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={() => {
-                    const link = document.createElement('a');
-                    link.href = api.getArchiveTimelapse(archive.id);
-                    link.download = `${archive.print_name || archive.filename}_timelapse.mp4`;
-                    link.click();
-                  }}
-                >
-                  <Download className="w-4 h-4" />
-                  Download
-                </Button>
-                <button
-                  onClick={() => setShowTimelapse(false)}
-                  className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
-                >
-                  <X className="w-5 h-5 text-bambu-gray" />
-                </button>
-              </div>
-            </div>
-            <div className="p-4">
-              <video
-                src={api.getArchiveTimelapse(archive.id)}
-                controls
-                autoPlay
-                className="w-full rounded-lg"
-              />
-            </div>
-          </div>
-        </div>
+        <TimelapseViewer
+          src={api.getArchiveTimelapse(archive.id)}
+          title={`${archive.print_name || archive.filename} - Timelapse`}
+          downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
+          onClose={() => setShowTimelapse(false)}
+        />
       )}
 
       {/* QR Code Modal */}

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


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


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


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-BPRATuOd.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DUX4pLTn.css">
+    <script type="module" crossorigin src="/assets/index-in5STeRb.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-q7064R9p.css">
   </head>
   <body>
     <div id="root"></div>

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