diff --git a/frontend-tools/chapters-editor/client/src/components/EditingTools.tsx b/frontend-tools/chapters-editor/client/src/components/EditingTools.tsx index fc3a5470..9097f1d1 100644 --- a/frontend-tools/chapters-editor/client/src/components/EditingTools.tsx +++ b/frontend-tools/chapters-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/chapters-editor/client/src/components/TimelineControls.tsx b/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx index a519e8ef..2d20c2d4 100644 --- a/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx +++ b/frontend-tools/chapters-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 { autoSaveVideo } from '../services/videoApi'; import logger from '../lib/logger'; import '../styles/TimelineControls.css'; import '../styles/TwoRowTooltip.css'; @@ -162,6 +162,96 @@ const TimelineControls = ({ const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId); + // 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, + chapterTitle: segment.chapterTitle, + })); + + 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]); + // Update editing title when selected segment changes useEffect(() => { if (selectedSegment) { @@ -816,6 +906,162 @@ 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:00.000', + endTime: '00:00:10.000', + chapterTitle: 'Chapter 1 (from saved data)', + }, + { + startTime: '00:00:12.000', + endTime: '00:00:17.000', + chapterTitle: 'Chapter 2 (from saved data)', + }, + { + startTime: '00:00:20.000', + endTime: '00:00:30.000', + chapterTitle: 'Chapter 3 (from saved data)', + }, + ], + 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: '', + chapterTitle: seg.chapterTitle || '', // Preserve chapter title from saved data + })); + + // 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 @@ -2446,6 +2692,8 @@ const TimelineControls = ({ placeholder="Chapter Title" value={editingChapterTitle} onChange={(e) => handleChapterTitleChange(e.target.value)} + onBlur={performAutoSave} + onMouseLeave={performAutoSave} rows={2} maxLength={200} onClick={(e) => e.stopPropagation()} @@ -4424,6 +4672,41 @@ const TimelineControls = ({ )} + {/* Auto saved time */} +
+ {isAutoSaving ? ( + <> + + Auto saving... + + ) : lastAutoSaveTime ? ( + `Auto saved: ${lastAutoSaveTime}` + ) : ( + 'Not saved yet' + )} +
+ {/* Save Chapters Button */}
+ {/* 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 e466b307..75e65f54 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,95 @@ 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}/save_trim`, { + 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): Promise => { try { // Attempt the real API call