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]);
const handleRemoveItem = (generationId: string) => {
const handleRemoveItem = (itemId: string) => {
if (!story) return;
removeItem.mutate(
{
storyId: story.id,
generationId,
itemId,
},
{
onError: (error) => {
@@ -360,7 +360,7 @@ export function StoryContent() {
item={item}
storyId={story.id}
index={index}
onRemove={() => handleRemoveItem(item.generation_id)}
onRemove={() => handleRemoveItem(item.id)}
currentTimeMs={currentTimeMs}
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 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 } from '@/lib/hooks/useStories';
import { useMoveStoryItem, useTrimStoryItem, useSplitStoryItem, useDuplicateStoryItem, useRemoveStoryItem } from '@/lib/hooks/useStories';
import { useStoryStore } from '@/stores/storyStore';
import type { StoryItemDetail } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
// Clip waveform component
function ClipWaveform({ generationId, width }: { generationId: string; width: number }) {
const containerRef = useRef<HTMLDivElement>(null);
// Clip waveform component with trim support
function ClipWaveform({
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);
// 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(() => {
if (!containerRef.current || width < 20) return;
if (!waveformRef.current || fullWaveformWidth < 20) return;
// Get CSS colors
const root = document.documentElement;
@@ -27,7 +51,7 @@ function ClipWaveform({ generationId, width }: { generationId: string; width: nu
const waveColor = getCSSVar('--accent-foreground');
const wavesurfer = WaveSurfer.create({
container: containerRef.current,
container: waveformRef.current,
waveColor,
progressColor: waveColor,
cursorWidth: 0,
@@ -50,9 +74,21 @@ function ClipWaveform({ generationId, width }: { generationId: string; width: nu
wavesurfer.destroy();
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 {
@@ -80,7 +116,21 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const resizeStartY = useRef(0);
const resizeStartHeight = useRef(0);
const moveItem = useMoveStoryItem();
const trimItem = useTrimStoryItem();
const splitItem = useSplitStoryItem();
const duplicateItem = useDuplicateStoryItem();
const removeItem = useRemoveStoryItem();
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)
const editorHeight = useStoryStore((state) => state.trackEditorHeight);
@@ -140,11 +190,16 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
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(() => {
if (items.length === 0) return 10000; // Default 10 seconds
return Math.max(
...items.map((item) => item.start_time_ms + item.duration * 1000),
...items.map((item) => item.start_time_ms + getEffectiveDuration(item)),
10000
);
}, [items]);
@@ -228,13 +283,246 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
}, [isResizing, handleResizeMove, handleResizeEnd]);
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!tracksRef.current || draggingItem) return;
if (!tracksRef.current || draggingItem || trimmingItem) return;
const rect = tracksRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
const timeMs = Math.max(0, pixelsToMs(x));
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 = (
e: React.MouseEvent,
item: StoryItemDetail
@@ -251,7 +539,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
y: rect.top - tracksRef.current.getBoundingClientRect().top,
});
setDraggingItem(item.generation_id);
setDraggingItem(item.id);
};
const handleDragMove = useCallback(
@@ -273,7 +561,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return;
}
const item = items.find((i) => i.generation_id === draggingItem);
const item = items.find((i) => i.id === draggingItem);
if (!item) {
setDraggingItem(null);
return;
@@ -292,7 +580,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
moveItem.mutate(
{
storyId,
generationId: item.generation_id,
itemId: item.id,
data: {
start_time_ms: newTimeMs,
track: newTrack,
@@ -302,7 +590,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
onError: (error) => {
toast({
title: 'Failed to move item',
description: error.message,
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
},
@@ -318,9 +606,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
// Calculate clip position and dimensions
const getClipStyle = (item: StoryItemDetail) => {
const isDragging = draggingItem === item.generation_id;
const isDragging = draggingItem === item.id;
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 top = isDragging ? dragPosition.y : trackIndex * TRACK_HEIGHT;
@@ -375,6 +664,39 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</span>
</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 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Zoom:</span>
@@ -421,15 +743,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
onMouseUp={draggingItem ? handleDragEnd : undefined}
onMouseLeave={draggingItem ? handleDragEnd : undefined}
>
{/* Time ruler */}
<div
className="h-6 border-b bg-muted/20 sticky top-0 z-10"
{/* Time ruler - clickable to seek */}
<button
type="button"
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
style={{ width: `${timelineWidth}px` }}
onClick={handleTimelineClick}
aria-label="Seek timeline"
>
{timeMarkers.map((ms) => (
<div
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` }}
>
<div className="h-2 w-px bg-border" />
@@ -438,19 +763,19 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</span>
</div>
))}
</div>
</button>
{/* Tracks area */}
<div
className="relative"
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
>
{/* Track backgrounds */}
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
{tracks.map((trackNumber, index) => (
<div
key={trackNumber}
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'
)}
style={{
@@ -470,35 +795,83 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Audio clips */}
{items.map((item) => {
const isDragging = draggingItem === item.generation_id;
const style = getClipStyle(item);
const clipWidth = msToPixels(item.duration * 1000);
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 (
<button
type="button"
key={item.generation_id}
<div
key={item.id}
className={cn(
'absolute rounded cursor-move select-none overflow-hidden z-10',
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
'flex flex-col justify-center',
isDragging && 'opacity-80 shadow-lg z-20',
!isDragging && 'transition-all duration-100'
'absolute rounded select-none overflow-visible z-10',
isSelected && 'ring-2 ring-primary ring-offset-1',
isTrimming && 'ring-2 ring-accent'
)}
style={style}
onMouseDown={(e) => handleDragStart(e, item)}
>
{/* Clip label */}
<div className="absolute top-0 left-1 right-1 z-10">
<p className="text-[9px] font-medium text-accent-foreground truncate">
{item.profile_name}
</p>
</div>
{/* Waveform */}
<div className="absolute inset-0 top-3">
<ClipWaveform generationId={item.generation_id} width={clipWidth} />
</div>
</button>
<button
type="button"
className={cn(
'w-full h-full rounded cursor-move overflow-hidden',
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
'flex flex-col justify-center',
isDragging && 'opacity-80 shadow-lg z-20',
!isDragging && 'transition-all duration-100'
)}
onClick={(e) => 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 */}
<div className="absolute top-0 left-1 right-1 z-10">
<p className="text-[9px] font-medium text-accent-foreground truncate">
{item.profile_name}
</p>
</div>
{/* Waveform */}
<div className="absolute inset-0 top-3">
<ClipWaveform
generationId={item.generation_id}
width={clipWidth}
trimStartMs={displayTrimStart}
trimEndMs={displayTrimEnd}
duration={item.duration}
/>
</div>
</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,
StoryItemReorder,
StoryItemMove,
StoryItemTrim,
StoryItemSplit,
} from './types';
class ApiClient {
@@ -406,8 +408,8 @@ class ApiClient {
});
}
async removeStoryItem(storyId: string, generationId: string): Promise<void> {
await this.request<void>(`/stories/${storyId}/items/${generationId}`, {
async removeStoryItem(storyId: string, itemId: string): Promise<void> {
await this.request<void>(`/stories/${storyId}/items/${itemId}`, {
method: 'DELETE',
});
}
@@ -426,13 +428,33 @@ class ApiClient {
});
}
async moveStoryItem(storyId: string, generationId: string, data: StoryItemMove): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${generationId}/move`, {
async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
method: 'PUT',
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> {
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
const response = await fetch(url);
+11
View File
@@ -144,6 +144,8 @@ export interface StoryItemDetail {
generation_id: string;
start_time_ms: number;
track: number;
trim_start_ms: number;
trim_end_ms: number;
created_at: string;
profile_id: string;
profile_name: string;
@@ -188,3 +190,12 @@ export interface StoryItemMove {
start_time_ms: 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 { 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';
export function useStories() {
@@ -70,8 +70,8 @@ export function useRemoveStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, generationId }: { storyId: string; generationId: string }) =>
apiClient.removeStoryItem(storyId, generationId),
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
apiClient.removeStoryItem(storyId, itemId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -109,8 +109,47 @@ export function useMoveStoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ storyId, generationId, data }: { storyId: string; generationId: string; data: StoryItemMove }) =>
apiClient.moveStoryItem(storyId, generationId, data),
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
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) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
+36 -22
View File
@@ -5,6 +5,7 @@ import { useStoryStore } from '@/stores/storyStore';
interface ActiveSource {
source: AudioBufferSourceNode;
itemId: string;
generationId: string;
startTimeMs: number;
endTimeMs: number;
@@ -26,9 +27,9 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const audioContextRef = useRef<AudioContext | null>(null);
// Master gain for volume control
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());
// Currently playing AudioBufferSourceNodes by generation_id
// Currently playing AudioBufferSourceNodes by item.id (unique per clip)
const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map());
// Animation frame for syncing visual playhead
const animationFrameRef = useRef<number | null>(null);
@@ -56,16 +57,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
return audioContextRef.current;
}, []);
// Stop a source
const stopSource = useCallback((generationId: string) => {
const activeSource = activeSourcesRef.current.get(generationId);
// Stop a source by item id
const stopSource = useCallback((itemId: string) => {
const activeSource = activeSourcesRef.current.get(itemId);
if (activeSource) {
try {
activeSource.source.stop();
} catch {
// Source may have already stopped
}
activeSourcesRef.current.delete(generationId);
activeSourcesRef.current.delete(itemId);
}
}, []);
@@ -123,8 +124,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
useEffect(() => {
return () => {
// Stop all sources
for (const [generationId] of activeSourcesRef.current) {
stopSource(generationId);
for (const [itemId] of activeSourcesRef.current) {
stopSource(itemId);
}
activeSourcesRef.current.clear();
@@ -151,7 +152,11 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
(storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => {
return itemList.filter((item) => {
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;
});
},
@@ -185,8 +190,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Stop all sources
const stopAllSources = useCallback(() => {
console.log('[StoryPlayback] Stopping all sources');
for (const [generationId] of activeSourcesRef.current) {
stopSource(generationId);
for (const [itemId] of activeSourcesRef.current) {
stopSource(itemId);
}
activeSourcesRef.current.clear();
}, [stopSource]);
@@ -199,18 +204,18 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Find all items that should be playing
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
for (const [generationId] of activeSourcesRef.current) {
if (!shouldBePlayingIds.has(generationId)) {
stopSource(generationId);
for (const [itemId] of activeSourcesRef.current) {
if (!shouldBePlayingIds.has(itemId)) {
stopSource(itemId);
}
}
// Schedule new sources for items that should be playing
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);
if (!buffer) {
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
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)
const offsetIntoBuffer = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000);
const duration = item.duration - offsetIntoBuffer;
// Offset is relative to the trimmed start of the clip
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
const startAtContextTime = Math.max(currentContextTime, itemStartContextTime);
console.log('[StoryPlayback] Scheduling source:', {
itemId: item.id,
generationId: item.generation_id,
storyTimeMs,
itemStart: item.start_time_ms,
@@ -243,20 +256,21 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const activeSource: ActiveSource = {
source,
itemId: item.id,
generationId: item.generation_id,
startTimeMs: item.start_time_ms,
endTimeMs: itemEndStoryTime,
};
activeSourcesRef.current.set(item.generation_id, activeSource);
activeSourcesRef.current.set(item.id, activeSource);
// Schedule playback
source.start(startAtContextTime, offsetIntoBuffer, duration);
// Clean up when source ends
source.onended = () => {
console.log('[StoryPlayback] Source ended:', item.generation_id);
activeSourcesRef.current.delete(item.generation_id);
console.log('[StoryPlayback] Source ended:', item.id);
activeSourcesRef.current.delete(item.id);
};
}
}
+4
View File
@@ -5,6 +5,8 @@ interface StoryPlaybackState {
// Selection
selectedStoryId: string | null;
setSelectedStoryId: (id: string | null) => void;
selectedClipId: string | null;
setSelectedClipId: (id: string | null) => void;
// Track editor UI state
trackEditorHeight: number;
@@ -34,6 +36,8 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
// Selection
selectedStoryId: null,
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
selectedClipId: null,
setSelectedClipId: (id) => set({ selectedClipId: id }),
// Track editor UI state
trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT,
+20
View File
@@ -71,6 +71,8 @@ class StoryItem(Base):
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
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)
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)
@@ -256,6 +258,24 @@ def _run_migrations(engine):
conn.execute(text("ALTER TABLE story_items ADD COLUMN track INTEGER NOT NULL DEFAULT 0"))
conn.commit()
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():
+48 -7
View File
@@ -770,14 +770,14 @@ async def add_story_item(
return item
@app.delete("/stories/{story_id}/items/{generation_id}")
@app.delete("/stories/{story_id}/items/{item_id}")
async def remove_story_item(
story_id: str,
generation_id: str,
item_id: str,
db: Session = Depends(get_db),
):
"""Remove a generation from a story."""
success = await stories.remove_item_from_story(story_id, generation_id, db)
"""Remove a story item from a story."""
success = await stories.remove_item_from_story(story_id, item_id, db)
if not success:
raise HTTPException(status_code=404, detail="Story item not found")
return {"message": "Item removed successfully"}
@@ -809,15 +809,56 @@ async def reorder_story_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(
story_id: str,
generation_id: str,
item_id: str,
data: models.StoryItemMove,
db: Session = Depends(get_db),
):
"""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:
raise HTTPException(status_code=404, detail="Story item not found")
return item
+13
View File
@@ -221,6 +221,8 @@ class StoryItemDetail(BaseModel):
generation_id: str
start_time_ms: int
track: int = 0
trim_start_ms: int = 0
trim_end_ms: int = 0
created_at: datetime
# Generation details
profile_id: str
@@ -277,3 +279,14 @@ class StoryItemMove(BaseModel):
"""Request model for moving a story item (position and/or track)."""
start_time_ms: int = Field(..., ge=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,
StoryItemBatchUpdate,
StoryItemMove,
StoryItemTrim,
StoryItemSplit,
)
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
from .utils.audio import load_audio, save_audio
@@ -129,6 +131,8 @@ async def get_story(
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
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,
profile_id=generation.profile_id,
profile_name=profile_name,
@@ -252,6 +256,8 @@ async def add_item_to_story(
generation_id=existing.generation_id,
start_time_ms=existing.start_time_ms,
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,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
@@ -321,6 +327,8 @@ async def add_item_to_story(
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
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,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
@@ -336,7 +344,7 @@ async def add_item_to_story(
async def move_story_item(
story_id: str,
generation_id: str,
item_id: str,
data: StoryItemMove,
db: Session,
) -> Optional[StoryItemDetail]:
@@ -345,7 +353,7 @@ async def move_story_item(
Args:
story_id: Story ID
generation_id: Generation ID of the item to move
item_id: Story item ID
data: New position and track data
db: Database session
@@ -354,14 +362,14 @@ async def move_story_item(
"""
# Get the item
item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id,
generation_id=generation_id
).first()
if not item:
return None
# 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:
return None
@@ -386,6 +394,8 @@ async def move_story_item(
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
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,
profile_id=generation.profile_id,
profile_name=profile.name if profile else "Unknown",
@@ -401,23 +411,23 @@ async def move_story_item(
async def remove_item_from_story(
story_id: str,
generation_id: str,
item_id: str,
db: Session,
) -> bool:
"""
Remove a generation from a story.
Remove a story item from a story.
Args:
story_id: Story ID
generation_id: Generation ID to remove
item_id: Story item ID to remove
db: Database session
Returns:
True if removed, False if not found
"""
item = db.query(DBStoryItem).filter_by(
id=item_id,
story_id=story_id,
generation_id=generation_id
).first()
if not item:
return False
@@ -434,6 +444,277 @@ async def remove_item_from_story(
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(
story_id: str,
data: StoryItemBatchUpdate,
@@ -538,6 +819,8 @@ async def reorder_story_items(
generation_id=item.generation_id,
start_time_ms=item.start_time_ms,
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,
profile_id=generation.profile_id,
profile_name=profile_name,
@@ -602,14 +885,31 @@ async def export_story_audio(
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
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
start_time_ms = item.start_time_ms
duration_ms = int(generation.duration * 1000)
audio_data.append({
'audio': audio,
'audio': trimmed_audio,
'start_time_ms': start_time_ms,
'duration_ms': duration_ms,
'duration_ms': effective_duration_ms,
})
except Exception:
# Skip files that can't be loaded
+1 -1
View File
@@ -4840,7 +4840,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voicebox"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"base64 0.22.1",
"core-foundation-sys",