From 036d90dc8eee31f2129e8d9cc8341247ed256dc0 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Wed, 28 Jan 2026 22:16:53 -0800 Subject: [PATCH] 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. --- .../components/StoriesTab/StoryContent.tsx | 6 +- .../StoriesTab/StoryTrackEditor.tsx | 469 ++++++++++++++++-- app/src/lib/api/client.ts | 30 +- app/src/lib/api/types.ts | 11 + app/src/lib/hooks/useStories.ts | 49 +- app/src/lib/hooks/useStoryPlayback.ts | 58 ++- app/src/stores/storyStore.ts | 4 + backend/database.py | 20 + backend/main.py | 55 +- backend/models.py | 13 + backend/stories.py | 322 +++++++++++- tauri/src-tauri/Cargo.lock | 2 +- 12 files changed, 938 insertions(+), 101 deletions(-) diff --git a/app/src/components/StoriesTab/StoryContent.tsx b/app/src/components/StoriesTab/StoryContent.tsx index f237e1fa..483e6657 100644 --- a/app/src/components/StoriesTab/StoryContent.tsx +++ b/app/src/components/StoriesTab/StoryContent.tsx @@ -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} /> diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 655ec7fb..e27e4706 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -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(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(null); const wavesurferRef = useRef(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
; + return ( +
+ {/* Inner container that holds the full waveform, offset to show only visible portion */} +
+
+ ); } 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(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) => { - 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) {
+ {/* Clip editing controls - center */} + {selectedClipId && ( +
+ + + +
+ )} + {/* Zoom controls - right side */}
Zoom: @@ -421,15 +743,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { onMouseUp={draggingItem ? handleDragEnd : undefined} onMouseLeave={draggingItem ? handleDragEnd : undefined} > - {/* Time ruler */} -
{timeMarkers.map((ms) => (
@@ -438,19 +763,19 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
))} -
+ {/* Tracks area */}
- {/* Track backgrounds */} + {/* Track backgrounds - pointer-events-none to allow clicks to pass through */} {tracks.map((trackNumber, index) => (
{ - 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 ( - + + + {/* Trim handles */} + {isSelected && ( + <> + {/* Left trim handle */} +
); })} diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index 5505196f..c8262e09 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -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 { - await this.request(`/stories/${storyId}/items/${generationId}`, { + async removeStoryItem(storyId: string, itemId: string): Promise { + await this.request(`/stories/${storyId}/items/${itemId}`, { method: 'DELETE', }); } @@ -426,13 +428,33 @@ class ApiClient { }); } - async moveStoryItem(storyId: string, generationId: string, data: StoryItemMove): Promise { - return this.request(`/stories/${storyId}/items/${generationId}/move`, { + async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise { + return this.request(`/stories/${storyId}/items/${itemId}/move`, { method: 'PUT', body: JSON.stringify(data), }); } + async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise { + return this.request(`/stories/${storyId}/items/${itemId}/trim`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise { + return this.request(`/stories/${storyId}/items/${itemId}/split`, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async duplicateStoryItem(storyId: string, itemId: string): Promise { + return this.request(`/stories/${storyId}/items/${itemId}/duplicate`, { + method: 'POST', + }); + } + async exportStoryAudio(storyId: string): Promise { const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`; const response = await fetch(url); diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index d8321dfe..5b31e9f3 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -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; +} diff --git a/app/src/lib/hooks/useStories.ts b/app/src/lib/hooks/useStories.ts index a100f837..8210cb2b 100644 --- a/app/src/lib/hooks/useStories.ts +++ b/app/src/lib/hooks/useStories.ts @@ -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] }); diff --git a/app/src/lib/hooks/useStoryPlayback.ts b/app/src/lib/hooks/useStoryPlayback.ts index 83b32088..f9678cdc 100644 --- a/app/src/lib/hooks/useStoryPlayback.ts +++ b/app/src/lib/hooks/useStoryPlayback.ts @@ -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(null); // Master gain for volume control const masterGainRef = useRef(null); - // Preloaded AudioBuffers by generation_id + // Preloaded AudioBuffers by generation_id (audio file is shared between split clips) const audioBuffersRef = useRef>(new Map()); - // Currently playing AudioBufferSourceNodes by generation_id + // Currently playing AudioBufferSourceNodes by item.id (unique per clip) const activeSourcesRef = useRef>(new Map()); // Animation frame for syncing visual playhead const animationFrameRef = useRef(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); }; } } diff --git a/app/src/stores/storyStore.ts b/app/src/stores/storyStore.ts index 968a6f00..ae46368e 100644 --- a/app/src/stores/storyStore.ts +++ b/app/src/stores/storyStore.ts @@ -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((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, diff --git a/backend/database.py b/backend/database.py index 9cb5de9e..ff59173e 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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(): diff --git a/backend/main.py b/backend/main.py index 11d87231..4b429f82 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/models.py b/backend/models.py index 8c8a6658..624f7f6a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/stories.py b/backend/stories.py index 407376d3..2a2f5abb 100644 --- a/backend/stories.py +++ b/backend/stories.py @@ -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 diff --git a/tauri/src-tauri/Cargo.lock b/tauri/src-tauri/Cargo.lock index 19b63038..2dfc0fcf 100644 --- a/tauri/src-tauri/Cargo.lock +++ b/tauri/src-tauri/Cargo.lock @@ -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",