Parcourir la 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 il y a 5 mois
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
 ## [0.1.6b] - 2025-12-28
 
 
 ### Added
 ### 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.
 - **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).
 - **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.
 - **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).
 - **Attachment file validation** - File type validation for project attachments (images, documents, 3D files, archives, scripts, configs).
 
 
 ### Changed
 ### 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.
 - **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.
 - **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.
 - **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)
 - 3D model preview (Three.js)
 - Duplicate detection & full-text search
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Photo attachments & failure analysis
+- Timelapse editor (trim, speed, music)
 - Re-print to any connected printer
 - Re-print to any connected printer
 - Archive comparison (side-by-side diff)
 - Archive comparison (side-by-side diff)
 
 

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

@@ -3,7 +3,7 @@ import logging
 import zipfile
 import zipfile
 from pathlib import Path
 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 fastapi.responses import FileResponse, Response
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 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():
     if not timelapse_path.exists():
         raise HTTPException(404, "Timelapse file not found")
         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(
     return FileResponse(
         path=timelapse_path,
         path=timelapse_path,
         media_type="video/mp4",
         media_type="video/mp4",
         filename=f"{archive.print_name or 'timelapse'}.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}
     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
 # 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`,
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   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) =>
   scanArchiveTimelapse: (id: number) =>
     request<{
     request<{
       status: string;
       status: string;
@@ -1432,6 +1432,56 @@ export const api = {
     }
     }
     return response.json();
     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
   // Photos
   getArchivePhotoUrl: (archiveId: number, filename: string) =>
   getArchivePhotoUrl: (archiveId: number, filename: string) =>
     `${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`,
     `${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 { 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 { Button } from './Button';
+import { TimelapseEditorModal } from './TimelapseEditorModal';
 
 
 interface TimelapseViewerProps {
 interface TimelapseViewerProps {
   src: string;
   src: string;
   title: string;
   title: string;
   downloadFilename: string;
   downloadFilename: string;
+  archiveId?: number;
   onClose: () => void;
   onClose: () => void;
+  onEdit?: () => void;
 }
 }
 
 
 const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
 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 videoRef = useRef<HTMLVideoElement>(null);
   const [isPlaying, setIsPlaying] = useState(true);
   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 [currentTime, setCurrentTime] = useState(0);
   const [duration, setDuration] = useState(0);
   const [duration, setDuration] = useState(0);
+  const [showEditor, setShowEditor] = useState(false);
 
 
   useEffect(() => {
   useEffect(() => {
     const video = videoRef.current;
     const video = videoRef.current;
@@ -109,6 +120,12 @@ export function TimelapseViewer({ src, title, downloadFilename, onClose }: Timel
             {title}
             {title}
           </h3>
           </h3>
           <div className="flex items-center gap-2">
           <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}>
             <Button variant="secondary" size="sm" onClick={handleDownload}>
               <Download className="w-4 h-4" />
               <Download className="w-4 h-4" />
               Download
               Download
@@ -208,6 +225,16 @@ export function TimelapseViewer({ src, title, downloadFilename, onClose }: Timel
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
+
+      {/* Timelapse Editor Modal */}
+      {showEditor && archiveId && (
+        <TimelapseEditorModal
+          archiveId={archiveId}
+          timelapseSrc={src}
+          onClose={() => setShowEditor(false)}
+          onSave={onEdit}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

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

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

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


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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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