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 */}
-
-
-
- {/* 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 */}
+
+
+
+ {/* 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 */}
-
- {/* Audio clips */}
- {items.map((item) => {
- const isDragging = draggingItem === item.id;
- const isSelected = selectedClipId === item.id;
- const isTrimming = trimmingItem === item.id;
-
- // Use temporary trim values during trimming for visual feedback
- const displayTrimStart = isTrimming && tempTrimValues ? 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 clipWidth = msToPixels(effectiveDuration);
-
- return (
-
-
+ ))}
+
- {/* 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 */}
+
+
+ {/* Audio clips */}
+ {items.map((item) => {
+ const isDragging = draggingItem === item.id;
+ const isSelected = selectedClipId === item.id;
+ const isTrimming = trimmingItem === item.id;
+
+ // Use temporary trim values during trimming for visual feedback
+ const displayTrimStart =
+ isTrimming && tempTrimValues
+ ? 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 clipWidth = msToPixels(effectiveDuration);
+
+ return (
+
+
handleClipClick(e, item)}
+ onMouseDown={(e) => {
+ // Only start drag if not clicking on trim handles
+ if (!(e.target as HTMLElement).closest('.trim-handle')) {
+ handleDragStart(e, item);
+ }
+ }}
+ >
+ {/* Clip label */}
+
+
+ {item.profile_name}
+
+
+ {/* Waveform */}
+
+
+
+
+
+ {/* Trim handles */}
+ {isSelected && (
+ <>
+ {/* Left trim handle */}
+
handleTrimStart(e, item, 'start')}
+ aria-label="Trim start"
+ />
+ {/* Right trim handle */}
+ handleTrimStart(e, item, 'end')}
+ aria-label="Trim end"
+ />
+ >
+ )}
+
+ );
+ })}
+
+ {/* 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,