formatting

This commit is contained in:
Jamie Pine
2026-01-28 22:23:27 -08:00
parent b55d8cc567
commit ea943876dc
2 changed files with 373 additions and 326 deletions
@@ -1,13 +1,29 @@
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js'; import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; 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 type { StoryItemDetail } from '@/lib/api/types';
import {
useDuplicateStoryItem,
useMoveStoryItem,
useRemoveStoryItem,
useSplitStoryItem,
useTrimStoryItem,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useStoryStore } from '@/stores/storyStore';
// Clip waveform component with trim support // Clip waveform component with trim support
function ClipWaveform({ function ClipWaveform({
@@ -15,7 +31,7 @@ function ClipWaveform({
width, width,
trimStartMs, trimStartMs,
trimEndMs, trimEndMs,
duration duration,
}: { }: {
generationId: string; generationId: string;
width: number; width: number;
@@ -28,15 +44,13 @@ function ClipWaveform({
// Calculate the full waveform width based on the original duration // Calculate the full waveform width based on the original duration
// The visible portion (width) represents the effective duration after trimming // The visible portion (width) represents the effective duration after trimming
const effectiveDurationMs = (duration * 1000) - trimStartMs - trimEndMs; const effectiveDurationMs = duration * 1000 - trimStartMs - trimEndMs;
const fullWaveformWidth = effectiveDurationMs > 0 const fullWaveformWidth =
? (width / effectiveDurationMs) * (duration * 1000) effectiveDurationMs > 0 ? (width / effectiveDurationMs) * (duration * 1000) : width;
: width;
// Calculate how much to offset the waveform to hide the trimmed start // Calculate how much to offset the waveform to hide the trimmed start
const offsetX = effectiveDurationMs > 0 const offsetX =
? (trimStartMs / (duration * 1000)) * fullWaveformWidth effectiveDurationMs > 0 ? (trimStartMs / (duration * 1000)) * fullWaveformWidth : 0;
: 0;
useEffect(() => { useEffect(() => {
if (!waveformRef.current || fullWaveformWidth < 20) return; if (!waveformRef.current || fullWaveformWidth < 20) return;
@@ -130,7 +144,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const [trimmingItem, setTrimmingItem] = useState<string | null>(null); const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null); const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
const [trimStartX, setTrimStartX] = useState(0); 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) // Track editor height from store (shared with FloatingGenerateBox)
const editorHeight = useStoryStore((state) => state.trackEditorHeight); const editorHeight = useStoryStore((state) => state.trackEditorHeight);
@@ -156,10 +173,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
...items.map((item) => { ...items.map((item) => {
const trimStart = item.trim_start_ms || 0; const trimStart = item.trim_start_ms || 0;
const trimEnd = item.trim_end_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; return item.start_time_ms + effectiveDuration;
}), }),
0 0,
); );
setActiveStory(storyId, items, totalDuration); setActiveStory(storyId, items, totalDuration);
} }
@@ -214,11 +231,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
// Calculate total duration (using effective durations) // Calculate total duration (using effective durations)
const totalDurationMs = useMemo(() => { const totalDurationMs = useMemo(() => {
if (items.length === 0) return 10000; // Default 10 seconds if (items.length === 0) return 10000; // Default 10 seconds
return Math.max( return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000);
...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), }, [items, getEffectiveDuration]);
10000
);
}, [items]);
// Calculate timeline width - at least full container width // Calculate timeline width - at least full container width
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding 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')}`; return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}; };
const msToPixels = useCallback( const msToPixels = useCallback((ms: number) => (ms / 1000) * pixelsPerSecond, [pixelsPerSecond]);
(ms: number) => (ms / 1000) * pixelsPerSecond,
[pixelsPerSecond]
);
const pixelsToMs = useCallback( const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]);
(px: number) => (px / pixelsPerSecond) * 1000,
[pixelsPerSecond]
);
const handleZoomIn = () => { const handleZoomIn = () => {
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND)); setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
@@ -265,22 +273,28 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
}; };
// Resize handlers // Resize handlers
const handleResizeStart = useCallback((e: React.MouseEvent) => { const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
setIsResizing(true); setIsResizing(true);
resizeStartY.current = e.clientY; resizeStartY.current = e.clientY;
resizeStartHeight.current = editorHeight; resizeStartHeight.current = editorHeight;
}, [editorHeight]); },
[editorHeight],
);
const handleResizeMove = useCallback((e: MouseEvent) => { const handleResizeMove = useCallback(
(e: MouseEvent) => {
if (!isResizing) return; if (!isResizing) return;
const deltaY = resizeStartY.current - e.clientY; const deltaY = resizeStartY.current - e.clientY;
const newHeight = Math.min( const newHeight = Math.min(
MAX_EDITOR_HEIGHT, MAX_EDITOR_HEIGHT,
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY) Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY),
); );
setEditorHeight(newHeight); setEditorHeight(newHeight);
}, [isResizing, setEditorHeight]); },
[isResizing, setEditorHeight],
);
const handleResizeEnd = useCallback(() => { const handleResizeEnd = useCallback(() => {
setIsResizing(false); setIsResizing(false);
@@ -328,8 +342,11 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
}; };
}; };
const trimStartItemRef = useRef<{
const trimStartItemRef = useRef<{ item: StoryItemDetail; initialTrimStart: number; initialTrimEnd: number } | null>(null); item: StoryItemDetail;
initialTrimStart: number;
initialTrimEnd: number;
} | null>(null);
const handleTrimMove = useCallback( const handleTrimMove = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
@@ -347,11 +364,21 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
if (trimSide === 'start') { if (trimSide === 'start') {
// Moving right increases trim_start (trims more from start) // Moving right increases trim_start (trims more from start)
// Moving left decreases trim_start (restores 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 { } else {
// Moving right decreases trim_end (restores from end) // Moving right decreases trim_end (restores from end)
// Moving left increases trim_end (trims more 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 // Validate that we don't exceed duration
@@ -365,7 +392,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
trim_end_ms: newTrimEnd, trim_end_ms: newTrimEnd,
}); });
}, },
[trimmingItem, trimSide, trimStartX, pixelsToMs] [trimmingItem, trimSide, trimStartX, pixelsToMs],
); );
const handleTrimEnd = useCallback(() => { const handleTrimEnd = useCallback(() => {
@@ -403,7 +430,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
variant: 'destructive', variant: 'destructive',
}); });
}, },
} },
); );
} }
@@ -448,9 +475,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
variant: 'destructive', variant: 'destructive',
}); });
}, },
} },
); );
}, [selectedClipId, items, currentTimeMs, getEffectiveDuration, storyId, splitItem, toast, setSelectedClipId]); }, [
selectedClipId,
items,
currentTimeMs,
getEffectiveDuration,
storyId,
splitItem,
toast,
setSelectedClipId,
]);
const handleDuplicate = useCallback(() => { const handleDuplicate = useCallback(() => {
if (!selectedClipId) return; if (!selectedClipId) return;
@@ -468,7 +504,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
variant: 'destructive', variant: 'destructive',
}); });
}, },
} },
); );
}, [selectedClipId, storyId, duplicateItem, toast]); }, [selectedClipId, storyId, duplicateItem, toast]);
@@ -491,7 +527,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
variant: 'destructive', variant: 'destructive',
}); });
}, },
} },
); );
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]); }, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
@@ -539,10 +575,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
} }
}, [trimmingItem, handleTrimMove, handleTrimEnd]); }, [trimmingItem, handleTrimMove, handleTrimEnd]);
const handleDragStart = ( const handleDragStart = (e: React.MouseEvent, item: StoryItemDetail) => {
e: React.MouseEvent,
item: StoryItemDetail
) => {
e.stopPropagation(); e.stopPropagation();
if (!tracksRef.current) return; if (!tracksRef.current) return;
@@ -568,7 +601,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
setDragPosition({ x: Math.max(0, x), y }); setDragPosition({ x: Math.max(0, x), y });
}, },
[draggingItem, dragOffset] [draggingItem, dragOffset],
); );
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
@@ -610,7 +643,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
variant: 'destructive', variant: 'destructive',
}); });
}, },
} },
); );
} }
@@ -650,7 +683,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return ( return (
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50"> <div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
<div className="border-t bg-background/30 backdrop-blur-2xl overflow-hidden relative" ref={containerRef}> <div
className="border-t bg-background/30 backdrop-blur-2xl overflow-hidden relative"
ref={containerRef}
>
{/* Resize handle at top */} {/* Resize handle at top */}
<button <button
type="button" type="button"
@@ -666,13 +702,15 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Play controls - left side */} {/* Play controls - left side */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
{isCurrentlyPlaying ? ( {isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleStop} disabled={!isCurrentlyPlaying}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleStop}
disabled={!isCurrentlyPlaying}
>
<Square className="h-3 w-3" /> <Square className="h-3 w-3" />
</Button> </Button>
<span className="text-xs text-muted-foreground tabular-nums ml-2"> <span className="text-xs text-muted-foreground tabular-nums ml-2">
@@ -738,7 +776,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
key={trackNumber} key={trackNumber}
className={cn( className={cn(
'border-b flex items-center justify-center', 'border-b flex items-center justify-center',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10' index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
)} )}
style={{ height: `${TRACK_HEIGHT}px` }} style={{ height: `${TRACK_HEIGHT}px` }}
> >
@@ -792,7 +830,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
key={trackNumber} key={trackNumber}
className={cn( className={cn(
'absolute left-0 right-0 border-b pointer-events-none', 'absolute left-0 right-0 border-b pointer-events-none',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10' index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
)} )}
style={{ style={{
top: `${index * TRACK_HEIGHT}px`, top: `${index * TRACK_HEIGHT}px`,
@@ -816,11 +854,19 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const isTrimming = trimmingItem === item.id; const isTrimming = trimmingItem === item.id;
// Use temporary trim values during trimming for visual feedback // Use temporary trim values during trimming for visual feedback
const displayTrimStart = isTrimming && tempTrimValues ? tempTrimValues.trim_start_ms : (item.trim_start_ms || 0); const displayTrimStart =
const displayTrimEnd = isTrimming && tempTrimValues ? tempTrimValues.trim_end_ms : (item.trim_end_ms || 0); isTrimming && tempTrimValues
const effectiveDuration = (item.duration * 1000) - displayTrimStart - displayTrimEnd; ? tempTrimValues.trim_start_ms
: item.trim_start_ms || 0;
const displayTrimEnd =
isTrimming && tempTrimValues ? tempTrimValues.trim_end_ms : item.trim_end_ms || 0;
const effectiveDuration = item.duration * 1000 - displayTrimStart - displayTrimEnd;
const style = getClipStyle({ ...item, trim_start_ms: displayTrimStart, trim_end_ms: displayTrimEnd }); const style = getClipStyle({
...item,
trim_start_ms: displayTrimStart,
trim_end_ms: displayTrimEnd,
});
const clipWidth = msToPixels(effectiveDuration); const clipWidth = msToPixels(effectiveDuration);
return ( return (
@@ -829,7 +875,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className={cn( className={cn(
'absolute rounded select-none overflow-visible z-10', 'absolute rounded select-none overflow-visible z-10',
isSelected && 'ring-2 ring-primary ring-offset-1', isSelected && 'ring-2 ring-primary ring-offset-1',
isTrimming && 'ring-2 ring-accent' isTrimming && 'ring-2 ring-accent',
)} )}
style={style} style={style}
> >
@@ -840,7 +886,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
'bg-accent/80 hover:bg-accent border border-accent-foreground/20', 'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
'flex flex-col justify-center', 'flex flex-col justify-center',
isDragging && 'opacity-80 shadow-lg z-20', isDragging && 'opacity-80 shadow-lg z-20',
!isDragging && 'transition-all duration-100' !isDragging && 'transition-all duration-100',
)} )}
onClick={(e) => handleClipClick(e, item)} onClick={(e) => handleClipClick(e, item)}
onMouseDown={(e) => { onMouseDown={(e) => {
+7 -6
View File
@@ -58,14 +58,11 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
// Calculate total duration from items // Calculate total duration from items
const maxEndTimeMs = Math.max( const maxEndTimeMs = Math.max(
...items.map((item) => item.start_time_ms + item.duration * 1000), ...items.map((item) => item.start_time_ms + item.duration * 1000),
0 0,
); );
// Find the minimum start time (first item) // Find the minimum start time (first item)
const minStartTimeMs = Math.min( const minStartTimeMs = Math.min(...items.map((item) => item.start_time_ms), 0);
...items.map((item) => item.start_time_ms),
0
);
// If resuming the same story, keep position; otherwise start at first item // If resuming the same story, keep position; otherwise start at first item
const currentState = get(); const currentState = get();
@@ -75,7 +72,11 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
console.log('[StoryStore] Play called:', { console.log('[StoryStore] Play called:', {
storyId, storyId,
itemCount: items.length, 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, maxEndTimeMs,
minStartTimeMs, minStartTimeMs,
startTimeMs, startTimeMs,