diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 15d98c6a..f2f4dbff 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -1,23 +1,39 @@ -import { Copy, GripHorizontal, Minus, Pause, Play, Plus, Scissors, Square, Trash2 } from 'lucide-react'; +import { + Copy, + GripHorizontal, + Minus, + Pause, + Play, + Plus, + Scissors, + Square, + Trash2, +} from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; -import { useMoveStoryItem, useTrimStoryItem, useSplitStoryItem, useDuplicateStoryItem, useRemoveStoryItem } from '@/lib/hooks/useStories'; -import { useStoryStore } from '@/stores/storyStore'; import type { StoryItemDetail } from '@/lib/api/types'; +import { + useDuplicateStoryItem, + useMoveStoryItem, + useRemoveStoryItem, + useSplitStoryItem, + useTrimStoryItem, +} from '@/lib/hooks/useStories'; import { cn } from '@/lib/utils/cn'; +import { useStoryStore } from '@/stores/storyStore'; // Clip waveform component with trim support -function ClipWaveform({ - generationId, - width, - trimStartMs, - trimEndMs, - duration -}: { - generationId: string; +function ClipWaveform({ + generationId, + width, + trimStartMs, + trimEndMs, + duration, +}: { + generationId: string; width: number; trimStartMs: number; trimEndMs: number; @@ -28,15 +44,13 @@ function ClipWaveform({ // Calculate the full waveform width based on the original duration // The visible portion (width) represents the effective duration after trimming - const effectiveDurationMs = (duration * 1000) - trimStartMs - trimEndMs; - const fullWaveformWidth = effectiveDurationMs > 0 - ? (width / effectiveDurationMs) * (duration * 1000) - : width; - + const effectiveDurationMs = duration * 1000 - trimStartMs - trimEndMs; + const fullWaveformWidth = + effectiveDurationMs > 0 ? (width / effectiveDurationMs) * (duration * 1000) : width; + // Calculate how much to offset the waveform to hide the trimmed start - const offsetX = effectiveDurationMs > 0 - ? (trimStartMs / (duration * 1000)) * fullWaveformWidth - : 0; + const offsetX = + effectiveDurationMs > 0 ? (trimStartMs / (duration * 1000)) * fullWaveformWidth : 0; useEffect(() => { if (!waveformRef.current || fullWaveformWidth < 20) return; @@ -79,9 +93,9 @@ function ClipWaveform({ return (
{/* Inner container that holds the full waveform, offset to show only visible portion */} -
state.selectedClipId); const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId); - + // Trim state const [trimmingItem, setTrimmingItem] = useState(null); const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null); const [trimStartX, setTrimStartX] = useState(0); - const [tempTrimValues, setTempTrimValues] = useState<{ trim_start_ms: number; trim_end_ms: number } | null>(null); + const [tempTrimValues, setTempTrimValues] = useState<{ + trim_start_ms: number; + trim_end_ms: number; + } | null>(null); // Track editor height from store (shared with FloatingGenerateBox) const editorHeight = useStoryStore((state) => state.trackEditorHeight); @@ -156,10 +173,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { ...items.map((item) => { const trimStart = item.trim_start_ms || 0; const trimEnd = item.trim_end_ms || 0; - const effectiveDuration = (item.duration * 1000) - trimStart - trimEnd; + const effectiveDuration = item.duration * 1000 - trimStart - trimEnd; return item.start_time_ms + effectiveDuration; }), - 0 + 0, ); setActiveStory(storyId, items, totalDuration); } @@ -214,11 +231,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { // Calculate total duration (using effective durations) const totalDurationMs = useMemo(() => { if (items.length === 0) return 10000; // Default 10 seconds - return Math.max( - ...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), - 10000 - ); - }, [items]); + return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000); + }, [items, getEffectiveDuration]); // Calculate timeline width - at least full container width const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding @@ -246,15 +260,9 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; - const msToPixels = useCallback( - (ms: number) => (ms / 1000) * pixelsPerSecond, - [pixelsPerSecond] - ); + const msToPixels = useCallback((ms: number) => (ms / 1000) * pixelsPerSecond, [pixelsPerSecond]); - const pixelsToMs = useCallback( - (px: number) => (px / pixelsPerSecond) * 1000, - [pixelsPerSecond] - ); + const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]); const handleZoomIn = () => { setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND)); @@ -265,22 +273,28 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { }; // Resize handlers - const handleResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setIsResizing(true); - resizeStartY.current = e.clientY; - resizeStartHeight.current = editorHeight; - }, [editorHeight]); + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + resizeStartY.current = e.clientY; + resizeStartHeight.current = editorHeight; + }, + [editorHeight], + ); - const handleResizeMove = useCallback((e: MouseEvent) => { - if (!isResizing) return; - const deltaY = resizeStartY.current - e.clientY; - const newHeight = Math.min( - MAX_EDITOR_HEIGHT, - Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY) - ); - setEditorHeight(newHeight); - }, [isResizing, setEditorHeight]); + const handleResizeMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + const deltaY = resizeStartY.current - e.clientY; + const newHeight = Math.min( + MAX_EDITOR_HEIGHT, + Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY), + ); + setEditorHeight(newHeight); + }, + [isResizing, setEditorHeight], + ); const handleResizeEnd = useCallback(() => { setIsResizing(false); @@ -328,44 +342,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { }; }; - - const trimStartItemRef = useRef<{ item: StoryItemDetail; initialTrimStart: number; initialTrimEnd: number } | null>(null); + const trimStartItemRef = useRef<{ + item: StoryItemDetail; + initialTrimStart: number; + initialTrimEnd: number; + } | null>(null); const handleTrimMove = useCallback( (e: MouseEvent) => { if (!trimmingItem || !trimSide || !trimStartItemRef.current) return; - + const deltaX = e.clientX - trimStartX; const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds - + const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current; const originalDurationMs = item.duration * 1000; - + let newTrimStart = initialTrimStart; let newTrimEnd = initialTrimEnd; - + if (trimSide === 'start') { // Moving right increases trim_start (trims more from start) // Moving left decreases trim_start (restores from start) - newTrimStart = Math.round(Math.max(0, Math.min(initialTrimStart + deltaMs, originalDurationMs - initialTrimEnd - 100))); + newTrimStart = Math.round( + Math.max( + 0, + Math.min(initialTrimStart + deltaMs, originalDurationMs - initialTrimEnd - 100), + ), + ); } else { // Moving right decreases trim_end (restores from end) // Moving left increases trim_end (trims more from end) - newTrimEnd = Math.round(Math.max(0, Math.min(initialTrimEnd - deltaMs, originalDurationMs - initialTrimStart - 100))); + newTrimEnd = Math.round( + Math.max( + 0, + Math.min(initialTrimEnd - deltaMs, originalDurationMs - initialTrimStart - 100), + ), + ); } - + // Validate that we don't exceed duration if (newTrimStart + newTrimEnd >= originalDurationMs - 100) { return; // Don't allow trimming to less than 100ms } - + // Update temporary trim values for visual feedback setTempTrimValues({ trim_start_ms: newTrimStart, trim_end_ms: newTrimEnd, }); }, - [trimmingItem, trimSide, trimStartX, pixelsToMs] + [trimmingItem, trimSide, trimStartX, pixelsToMs], ); const handleTrimEnd = useCallback(() => { @@ -378,12 +405,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { } const { initialTrimStart, initialTrimEnd } = trimStartItemRef.current; - + // Use temporary trim values if available, otherwise use initial values // Ensure values are integers for the backend const finalTrimStart = Math.round(tempTrimValues?.trim_start_ms ?? initialTrimStart); const finalTrimEnd = Math.round(tempTrimValues?.trim_end_ms ?? initialTrimEnd); - + // Only update if values changed if (finalTrimStart !== initialTrimStart || finalTrimEnd !== initialTrimEnd) { trimItem.mutate( @@ -403,10 +430,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { variant: 'destructive', }); }, - } + }, ); } - + setTrimmingItem(null); setTrimSide(null); setTempTrimValues(null); @@ -415,13 +442,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const handleSplit = useCallback(() => { if (!selectedClipId) return; - + const item = items.find((i) => i.id === selectedClipId); if (!item) return; const splitTimeMs = currentTimeMs - item.start_time_ms; const effectiveDuration = getEffectiveDuration(item); - + if (splitTimeMs <= 0 || splitTimeMs >= effectiveDuration) { toast({ title: 'Invalid split point', @@ -448,9 +475,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { variant: 'destructive', }); }, - } + }, ); - }, [selectedClipId, items, currentTimeMs, getEffectiveDuration, storyId, splitItem, toast, setSelectedClipId]); + }, [ + selectedClipId, + items, + currentTimeMs, + getEffectiveDuration, + storyId, + splitItem, + toast, + setSelectedClipId, + ]); const handleDuplicate = useCallback(() => { if (!selectedClipId) return; @@ -468,7 +504,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { variant: 'destructive', }); }, - } + }, ); }, [selectedClipId, storyId, duplicateItem, toast]); @@ -491,7 +527,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { variant: 'destructive', }); }, - } + }, ); }, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]); @@ -539,10 +575,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { } }, [trimmingItem, handleTrimMove, handleTrimEnd]); - const handleDragStart = ( - e: React.MouseEvent, - item: StoryItemDetail - ) => { + const handleDragStart = (e: React.MouseEvent, item: StoryItemDetail) => { e.stopPropagation(); if (!tracksRef.current) return; @@ -568,7 +601,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { setDragPosition({ x: Math.max(0, x), y }); }, - [draggingItem, dragOffset] + [draggingItem, dragOffset], ); const handleDragEnd = useCallback(() => { @@ -610,7 +643,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { variant: 'destructive', }); }, - } + }, ); } @@ -650,257 +683,270 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { return (
-
- {/* Resize handle at top */} - - - {/* Toolbar */} -
- {/* Play controls - left side */} -
- - - - {formatTime(currentTimeMs)} / {formatTime(totalDurationMs)} - -
- - {/* Clip editing controls - center */} - {selectedClipId && ( -
- - - -
- )} - - {/* Zoom controls - right side */} -
- Zoom: - - -
-
- - {/* Timeline container with track labels sidebar */} -
- {/* Track labels sidebar - fixed width */} -
- {/* Spacer for time ruler */} -
- {/* Track labels */} -
- {tracks.map((trackNumber, index) => ( -
- - {trackNumber} - -
- ))} -
-
- - {/* Scrollable timeline area */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */} -
- {/* Time ruler - clickable to seek */} - + + {/* Toolbar */} +
+ {/* Play controls - left side */} +
+ + + + {formatTime(currentTimeMs)} / {formatTime(totalDurationMs)} + +
+ + {/* Clip editing controls - center */} + {selectedClipId && ( +
+ + + + + +
+ )} - {/* Tracks area */} + {/* Zoom controls - right side */} +
+ Zoom: + + +
+
+ + {/* Timeline container with track labels sidebar */} +
+ {/* Track labels sidebar - fixed width */} +
+ {/* Spacer for time ruler */} +
+ {/* Track labels */} +
+ {tracks.map((trackNumber, index) => ( +
+ + {trackNumber} + +
+ ))} +
+
+ + {/* Scrollable timeline area */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
- {/* Track backgrounds - pointer-events-none to allow clicks to pass through */} - {tracks.map((trackNumber, index) => ( -
- ))} - - {/* Click area for seeking - z-index lower than clips */} + {/* Time ruler - clickable to seek */} - - {/* Trim handles */} - {isSelected && ( - <> - {/* Left trim handle */} -
- ); - })} +
+ + {formatTime(ms)} + +
+ ))} + - {/* Playhead - always visible */} -
-
+ {/* Tracks area */} +
+ {/* Track backgrounds - pointer-events-none to allow clicks to pass through */} + {tracks.map((trackNumber, index) => ( +
+ ))} + + {/* Click area for seeking - z-index lower than clips */} + + + {/* Trim handles */} + {isSelected && ( + <> + {/* Left trim handle */} +
+ ); + })} + + {/* Playhead - always visible */} +
+
+
+
-
-
); diff --git a/app/src/stores/storyStore.ts b/app/src/stores/storyStore.ts index c20f6254..a07e8048 100644 --- a/app/src/stores/storyStore.ts +++ b/app/src/stores/storyStore.ts @@ -58,14 +58,11 @@ export const useStoryStore = create((set, get) => ({ // Calculate total duration from items const maxEndTimeMs = Math.max( ...items.map((item) => item.start_time_ms + item.duration * 1000), - 0 + 0, ); // Find the minimum start time (first item) - const minStartTimeMs = Math.min( - ...items.map((item) => item.start_time_ms), - 0 - ); + const minStartTimeMs = Math.min(...items.map((item) => item.start_time_ms), 0); // If resuming the same story, keep position; otherwise start at first item const currentState = get(); @@ -75,7 +72,11 @@ export const useStoryStore = create((set, get) => ({ console.log('[StoryStore] Play called:', { storyId, itemCount: items.length, - items: items.map(i => ({ id: i.generation_id, start: i.start_time_ms, duration: i.duration })), + items: items.map((i) => ({ + id: i.generation_id, + start: i.start_time_ms, + duration: i.duration, + })), maxEndTimeMs, minStartTimeMs, startTimeMs, @@ -92,7 +93,7 @@ export const useStoryStore = create((set, get) => ({ }, pause: () => { - set({ + set({ isPlaying: false, // Keep timing anchors so we can resume from same position }); @@ -113,7 +114,7 @@ export const useStoryStore = create((set, get) => ({ seek: (timeMs) => { const state = get(); const clampedTime = Math.max(0, Math.min(timeMs, state.totalDurationMs)); - set({ + set({ currentTimeMs: clampedTime, // Reset timing anchors - will be set by hook when playback resumes playbackStartContextTime: null,