Browse Source

- Add timelapse editor with trim, speed adjustment, and music overlay
- Add TimelapseEditorModal component with visual timeline scrubber
- Add timelapse processing endpoints (info, thumbnails, process)
- Add TimelapseProcessor service using FFmpeg for video processing
- Support trim (start/end), speed (0.25x-4x), and audio overlay
- Add Edit button to TimelapseViewer
- Fix browser caching with timestamp query parameter
- Change default timelapse playback speed from 2x to 1x

maziggy 5 months ago
parent
commit
b1be35ba8c

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6b] - 2025-12-28
 
 ### Added
+- **Timelapse editor** - Edit timelapse videos with trim, speed adjustment (0.25x-4x), and music overlay. Uses FFmpeg for server-side processing with browser-based preview.
 - **Docker printer discovery** - Subnet scanning for discovering printers when running in Docker with `network_mode: host`. Automatically detects Docker environment and shows subnet input field in Add Printer dialog.
 - **Printer model mapping** - Discovery now shows friendly model names (X1C, H2D, P1S) instead of raw SSDP codes (BL-P001, O1D, C11).
 - **Discovery API tests** - Comprehensive test coverage for discovery endpoints.
@@ -14,6 +15,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Attachment file validation** - File type validation for project attachments (images, documents, 3D files, archives, scripts, configs).
 
 ### Changed
+- **Timelapse viewer** - Default playback speed changed from 2x to 1x.
 - **GitHub issue template** - Added mandatory printer firmware version field and LAN-only mode checkbox for better bug reports.
 - **Docker compose** - Clearer comments explaining `network_mode: host` requirement for printer discovery and camera streaming.
 - **Project card design** - Enhanced visual polish with gradients, shadows, and glow effects on hover.

+ 1 - 0
README.md

@@ -48,6 +48,7 @@
 - 3D model preview (Three.js)
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
+- Timelapse editor (trim, speed, music)
 - Re-print to any connected printer
 - Archive comparison (side-by-side diff)
 

+ 170 - 1
backend/app/api/routes/archives.py

@@ -3,7 +3,7 @@ import logging
 import zipfile
 from pathlib import Path
 
-from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
+from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi.responses import FileResponse, Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -875,10 +875,17 @@ async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
     if not timelapse_path.exists():
         raise HTTPException(404, "Timelapse file not found")
 
+    # Use file modification time as ETag to bust cache after processing
+    mtime = int(timelapse_path.stat().st_mtime)
+
     return FileResponse(
         path=timelapse_path,
         media_type="video/mp4",
         filename=f"{archive.print_name or 'timelapse'}.mp4",
+        headers={
+            "Cache-Control": "no-cache, must-revalidate",
+            "ETag": f'"{mtime}"',
+        },
     )
 
 
@@ -1160,6 +1167,168 @@ async def upload_timelapse(
     return {"status": "attached", "filename": file.filename}
 
 
+@router.get("/{archive_id}/timelapse/info")
+async def get_timelapse_info(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get timelapse video metadata for editor."""
+    from backend.app.schemas.timelapse import TimelapseInfoResponse
+    from backend.app.services.timelapse_processor import TimelapseProcessor
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive or not archive.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not timelapse_path.exists():
+        raise HTTPException(404, "Timelapse file not found")
+
+    try:
+        processor = TimelapseProcessor(timelapse_path)
+        info = await processor.get_info()
+        return TimelapseInfoResponse(**info)
+    except Exception as e:
+        logger.error(f"Failed to get timelapse info: {e}")
+        raise HTTPException(500, f"Failed to get video info: {str(e)}")
+
+
+@router.get("/{archive_id}/timelapse/thumbnails")
+async def get_timelapse_thumbnails(
+    archive_id: int,
+    count: int = Query(10, ge=1, le=30),
+    width: int = Query(160, ge=80, le=320),
+    db: AsyncSession = Depends(get_db),
+):
+    """Generate timeline thumbnail frames for visual scrubbing."""
+    import base64
+
+    from backend.app.schemas.timelapse import ThumbnailResponse
+    from backend.app.services.timelapse_processor import TimelapseProcessor
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive or not archive.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not timelapse_path.exists():
+        raise HTTPException(404, "Timelapse file not found")
+
+    try:
+        processor = TimelapseProcessor(timelapse_path)
+        thumbnails = await processor.generate_thumbnails(count, width)
+
+        return ThumbnailResponse(
+            thumbnails=[base64.b64encode(data).decode() for _, data in thumbnails],
+            timestamps=[ts for ts, _ in thumbnails],
+        )
+    except Exception as e:
+        logger.error(f"Failed to generate thumbnails: {e}")
+        raise HTTPException(500, f"Failed to generate thumbnails: {str(e)}")
+
+
+@router.post("/{archive_id}/timelapse/process")
+async def process_timelapse(
+    archive_id: int,
+    trim_start: float = Form(0),
+    trim_end: float = Form(None),
+    speed: float = Form(1.0),
+    save_mode: str = Form("new"),
+    output_filename: str = Form(None),
+    audio: UploadFile = File(None),
+    db: AsyncSession = Depends(get_db),
+):
+    """Process timelapse with trim, speed, and optional audio overlay."""
+    import shutil
+    import tempfile
+
+    from backend.app.schemas.timelapse import ProcessResponse
+    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 save_mode not in ("replace", "new"):
+        raise HTTPException(400, "save_mode must be 'replace' or 'new'")
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive or not archive.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not timelapse_path.exists():
+        raise HTTPException(404, "Timelapse file not found")
+
+    archive_dir = timelapse_path.parent
+
+    # Handle audio file
+    audio_temp_path = None
+    if audio and audio.filename:
+        # Validate audio file extension
+        if not audio.filename.lower().endswith((".mp3", ".wav", ".m4a", ".aac", ".ogg")):
+            raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
+
+        audio_content = await audio.read()
+        suffix = Path(audio.filename).suffix
+        audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
+        audio_temp_path.write_bytes(audio_content)
+
+    try:
+        processor = TimelapseProcessor(timelapse_path)
+
+        # 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
+            filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
+            if not filename.endswith(".mp4"):
+                filename += ".mp4"
+            output_path = archive_dir / filename
+
+        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")
+
+        # 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}"
+
+        return ProcessResponse(
+            status="completed",
+            output_path=final_path,
+            message=message,
+        )
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Timelapse processing failed: {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()
+
+
 # ============================================
 # Photo Endpoints
 # ============================================

+ 30 - 0
backend/app/schemas/timelapse.py

@@ -0,0 +1,30 @@
+"""Schemas for timelapse video processing."""
+
+from pydantic import BaseModel, Field
+
+
+class TimelapseInfoResponse(BaseModel):
+    """Video metadata response."""
+
+    duration: float = Field(description="Video duration in seconds")
+    width: int = Field(description="Video width in pixels")
+    height: int = Field(description="Video height in pixels")
+    fps: float = Field(description="Frames per second")
+    codec: str = Field(description="Video codec name")
+    file_size: int = Field(description="File size in bytes")
+    has_audio: bool = Field(description="Whether video has audio track")
+
+
+class ThumbnailResponse(BaseModel):
+    """Timeline thumbnail response."""
+
+    thumbnails: list[str] = Field(description="Base64 encoded JPEG thumbnails")
+    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")

+ 264 - 0
backend/app/services/timelapse_processor.py

@@ -0,0 +1,264 @@
+"""Timelapse video processing service using FFmpeg."""
+
+import asyncio
+import json
+import logging
+import tempfile
+from pathlib import Path
+
+from backend.app.services.camera import get_ffmpeg_path
+
+logger = logging.getLogger(__name__)
+
+
+class TimelapseProcessor:
+    """Service for processing timelapse videos with FFmpeg."""
+
+    def __init__(self, input_path: Path):
+        self.input_path = input_path
+        self.ffmpeg = get_ffmpeg_path()
+        if not self.ffmpeg:
+            raise RuntimeError("FFmpeg not found")
+        # Derive ffprobe path from ffmpeg path
+        self.ffprobe = self.ffmpeg.replace("ffmpeg", "ffprobe")
+
+    async def get_info(self) -> dict:
+        """Get video metadata using ffprobe."""
+        cmd = [
+            self.ffprobe,
+            "-v",
+            "quiet",
+            "-print_format",
+            "json",
+            "-show_format",
+            "-show_streams",
+            str(self.input_path),
+        ]
+
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            logger.error(f"ffprobe failed: {stderr.decode()}")
+            raise RuntimeError(f"ffprobe failed: {stderr.decode()}")
+
+        data = json.loads(stdout.decode())
+        video_stream = next(
+            (s for s in data.get("streams", []) if s.get("codec_type") == "video"),
+            {},
+        )
+        audio_stream = next(
+            (s for s in data.get("streams", []) if s.get("codec_type") == "audio"),
+            None,
+        )
+
+        # Parse frame rate (can be "30/1" or "29.97")
+        fps = 30.0
+        r_frame_rate = video_stream.get("r_frame_rate", "30/1")
+        try:
+            if "/" in r_frame_rate:
+                num, den = r_frame_rate.split("/")
+                fps = float(num) / float(den)
+            else:
+                fps = float(r_frame_rate)
+        except (ValueError, ZeroDivisionError):
+            pass
+
+        return {
+            "duration": float(data.get("format", {}).get("duration", 0)),
+            "width": video_stream.get("width", 0),
+            "height": video_stream.get("height", 0),
+            "fps": fps,
+            "codec": video_stream.get("codec_name", "unknown"),
+            "file_size": int(data.get("format", {}).get("size", 0)),
+            "has_audio": audio_stream is not None,
+        }
+
+    async def generate_thumbnails(
+        self,
+        count: int = 10,
+        width: int = 160,
+    ) -> list[tuple[float, bytes]]:
+        """Generate evenly-spaced thumbnail frames."""
+        info = await self.get_info()
+        duration = info["duration"]
+
+        if duration <= 0:
+            return []
+
+        interval = duration / max(count, 1)
+        thumbnails = []
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            for i in range(count):
+                timestamp = i * interval
+                output_path = Path(tmpdir) / f"thumb_{i:03d}.jpg"
+
+                cmd = [
+                    self.ffmpeg,
+                    "-y",
+                    "-ss",
+                    str(timestamp),
+                    "-i",
+                    str(self.input_path),
+                    "-vframes",
+                    "1",
+                    "-vf",
+                    f"scale={width}:-1",
+                    "-q:v",
+                    "5",
+                    str(output_path),
+                ]
+
+                process = await asyncio.create_subprocess_exec(
+                    *cmd,
+                    stdout=asyncio.subprocess.PIPE,
+                    stderr=asyncio.subprocess.PIPE,
+                )
+                await process.communicate()
+
+                if output_path.exists():
+                    thumbnails.append((timestamp, output_path.read_bytes()))
+
+        return thumbnails
+
+    async def process(
+        self,
+        output_path: Path,
+        trim_start: float = 0,
+        trim_end: float | None = None,
+        speed: float = 1.0,
+        audio_path: Path | None = None,
+        audio_volume: float = 1.0,
+    ) -> bool:
+        """Process video with trim, speed, and optional audio overlay.
+
+        Args:
+            output_path: Where to save the processed video
+            trim_start: Start time in seconds
+            trim_end: End time in seconds (None = full duration)
+            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)
+
+        Returns:
+            True if processing succeeded, False otherwise
+        """
+        # Build FFmpeg command
+        cmd = [self.ffmpeg, "-y"]
+
+        # Input seeking (fast seek before input)
+        if trim_start > 0:
+            cmd.extend(["-ss", str(trim_start)])
+
+        cmd.extend(["-i", str(self.input_path)])
+
+        # Add audio input if provided
+        if audio_path:
+            cmd.extend(["-i", str(audio_path)])
+
+        # Duration limit
+        if trim_end is not None and trim_end > trim_start:
+            duration = trim_end - trim_start
+            cmd.extend(["-t", str(duration)])
+
+        # Build filters - use filter_complex when we have audio overlay
+        video_filter = ""
+        if speed != 1.0:
+            # setpts changes video speed: PTS/speed = faster, PTS*speed = slower
+            setpts_value = 1.0 / speed
+            video_filter = f"setpts={setpts_value}*PTS"
+
+        if audio_path:
+            # Use filter_complex for audio overlay (can't mix with -vf/-af)
+            filter_parts = []
+
+            # Video filter
+            if video_filter:
+                filter_parts.append(f"[0:v]{video_filter}[v]")
+                video_out = "[v]"
+            else:
+                video_out = "0:v"
+
+            # Audio filter with volume
+            filter_parts.append(f"[1:a]volume={audio_volume}[a]")
+
+            cmd.extend(["-filter_complex", ";".join(filter_parts)])
+            cmd.extend(["-map", video_out, "-map", "[a]"])
+            cmd.extend(["-shortest"])
+        elif speed != 1.0:
+            # No audio overlay - use simple -vf and -af
+            if video_filter:
+                cmd.extend(["-vf", video_filter])
+            # Adjust original audio speed with atempo
+            atempo_chain = self._build_atempo_chain(speed)
+            if atempo_chain:
+                cmd.extend(["-af", atempo_chain])
+
+        # Output settings
+        cmd.extend(
+            [
+                "-c:v",
+                "libx264",
+                "-preset",
+                "fast",
+                "-crf",
+                "23",
+                "-c:a",
+                "aac",
+                "-b:a",
+                "128k",
+                "-movflags",
+                "+faststart",  # Enable streaming
+                str(output_path),
+            ]
+        )
+
+        logger.info(f"Processing timelapse: {' '.join(cmd)}")
+
+        # Run FFmpeg
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        _, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            logger.error(f"FFmpeg processing failed: {stderr.decode()}")
+            return False
+
+        return output_path.exists()
+
+    def _build_atempo_chain(self, speed: float) -> str:
+        """Build atempo filter chain.
+
+        atempo filter only supports values between 0.5 and 2.0,
+        so we chain multiple filters for extreme speeds.
+        """
+        if speed == 1.0:
+            return ""
+
+        filters = []
+        remaining_speed = speed
+
+        # Handle speeds > 2.0 by chaining atempo=2.0
+        while remaining_speed > 2.0:
+            filters.append("atempo=2.0")
+            remaining_speed /= 2.0
+
+        # Handle speeds < 0.5 by chaining atempo=0.5
+        while remaining_speed < 0.5:
+            filters.append("atempo=0.5")
+            remaining_speed *= 2.0
+
+        # Add final atempo for remaining adjustment
+        if 0.5 <= remaining_speed <= 2.0 and remaining_speed != 1.0:
+            filters.append(f"atempo={remaining_speed:.4f}")
+
+        return ",".join(filters)

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

@@ -1404,7 +1404,7 @@ export const api = {
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
-  getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse`,
+  getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
   scanArchiveTimelapse: (id: number) =>
     request<{
       status: string;
@@ -1432,6 +1432,56 @@ export const api = {
     }
     return response.json();
   },
+  // Timelapse Editor
+  getTimelapseInfo: (archiveId: number) =>
+    request<{
+      duration: number;
+      width: number;
+      height: number;
+      fps: number;
+      codec: string;
+      file_size: number;
+      has_audio: boolean;
+    }>(`/archives/${archiveId}/timelapse/info`),
+  getTimelapseThumbnails: (archiveId: number, count: number = 10) =>
+    request<{
+      thumbnails: string[];
+      timestamps: number[];
+    }>(`/archives/${archiveId}/timelapse/thumbnails?count=${count}`),
+  processTimelapse: async (
+    archiveId: number,
+    params: {
+      trimStart?: number;
+      trimEnd?: number;
+      speed?: number;
+      saveMode: 'replace' | 'new';
+      outputFilename?: string;
+    },
+    audioFile?: File
+  ): Promise<{ status: string; output_path: string | null; message: string }> => {
+    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));
+    formData.append('save_mode', params.saveMode);
+    if (params.outputFilename) {
+      formData.append('output_filename', params.outputFilename);
+    }
+    if (audioFile) {
+      formData.append('audio', audioFile);
+    }
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/process`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
   // Photos
   getArchivePhotoUrl: (archiveId: number, filename: string) =>
     `${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`,

+ 546 - 0
frontend/src/components/TimelapseEditorModal.tsx

@@ -0,0 +1,546 @@
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import {
+  X,
+  Save,
+  Film,
+  Play,
+  Pause,
+  Scissors,
+  Gauge,
+  Music,
+  Upload,
+  Trash2,
+  Volume2,
+  VolumeX,
+  Loader2,
+} from 'lucide-react';
+import { Button } from './Button';
+import { api } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+
+interface TimelapseEditorModalProps {
+  archiveId: number;
+  timelapseSrc: string;
+  onClose: () => void;
+  onSave?: () => void;
+}
+
+const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
+
+function formatTime(seconds: number): string {
+  const mins = Math.floor(seconds / 60);
+  const secs = Math.floor(seconds % 60);
+  return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+export function TimelapseEditorModal({
+  archiveId,
+  timelapseSrc,
+  onClose,
+  onSave,
+}: TimelapseEditorModalProps) {
+  const { showToast } = useToast();
+  const videoRef = useRef<HTMLVideoElement>(null);
+  const audioRef = useRef<HTMLAudioElement>(null);
+
+  // Video state
+  const [isPlaying, setIsPlaying] = useState(false);
+  const [currentTime, setCurrentTime] = useState(0);
+  const [duration, setDuration] = useState(0);
+
+  // Editor state
+  const [trimStart, setTrimStart] = useState(0);
+  const [trimEnd, setTrimEnd] = useState(0);
+  const [speed, setSpeed] = useState(1);
+  const [audioFile, setAudioFile] = useState<File | null>(null);
+  const [audioUrl, setAudioUrl] = useState<string | null>(null);
+  const [audioVolume, setAudioVolume] = useState(0.8);
+  const [audioMuted, setAudioMuted] = useState(false);
+
+
+  // Fetch video info
+  const { data: videoInfo, isLoading: isLoadingInfo } = useQuery({
+    queryKey: ['timelapse-info', archiveId],
+    queryFn: () => api.getTimelapseInfo(archiveId),
+  });
+
+  // Fetch thumbnails
+  const { data: thumbnailData } = useQuery({
+    queryKey: ['timelapse-thumbnails', archiveId],
+    queryFn: () => api.getTimelapseThumbnails(archiveId, 15),
+  });
+
+  // Process mutation
+  const processMutation = useMutation({
+    mutationFn: () =>
+      api.processTimelapse(
+        archiveId,
+        {
+          trimStart,
+          trimEnd,
+          speed,
+          saveMode: 'replace',
+        },
+        audioFile || undefined
+      ),
+    onSuccess: (data) => {
+      showToast(data.message, 'success');
+      onSave?.();
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Processing failed', 'error');
+    },
+  });
+
+  // Initialize trimEnd when duration is available
+  useEffect(() => {
+    if (videoInfo?.duration && trimEnd === 0) {
+      setTrimEnd(videoInfo.duration);
+    }
+  }, [videoInfo?.duration, trimEnd]);
+
+  // Close on Escape
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        onClose();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Video event handlers
+  useEffect(() => {
+    const video = videoRef.current;
+    if (!video) return;
+
+    const handleTimeUpdate = () => {
+      const time = video.currentTime;
+      setCurrentTime(time);
+
+      // Loop within trim region
+      if (time >= trimEnd) {
+        video.currentTime = trimStart;
+      }
+    };
+
+    const handleDurationChange = () => {
+      setDuration(video.duration);
+      if (trimEnd === 0) {
+        setTrimEnd(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);
+    };
+  }, [trimStart, trimEnd]);
+
+  // Sync audio with video
+  useEffect(() => {
+    const audio = audioRef.current;
+    const video = videoRef.current;
+    if (!audio || !video || !audioUrl) return;
+
+    audio.currentTime = video.currentTime;
+    audio.playbackRate = video.playbackRate;
+
+    if (isPlaying && !audioMuted) {
+      audio.play().catch(() => {});
+    } else {
+      audio.pause();
+    }
+  }, [isPlaying, audioUrl, audioMuted]);
+
+  // Update audio volume
+  useEffect(() => {
+    if (audioRef.current) {
+      audioRef.current.volume = audioMuted ? 0 : audioVolume;
+    }
+  }, [audioVolume, audioMuted]);
+
+  // Update playback rate
+  useEffect(() => {
+    if (videoRef.current) {
+      videoRef.current.playbackRate = speed;
+    }
+    if (audioRef.current) {
+      audioRef.current.playbackRate = speed;
+    }
+  }, [speed]);
+
+  const togglePlay = useCallback(() => {
+    const video = videoRef.current;
+    if (!video) return;
+
+    if (isPlaying) {
+      video.pause();
+    } else {
+      // Start from trim start if before it
+      if (video.currentTime < trimStart) {
+        video.currentTime = trimStart;
+      }
+      video.play();
+    }
+  }, [isPlaying, trimStart]);
+
+  const handleSeek = (time: number) => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = Math.max(trimStart, Math.min(trimEnd, time));
+  };
+
+  const handleAudioUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    // Cleanup previous URL
+    if (audioUrl) {
+      URL.revokeObjectURL(audioUrl);
+    }
+
+    setAudioFile(file);
+    setAudioUrl(URL.createObjectURL(file));
+  };
+
+  const removeAudio = () => {
+    if (audioUrl) {
+      URL.revokeObjectURL(audioUrl);
+    }
+    setAudioFile(null);
+    setAudioUrl(null);
+  };
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      if (audioUrl) {
+        URL.revokeObjectURL(audioUrl);
+      }
+    };
+  }, [audioUrl]);
+
+  const trimmedDuration = trimEnd - trimStart;
+  const outputDuration = trimmedDuration / speed;
+
+  if (isLoadingInfo) {
+    return (
+      <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
+        <div className="flex items-center gap-3 text-white">
+          <Loader2 className="w-6 h-6 animate-spin" />
+          Loading video info...
+        </div>
+      </div>
+    );
+  }
+
+  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-5xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
+          <h3 className="text-lg font-semibold text-white flex items-center gap-2">
+            <Film className="w-5 h-5 text-bambu-green" />
+            Edit Timelapse
+          </h3>
+          <div className="flex items-center gap-2">
+            <Button
+              variant="primary"
+              size="sm"
+              onClick={() => processMutation.mutate()}
+              disabled={processMutation.isPending}
+            >
+              {processMutation.isPending ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  Processing...
+                </>
+              ) : (
+                <>
+                  <Save className="w-4 h-4" />
+                  Save
+                </>
+              )}
+            </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>
+
+        {/* Content */}
+        <div className="flex-1 overflow-y-auto p-4 space-y-4">
+          {/* Video Preview */}
+          <div className="relative">
+            <video
+              ref={videoRef}
+              src={timelapseSrc}
+              className="w-full rounded-lg bg-black"
+              onClick={togglePlay}
+              muted={!!audioUrl}
+            />
+
+            {/* Play overlay */}
+            {!isPlaying && (
+              <button
+                onClick={togglePlay}
+                className="absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition-colors"
+              >
+                <div className="p-4 bg-bambu-green rounded-full">
+                  <Play className="w-8 h-8 text-white" />
+                </div>
+              </button>
+            )}
+
+            {/* Hidden audio element for music overlay preview */}
+            {audioUrl && (
+              <audio ref={audioRef} src={audioUrl} loop />
+            )}
+          </div>
+
+          {/* Timeline with Thumbnails */}
+          <div className="space-y-2">
+            <div className="flex items-center gap-2 text-sm text-bambu-gray">
+              <Scissors className="w-4 h-4" />
+              <span>Trim</span>
+              <span className="ml-auto">
+                {formatTime(trimStart)} - {formatTime(trimEnd)} ({formatTime(trimmedDuration)})
+              </span>
+            </div>
+
+            {/* Thumbnail strip */}
+            <div className="relative h-16 bg-bambu-dark rounded-lg overflow-hidden">
+              {/* Thumbnails background */}
+              <div className="absolute inset-0 flex">
+                {thumbnailData?.thumbnails.map((thumb, i) => (
+                  <div
+                    key={i}
+                    className="flex-1 bg-cover bg-center"
+                    style={{
+                      backgroundImage: `url(data:image/jpeg;base64,${thumb})`,
+                    }}
+                  />
+                ))}
+              </div>
+
+              {/* Trim overlay - grayed out areas */}
+              <div
+                className="absolute inset-y-0 left-0 bg-black/60"
+                style={{ width: `${(trimStart / duration) * 100}%` }}
+              />
+              <div
+                className="absolute inset-y-0 right-0 bg-black/60"
+                style={{ width: `${((duration - trimEnd) / duration) * 100}%` }}
+              />
+
+              {/* Selected region border */}
+              <div
+                className="absolute inset-y-0 border-2 border-bambu-green"
+                style={{
+                  left: `${(trimStart / duration) * 100}%`,
+                  right: `${((duration - trimEnd) / duration) * 100}%`,
+                }}
+              />
+
+              {/* Current time indicator */}
+              <div
+                className="absolute top-0 bottom-0 w-0.5 bg-white shadow-lg"
+                style={{ left: `${(currentTime / duration) * 100}%` }}
+              />
+
+              {/* Trim handles */}
+              <input
+                type="range"
+                min={0}
+                max={duration}
+                step={0.1}
+                value={trimStart}
+                onChange={(e) => {
+                  const val = parseFloat(e.target.value);
+                  if (val < trimEnd - 1) {
+                    setTrimStart(val);
+                    if (videoRef.current && videoRef.current.currentTime < val) {
+                      videoRef.current.currentTime = val;
+                    }
+                  }
+                }}
+                className="absolute inset-0 w-full opacity-0 cursor-ew-resize"
+                style={{ clipPath: 'inset(0 50% 0 0)' }}
+              />
+              <input
+                type="range"
+                min={0}
+                max={duration}
+                step={0.1}
+                value={trimEnd}
+                onChange={(e) => {
+                  const val = parseFloat(e.target.value);
+                  if (val > trimStart + 1) {
+                    setTrimEnd(val);
+                  }
+                }}
+                className="absolute inset-0 w-full opacity-0 cursor-ew-resize"
+                style={{ clipPath: 'inset(0 0 0 50%)' }}
+              />
+            </div>
+
+            {/* Playback scrubber */}
+            <input
+              type="range"
+              min={0}
+              max={duration}
+              step={0.1}
+              value={currentTime}
+              onChange={(e) => handleSeek(parseFloat(e.target.value))}
+              className="w-full 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"
+            />
+
+            {/* Play controls */}
+            <div className="flex items-center justify-center gap-2">
+              <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>
+            </div>
+          </div>
+
+          {/* Speed Control */}
+          <div className="space-y-2">
+            <div className="flex items-center gap-2 text-sm text-bambu-gray">
+              <Gauge className="w-4 h-4" />
+              <span>Speed</span>
+              <span className="ml-auto">{speed}x (output: {formatTime(outputDuration)})</span>
+            </div>
+            <div className="flex gap-1">
+              {SPEED_OPTIONS.map((s) => (
+                <button
+                  key={s}
+                  onClick={() => setSpeed(s)}
+                  className={`flex-1 px-2 py-2 text-sm rounded transition-colors ${
+                    speed === s
+                      ? 'bg-bambu-green text-white'
+                      : 'bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary'
+                  }`}
+                >
+                  {s}x
+                </button>
+              ))}
+            </div>
+          </div>
+
+          {/* Audio Upload */}
+          <div className="space-y-2">
+            <div className="flex items-center gap-2 text-sm text-bambu-gray">
+              <Music className="w-4 h-4" />
+              <span>Music Overlay</span>
+            </div>
+
+            {audioFile ? (
+              <div className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg">
+                <Music className="w-5 h-5 text-bambu-green" />
+                <div className="flex-1 min-w-0">
+                  <p className="text-sm text-white truncate">{audioFile.name}</p>
+                  <p className="text-xs text-bambu-gray">
+                    {(audioFile.size / 1024 / 1024).toFixed(1)} MB
+                  </p>
+                </div>
+
+                {/* Volume control */}
+                <button
+                  onClick={() => setAudioMuted(!audioMuted)}
+                  className="p-2 hover:bg-bambu-dark-tertiary rounded transition-colors"
+                >
+                  {audioMuted ? (
+                    <VolumeX className="w-4 h-4 text-bambu-gray" />
+                  ) : (
+                    <Volume2 className="w-4 h-4 text-bambu-green" />
+                  )}
+                </button>
+                <input
+                  type="range"
+                  min={0}
+                  max={1}
+                  step={0.1}
+                  value={audioVolume}
+                  onChange={(e) => setAudioVolume(parseFloat(e.target.value))}
+                  className="w-20 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"
+                />
+
+                <button
+                  onClick={removeAudio}
+                  className="p-2 hover:bg-red-500/20 rounded transition-colors"
+                >
+                  <Trash2 className="w-4 h-4 text-red-400" />
+                </button>
+              </div>
+            ) : (
+              <label className="flex flex-col items-center justify-center gap-2 p-6 border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green/50 transition-colors">
+                <Upload className="w-8 h-8 text-bambu-gray" />
+                <span className="text-sm text-bambu-gray">
+                  Drop audio file or click to upload
+                </span>
+                <span className="text-xs text-bambu-gray/60">
+                  MP3, WAV, M4A, AAC, OGG
+                </span>
+                <input
+                  type="file"
+                  accept=".mp3,.wav,.m4a,.aac,.ogg,audio/*"
+                  onChange={handleAudioUpload}
+                  className="hidden"
+                />
+              </label>
+            )}
+          </div>
+
+          {/* Summary */}
+          <div className="p-3 bg-bambu-dark rounded-lg text-sm space-y-1">
+            <p className="text-bambu-gray">
+              <span className="text-white">Original:</span> {formatTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}
+            </p>
+            <p className="text-bambu-gray">
+              <span className="text-white">Output:</span> {formatTime(outputDuration)} @ {speed}x speed
+              {audioFile && ` + music overlay`}
+            </p>
+          </div>
+        </div>
+
+        {/* Processing overlay */}
+        {processMutation.isPending && (
+          <div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center gap-4">
+            <Loader2 className="w-12 h-12 text-bambu-green animate-spin" />
+            <p className="text-white text-lg">Processing timelapse...</p>
+            <p className="text-bambu-gray text-sm">This may take a few moments</p>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 30 - 3
frontend/src/components/TimelapseViewer.tsx

@@ -1,22 +1,33 @@
 import { useState, useRef, useEffect } from 'react';
-import { X, Download, Film, Play, Pause, SkipBack, SkipForward } from 'lucide-react';
+import { X, Download, Film, Play, Pause, SkipBack, SkipForward, Pencil } from 'lucide-react';
 import { Button } from './Button';
+import { TimelapseEditorModal } from './TimelapseEditorModal';
 
 interface TimelapseViewerProps {
   src: string;
   title: string;
   downloadFilename: string;
+  archiveId?: number;
   onClose: () => void;
+  onEdit?: () => void;
 }
 
 const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
 
-export function TimelapseViewer({ src, title, downloadFilename, onClose }: TimelapseViewerProps) {
+export function TimelapseViewer({
+  src,
+  title,
+  downloadFilename,
+  archiveId,
+  onClose,
+  onEdit,
+}: TimelapseViewerProps) {
   const videoRef = useRef<HTMLVideoElement>(null);
   const [isPlaying, setIsPlaying] = useState(true);
-  const [playbackRate, setPlaybackRate] = useState(2); // Default to 2x for timelapse
+  const [playbackRate, setPlaybackRate] = useState(1); // Default to 1x
   const [currentTime, setCurrentTime] = useState(0);
   const [duration, setDuration] = useState(0);
+  const [showEditor, setShowEditor] = useState(false);
 
   useEffect(() => {
     const video = videoRef.current;
@@ -109,6 +120,12 @@ export function TimelapseViewer({ src, title, downloadFilename, onClose }: Timel
             {title}
           </h3>
           <div className="flex items-center gap-2">
+            {archiveId && (
+              <Button variant="secondary" size="sm" onClick={() => setShowEditor(true)}>
+                <Pencil className="w-4 h-4" />
+                Edit
+              </Button>
+            )}
             <Button variant="secondary" size="sm" onClick={handleDownload}>
               <Download className="w-4 h-4" />
               Download
@@ -208,6 +225,16 @@ export function TimelapseViewer({ src, title, downloadFilename, onClose }: Timel
           </div>
         </div>
       </div>
+
+      {/* Timelapse Editor Modal */}
+      {showEditor && archiveId && (
+        <TimelapseEditorModal
+          archiveId={archiveId}
+          timelapseSrc={src}
+          onClose={() => setShowEditor(false)}
+          onSave={onEdit}
+        />
+      )}
     </div>
   );
 }

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

@@ -785,7 +785,12 @@ function ArchiveCard({
           src={api.getArchiveTimelapse(archive.id)}
           title={`${archive.print_name || archive.filename} - Timelapse`}
           downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
+          archiveId={archive.id}
           onClose={() => setShowTimelapse(false)}
+          onEdit={() => {
+            queryClient.invalidateQueries({ queryKey: ['archives'] });
+            setShowTimelapse(false);  // Close viewer to reload fresh video
+          }}
         />
       )}
 

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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BonMKEhM.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DGJjegsB.css">
+    <script type="module" crossorigin src="/assets/index-BB4adzNL.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Cwam9OQ3.css">
   </head>
   <body>
     <div id="root"></div>

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