- 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
This commit is contained in:
maziggy
2025-12-28 13:06:30 +01:00
parent db5cb86d3a
commit b1be35ba8c
13 changed files with 1507 additions and 408 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)}`,
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>