Enhance story item management with trimming, splitting, and duplication features

- Updated StoryTrackEditor and StoryContent components to support trimming and splitting of story items.
- Introduced new API endpoints for trimming, splitting, and duplicating story items, enhancing item management capabilities.
- Refactored related hooks and state management to accommodate new functionalities.
- Improved data models to include trim start and end times for better audio playback control.
- Enhanced UI interactions for selecting and managing story items within the track editor.
This commit is contained in:
Jamie Pine
2026-01-28 22:16:53 -08:00
parent 27ae6dfbab
commit 036d90dc8e
12 changed files with 938 additions and 101 deletions
@@ -131,13 +131,13 @@ export function StoryContent() {
} }
}, [isPlaying]); }, [isPlaying]);
const handleRemoveItem = (generationId: string) => { const handleRemoveItem = (itemId: string) => {
if (!story) return; if (!story) return;
removeItem.mutate( removeItem.mutate(
{ {
storyId: story.id, storyId: story.id,
generationId, itemId,
}, },
{ {
onError: (error) => { onError: (error) => {
@@ -360,7 +360,7 @@ export function StoryContent() {
item={item} item={item}
storyId={story.id} storyId={story.id}
index={index} index={index}
onRemove={() => handleRemoveItem(item.generation_id)} onRemove={() => handleRemoveItem(item.id)}
currentTimeMs={currentTimeMs} currentTimeMs={currentTimeMs}
isPlaying={isPlaying && playbackStoryId === story.id} isPlaying={isPlaying && playbackStoryId === story.id}
/> />
@@ -1,21 +1,45 @@
import { GripHorizontal, Minus, Pause, Play, Plus, Square } 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 } from '@/lib/hooks/useStories'; import { useMoveStoryItem, useTrimStoryItem, useSplitStoryItem, useDuplicateStoryItem, useRemoveStoryItem } from '@/lib/hooks/useStories';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import type { StoryItemDetail } from '@/lib/api/types'; import type { StoryItemDetail } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
// Clip waveform component // Clip waveform component with trim support
function ClipWaveform({ generationId, width }: { generationId: string; width: number }) { function ClipWaveform({
const containerRef = useRef<HTMLDivElement>(null); generationId,
width,
trimStartMs,
trimEndMs,
duration
}: {
generationId: string;
width: number;
trimStartMs: number;
trimEndMs: number;
duration: number;
}) {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null); const wavesurferRef = useRef<WaveSurfer | null>(null);
// 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;
// Calculate how much to offset the waveform to hide the trimmed start
const offsetX = effectiveDurationMs > 0
? (trimStartMs / (duration * 1000)) * fullWaveformWidth
: 0;
useEffect(() => { useEffect(() => {
if (!containerRef.current || width < 20) return; if (!waveformRef.current || fullWaveformWidth < 20) return;
// Get CSS colors // Get CSS colors
const root = document.documentElement; const root = document.documentElement;
@@ -27,7 +51,7 @@ function ClipWaveform({ generationId, width }: { generationId: string; width: nu
const waveColor = getCSSVar('--accent-foreground'); const waveColor = getCSSVar('--accent-foreground');
const wavesurfer = WaveSurfer.create({ const wavesurfer = WaveSurfer.create({
container: containerRef.current, container: waveformRef.current,
waveColor, waveColor,
progressColor: waveColor, progressColor: waveColor,
cursorWidth: 0, cursorWidth: 0,
@@ -50,9 +74,21 @@ function ClipWaveform({ generationId, width }: { generationId: string; width: nu
wavesurfer.destroy(); wavesurfer.destroy();
wavesurferRef.current = null; wavesurferRef.current = null;
}; };
}, [generationId, width]); }, [generationId, fullWaveformWidth]);
return <div ref={containerRef} className="w-full h-full opacity-60" />; return (
<div className="w-full h-full opacity-60 overflow-hidden">
{/* Inner container that holds the full waveform, offset to show only visible portion */}
<div
ref={waveformRef}
style={{
width: `${fullWaveformWidth}px`,
transform: `translateX(-${offsetX}px)`,
}}
className="h-full"
/>
</div>
);
} }
interface StoryTrackEditorProps { interface StoryTrackEditorProps {
@@ -80,8 +116,22 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const resizeStartY = useRef(0); const resizeStartY = useRef(0);
const resizeStartHeight = useRef(0); const resizeStartHeight = useRef(0);
const moveItem = useMoveStoryItem(); const moveItem = useMoveStoryItem();
const trimItem = useTrimStoryItem();
const splitItem = useSplitStoryItem();
const duplicateItem = useDuplicateStoryItem();
const removeItem = useRemoveStoryItem();
const { toast } = useToast(); const { toast } = useToast();
// Selection state
const selectedClipId = useStoryStore((state) => state.selectedClipId);
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
// Trim state
const [trimmingItem, setTrimmingItem] = useState<string | null>(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);
// 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);
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight); const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
@@ -140,11 +190,16 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// Calculate total duration // Calculate effective duration (accounting for trims)
const getEffectiveDuration = (item: StoryItemDetail) => {
return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0);
};
// 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 + item.duration * 1000), ...items.map((item) => item.start_time_ms + getEffectiveDuration(item)),
10000 10000
); );
}, [items]); }, [items]);
@@ -228,13 +283,246 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
}, [isResizing, handleResizeMove, handleResizeEnd]); }, [isResizing, handleResizeMove, handleResizeEnd]);
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!tracksRef.current || draggingItem) return; if (!tracksRef.current || draggingItem || trimmingItem) return;
const rect = tracksRef.current.getBoundingClientRect(); const rect = tracksRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + tracksRef.current.scrollLeft; const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
const timeMs = Math.max(0, pixelsToMs(x)); const timeMs = Math.max(0, pixelsToMs(x));
seek(timeMs); seek(timeMs);
// Deselect clip when clicking on timeline
setSelectedClipId(null);
}; };
const handleClipClick = (e: React.MouseEvent, item: StoryItemDetail) => {
e.stopPropagation();
if (draggingItem || trimmingItem) return;
setSelectedClipId(item.id);
};
const handleTrimStart = (e: React.MouseEvent, item: StoryItemDetail, side: 'start' | 'end') => {
e.stopPropagation();
if (!tracksRef.current) return;
setTrimmingItem(item.id);
setTrimSide(side);
setSelectedClipId(item.id);
setTrimStartX(e.clientX);
trimStartItemRef.current = {
item,
initialTrimStart: item.trim_start_ms || 0,
initialTrimEnd: item.trim_end_ms || 0,
};
};
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)));
} 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)));
}
// 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]
);
const handleTrimEnd = useCallback(() => {
if (!trimmingItem || !trimSide || !trimStartItemRef.current) {
setTrimmingItem(null);
setTrimSide(null);
setTempTrimValues(null);
trimStartItemRef.current = null;
return;
}
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(
{
storyId,
itemId: trimmingItem,
data: {
trim_start_ms: finalTrimStart,
trim_end_ms: finalTrimEnd,
},
},
{
onError: (error) => {
toast({
title: 'Failed to trim clip',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
},
}
);
}
setTrimmingItem(null);
setTrimSide(null);
setTempTrimValues(null);
trimStartItemRef.current = null;
}, [trimmingItem, trimSide, tempTrimValues, storyId, trimItem, toast]);
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',
description: 'Playhead must be within the selected clip',
variant: 'destructive',
});
return;
}
splitItem.mutate(
{
storyId,
itemId: selectedClipId,
data: { split_time_ms: splitTimeMs },
},
{
onSuccess: () => {
setSelectedClipId(null);
},
onError: (error) => {
toast({
title: 'Failed to split clip',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
},
}
);
}, [selectedClipId, items, currentTimeMs, getEffectiveDuration, storyId, splitItem, toast, setSelectedClipId]);
const handleDuplicate = useCallback(() => {
if (!selectedClipId) return;
duplicateItem.mutate(
{
storyId,
itemId: selectedClipId,
},
{
onError: (error) => {
toast({
title: 'Failed to duplicate clip',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
},
}
);
}, [selectedClipId, storyId, duplicateItem, toast]);
const handleDelete = useCallback(() => {
if (!selectedClipId) return;
removeItem.mutate(
{
storyId,
itemId: selectedClipId,
},
{
onSuccess: () => {
setSelectedClipId(null);
},
onError: (error) => {
toast({
title: 'Failed to delete clip',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
},
}
);
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle shortcuts when editor is focused or no input is focused
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (e.key === 'Escape') {
setSelectedClipId(null);
} else if (e.key === 's' || e.key === 'S') {
if (selectedClipId) {
e.preventDefault();
handleSplit();
}
} else if (e.key === 'd' || e.key === 'D') {
if (selectedClipId && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleDuplicate();
}
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedClipId) {
e.preventDefault();
handleDelete();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedClipId, handleSplit, handleDuplicate, handleDelete, setSelectedClipId]);
// Add global mouse listeners for trimming
useEffect(() => {
if (trimmingItem) {
window.addEventListener('mousemove', handleTrimMove);
window.addEventListener('mouseup', handleTrimEnd);
return () => {
window.removeEventListener('mousemove', handleTrimMove);
window.removeEventListener('mouseup', handleTrimEnd);
};
}
}, [trimmingItem, handleTrimMove, handleTrimEnd]);
const handleDragStart = ( const handleDragStart = (
e: React.MouseEvent, e: React.MouseEvent,
item: StoryItemDetail item: StoryItemDetail
@@ -251,7 +539,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft, x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
y: rect.top - tracksRef.current.getBoundingClientRect().top, y: rect.top - tracksRef.current.getBoundingClientRect().top,
}); });
setDraggingItem(item.generation_id); setDraggingItem(item.id);
}; };
const handleDragMove = useCallback( const handleDragMove = useCallback(
@@ -273,7 +561,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return; return;
} }
const item = items.find((i) => i.generation_id === draggingItem); const item = items.find((i) => i.id === draggingItem);
if (!item) { if (!item) {
setDraggingItem(null); setDraggingItem(null);
return; return;
@@ -292,7 +580,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
moveItem.mutate( moveItem.mutate(
{ {
storyId, storyId,
generationId: item.generation_id, itemId: item.id,
data: { data: {
start_time_ms: newTimeMs, start_time_ms: newTimeMs,
track: newTrack, track: newTrack,
@@ -302,7 +590,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
onError: (error) => { onError: (error) => {
toast({ toast({
title: 'Failed to move item', title: 'Failed to move item',
description: error.message, description: error instanceof Error ? error.message : String(error),
variant: 'destructive', variant: 'destructive',
}); });
}, },
@@ -318,9 +606,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
// Calculate clip position and dimensions // Calculate clip position and dimensions
const getClipStyle = (item: StoryItemDetail) => { const getClipStyle = (item: StoryItemDetail) => {
const isDragging = draggingItem === item.generation_id; const isDragging = draggingItem === item.id;
const trackIndex = getTrackIndex(item.track); const trackIndex = getTrackIndex(item.track);
const width = msToPixels(item.duration * 1000); const effectiveDuration = getEffectiveDuration(item);
const width = msToPixels(effectiveDuration);
const left = isDragging ? dragPosition.x : msToPixels(item.start_time_ms); const left = isDragging ? dragPosition.x : msToPixels(item.start_time_ms);
const top = isDragging ? dragPosition.y : trackIndex * TRACK_HEIGHT; const top = isDragging ? dragPosition.y : trackIndex * TRACK_HEIGHT;
@@ -375,6 +664,39 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</span> </span>
</div> </div>
{/* Clip editing controls - center */}
{selectedClipId && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSplit}
title="Split at playhead (S)"
>
<Scissors className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleDuplicate}
title="Duplicate (Cmd/Ctrl+D)"
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleDelete}
title="Delete (Delete/Backspace)"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
{/* Zoom controls - right side */} {/* Zoom controls - right side */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Zoom:</span> <span className="text-xs text-muted-foreground">Zoom:</span>
@@ -421,15 +743,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
onMouseUp={draggingItem ? handleDragEnd : undefined} onMouseUp={draggingItem ? handleDragEnd : undefined}
onMouseLeave={draggingItem ? handleDragEnd : undefined} onMouseLeave={draggingItem ? handleDragEnd : undefined}
> >
{/* Time ruler */} {/* Time ruler - clickable to seek */}
<div <button
className="h-6 border-b bg-muted/20 sticky top-0 z-10" type="button"
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
style={{ width: `${timelineWidth}px` }} style={{ width: `${timelineWidth}px` }}
onClick={handleTimelineClick}
aria-label="Seek timeline"
> >
{timeMarkers.map((ms) => ( {timeMarkers.map((ms) => (
<div <div
key={ms} key={ms}
className="absolute top-0 h-full flex flex-col justify-end" className="absolute top-0 h-full flex flex-col justify-end pointer-events-none"
style={{ left: `${msToPixels(ms)}px` }} style={{ left: `${msToPixels(ms)}px` }}
> >
<div className="h-2 w-px bg-border" /> <div className="h-2 w-px bg-border" />
@@ -438,19 +763,19 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</span> </span>
</div> </div>
))} ))}
</div> </button>
{/* Tracks area */} {/* Tracks area */}
<div <div
className="relative" className="relative"
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }} style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
> >
{/* Track backgrounds */} {/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
{tracks.map((trackNumber, index) => ( {tracks.map((trackNumber, index) => (
<div <div
key={trackNumber} key={trackNumber}
className={cn( className={cn(
'absolute left-0 right-0 border-b', '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={{
@@ -470,23 +795,44 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Audio clips */} {/* Audio clips */}
{items.map((item) => { {items.map((item) => {
const isDragging = draggingItem === item.generation_id; const isDragging = draggingItem === item.id;
const style = getClipStyle(item); const isSelected = selectedClipId === item.id;
const clipWidth = msToPixels(item.duration * 1000); 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 ( return (
<div
key={item.id}
className={cn(
'absolute rounded select-none overflow-visible z-10',
isSelected && 'ring-2 ring-primary ring-offset-1',
isTrimming && 'ring-2 ring-accent'
)}
style={style}
>
<button <button
type="button" type="button"
key={item.generation_id}
className={cn( className={cn(
'absolute rounded cursor-move select-none overflow-hidden z-10', 'w-full h-full rounded cursor-move overflow-hidden',
'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'
)} )}
style={style} onClick={(e) => handleClipClick(e, item)}
onMouseDown={(e) => handleDragStart(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 */} {/* Clip label */}
<div className="absolute top-0 left-1 right-1 z-10"> <div className="absolute top-0 left-1 right-1 z-10">
@@ -496,9 +842,36 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</div> </div>
{/* Waveform */} {/* Waveform */}
<div className="absolute inset-0 top-3"> <div className="absolute inset-0 top-3">
<ClipWaveform generationId={item.generation_id} width={clipWidth} /> <ClipWaveform
generationId={item.generation_id}
width={clipWidth}
trimStartMs={displayTrimStart}
trimEndMs={displayTrimEnd}
duration={item.duration}
/>
</div> </div>
</button> </button>
{/* Trim handles */}
{isSelected && (
<>
{/* Left trim handle */}
<button
type="button"
className="trim-handle absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-l"
onMouseDown={(e) => handleTrimStart(e, item, 'start')}
aria-label="Trim start"
/>
{/* Right trim handle */}
<button
type="button"
className="trim-handle absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-r"
onMouseDown={(e) => handleTrimStart(e, item, 'end')}
aria-label="Trim end"
/>
</>
)}
</div>
); );
})} })}
+26 -4
View File
@@ -21,6 +21,8 @@ import type {
StoryItemBatchUpdate, StoryItemBatchUpdate,
StoryItemReorder, StoryItemReorder,
StoryItemMove, StoryItemMove,
StoryItemTrim,
StoryItemSplit,
} from './types'; } from './types';
class ApiClient { class ApiClient {
@@ -406,8 +408,8 @@ class ApiClient {
}); });
} }
async removeStoryItem(storyId: string, generationId: string): Promise<void> { async removeStoryItem(storyId: string, itemId: string): Promise<void> {
await this.request<void>(`/stories/${storyId}/items/${generationId}`, { await this.request<void>(`/stories/${storyId}/items/${itemId}`, {
method: 'DELETE', method: 'DELETE',
}); });
} }
@@ -426,13 +428,33 @@ class ApiClient {
}); });
} }
async moveStoryItem(storyId: string, generationId: string, data: StoryItemMove): Promise<StoryItemDetail> { async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${generationId}/move`, { return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise<StoryItemDetail[]> {
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async duplicateStoryItem(storyId: string, itemId: string): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/duplicate`, {
method: 'POST',
});
}
async exportStoryAudio(storyId: string): Promise<Blob> { async exportStoryAudio(storyId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`; const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
const response = await fetch(url); const response = await fetch(url);
+11
View File
@@ -144,6 +144,8 @@ export interface StoryItemDetail {
generation_id: string; generation_id: string;
start_time_ms: number; start_time_ms: number;
track: number; track: number;
trim_start_ms: number;
trim_end_ms: number;
created_at: string; created_at: string;
profile_id: string; profile_id: string;
profile_name: string; profile_name: string;
@@ -188,3 +190,12 @@ export interface StoryItemMove {
start_time_ms: number; start_time_ms: number;
track: number; track: number;
} }
export interface StoryItemTrim {
trim_start_ms: number;
trim_end_ms: number;
}
export interface StoryItemSplit {
split_time_ms: number;
}
+44 -5
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove } from '@/lib/api/types'; import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
export function useStories() { export function useStories() {
@@ -70,8 +70,8 @@ export function useRemoveStoryItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ storyId, generationId }: { storyId: string; generationId: string }) => mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
apiClient.removeStoryItem(storyId, generationId), apiClient.removeStoryItem(storyId, itemId),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -109,8 +109,47 @@ export function useMoveStoryItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ storyId, generationId, data }: { storyId: string; generationId: string; data: StoryItemMove }) => mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
apiClient.moveStoryItem(storyId, generationId, data), apiClient.moveStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useTrimStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
apiClient.trimStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useSplitStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
apiClient.splitStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useDuplicateStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
apiClient.duplicateStoryItem(storyId, itemId),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
+36 -22
View File
@@ -5,6 +5,7 @@ import { useStoryStore } from '@/stores/storyStore';
interface ActiveSource { interface ActiveSource {
source: AudioBufferSourceNode; source: AudioBufferSourceNode;
itemId: string;
generationId: string; generationId: string;
startTimeMs: number; startTimeMs: number;
endTimeMs: number; endTimeMs: number;
@@ -26,9 +27,9 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
// Master gain for volume control // Master gain for volume control
const masterGainRef = useRef<GainNode | null>(null); const masterGainRef = useRef<GainNode | null>(null);
// Preloaded AudioBuffers by generation_id // Preloaded AudioBuffers by generation_id (audio file is shared between split clips)
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map()); const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
// Currently playing AudioBufferSourceNodes by generation_id // Currently playing AudioBufferSourceNodes by item.id (unique per clip)
const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map()); const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map());
// Animation frame for syncing visual playhead // Animation frame for syncing visual playhead
const animationFrameRef = useRef<number | null>(null); const animationFrameRef = useRef<number | null>(null);
@@ -56,16 +57,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
return audioContextRef.current; return audioContextRef.current;
}, []); }, []);
// Stop a source // Stop a source by item id
const stopSource = useCallback((generationId: string) => { const stopSource = useCallback((itemId: string) => {
const activeSource = activeSourcesRef.current.get(generationId); const activeSource = activeSourcesRef.current.get(itemId);
if (activeSource) { if (activeSource) {
try { try {
activeSource.source.stop(); activeSource.source.stop();
} catch { } catch {
// Source may have already stopped // Source may have already stopped
} }
activeSourcesRef.current.delete(generationId); activeSourcesRef.current.delete(itemId);
} }
}, []); }, []);
@@ -123,8 +124,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
useEffect(() => { useEffect(() => {
return () => { return () => {
// Stop all sources // Stop all sources
for (const [generationId] of activeSourcesRef.current) { for (const [itemId] of activeSourcesRef.current) {
stopSource(generationId); stopSource(itemId);
} }
activeSourcesRef.current.clear(); activeSourcesRef.current.clear();
@@ -151,7 +152,11 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
(storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => { (storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => {
return itemList.filter((item) => { return itemList.filter((item) => {
const itemStart = item.start_time_ms; const itemStart = item.start_time_ms;
const itemEnd = item.start_time_ms + item.duration * 1000; // Use effective duration (accounting for trims)
const trimStartMs = item.trim_start_ms || 0;
const trimEndMs = item.trim_end_ms || 0;
const effectiveDurationMs = item.duration * 1000 - trimStartMs - trimEndMs;
const itemEnd = item.start_time_ms + effectiveDurationMs;
return storyTimeMs >= itemStart && storyTimeMs < itemEnd; return storyTimeMs >= itemStart && storyTimeMs < itemEnd;
}); });
}, },
@@ -185,8 +190,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Stop all sources // Stop all sources
const stopAllSources = useCallback(() => { const stopAllSources = useCallback(() => {
console.log('[StoryPlayback] Stopping all sources'); console.log('[StoryPlayback] Stopping all sources');
for (const [generationId] of activeSourcesRef.current) { for (const [itemId] of activeSourcesRef.current) {
stopSource(generationId); stopSource(itemId);
} }
activeSourcesRef.current.clear(); activeSourcesRef.current.clear();
}, [stopSource]); }, [stopSource]);
@@ -199,18 +204,18 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Find all items that should be playing // Find all items that should be playing
const shouldBePlaying = findActiveItems(storyTimeMs, itemList); const shouldBePlaying = findActiveItems(storyTimeMs, itemList);
const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.generation_id)); const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.id));
// Stop sources that shouldn't be playing anymore // Stop sources that shouldn't be playing anymore
for (const [generationId] of activeSourcesRef.current) { for (const [itemId] of activeSourcesRef.current) {
if (!shouldBePlayingIds.has(generationId)) { if (!shouldBePlayingIds.has(itemId)) {
stopSource(generationId); stopSource(itemId);
} }
} }
// Schedule new sources for items that should be playing // Schedule new sources for items that should be playing
for (const item of shouldBePlaying) { for (const item of shouldBePlaying) {
if (!activeSourcesRef.current.has(item.generation_id)) { if (!activeSourcesRef.current.has(item.id)) {
const buffer = audioBuffersRef.current.get(item.generation_id); const buffer = audioBuffersRef.current.get(item.generation_id);
if (!buffer) { if (!buffer) {
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id); console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
@@ -219,16 +224,24 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Calculate when this item should start in AudioContext time // Calculate when this item should start in AudioContext time
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms); const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
const itemEndStoryTime = item.start_time_ms + item.duration * 1000;
// Calculate effective duration and trim offsets
const trimStartSec = (item.trim_start_ms || 0) / 1000;
const trimEndSec = (item.trim_end_ms || 0) / 1000;
const effectiveDuration = item.duration - trimStartSec - trimEndSec;
const itemEndStoryTime = item.start_time_ms + effectiveDuration * 1000;
// Calculate offset into the buffer (if seeking mid-way) // Calculate offset into the buffer (if seeking mid-way)
const offsetIntoBuffer = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000); // Offset is relative to the trimmed start of the clip
const duration = item.duration - offsetIntoBuffer; const offsetIntoEffectiveClip = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000);
const offsetIntoBuffer = trimStartSec + offsetIntoEffectiveClip;
const duration = effectiveDuration - offsetIntoEffectiveClip;
// If the item should have already started, schedule it to start immediately // If the item should have already started, schedule it to start immediately
const startAtContextTime = Math.max(currentContextTime, itemStartContextTime); const startAtContextTime = Math.max(currentContextTime, itemStartContextTime);
console.log('[StoryPlayback] Scheduling source:', { console.log('[StoryPlayback] Scheduling source:', {
itemId: item.id,
generationId: item.generation_id, generationId: item.generation_id,
storyTimeMs, storyTimeMs,
itemStart: item.start_time_ms, itemStart: item.start_time_ms,
@@ -243,20 +256,21 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const activeSource: ActiveSource = { const activeSource: ActiveSource = {
source, source,
itemId: item.id,
generationId: item.generation_id, generationId: item.generation_id,
startTimeMs: item.start_time_ms, startTimeMs: item.start_time_ms,
endTimeMs: itemEndStoryTime, endTimeMs: itemEndStoryTime,
}; };
activeSourcesRef.current.set(item.generation_id, activeSource); activeSourcesRef.current.set(item.id, activeSource);
// Schedule playback // Schedule playback
source.start(startAtContextTime, offsetIntoBuffer, duration); source.start(startAtContextTime, offsetIntoBuffer, duration);
// Clean up when source ends // Clean up when source ends
source.onended = () => { source.onended = () => {
console.log('[StoryPlayback] Source ended:', item.generation_id); console.log('[StoryPlayback] Source ended:', item.id);
activeSourcesRef.current.delete(item.generation_id); activeSourcesRef.current.delete(item.id);
}; };
} }
} }
+4
View File
@@ -5,6 +5,8 @@ interface StoryPlaybackState {
// Selection // Selection
selectedStoryId: string | null; selectedStoryId: string | null;
setSelectedStoryId: (id: string | null) => void; setSelectedStoryId: (id: string | null) => void;
selectedClipId: string | null;
setSelectedClipId: (id: string | null) => void;
// Track editor UI state // Track editor UI state
trackEditorHeight: number; trackEditorHeight: number;
@@ -34,6 +36,8 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
// Selection // Selection
selectedStoryId: null, selectedStoryId: null,
setSelectedStoryId: (id) => set({ selectedStoryId: id }), setSelectedStoryId: (id) => set({ selectedStoryId: id }),
selectedClipId: null,
setSelectedClipId: (id) => set({ selectedClipId: id }),
// Track editor UI state // Track editor UI state
trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT, trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT,
+20
View File
@@ -71,6 +71,8 @@ class StoryItem(Base):
generation_id = Column(String, ForeignKey("generations.id"), nullable=False) generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track) track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
trim_end_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from end
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
@@ -257,6 +259,24 @@ def _run_migrations(engine):
conn.commit() conn.commit()
print("Added track column to story_items") print("Added track column to story_items")
# Migration: Add trim columns if they don't exist
# Re-check columns after potential track migration
columns = {col['name'] for col in inspector.get_columns('story_items')}
if 'trim_start_ms' not in columns:
print("Migrating story_items: adding trim_start_ms column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_start_ms INTEGER NOT NULL DEFAULT 0"))
conn.commit()
print("Added trim_start_ms column to story_items")
columns = {col['name'] for col in inspector.get_columns('story_items')}
if 'trim_end_ms' not in columns:
print("Migrating story_items: adding trim_end_ms column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_end_ms INTEGER NOT NULL DEFAULT 0"))
conn.commit()
print("Added trim_end_ms column to story_items")
def get_db(): def get_db():
"""Get database session (generator for dependency injection).""" """Get database session (generator for dependency injection)."""
+48 -7
View File
@@ -770,14 +770,14 @@ async def add_story_item(
return item return item
@app.delete("/stories/{story_id}/items/{generation_id}") @app.delete("/stories/{story_id}/items/{item_id}")
async def remove_story_item( async def remove_story_item(
story_id: str, story_id: str,
generation_id: str, item_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Remove a generation from a story.""" """Remove a story item from a story."""
success = await stories.remove_item_from_story(story_id, generation_id, db) success = await stories.remove_item_from_story(story_id, item_id, db)
if not success: if not success:
raise HTTPException(status_code=404, detail="Story item not found") raise HTTPException(status_code=404, detail="Story item not found")
return {"message": "Item removed successfully"} return {"message": "Item removed successfully"}
@@ -809,15 +809,56 @@ async def reorder_story_items(
return items return items
@app.put("/stories/{story_id}/items/{generation_id}/move", response_model=models.StoryItemDetail) @app.put("/stories/{story_id}/items/{item_id}/move", response_model=models.StoryItemDetail)
async def move_story_item( async def move_story_item(
story_id: str, story_id: str,
generation_id: str, item_id: str,
data: models.StoryItemMove, data: models.StoryItemMove,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Move a story item (update position and/or track).""" """Move a story item (update position and/or track)."""
item = await stories.move_story_item(story_id, generation_id, data, db) item = await stories.move_story_item(story_id, item_id, data, db)
if item is None:
raise HTTPException(status_code=404, detail="Story item not found")
return item
@app.put("/stories/{story_id}/items/{item_id}/trim", response_model=models.StoryItemDetail)
async def trim_story_item(
story_id: str,
item_id: str,
data: models.StoryItemTrim,
db: Session = Depends(get_db),
):
"""Trim a story item (update trim_start_ms and trim_end_ms)."""
item = await stories.trim_story_item(story_id, item_id, data, db)
if item is None:
raise HTTPException(status_code=404, detail="Story item not found or invalid trim values")
return item
@app.post("/stories/{story_id}/items/{item_id}/split", response_model=List[models.StoryItemDetail])
async def split_story_item(
story_id: str,
item_id: str,
data: models.StoryItemSplit,
db: Session = Depends(get_db),
):
"""Split a story item at a given time, creating two clips."""
items = await stories.split_story_item(story_id, item_id, data, db)
if items is None:
raise HTTPException(status_code=404, detail="Story item not found or invalid split point")
return items
@app.post("/stories/{story_id}/items/{item_id}/duplicate", response_model=models.StoryItemDetail)
async def duplicate_story_item(
story_id: str,
item_id: str,
db: Session = Depends(get_db),
):
"""Duplicate a story item, creating a copy with all properties."""
item = await stories.duplicate_story_item(story_id, item_id, db)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="Story item not found") raise HTTPException(status_code=404, detail="Story item not found")
return item return item
+13
View File
@@ -221,6 +221,8 @@ class StoryItemDetail(BaseModel):
generation_id: str generation_id: str
start_time_ms: int start_time_ms: int
track: int = 0 track: int = 0
trim_start_ms: int = 0
trim_end_ms: int = 0
created_at: datetime created_at: datetime
# Generation details # Generation details
profile_id: str profile_id: str
@@ -277,3 +279,14 @@ class StoryItemMove(BaseModel):
"""Request model for moving a story item (position and/or track).""" """Request model for moving a story item (position and/or track)."""
start_time_ms: int = Field(..., ge=0) start_time_ms: int = Field(..., ge=0)
track: int = 0 track: int = 0
class StoryItemTrim(BaseModel):
"""Request model for trimming a story item."""
trim_start_ms: int = Field(..., ge=0)
trim_end_ms: int = Field(..., ge=0)
class StoryItemSplit(BaseModel):
"""Request model for splitting a story item."""
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
+311 -11
View File
@@ -18,6 +18,8 @@ from .models import (
StoryItemCreate, StoryItemCreate,
StoryItemBatchUpdate, StoryItemBatchUpdate,
StoryItemMove, StoryItemMove,
StoryItemTrim,
StoryItemSplit,
) )
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
from .utils.audio import load_audio, save_audio from .utils.audio import load_audio, save_audio
@@ -129,6 +131,8 @@ async def get_story(
generation_id=item.generation_id, generation_id=item.generation_id,
start_time_ms=item.start_time_ms, start_time_ms=item.start_time_ms,
track=item.track, track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0),
created_at=item.created_at, created_at=item.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile_name, profile_name=profile_name,
@@ -252,6 +256,8 @@ async def add_item_to_story(
generation_id=existing.generation_id, generation_id=existing.generation_id,
start_time_ms=existing.start_time_ms, start_time_ms=existing.start_time_ms,
track=existing.track, track=existing.track,
trim_start_ms=getattr(existing, 'trim_start_ms', 0),
trim_end_ms=getattr(existing, 'trim_end_ms', 0),
created_at=existing.created_at, created_at=existing.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown", profile_name=profile.name if profile else "Unknown",
@@ -321,6 +327,8 @@ async def add_item_to_story(
generation_id=item.generation_id, generation_id=item.generation_id,
start_time_ms=item.start_time_ms, start_time_ms=item.start_time_ms,
track=item.track, track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0),
created_at=item.created_at, created_at=item.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown", profile_name=profile.name if profile else "Unknown",
@@ -336,7 +344,7 @@ async def add_item_to_story(
async def move_story_item( async def move_story_item(
story_id: str, story_id: str,
generation_id: str, item_id: str,
data: StoryItemMove, data: StoryItemMove,
db: Session, db: Session,
) -> Optional[StoryItemDetail]: ) -> Optional[StoryItemDetail]:
@@ -345,7 +353,7 @@ async def move_story_item(
Args: Args:
story_id: Story ID story_id: Story ID
generation_id: Generation ID of the item to move item_id: Story item ID
data: New position and track data data: New position and track data
db: Database session db: Database session
@@ -354,14 +362,14 @@ async def move_story_item(
""" """
# Get the item # Get the item
item = db.query(DBStoryItem).filter_by( item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id, story_id=story_id,
generation_id=generation_id
).first() ).first()
if not item: if not item:
return None return None
# Get the generation # Get the generation
generation = db.query(DBGeneration).filter_by(id=generation_id).first() generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
if not generation: if not generation:
return None return None
@@ -386,6 +394,8 @@ async def move_story_item(
generation_id=item.generation_id, generation_id=item.generation_id,
start_time_ms=item.start_time_ms, start_time_ms=item.start_time_ms,
track=item.track, track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0),
created_at=item.created_at, created_at=item.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown", profile_name=profile.name if profile else "Unknown",
@@ -401,23 +411,23 @@ async def move_story_item(
async def remove_item_from_story( async def remove_item_from_story(
story_id: str, story_id: str,
generation_id: str, item_id: str,
db: Session, db: Session,
) -> bool: ) -> bool:
""" """
Remove a generation from a story. Remove a story item from a story.
Args: Args:
story_id: Story ID story_id: Story ID
generation_id: Generation ID to remove item_id: Story item ID to remove
db: Database session db: Database session
Returns: Returns:
True if removed, False if not found True if removed, False if not found
""" """
item = db.query(DBStoryItem).filter_by( item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id, story_id=story_id,
generation_id=generation_id
).first() ).first()
if not item: if not item:
return False return False
@@ -434,6 +444,277 @@ async def remove_item_from_story(
return True return True
async def trim_story_item(
story_id: str,
item_id: str,
data: StoryItemTrim,
db: Session,
) -> Optional[StoryItemDetail]:
"""
Trim a story item (update trim_start_ms and trim_end_ms).
Args:
story_id: Story ID
item_id: Story item ID
data: Trim data (trim_start_ms, trim_end_ms)
db: Database session
Returns:
Updated item detail or None if not found
"""
# Get the item
item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id,
).first()
if not item:
return None
# Get the generation
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
if not generation:
return None
# Validate trim values don't exceed duration
max_duration_ms = int(generation.duration * 1000)
if data.trim_start_ms + data.trim_end_ms >= max_duration_ms:
return None # Invalid trim - would result in zero or negative duration
# Update trim values
item.trim_start_ms = data.trim_start_ms
item.trim_end_ms = data.trim_end_ms
# Update story updated_at
story = db.query(DBStory).filter_by(id=story_id).first()
if story:
story.updated_at = datetime.utcnow()
db.commit()
db.refresh(item)
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail(
id=item.id,
story_id=item.story_id,
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
track=item.track,
trim_start_ms=item.trim_start_ms,
trim_end_ms=item.trim_end_ms,
created_at=item.created_at,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
async def split_story_item(
story_id: str,
item_id: str,
data: StoryItemSplit,
db: Session,
) -> Optional[List[StoryItemDetail]]:
"""
Split a story item at a given time, creating two clips.
Args:
story_id: Story ID
item_id: Story item ID to split
data: Split data (split_time_ms - time within clip to split at)
db: Database session
Returns:
List of two updated item details (original and new) or None if not found/invalid
"""
# Get the item
item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id,
).first()
if not item:
return None
# Get the generation
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
if not generation:
return None
# Calculate effective duration and validate split point
current_trim_start = getattr(item, 'trim_start_ms', 0)
current_trim_end = getattr(item, 'trim_end_ms', 0)
original_duration_ms = int(generation.duration * 1000)
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
# Validate split_time_ms is within the effective duration
if data.split_time_ms <= 0 or data.split_time_ms >= effective_duration_ms:
return None # Invalid split point
# Calculate the absolute time in the original audio where we're splitting
absolute_split_ms = current_trim_start + data.split_time_ms
# Update original clip: trim from the end
item.trim_end_ms = original_duration_ms - absolute_split_ms
# Create new clip: starts after the split, trimmed from the start
new_item = DBStoryItem(
id=str(uuid.uuid4()),
story_id=story_id,
generation_id=item.generation_id, # Same generation, different trim
start_time_ms=item.start_time_ms + data.split_time_ms,
track=item.track,
trim_start_ms=absolute_split_ms,
trim_end_ms=current_trim_end,
created_at=datetime.utcnow(),
)
db.add(new_item)
# Update story updated_at
story = db.query(DBStory).filter_by(id=story_id).first()
if story:
story.updated_at = datetime.utcnow()
db.commit()
db.refresh(item)
db.refresh(new_item)
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
profile_name = profile.name if profile else "Unknown"
# Build response items
original_item_detail = StoryItemDetail(
id=item.id,
story_id=item.story_id,
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
track=item.track,
trim_start_ms=item.trim_start_ms,
trim_end_ms=item.trim_end_ms,
created_at=item.created_at,
profile_id=generation.profile_id,
profile_name=profile_name,
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
new_item_detail = StoryItemDetail(
id=new_item.id,
story_id=new_item.story_id,
generation_id=new_item.generation_id,
start_time_ms=new_item.start_time_ms,
track=new_item.track,
trim_start_ms=new_item.trim_start_ms,
trim_end_ms=new_item.trim_end_ms,
created_at=new_item.created_at,
profile_id=generation.profile_id,
profile_name=profile_name,
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
return [original_item_detail, new_item_detail]
async def duplicate_story_item(
story_id: str,
item_id: str,
db: Session,
) -> Optional[StoryItemDetail]:
"""
Duplicate a story item, creating a copy with all properties.
Args:
story_id: Story ID
item_id: Story item ID to duplicate
db: Database session
Returns:
New item detail or None if not found
"""
# Get the original item
original_item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id,
).first()
if not original_item:
return None
# Get the generation
generation = db.query(DBGeneration).filter_by(id=original_item.generation_id).first()
if not generation:
return None
# Calculate effective duration
current_trim_start = getattr(original_item, 'trim_start_ms', 0)
current_trim_end = getattr(original_item, 'trim_end_ms', 0)
original_duration_ms = int(generation.duration * 1000)
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
# Create duplicate item - place it right after the original
new_item = DBStoryItem(
id=str(uuid.uuid4()),
story_id=story_id,
generation_id=original_item.generation_id, # Same generation as original
start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap
track=original_item.track,
trim_start_ms=current_trim_start,
trim_end_ms=current_trim_end,
created_at=datetime.utcnow(),
)
db.add(new_item)
# Update story updated_at
story = db.query(DBStory).filter_by(id=story_id).first()
if story:
story.updated_at = datetime.utcnow()
db.commit()
db.refresh(new_item)
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
return StoryItemDetail(
id=new_item.id,
story_id=new_item.story_id,
generation_id=new_item.generation_id,
start_time_ms=new_item.start_time_ms,
track=new_item.track,
trim_start_ms=new_item.trim_start_ms,
trim_end_ms=new_item.trim_end_ms,
created_at=new_item.created_at,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
text=generation.text,
language=generation.language,
audio_path=generation.audio_path,
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
generation_created_at=generation.created_at,
)
async def update_story_item_times( async def update_story_item_times(
story_id: str, story_id: str,
data: StoryItemBatchUpdate, data: StoryItemBatchUpdate,
@@ -538,6 +819,8 @@ async def reorder_story_items(
generation_id=item.generation_id, generation_id=item.generation_id,
start_time_ms=item.start_time_ms, start_time_ms=item.start_time_ms,
track=item.track, track=item.track,
trim_start_ms=getattr(item, 'trim_start_ms', 0),
trim_end_ms=getattr(item, 'trim_end_ms', 0),
created_at=item.created_at, created_at=item.created_at,
profile_id=generation.profile_id, profile_id=generation.profile_id,
profile_name=profile_name, profile_name=profile_name,
@@ -602,14 +885,31 @@ async def export_story_audio(
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate) audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
sample_rate = sr # Use actual sample rate from first file sample_rate = sr # Use actual sample rate from first file
# Get trim values
trim_start_ms = getattr(item, 'trim_start_ms', 0)
trim_end_ms = getattr(item, 'trim_end_ms', 0)
# Calculate effective duration
original_duration_ms = int(generation.duration * 1000)
effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms
# Slice audio based on trim values
trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate)
trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate)
# Extract the trimmed portion
if trim_end_ms > 0:
trimmed_audio = audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:]
else:
trimmed_audio = audio[trim_start_sample:]
# Store audio with its timecode info # Store audio with its timecode info
start_time_ms = item.start_time_ms start_time_ms = item.start_time_ms
duration_ms = int(generation.duration * 1000)
audio_data.append({ audio_data.append({
'audio': audio, 'audio': trimmed_audio,
'start_time_ms': start_time_ms, 'start_time_ms': start_time_ms,
'duration_ms': duration_ms, 'duration_ms': effective_duration_ms,
}) })
except Exception: except Exception:
# Skip files that can't be loaded # Skip files that can't be loaded
+1 -1
View File
@@ -4840,7 +4840,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "voicebox" name = "voicebox"
version = "0.1.5" version = "0.1.6"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"core-foundation-sys", "core-foundation-sys",