diff --git a/frontend-tools/chapters-editor/client/src/App.tsx b/frontend-tools/chapters-editor/client/src/App.tsx
index a51f6379..905e697c 100644
--- a/frontend-tools/chapters-editor/client/src/App.tsx
+++ b/frontend-tools/chapters-editor/client/src/App.tsx
@@ -1,303 +1,277 @@
-import { useRef, useEffect, useState } from "react";
-import { formatTime, formatDetailedTime } from "./lib/timeUtils";
-import logger from "./lib/logger";
-import VideoPlayer from "@/components/VideoPlayer";
-import TimelineControls from "@/components/TimelineControls";
-import EditingTools from "@/components/EditingTools";
-import ClipSegments from "@/components/ClipSegments";
-import MobilePlayPrompt from "@/components/IOSPlayPrompt";
-import useVideoTrimmer from "@/hooks/useVideoTrimmer";
+import { formatDetailedTime } from './lib/timeUtils';
+import logger from './lib/logger';
+import VideoPlayer from '@/components/VideoPlayer';
+import TimelineControls from '@/components/TimelineControls';
+import EditingTools from '@/components/EditingTools';
+import ClipSegments from '@/components/ClipSegments';
+import MobilePlayPrompt from '@/components/IOSPlayPrompt';
+import useVideoChapters from '@/hooks/useVideoChapters';
const App = () => {
- const {
- videoRef,
- currentTime,
- duration,
- isPlaying,
- setIsPlaying,
- isMuted,
- thumbnails,
- trimStart,
- trimEnd,
- splitPoints,
- zoomLevel,
- clipSegments,
- hasUnsavedChanges,
- historyPosition,
- history,
- handleTrimStartChange,
- handleTrimEndChange,
- handleZoomChange,
- handleMobileSafeSeek,
- handleSplit,
- handleReset,
- handleUndo,
- handleRedo,
- toggleMute,
- handleSave,
- handleSaveACopy,
- handleSaveSegments,
- isMobile,
- videoInitialized,
- setVideoInitialized,
- isPlayingSegments,
- handlePlaySegments
- } = useVideoTrimmer();
+ const {
+ videoRef,
+ currentTime,
+ duration,
+ isPlaying,
+ setIsPlaying,
+ isMuted,
+ thumbnails,
+ trimStart,
+ trimEnd,
+ splitPoints,
+ zoomLevel,
+ clipSegments,
+ selectedSegmentId,
+ hasUnsavedChanges,
+ historyPosition,
+ history,
+ handleTrimStartChange,
+ handleTrimEndChange,
+ handleZoomChange,
+ handleMobileSafeSeek,
+ handleSplit,
+ handleReset,
+ handleUndo,
+ handleRedo,
+ toggleMute,
+ handleSegmentUpdate,
+ handleChapterSave,
+ handleSelectedSegmentChange,
+ isMobile,
+ videoInitialized,
+ setVideoInitialized,
+ isPlayingSegments,
+ handlePlaySegments,
+ } = useVideoChapters();
- // Function to play from the beginning
- const playFromBeginning = () => {
- if (videoRef.current) {
- videoRef.current.currentTime = 0;
- handleMobileSafeSeek(0);
- if (!isPlaying) {
- handlePlay();
- }
- }
- };
+ const handlePlay = () => {
+ if (!videoRef.current) return;
- // Function to jump 15 seconds backward
- const jumpBackward15 = () => {
- const newTime = Math.max(0, currentTime - 15);
- handleMobileSafeSeek(newTime);
- };
+ const video = videoRef.current;
- // Function to jump 15 seconds forward
- const jumpForward15 = () => {
- const newTime = Math.min(duration, currentTime + 15);
- handleMobileSafeSeek(newTime);
- };
+ // If already playing, just pause the video
+ if (isPlaying) {
+ video.pause();
+ setIsPlaying(false);
+ return;
+ }
- const handlePlay = () => {
- if (!videoRef.current) return;
+ const currentPosition = Number(video.currentTime.toFixed(6));
- const video = videoRef.current;
+ // Find the next stopping point based on current position
+ let stopTime = duration;
+ let currentSegment = null;
+ let nextSegment = null;
- // If already playing, just pause the video
- if (isPlaying) {
- video.pause();
- setIsPlaying(false);
- return;
- }
+ // Sort segments by start time to ensure correct order
+ const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
- const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
+ // First, check if we're inside a segment or exactly at its start/end
+ currentSegment = sortedSegments.find((seg) => {
+ const segStartTime = Number(seg.startTime.toFixed(6));
+ const segEndTime = Number(seg.endTime.toFixed(6));
- // Find the next stopping point based on current position
- let stopTime = duration;
- let currentSegment = null;
- let nextSegment = null;
+ // Check if we're inside the segment
+ if (currentPosition > segStartTime && currentPosition < segEndTime) {
+ return true;
+ }
+ // Check if we're exactly at the start
+ if (currentPosition === segStartTime) {
+ return true;
+ }
+ // Check if we're exactly at the end
+ if (currentPosition === segEndTime) {
+ // If we're at the end of a segment, we should look for the next one
+ return false;
+ }
+ return false;
+ });
- // Sort segments by start time to ensure correct order
- const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ // If we're not in a segment, find the next segment
+ if (!currentSegment) {
+ nextSegment = sortedSegments.find((seg) => {
+ const segStartTime = Number(seg.startTime.toFixed(6));
+ return segStartTime > currentPosition;
+ });
+ }
- // First, check if we're inside a segment or exactly at its start/end
- currentSegment = sortedSegments.find((seg) => {
- const segStartTime = Number(seg.startTime.toFixed(6));
- const segEndTime = Number(seg.endTime.toFixed(6));
+ // Determine where to stop based on position
+ if (currentSegment) {
+ // If we're in a segment, stop at its end
+ stopTime = Number(currentSegment.endTime.toFixed(6));
+ } else if (nextSegment) {
+ // If we're in a cutaway and there's a next segment, stop at its start
+ stopTime = Number(nextSegment.startTime.toFixed(6));
+ }
- // Check if we're inside the segment
- if (currentPosition > segStartTime && currentPosition < segEndTime) {
- return true;
- }
- // Check if we're exactly at the start
- if (currentPosition === segStartTime) {
- return true;
- }
- // Check if we're exactly at the end
- if (currentPosition === segEndTime) {
- // If we're at the end of a segment, we should look for the next one
- return false;
- }
- return false;
- });
+ // Create a boundary checker function with high precision
+ const checkBoundary = () => {
+ if (!video) return;
- // If we're not in a segment, find the next segment
- if (!currentSegment) {
- nextSegment = sortedSegments.find((seg) => {
- const segStartTime = Number(seg.startTime.toFixed(6));
- return segStartTime > currentPosition;
- });
- }
+ const currentPosition = Number(video.currentTime.toFixed(6));
+ const timeLeft = Number((stopTime - currentPosition).toFixed(6));
- // Determine where to stop based on position
- if (currentSegment) {
- // If we're in a segment, stop at its end
- stopTime = Number(currentSegment.endTime.toFixed(6));
- } else if (nextSegment) {
- // If we're in a cutaway and there's a next segment, stop at its start
- stopTime = Number(nextSegment.startTime.toFixed(6));
- }
+ // If we've reached or passed the boundary
+ if (timeLeft <= 0 || currentPosition >= stopTime) {
+ // First pause playback
+ video.pause();
- // Create a boundary checker function with high precision
- const checkBoundary = () => {
- if (!video) return;
+ // Force exact position with multiple verification attempts
+ const setExactPosition = () => {
+ if (!video) return;
- const currentPosition = Number(video.currentTime.toFixed(6));
- const timeLeft = Number((stopTime - currentPosition).toFixed(6));
+ // Set to exact boundary time
+ video.currentTime = stopTime;
+ handleMobileSafeSeek(stopTime);
- // If we've reached or passed the boundary
- if (timeLeft <= 0 || currentPosition >= stopTime) {
- // First pause playback
- video.pause();
+ const actualPosition = Number(video.currentTime.toFixed(6));
+ const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
- // Force exact position with multiple verification attempts
- const setExactPosition = () => {
- if (!video) return;
+ logger.debug('Position verification:', {
+ target: formatDetailedTime(stopTime),
+ actual: formatDetailedTime(actualPosition),
+ difference: difference,
+ });
- // Set to exact boundary time
- video.currentTime = stopTime;
- handleMobileSafeSeek(stopTime);
+ // If we're not exactly at the target position, try one more time
+ if (difference > 0) {
+ video.currentTime = stopTime;
+ handleMobileSafeSeek(stopTime);
+ }
+ };
- const actualPosition = Number(video.currentTime.toFixed(6));
- const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
+ // Multiple attempts to ensure precision, with increasing delays
+ setExactPosition();
+ setTimeout(setExactPosition, 5); // Quick first retry
+ setTimeout(setExactPosition, 10); // Second retry
+ setTimeout(setExactPosition, 20); // Third retry if needed
+ setTimeout(setExactPosition, 50); // Final verification
- logger.debug("Position verification:", {
- target: formatDetailedTime(stopTime),
- actual: formatDetailedTime(actualPosition),
- difference: difference
- });
+ // Remove our boundary checker
+ video.removeEventListener('timeupdate', checkBoundary);
+ setIsPlaying(false);
- // If we're not exactly at the target position, try one more time
- if (difference > 0) {
- video.currentTime = stopTime;
- handleMobileSafeSeek(stopTime);
- }
+ // Log the final position for debugging
+ logger.debug('Stopped at position:', {
+ target: formatDetailedTime(stopTime),
+ actual: formatDetailedTime(video.currentTime),
+ type: currentSegment ? 'segment end' : nextSegment ? 'next segment start' : 'end of video',
+ segment: currentSegment
+ ? {
+ id: currentSegment.id,
+ start: formatDetailedTime(currentSegment.startTime),
+ end: formatDetailedTime(currentSegment.endTime),
+ }
+ : null,
+ nextSegment: nextSegment
+ ? {
+ id: nextSegment.id,
+ start: formatDetailedTime(nextSegment.startTime),
+ end: formatDetailedTime(nextSegment.endTime),
+ }
+ : null,
+ });
+
+ return;
+ }
};
- // Multiple attempts to ensure precision, with increasing delays
- setExactPosition();
- setTimeout(setExactPosition, 5); // Quick first retry
- setTimeout(setExactPosition, 10); // Second retry
- setTimeout(setExactPosition, 20); // Third retry if needed
- setTimeout(setExactPosition, 50); // Final verification
+ // Start our boundary checker
+ video.addEventListener('timeupdate', checkBoundary);
- // Remove our boundary checker
- video.removeEventListener("timeupdate", checkBoundary);
- setIsPlaying(false);
-
- // Log the final position for debugging
- logger.debug("Stopped at position:", {
- target: formatDetailedTime(stopTime),
- actual: formatDetailedTime(video.currentTime),
- type: currentSegment
- ? "segment end"
- : nextSegment
- ? "next segment start"
- : "end of video",
- segment: currentSegment
- ? {
- id: currentSegment.id,
- start: formatDetailedTime(currentSegment.startTime),
- end: formatDetailedTime(currentSegment.endTime)
- }
- : null,
- nextSegment: nextSegment
- ? {
- id: nextSegment.id,
- start: formatDetailedTime(nextSegment.startTime),
- end: formatDetailedTime(nextSegment.endTime)
- }
- : null
- });
-
- return;
- }
+ // Start playing
+ video
+ .play()
+ .then(() => {
+ setIsPlaying(true);
+ setVideoInitialized(true);
+ logger.debug('Playback started:', {
+ from: formatDetailedTime(currentPosition),
+ to: formatDetailedTime(stopTime),
+ currentSegment: currentSegment
+ ? {
+ id: currentSegment.id,
+ start: formatDetailedTime(currentSegment.startTime),
+ end: formatDetailedTime(currentSegment.endTime),
+ }
+ : 'None',
+ nextSegment: nextSegment
+ ? {
+ id: nextSegment.id,
+ start: formatDetailedTime(nextSegment.startTime),
+ end: formatDetailedTime(nextSegment.endTime),
+ }
+ : 'None',
+ });
+ })
+ .catch((err) => {
+ console.error('Error playing video:', err);
+ });
};
- // Start our boundary checker
- video.addEventListener("timeupdate", checkBoundary);
+ return (
+
+
- // Start playing
- video
- .play()
- .then(() => {
- setIsPlaying(true);
- setVideoInitialized(true);
- logger.debug("Playback started:", {
- from: formatDetailedTime(currentPosition),
- to: formatDetailedTime(stopTime),
- currentSegment: currentSegment
- ? {
- id: currentSegment.id,
- start: formatDetailedTime(currentSegment.startTime),
- end: formatDetailedTime(currentSegment.endTime)
- }
- : "None",
- nextSegment: nextSegment
- ? {
- id: nextSegment.id,
- start: formatDetailedTime(nextSegment.startTime),
- end: formatDetailedTime(nextSegment.endTime)
- }
- : "None"
- });
- })
- .catch((err) => {
- console.error("Error playing video:", err);
- });
- };
+
+ {/* Video Player */}
+
- return (
-
-
+ {/* Editing Tools */}
+
0}
+ canRedo={historyPosition < history.length - 1}
+ />
-
- {/* Video Player */}
-
+ {/* Timeline Controls */}
+
- {/* Editing Tools */}
- 0}
- canRedo={historyPosition < history.length - 1}
- />
-
- {/* Timeline Controls */}
-
-
- {/* Clip Segments */}
-
-
-
- );
+ {/* Clip Segments */}
+
+
+
+ );
};
export default App;
diff --git a/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx b/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx
index 02bb8c23..45fc87ac 100644
--- a/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx
+++ b/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx
@@ -7,13 +7,15 @@ export interface Segment {
startTime: number;
endTime: number;
thumbnail: string;
+ chapterTitle?: string;
}
interface ClipSegmentsProps {
segments: Segment[];
+ selectedSegmentId?: number | null;
}
-const ClipSegments = ({ segments }: ClipSegmentsProps) => {
+const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
// Sort segments by startTime
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
@@ -33,19 +35,31 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
return `segment-default-color segment-color-${(index % 8) + 1}`;
};
+ // Get selected segment
+ const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
+
return (
-
Clip Segments
+
Chapters
{sortedSegments.map((segment, index) => (
-
+
-
Segment {index + 1}
+
+ {segment.chapterTitle ? (
+ {segment.chapterTitle}
+ ) : (
+ Chapter {index + 1}
+ )}
+
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
@@ -74,7 +88,9 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
))}
{sortedSegments.length === 0 && (
-
No segments created yet. Use the split button to create segments.
+
+ No chapters created yet. Use the split button to create chapter segments.
+
)}
);
diff --git a/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx b/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx
index ab057cc3..a519e8ef 100644
--- a/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx
+++ b/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx
@@ -35,19 +35,20 @@ interface TimelineControlsProps {
splitPoints: number[];
zoomLevel: number;
clipSegments: Segment[];
+ selectedSegmentId?: number | null;
+ onSelectedSegmentChange?: (segmentId: number | null) => void;
+ onSegmentUpdate?: (segmentId: number, updates: Partial
) => void;
+ onChapterSave?: (chapters: { name: string; from: string; to: string }[]) => void;
onTrimStartChange: (time: number) => void;
onTrimEndChange: (time: number) => void;
onZoomChange: (level: number) => void;
onSeek: (time: number) => void;
videoRef: React.RefObject;
- onSave?: () => void;
- onSaveACopy?: () => void;
- onSaveSegments?: () => void;
hasUnsavedChanges?: boolean;
isIOSUninitialized?: boolean;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
- onPlayPause: () => void; // Add this prop
+ onPlayPause: () => void;
isPlayingSegments?: boolean;
}
@@ -109,14 +110,15 @@ const TimelineControls = ({
splitPoints,
zoomLevel,
clipSegments,
+ selectedSegmentId: externalSelectedSegmentId,
+ onSelectedSegmentChange,
+ onSegmentUpdate,
+ onChapterSave,
onTrimStartChange,
onTrimEndChange,
onZoomChange,
onSeek,
videoRef,
- onSave,
- onSaveACopy,
- onSaveSegments,
hasUnsavedChanges = false,
isIOSUninitialized = false,
isPlaying,
@@ -127,7 +129,17 @@ const TimelineControls = ({
const timelineRef = useRef(null);
const leftHandleRef = useRef(null);
const rightHandleRef = useRef(null);
- const [selectedSegmentId, setSelectedSegmentId] = useState(null);
+ // Use external selectedSegmentId if provided, otherwise use internal state
+ const [internalSelectedSegmentId, setInternalSelectedSegmentId] = useState(null);
+ const selectedSegmentId =
+ externalSelectedSegmentId !== undefined ? externalSelectedSegmentId : internalSelectedSegmentId;
+ const setSelectedSegmentId = (segmentId: number | null) => {
+ if (onSelectedSegmentChange) {
+ onSelectedSegmentChange(segmentId);
+ } else {
+ setInternalSelectedSegmentId(segmentId);
+ }
+ };
const [showEmptySpaceTooltip, setShowEmptySpaceTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const [clickedTime, setClickedTime] = useState(0);
@@ -142,6 +154,49 @@ const TimelineControls = ({
// Reference for the scrollable container
const scrollContainerRef = useRef(null);
+ // Chapter editor state
+ const [editingChapterTitle, setEditingChapterTitle] = useState('');
+ const [chapterHasUnsavedChanges, setChapterHasUnsavedChanges] = useState(false);
+
+ // Sort segments by startTime for chapter editor
+ const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
+
+ // Update editing title when selected segment changes
+ useEffect(() => {
+ if (selectedSegment) {
+ setEditingChapterTitle(selectedSegment.chapterTitle || '');
+ } else {
+ setEditingChapterTitle('');
+ }
+ }, [selectedSegmentId, selectedSegment]);
+
+ // Handle chapter title change
+ const handleChapterTitleChange = (value: string) => {
+ setEditingChapterTitle(value);
+ setChapterHasUnsavedChanges(true);
+
+ // Update the segment immediately
+ if (selectedSegmentId && onSegmentUpdate) {
+ onSegmentUpdate(selectedSegmentId, { chapterTitle: value });
+ }
+ };
+
+ // Handle save chapters
+ const handleSaveChapters = () => {
+ if (!onChapterSave) return;
+
+ // Convert segments to chapter format
+ const chapters = sortedSegments.map((segment, index) => ({
+ name: segment.chapterTitle || `Chapter ${index + 1}`,
+ from: formatDetailedTime(segment.startTime),
+ to: formatDetailedTime(segment.endTime),
+ }));
+
+ onChapterSave(chapters);
+ setChapterHasUnsavedChanges(false);
+ };
+
// Helper function for time adjustment buttons to maintain playback state
const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => {
e.stopPropagation();
@@ -310,16 +365,14 @@ const TimelineControls = ({
};
// Modal states
- const [showSaveModal, setShowSaveModal] = useState(false);
- const [showSaveAsModal, setShowSaveAsModal] = useState(false);
- const [showSaveSegmentsModal, setShowSaveSegmentsModal] = useState(false);
+ const [showSaveChaptersModal, setShowSaveChaptersModal] = useState(false);
const [showProcessingModal, setShowProcessingModal] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [redirectUrl, setRedirectUrl] = useState('');
- const [saveType, setSaveType] = useState<'save' | 'copy' | 'segments'>('save');
+ const [saveType, setSaveType] = useState<'chapters'>('chapters');
// Calculate positions as percentages
const currentTimePercent = duration > 0 ? (currentTime / duration) * 100 : 0;
@@ -328,218 +381,55 @@ const TimelineControls = ({
// No need for an extra effect here as we handle displayTime updates in the segment playback effect
- // Save and API handlers
- const handleSaveConfirm = async () => {
+ // Save Chapters handler
+ const handleSaveChaptersConfirm = async () => {
// Close confirmation modal and show processing modal
- setShowSaveModal(false);
+ setShowSaveChaptersModal(false);
setShowProcessingModal(true);
- setSaveType('save');
+ setSaveType('chapters');
try {
- // Format segments data for API request
- const segments = clipSegments.map((segment) => ({
- startTime: formatDetailedTime(segment.startTime),
- endTime: formatDetailedTime(segment.endTime),
- }));
+ // Format chapters data for API request
+ const chapters = clipSegments
+ .filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
+ .map((segment) => ({
+ name: segment.chapterTitle || `Chapter ${segment.id}`,
+ from: formatDetailedTime(segment.startTime),
+ to: formatDetailedTime(segment.endTime),
+ }));
- const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
- const redirectURL = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectURL) || null;
+ if (chapters.length === 0) {
+ setErrorMessage('No chapters with titles found');
+ setShowErrorModal(true);
+ setShowProcessingModal(false);
+ return;
+ }
- // Log the request details for debugging
- logger.debug('Save request:', {
- mediaId,
- segments,
- saveAsCopy: false,
- redirectURL,
- });
+ // Call the onChapterSave function if provided
+ if (onChapterSave) {
+ await onChapterSave(chapters);
+ setShowProcessingModal(false);
+ setSuccessMessage('Chapters saved successfully!');
- const response = await trimVideo(mediaId, {
- segments,
- saveAsCopy: false,
- });
+ // Set redirect URL to media page
+ const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
+ if (mediaId) {
+ setRedirectUrl(`/view?m=${mediaId}`);
+ }
- // Log the response for debugging
- logger.debug('Save response:', response);
-
- // Hide processing modal
- setShowProcessingModal(false);
-
- // Check if response indicates success (200 OK)
- if (response.status === 200) {
- // For "Save", use the redirectURL from the window or response
- const finalRedirectUrl = redirectURL || response.url_redirect;
- logger.debug('Using redirect URL:', finalRedirectUrl);
-
- setRedirectUrl(finalRedirectUrl);
- setSuccessMessage('Video saved successfully!');
-
- // Show success modal
setShowSuccessModal(true);
- } else if (response.status === 400) {
- // Set error message from response and show error modal
- const errorMsg = response.error || 'An error occurred during processing';
- logger.debug('Save error (400):', errorMsg);
- setErrorMessage(errorMsg);
- setShowErrorModal(true);
} else {
- // Handle other status codes as needed
- logger.debug('Save error (unknown status):', response);
- setErrorMessage('An unexpected error occurred');
+ setErrorMessage('Chapter save function not available');
setShowErrorModal(true);
+ setShowProcessingModal(false);
}
} catch (error) {
- logger.error('Error processing video:', error);
+ logger.error('Error saving chapters:', error);
setShowProcessingModal(false);
// Set error message and show error modal
- const errorMsg = error instanceof Error ? error.message : 'An error occurred during processing';
- logger.debug('Save error (exception):', errorMsg);
- setErrorMessage(errorMsg);
- setShowErrorModal(true);
- }
- };
-
- const handleSaveAsCopyConfirm = async () => {
- // Close confirmation modal and show processing modal
- setShowSaveAsModal(false);
- setShowProcessingModal(true);
- setSaveType('copy');
-
- try {
- // Format segments data for API request
- const segments = clipSegments.map((segment) => ({
- startTime: formatDetailedTime(segment.startTime),
- endTime: formatDetailedTime(segment.endTime),
- }));
-
- const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
- const redirectUserMediaURL =
- (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectUserMediaURL) || null;
-
- // Log the request details for debugging
- logger.debug('Save as copy request:', {
- mediaId,
- segments,
- saveAsCopy: true,
- redirectUserMediaURL,
- });
-
- const response = await trimVideo(mediaId, {
- segments,
- saveAsCopy: true,
- });
-
- // Log the response for debugging
- logger.debug('Save as copy response:', response);
-
- // Hide processing modal
- setShowProcessingModal(false);
-
- // Check if response indicates success (200 OK)
- if (response.status === 200) {
- // For "Save As Copy", use the redirectUserMediaURL from the window
- const finalRedirectUrl = redirectUserMediaURL || response.url_redirect;
- logger.debug('Using redirect user media URL:', finalRedirectUrl);
-
- setRedirectUrl(finalRedirectUrl);
- setSuccessMessage('Video saved as a new copy!');
-
- // Show success modal
- setShowSuccessModal(true);
- } else if (response.status === 400) {
- // Set error message from response and show error modal
- const errorMsg = response.error || 'An error occurred during processing';
- logger.debug('Save as copy error (400):', errorMsg);
- setErrorMessage(errorMsg);
- setShowErrorModal(true);
- } else {
- // Handle other status codes as needed
- logger.debug('Save as copy error (unknown status):', response);
- setErrorMessage('An unexpected error occurred');
- setShowErrorModal(true);
- }
- } catch (error) {
- logger.error('Error processing video:', error);
- setShowProcessingModal(false);
-
- // Set error message and show error modal
- const errorMsg = error instanceof Error ? error.message : 'An error occurred during processing';
- logger.debug('Save as copy error (exception):', errorMsg);
- setErrorMessage(errorMsg);
- setShowErrorModal(true);
- }
- };
-
- const handleSaveSegmentsConfirm = async () => {
- // Close confirmation modal and show processing modal
- setShowSaveSegmentsModal(false);
- setShowProcessingModal(true);
- setSaveType('segments');
-
- try {
- // Format segments data for API request, with each segment saved as a separate file
- const segments = clipSegments.map((segment) => ({
- startTime: formatDetailedTime(segment.startTime),
- endTime: formatDetailedTime(segment.endTime),
- name: segment.name, // Include segment name for individual files
- }));
-
- const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
- const redirectUserMediaURL =
- (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectUserMediaURL) || null;
-
- // Log the request details for debugging
- logger.debug('Save segments request:', {
- mediaId,
- segments,
- saveAsCopy: true,
- saveIndividualSegments: true,
- redirectUserMediaURL,
- });
-
- const response = await trimVideo(mediaId, {
- segments,
- saveAsCopy: true,
- saveIndividualSegments: true,
- });
-
- // Log the response for debugging
- logger.debug('Save segments response:', response);
-
- // Hide processing modal
- setShowProcessingModal(false);
-
- // Check if response indicates success (200 OK)
- if (response.status === 200) {
- // For "Save Segments", use the redirectUserMediaURL from the window
- const finalRedirectUrl = redirectUserMediaURL || response.url_redirect;
- logger.debug('Using redirect user media URL for segments:', finalRedirectUrl);
-
- setRedirectUrl(finalRedirectUrl);
- setSuccessMessage(`${segments.length} segments saved successfully!`);
-
- // Show success modal
- setShowSuccessModal(true);
- } else if (response.status === 400) {
- // Set error message from response and show error modal
- const errorMsg = response.error || 'An error occurred during processing';
- logger.debug('Save segments error (400):', errorMsg);
- setErrorMessage(errorMsg);
- setShowErrorModal(true);
- } else {
- // Handle other status codes as needed
- logger.debug('Save segments error (unknown status):', response);
- setErrorMessage('An unexpected error occurred');
- setShowErrorModal(true);
- }
- } catch (error) {
- // Handle errors
- logger.error('Error processing video segments:', error);
- setShowProcessingModal(false);
-
- // Set error message and show error modal
- const errorMsg = error instanceof Error ? error.message : 'An error occurred during processing';
- logger.debug('Save segments error (exception):', errorMsg);
+ const errorMsg = error instanceof Error ? error.message : 'An error occurred while saving chapters';
+ logger.debug('Save chapters error (exception):', errorMsg);
setErrorMessage(errorMsg);
setShowErrorModal(true);
}
@@ -2407,9 +2297,6 @@ const TimelineControls = ({
// Set redirect timeout
redirectTimeout = setTimeout(() => {
- // Reset unsaved changes flag before navigating away
- if (onSave) onSave();
-
// Redirect to the URL
logger.debug('Automatically redirecting to:', redirectUrl);
window.location.href = redirectUrl;
@@ -2421,7 +2308,15 @@ const TimelineControls = ({
if (countdownInterval) clearInterval(countdownInterval);
if (redirectTimeout) clearTimeout(redirectTimeout);
};
- }, [showSuccessModal, redirectUrl, onSave]);
+ }, [showSuccessModal, redirectUrl]);
+
+ // Effect to handle redirect after success modal is closed
+ useEffect(() => {
+ if (!showSuccessModal && redirectUrl) {
+ logger.debug('Redirecting to:', redirectUrl);
+ window.location.href = redirectUrl;
+ }
+ }, [redirectUrl, saveType, showSuccessModal]);
return (
@@ -2543,6 +2438,23 @@ const TimelineControls = ({
}
}}
>
+ {/* Chapter Editor for this segment */}
+ {selectedSegmentId && (
+
+
+ )}
+
{/* First row with time adjustment buttons */}
-
{/* Second row with action buttons */}
+
{/* Helper function to show tooltip at current position */}
{/* This is defined within the component to access state variables and functions */}
@@ -4512,99 +4424,44 @@ const TimelineControls = ({
)}
- {/* Save Buttons Row */}
+ {/* Save Chapters Button */}
- {onSave && (
- setShowSaveModal(true)}
- className="save-button"
- data-tooltip="Save changes"
- >
- Save
-
- )}
-
- {onSaveACopy && (
- setShowSaveAsModal(true)}
- className="save-copy-button"
- data-tooltip="Save as a new copy"
- >
- Save as Copy
-
- )}
-
- {onSaveSegments && (
- setShowSaveSegmentsModal(true)}
- className="save-segments-button"
- data-tooltip="Save segments as separate files"
- >
- Save Segments
-
- )}
+ setShowSaveChaptersModal(true)}
+ className="save-chapters-button"
+ data-tooltip="Save chapters"
+ disabled={clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0}
+ >
+ Save Chapters
+
{/* Save Confirmation Modal */}
setShowSaveModal(false)}
- title="Save Changes"
+ isOpen={showSaveChaptersModal}
+ onClose={() => setShowSaveChaptersModal(false)}
+ title="Save Chapters"
actions={
<>
setShowSaveModal(false)}
+ onClick={() => setShowSaveChaptersModal(false)}
>
Cancel
{
- // Reset unsaved changes flag before saving
- if (onSave) onSave();
- handleSaveConfirm();
- }}
+ onClick={handleSaveChaptersConfirm}
>
- Confirm Save
+ Save Chapters
>
}
>
- You're about to replace the original video with this trimmed version. This can't be undone.
-
-
-
- {/* Save As Copy Modal */}
-
setShowSaveAsModal(false)}
- title="Save As New Copy"
- actions={
- <>
- setShowSaveAsModal(false)}
- >
- Cancel
-
- {
- // Reset unsaved changes flag before saving
- if (onSaveACopy) onSaveACopy();
- handleSaveAsCopyConfirm();
- }}
- >
- Confirm Save As Copy
-
- >
- }
- >
-
- You're about to save a new copy with your edits. The original video will stay the same. Find
- the new file in your My Media folder - named after the original file.
+ Are you sure you want to save the chapters? This will save{' '}
+ {clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length} chapters to the
+ database.
@@ -4616,38 +4473,6 @@ const TimelineControls = ({
Please wait while your video is being processed...
- {/* Save Segments Modal */}
-
setShowSaveSegmentsModal(false)}
- title="Save Segments"
- actions={
- <>
- setShowSaveSegmentsModal(false)}
- >
- Cancel
-
- {
- // Reset unsaved changes flag before saving
- if (onSaveSegments) onSaveSegments();
- handleSaveSegmentsConfirm();
- }}
- >
- Save Segments
-
- >
- }
- >
-
- You're about to save each segment as a separate video. Find the new files in your My Media
- folder - named after the original file.
-
-
-
{/* Success Modal */}
*/}
- {saveType === 'segments'
- ? 'You will be redirected to your '
- : 'You will be redirected to your '}
+ You will be redirected to your{' '}
media page
{' in '}
- 10 seconds.{' '}
- {saveType === 'segments'
- ? 'The new video(s) will soon be there.'
- : 'Changes to the video might take a few minutes to be applied.'}
+ 10 seconds. Your chapters have been saved
+ successfully.
diff --git a/frontend-tools/chapters-editor/client/src/hooks/useVideoTrimmer.tsx b/frontend-tools/chapters-editor/client/src/hooks/useVideoChapters.tsx
similarity index 83%
rename from frontend-tools/chapters-editor/client/src/hooks/useVideoTrimmer.tsx
rename to frontend-tools/chapters-editor/client/src/hooks/useVideoChapters.tsx
index 612b5586..3ef81892 100644
--- a/frontend-tools/chapters-editor/client/src/hooks/useVideoTrimmer.tsx
+++ b/frontend-tools/chapters-editor/client/src/hooks/useVideoChapters.tsx
@@ -13,7 +13,19 @@ interface EditorState {
action?: string;
}
-const useVideoTrimmer = () => {
+const useVideoChapters = () => {
+ // Helper function to parse time string (HH:MM:SS.mmm) to seconds
+ const parseTimeToSeconds = (timeString: string): number => {
+ const parts = timeString.split(':');
+ if (parts.length !== 3) return 0;
+
+ const hours = parseInt(parts[0], 10) || 0;
+ const minutes = parseInt(parts[1], 10) || 0;
+ const seconds = parseFloat(parts[2]) || 0;
+
+ return hours * 3600 + minutes * 60 + seconds;
+ };
+
// Video element reference and state
const videoRef = useRef
(null);
const [currentTime, setCurrentTime] = useState(0);
@@ -31,6 +43,9 @@ const useVideoTrimmer = () => {
// Clip segments state
const [clipSegments, setClipSegments] = useState([]);
+ // Selected segment state for chapter editing
+ const [selectedSegmentId, setSelectedSegmentId] = useState(null);
+
// History state for undo/redo
const [history, setHistory] = useState([]);
const [historyPosition, setHistoryPosition] = useState(-1);
@@ -94,31 +109,88 @@ const useVideoTrimmer = () => {
setDuration(video.duration);
setTrimEnd(video.duration);
- // Generate placeholders and create initial segment
+ // Generate placeholders and create initial segments
const initializeEditor = async () => {
- // Generate thumbnail for initial segment
- const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
+ let initialSegments: Segment[] = [];
- // Create an initial segment that spans the entire video
- const initialSegment: Segment = {
- id: 1,
- name: 'segment',
- startTime: 0,
- endTime: video.duration,
- thumbnail: segmentThumbnail,
- };
+ // Check if we have existing chapters from the backend
+ const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [
+ {
+ name: 'Chapter 1',
+ from: '00:00:00',
+ to: '00:00:03',
+ },
+ {
+ name: 'Chapter 2',
+ from: '00:00:03',
+ to: '00:00:06',
+ },
+ {
+ name: 'Chapter 3',
+ from: '00:00:09',
+ to: '00:00:12',
+ },
+ {
+ name: 'Chapter 4',
+ from: '00:00:15',
+ to: '00:00:18',
+ },
+ {
+ name: 'Chapter 5',
+ from: '00:00:21',
+ to: '00:00:24',
+ },
+ ];
- // Initialize history state with the full-length segment
+ if (existingChapters.length > 0) {
+ // Create segments from existing chapters
+ for (let i = 0; i < existingChapters.length; i++) {
+ const chapter = existingChapters[i];
+
+ // Parse time strings to seconds
+ const startTime = parseTimeToSeconds(chapter.from);
+ const endTime = parseTimeToSeconds(chapter.to);
+
+ // Generate thumbnail for this segment
+ const segmentThumbnail = await generateThumbnail(video, (startTime + endTime) / 2);
+
+ const segment: Segment = {
+ id: i + 1,
+ name: `segment-${i + 1}`,
+ startTime: startTime,
+ endTime: endTime,
+ thumbnail: segmentThumbnail,
+ chapterTitle: chapter.name, // Set the chapter title from backend data
+ };
+
+ initialSegments.push(segment);
+ }
+ } else {
+ // Create a default segment that spans the entire video (fallback)
+ const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
+
+ const initialSegment: Segment = {
+ id: 1,
+ name: 'segment',
+ startTime: 0,
+ endTime: video.duration,
+ thumbnail: segmentThumbnail,
+ };
+
+ initialSegments = [initialSegment];
+ }
+
+ // Initialize history state with the segments
const initialState: EditorState = {
trimStart: 0,
trimEnd: video.duration,
splitPoints: [],
- clipSegments: [initialSegment],
+ clipSegments: initialSegments,
};
setHistory([initialState]);
setHistoryPosition(0);
- setClipSegments([initialSegment]);
+ setClipSegments(initialSegments);
// Generate timeline thumbnails
const count = 6;
@@ -696,99 +768,81 @@ const useVideoTrimmer = () => {
setIsMuted(!isMuted);
};
- // Handle save action
- const handleSave = () => {
- // Sort segments chronologically by start time before saving
- const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
-
- // Create the JSON data for saving
- const saveData = {
- type: 'save',
- segments: sortedSegments.map((segment) => ({
- startTime: formatDetailedTime(segment.startTime),
- endTime: formatDetailedTime(segment.endTime),
- })),
- };
-
- // Display JSON in alert (for demonstration purposes)
- if (process.env.NODE_ENV === 'development') {
- console.debug('Saving data:', saveData);
- }
-
- // Mark as saved - no unsaved changes
- setHasUnsavedChanges(false);
-
- // Debug message
- if (process.env.NODE_ENV === 'development') {
- console.debug('Changes saved - reset unsaved changes flag');
- }
-
- // Save to history with special "save" action to mark saved state
- saveState('save');
-
- // In a real implementation, this would make a POST request to save the data
- // logger.debug("Save data:", saveData);
+ // Handle updating a specific segment
+ const handleSegmentUpdate = (segmentId: number, updates: Partial) => {
+ setClipSegments((prevSegments) =>
+ prevSegments.map((segment) => (segment.id === segmentId ? { ...segment, ...updates } : segment))
+ );
+ setHasUnsavedChanges(true);
};
- // Handle save a copy action
- const handleSaveACopy = () => {
- // Sort segments chronologically by start time before saving
- const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ // Handle saving chapters to database
+ const handleChapterSave = async (chapters: { name: string; from: string; to: string }[]) => {
+ try {
+ // Get media ID from window.MEDIA_DATA
+ const mediaId = (window as any).MEDIA_DATA?.mediaId;
+ if (!mediaId) {
+ console.error('No media ID found');
+ return;
+ }
- // Create the JSON data for saving as a copy
- const saveData = {
- type: 'save_as_a_copy',
- segments: sortedSegments.map((segment) => ({
- startTime: formatDetailedTime(segment.startTime),
- endTime: formatDetailedTime(segment.endTime),
- })),
- };
+ // Convert chapters to backend expected format
+ const backendChapters = chapters.map((chapter) => ({
+ start: chapter.from,
+ title: chapter.name,
+ }));
- // Display JSON in alert (for demonstration purposes)
- if (process.env.NODE_ENV === 'development') {
- console.debug('Saving data as copy:', saveData);
+ // Create the API request body
+ const requestData = {
+ chapters: backendChapters,
+ };
+
+ console.log('Saving chapters:', requestData);
+
+ // Make API call to save chapters
+ const csrfToken = getCsrfToken();
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ };
+
+ if (csrfToken) {
+ headers['X-CSRFToken'] = csrfToken;
+ }
+
+ const response = await fetch(`/api/v1/media/${mediaId}/chapters`, {
+ // TODO: Backend API is not ready yet
+ method: 'POST',
+ headers,
+ body: JSON.stringify(requestData),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to save chapters: ${response.status}`);
+ }
+
+ const result = await response.json();
+ console.log('Chapters saved successfully:', result);
+
+ // Mark as saved - no unsaved changes
+ setHasUnsavedChanges(false);
+ } catch (error) {
+ console.error('Error saving chapters:', error);
+ // You might want to show a user-friendly error message here
}
-
- // Mark as saved - no unsaved changes
- setHasUnsavedChanges(false);
-
- // Debug message
- if (process.env.NODE_ENV === 'development') {
- console.debug('Changes saved as copy - reset unsaved changes flag');
- }
-
- // Save to history with special "save_copy" action to mark saved state
- saveState('save_copy');
};
- // Handle save segments individually action
- const handleSaveSegments = () => {
- // Sort segments chronologically by start time before saving
- const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
+ // Helper function to get CSRF token
+ const getCsrfToken = (): string => {
+ const name = 'csrftoken';
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(';').shift() || '';
+ return '';
+ };
- // Create the JSON data for saving individual segments
- const saveData = {
- type: 'save_segments',
- segments: sortedSegments.map((segment) => ({
- name: segment.name,
- startTime: formatDetailedTime(segment.startTime),
- endTime: formatDetailedTime(segment.endTime),
- })),
- };
-
- // Display JSON in alert (for demonstration purposes)
- if (process.env.NODE_ENV === 'development') {
- console.debug('Saving data as segments:', saveData);
- }
-
- // Mark as saved - no unsaved changes
- setHasUnsavedChanges(false);
-
- // Debug message
- logger.debug('All segments saved individually - reset unsaved changes flag');
-
- // Save to history with special "save_segments" action to mark saved state
- saveState('save_segments');
+ // Handle selected segment change
+ const handleSelectedSegmentChange = (segmentId: number | null) => {
+ setSelectedSegmentId(segmentId);
};
// Handle seeking with mobile check
@@ -928,6 +982,7 @@ const useVideoTrimmer = () => {
splitPoints,
zoomLevel,
clipSegments,
+ selectedSegmentId,
hasUnsavedChanges,
historyPosition,
history,
@@ -941,13 +996,13 @@ const useVideoTrimmer = () => {
handleRedo,
handlePlaySegments,
toggleMute,
- handleSave,
- handleSaveACopy,
- handleSaveSegments,
+ handleSegmentUpdate,
+ handleChapterSave,
+ handleSelectedSegmentChange,
isMobile,
videoInitialized,
setVideoInitialized,
};
};
-export default useVideoTrimmer;
+export default useVideoChapters;
diff --git a/frontend-tools/chapters-editor/client/src/styles/ClipSegments.css b/frontend-tools/chapters-editor/client/src/styles/ClipSegments.css
index 6b8b4c10..816b7050 100644
--- a/frontend-tools/chapters-editor/client/src/styles/ClipSegments.css
+++ b/frontend-tools/chapters-editor/client/src/styles/ClipSegments.css
@@ -71,13 +71,125 @@
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
+ .clip-segments-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+ }
+
.clip-segments-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground, #333);
+ margin: 0;
+ }
+
+ .save-chapters-button {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background-color: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: #2563eb;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
+ }
+
+ &.has-changes {
+ background-color: #10b981;
+ animation: pulse-green 2s infinite;
+ }
+
+ &.has-changes:hover {
+ background-color: #059669;
+ }
+
+ svg {
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+
+ @keyframes pulse-green {
+ 0%,
+ 100% {
+ background-color: #10b981;
+ }
+ 50% {
+ background-color: #34d399;
+ }
+ }
+
+ .chapter-editor {
+ background-color: #f8fafc;
+ border: 2px solid #3b82f6;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ transition: all 0.2s ease;
+ }
+
+ .chapter-editor-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
margin-bottom: 0.75rem;
}
+ .chapter-editor-header h4 {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #1f2937;
+ }
+
+ .chapter-editor-segment {
+ font-size: 0.75rem;
+ color: #6b7280;
+ background-color: #e5e7eb;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ }
+
+ .chapter-title-input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ resize: vertical;
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+
+ &:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ }
+
+ &::placeholder {
+ color: #9ca3af;
+ }
+ }
+
+ .chapter-editor-info {
+ margin-top: 0.5rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+ font-style: italic;
+ }
+
.segment-item {
display: flex;
align-items: center;
@@ -86,11 +198,17 @@
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
- transition: box-shadow 0.2s ease;
+ transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
+
+ &.selected {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ background-color: rgba(59, 130, 246, 0.05);
+ }
}
.segment-content {
@@ -119,6 +237,16 @@
color: black;
}
+ .chapter-title {
+ color: #1f2937;
+ font-weight: 600;
+ }
+
+ .default-title {
+ color: #6b7280;
+ font-style: italic;
+ }
+
.segment-time {
font-size: 0.75rem;
color: black;
@@ -193,4 +321,28 @@
.segment-color-8 {
background-color: rgba(250, 204, 21, 0.15);
}
+
+ /* Responsive styles */
+ @media (max-width: 768px) {
+ .clip-segments-header {
+ flex-direction: column;
+ gap: 0.75rem;
+ align-items: stretch;
+ }
+
+ .save-chapters-button {
+ justify-content: center;
+ }
+
+ .chapter-editor-header {
+ flex-direction: column;
+ gap: 0.5rem;
+ align-items: flex-start;
+ }
+
+ .chapter-editor-segment {
+ align-self: stretch;
+ text-align: center;
+ }
+ }
}
diff --git a/frontend-tools/chapters-editor/client/src/styles/TimelineControls.css b/frontend-tools/chapters-editor/client/src/styles/TimelineControls.css
index ba799ab2..5bea1599 100644
--- a/frontend-tools/chapters-editor/client/src/styles/TimelineControls.css
+++ b/frontend-tools/chapters-editor/client/src/styles/TimelineControls.css
@@ -315,10 +315,14 @@
min-width: 150px;
text-align: center;
pointer-events: auto;
- top: -100px !important;
+ top: -105px !important;
transform: translateY(-10px);
}
+ .segment-tooltip {
+ top: -165px !important;
+ }
+
.segment-tooltip:after,
.empty-space-tooltip:after {
content: "";
@@ -872,3 +876,168 @@
margin-right: 0.5rem;
color: #3b82f6;
}
+
+/* Chapter Editor Styles */
+.chapter-editor {
+ background-color: #f8fafc;
+ border: 2px solid #3b82f6;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ transition: all 0.2s ease;
+}
+
+.chapter-editor-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+ gap: 1rem;
+}
+
+.chapter-editor-title-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ flex: 1;
+}
+
+.chapter-editor-header h4 {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #1f2937;
+}
+
+.chapter-editor-segment {
+ font-size: 0.75rem;
+ color: #6b7280;
+ background-color: #e5e7eb;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ display: inline-block;
+}
+
+.save-chapters-button {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background-color: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.save-chapters-button:hover {
+ background-color: #2563eb;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
+}
+
+.save-chapters-button.has-changes {
+ background-color: #10b981;
+ animation: pulse-green-chapters 2s infinite;
+}
+
+.save-chapters-button.has-changes:hover {
+ background-color: #059669;
+}
+
+.save-chapters-button svg {
+ width: 1rem;
+ height: 1rem;
+}
+
+@keyframes pulse-green-chapters {
+ 0%,
+ 100% {
+ background-color: #10b981;
+ }
+ 50% {
+ background-color: #34d399;
+ }
+}
+
+.chapter-title-input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ resize: vertical;
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+.chapter-title-input:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.chapter-title-input::placeholder {
+ color: #9ca3af;
+}
+
+.chapter-editor-info {
+ margin-top: 0.5rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+ font-style: italic;
+}
+
+/* Chapter Editor Responsive styles */
+@media (max-width: 768px) {
+ .chapter-editor-header {
+ flex-direction: column;
+ gap: 0.75rem;
+ align-items: stretch;
+ }
+
+ .save-chapters-button {
+ justify-content: center;
+ align-self: stretch;
+ }
+
+ .chapter-editor-segment {
+ text-align: center;
+ }
+}
+
+/* Tooltip Chapter Editor Styles */
+.tooltip-chapter-editor {
+ background-color: rgba(255, 255, 255, 0.95);
+ border-radius: 0.375rem;
+ pointer-events: auto; /* Ensure it can receive clicks */
+}
+
+.tooltip-chapter-input {
+ width: 100%;
+ padding: 0.5rem;
+ border: 2px solid #ccc;
+ border-radius: 0.25rem;
+ background-color: white;
+ color: black;
+ font-size: 0.75rem;
+ resize: none;
+ outline: none;
+ box-sizing: border-box;
+ max-height: 70px !important;
+}
+
+.tooltip-chapter-input:focus {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
+}
+
+.tooltip-chapter-input::placeholder {
+ color: #999;
+}
diff --git a/frontend-tools/chapters-editor/vite.chapters-editor.config.ts b/frontend-tools/chapters-editor/vite.chapters-editor.config.ts
index e9d1e3fe..e2266a18 100644
--- a/frontend-tools/chapters-editor/vite.chapters-editor.config.ts
+++ b/frontend-tools/chapters-editor/vite.chapters-editor.config.ts
@@ -30,12 +30,10 @@ export default defineConfig({
},
rollupOptions: {
output: {
- // Ensure CSS file has a predictable name
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'chapters-editor.css';
return assetInfo.name;
},
- // Add this to ensure the final bundle exposes React correctly
globals: {
react: 'React',
'react-dom': 'ReactDOM',
diff --git a/frontend-tools/video-editor/client/src/components/TimelineControls.tsx b/frontend-tools/video-editor/client/src/components/TimelineControls.tsx
index ab057cc3..2354b1de 100644
--- a/frontend-tools/video-editor/client/src/components/TimelineControls.tsx
+++ b/frontend-tools/video-editor/client/src/components/TimelineControls.tsx
@@ -47,7 +47,7 @@ interface TimelineControlsProps {
isIOSUninitialized?: boolean;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
- onPlayPause: () => void; // Add this prop
+ onPlayPause: () => void;
isPlayingSegments?: boolean;
}