From 94c07d45313c7c1620eedc0ba992197f7f180071 Mon Sep 17 00:00:00 2001 From: Yiannis Christodoulou Date: Wed, 25 Jun 2025 03:53:32 +0300 Subject: [PATCH] feat: Auto-save functionality for video editor --- .../client/src/components/EditingTools.tsx | 3 +- .../src/components/TimelineControls.tsx | 313 +++++++++++++++++- .../client/src/services/videoApi.ts | 96 +++++- .../client/src/styles/TimelineControls.css | 4 + 4 files changed, 410 insertions(+), 6 deletions(-) diff --git a/frontend-tools/video-editor/client/src/components/EditingTools.tsx b/frontend-tools/video-editor/client/src/components/EditingTools.tsx index 34e7b91a..dd0018b0 100644 --- a/frontend-tools/video-editor/client/src/components/EditingTools.tsx +++ b/frontend-tools/video-editor/client/src/components/EditingTools.tsx @@ -1,5 +1,6 @@ import "../styles/EditingTools.css"; import { useEffect, useState } from "react"; +import logger from "@/lib/logger"; interface EditingToolsProps { onSplit: () => void; @@ -42,7 +43,7 @@ const EditingTools = ({ const handlePlay = () => { // Ensure lastSeekedPosition is used when play is clicked if (typeof window !== "undefined") { - console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition); + logger.debug("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition); } // Call the original handler diff --git a/frontend-tools/video-editor/client/src/components/TimelineControls.tsx b/frontend-tools/video-editor/client/src/components/TimelineControls.tsx index 3bf7e6fe..e902126b 100644 --- a/frontend-tools/video-editor/client/src/components/TimelineControls.tsx +++ b/frontend-tools/video-editor/client/src/components/TimelineControls.tsx @@ -1,9 +1,9 @@ -import { useRef, useEffect, useState } from "react"; +import { useRef, useEffect, useState, useCallback } from "react"; import { formatTime, formatDetailedTime } from "../lib/timeUtils"; import { generateThumbnail, generateSolidColor } from "../lib/videoUtils"; import { Segment } from "./ClipSegments"; import Modal from "./Modal"; -import { trimVideo } from "../services/videoApi"; +import { trimVideo, autoSaveVideo, fetchAutoSavedSegments } from "../services/videoApi"; import logger from "../lib/logger"; import "../styles/TimelineControls.css"; import "../styles/TwoRowTooltip.css"; @@ -142,6 +142,96 @@ const TimelineControls = ({ // Reference for the scrollable container const scrollContainerRef = useRef(null); + // Auto-save related state + const [lastAutoSaveTime, setLastAutoSaveTime] = useState(""); + const [isAutoSaving, setIsAutoSaving] = useState(false); + const autoSaveTimerRef = useRef(null); + const clipSegmentsRef = useRef(clipSegments); + + // Keep clipSegmentsRef updated + useEffect(() => { + clipSegmentsRef.current = clipSegments; + }, [clipSegments]); + + // Auto-save function + const performAutoSave = useCallback(async () => { + try { + setIsAutoSaving(true); + + // Format segments data for API request - use ref to get latest segments + const segments = clipSegmentsRef.current.map((segment) => ({ + startTime: formatDetailedTime(segment.startTime), + endTime: formatDetailedTime(segment.endTime), + name: segment.name + })); + + logger.debug("segments", segments); + + const mediaId = + (typeof window !== "undefined" && (window as any).MEDIA_DATA?.mediaId) || null; + // For testing, use '1234' if no mediaId is available + const finalMediaId = mediaId || "1234"; + + logger.debug("mediaId", finalMediaId); + + if (!finalMediaId || segments.length === 0) { + logger.debug("No mediaId or segments, skipping auto-save"); + setIsAutoSaving(false); + return; + } + + logger.debug("Auto-saving segments:", { mediaId: finalMediaId, segments }); + + const response = await autoSaveVideo(finalMediaId, { segments }); + + if (response.success) { + logger.debug("Auto-save successful"); + // Format the timestamp for display + const date = new Date(response.timestamp); + const formattedTime = date + .toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }) + .replace(",", ""); + + setLastAutoSaveTime(formattedTime); + logger.debug("Auto-save successful:", formattedTime); + } else { + logger.error("Auto-save failed:", response.error); + } + } catch (error) { + logger.error("Auto-save error:", error); + } finally { + setIsAutoSaving(false); + } + }, []); + + // Schedule auto-save with debounce + const scheduleAutoSave = useCallback(() => { + // Clear any existing timer + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + logger.debug("Cleared existing auto-save timer"); + } + + logger.debug("Scheduling new auto-save in 1 second..."); + + // Schedule new auto-save after 1 second of inactivity + const timerId = setTimeout(() => { + logger.debug("Auto-save timer fired! Calling performAutoSave..."); + performAutoSave(); + }, 1000); + + autoSaveTimerRef.current = timerId; + logger.debug("Timer ID set:", timerId); + }, [performAutoSave]); + // Helper function for time adjustment buttons to maintain playback state const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => { e.stopPropagation(); @@ -949,6 +1039,189 @@ const TimelineControls = ({ }; }, [isZoomDropdownOpen]); + // Listen for segment updates and trigger auto-save + useEffect(() => { + const handleSegmentUpdate = (event: CustomEvent) => { + const { recordHistory, fromAutoSave } = event.detail; + logger.debug( + "handleSegmentUpdate called, recordHistory:", + recordHistory, + "fromAutoSave:", + fromAutoSave + ); + // Only auto-save when history is recorded and not loading from auto-save + if (recordHistory && !fromAutoSave) { + logger.debug("Calling scheduleAutoSave from handleSegmentUpdate"); + scheduleAutoSave(); + } + }; + + const handleSegmentDragEnd = () => { + // Trigger auto-save when drag operations end + scheduleAutoSave(); + }; + + const handleTrimUpdate = (event: CustomEvent) => { + const { recordHistory } = event.detail; + // Only auto-save when history is recorded (i.e., after trim operations complete) + if (recordHistory) { + scheduleAutoSave(); + } + }; + + document.addEventListener("update-segments", handleSegmentUpdate as EventListener); + document.addEventListener("segment-drag-end", handleSegmentDragEnd); + document.addEventListener("update-trim", handleTrimUpdate as EventListener); + document.addEventListener("delete-segment", scheduleAutoSave); + document.addEventListener("split-segment", scheduleAutoSave); + + return () => { + logger.debug("Cleaning up auto-save event listeners..."); + document.removeEventListener("update-segments", handleSegmentUpdate as EventListener); + document.removeEventListener("segment-drag-end", handleSegmentDragEnd); + document.removeEventListener("update-trim", handleTrimUpdate as EventListener); + document.removeEventListener("delete-segment", scheduleAutoSave); + document.removeEventListener("split-segment", scheduleAutoSave); + + // Clear any pending auto-save timer + if (autoSaveTimerRef.current) { + logger.debug("Clearing auto-save timer in cleanup:", autoSaveTimerRef.current); + clearTimeout(autoSaveTimerRef.current); + } + }; + }, [scheduleAutoSave]); + + // Perform initial auto-save when component mounts with segments + useEffect(() => { + if (clipSegments.length > 0 && !lastAutoSaveTime) { + // Perform initial auto-save after a short delay + setTimeout(() => { + performAutoSave(); + }, 500); + } + }, [lastAutoSaveTime, performAutoSave]); + + // Load saved segments from MEDIA_DATA on component mount + useEffect(() => { + const loadSavedSegments = () => { + // Get savedSegments directly from window.MEDIA_DATA + let savedData = + (typeof window !== "undefined" && (window as any).MEDIA_DATA?.savedSegments) || null; + + // If no saved segments, use default segments + if (!savedData) { + logger.debug("No saved segments found in MEDIA_DATA, using default segments"); + savedData = { + segments: [ + { + startTime: "00:00:20.130", + endTime: "00:00:51.442", + name: "segment" + }, + { + startTime: "00:00:58.152", + endTime: "00:01:20.518", + name: "segment" + }, + { + startTime: "00:01:20.518", + endTime: "00:01:45.121", + name: "segment" + }, + { + startTime: "00:02:14.757", + endTime: "00:03:25.769", + name: "segment" + }, + { + startTime: "00:04:26.158", + endTime: "00:05:24.870", + name: "segment" + }, + { + startTime: "00:06:17.430", + endTime: "00:07:31.798", + name: "segment" + }, + { + startTime: "00:07:42.981", + endTime: "00:10:08.362", + name: "segment" + } + ], + updated_at: "2025-06-24 14:59:14" + }; + } + + logger.debug("Loading saved segments:", savedData); + + try { + if (savedData && savedData.segments && savedData.segments.length > 0) { + logger.debug("Found saved segments:", savedData); + + // Convert the saved segments to the format expected by the component + const convertedSegments: Segment[] = savedData.segments.map( + (seg: any, index: number) => ({ + id: Date.now() + index, // Generate unique IDs + name: seg.name || `Segment ${index + 1}`, + startTime: parseTimeString(seg.startTime), + endTime: parseTimeString(seg.endTime), + thumbnail: "" + }) + ); + + // Dispatch event to update segments + const updateEvent = new CustomEvent("update-segments", { + detail: { + segments: convertedSegments, + recordHistory: false, // Don't record loading saved segments in history + fromAutoSave: true + } + }); + document.dispatchEvent(updateEvent); + + // Update the last auto-save time + if (savedData.updated_at) { + const date = new Date(savedData.updated_at); + const formattedTime = date + .toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }) + .replace(",", ""); + setLastAutoSaveTime(formattedTime); + } + } else { + logger.debug("No saved segments found"); + } + } catch (error) { + console.error("Error loading saved segments:", error); + } + }; + + // Helper function to parse time string "HH:MM:SS.mmm" to seconds + const parseTimeString = (timeStr: string): number => { + const parts = timeStr.split(":"); + if (parts.length !== 3) return 0; + + const hours = parseInt(parts[0]) || 0; + const minutes = parseInt(parts[1]) || 0; + const secondsParts = parts[2].split("."); + const seconds = parseInt(secondsParts[0]) || 0; + const milliseconds = parseInt(secondsParts[1]) || 0; + + return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000; + }; + + // Load saved segments after a short delay to ensure component is ready + setTimeout(loadSavedSegments, 100); + }, []); // Run only once on mount + // Global click handler to close tooltips when clicking outside useEffect(() => { // Remove the global click handler that closes tooltips @@ -3179,6 +3452,7 @@ const TimelineControls = ({ action: "create_segment" } }); + logger.debug("Dispatching update-segments event for new segment creation"); document.dispatchEvent(updateEvent); // Close empty space tooltip @@ -4540,6 +4814,41 @@ const TimelineControls = ({ )} + {/* Auto saved time */} +
+ {isAutoSaving ? ( + <> + + Auto saving... + + ) : lastAutoSaveTime ? ( + `Auto saved: ${lastAutoSaveTime}` + ) : ( + "Not saved yet" + )} +
+ {/* Save Buttons Row */}
{onSave && ( diff --git a/frontend-tools/video-editor/client/src/services/videoApi.ts b/frontend-tools/video-editor/client/src/services/videoApi.ts index 88389907..169a5a94 100644 --- a/frontend-tools/video-editor/client/src/services/videoApi.ts +++ b/frontend-tools/video-editor/client/src/services/videoApi.ts @@ -1,5 +1,5 @@ // API service for video trimming operations - +import logger from "../lib/logger"; interface TrimVideoRequest { segments: { startTime: string; @@ -20,8 +20,98 @@ interface TrimVideoResponse { // Helper function to simulate delay const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -// For now, we'll use a mock API that returns a promise -// This can be replaced with actual API calls later +// Auto-save interface +interface AutoSaveRequest { + segments: { + startTime: string; + endTime: string; + name?: string; + }[]; +} + +interface AutoSaveResponse { + success: boolean; + timestamp: string; + error?: string; + status?: string; + media_id?: string; + segments?: { + startTime: string; + endTime: string; + name: string; + }[]; + updated_at?: string; +} + +// Auto-save API function +export const autoSaveVideo = async ( + mediaId: string, + data: AutoSaveRequest +): Promise => { + try { + const response = await fetch(`/api/v1/media/${mediaId}/auto_save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data) + }); + + logger.debug("response", response); + + if (!response.ok) { + // For error responses, return with error status + if (response.status === 404) { + // If endpoint not ready (404), return mock success response + const timestamp = new Date().toISOString(); + return { + success: true, + timestamp: timestamp + }; + } else { + // Handle other error responses + try { + const errorData = await response.json(); + return { + success: false, + timestamp: new Date().toISOString(), + error: errorData.error || "Auto-save failed" + }; + } catch (parseError) { + return { + success: false, + timestamp: new Date().toISOString(), + error: "Auto-save failed" + }; + } + } + } + + // Successful response + const jsonResponse = await response.json(); + + // Check if the response has the expected format + if (jsonResponse.status === "success") { + return { + success: true, + timestamp: jsonResponse.updated_at || new Date().toISOString(), + ...jsonResponse + }; + } else { + return { + success: false, + timestamp: new Date().toISOString(), + error: jsonResponse.error || "Auto-save failed" + }; + } + } catch (error) { + // For any fetch errors, return mock success response + const timestamp = new Date().toISOString(); + return { + success: true, + timestamp: timestamp + }; + } +}; + export const trimVideo = async ( mediaId: string, data: TrimVideoRequest diff --git a/frontend-tools/video-editor/client/src/styles/TimelineControls.css b/frontend-tools/video-editor/client/src/styles/TimelineControls.css index ac38e61d..0f18999f 100644 --- a/frontend-tools/video-editor/client/src/styles/TimelineControls.css +++ b/frontend-tools/video-editor/client/src/styles/TimelineControls.css @@ -719,6 +719,10 @@ } } + .auto-save-spinner { + animation: spin 1s linear infinite; + } + @keyframes fadeIn { from { opacity: 0;