mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -07:00
defer story add until TTS completes, add generating pill to story editor, fix item placement per-track
This commit is contained in:
@@ -12,12 +12,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories';
|
import { useStory } from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { ParalinguisticInput } from './ParalinguisticInput';
|
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||||
@@ -44,8 +44,7 @@ export function FloatingGenerateBox({
|
|||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
const { data: currentStory } = useStory(selectedStoryId);
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
const addStoryItem = useAddStoryItem();
|
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// Calculate if track editor is visible (on stories route with items)
|
// Calculate if track editor is visible (on stories route with items)
|
||||||
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||||
@@ -53,25 +52,9 @@ export function FloatingGenerateBox({
|
|||||||
const { form, handleSubmit, isPending } = useGenerationForm({
|
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||||
onSuccess: async (generationId) => {
|
onSuccess: async (generationId) => {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
// If on stories route and a story is selected, add generation to story
|
// Defer the story add until TTS completes — useGenerationProgress handles it
|
||||||
if (isStoriesRoute && selectedStoryId && generationId) {
|
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||||
try {
|
addPendingStoryAdd(generationId, selectedStoryId);
|
||||||
await addStoryItem.mutateAsync({
|
|
||||||
storyId: selectedStoryId,
|
|
||||||
data: { generation_id: generationId },
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: 'Added to story',
|
|
||||||
description: `Generation added to "${currentStory?.name || 'story'}"`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to add to story',
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : 'Could not add generation to story',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Download, Plus } from 'lucide-react';
|
import { Download, Plus } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Loader from 'react-loaders';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
useStory,
|
useStory,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { SortableStoryChatItem } from './StoryChatItem';
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ export function StoryContent() {
|
|||||||
const addStoryItem = useAddStoryItem();
|
const addStoryItem = useAddStoryItem();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||||
|
|
||||||
// Add generation popover state
|
// Add generation popover state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -54,8 +59,7 @@ export function StoryContent() {
|
|||||||
return historyData.items.filter(
|
return historyData.items.filter(
|
||||||
(gen) =>
|
(gen) =>
|
||||||
!storyGenerationIds.has(gen.id) &&
|
!storyGenerationIds.has(gen.id) &&
|
||||||
(gen.text.toLowerCase().includes(query) ||
|
(gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
|
||||||
gen.profile_name.toLowerCase().includes(query)),
|
|
||||||
);
|
);
|
||||||
}, [historyData, story, searchQuery]);
|
}, [historyData, story, searchQuery]);
|
||||||
|
|
||||||
@@ -267,7 +271,31 @@ export function StoryContent() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
<AnimatePresence>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, width: 'auto' }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
|
||||||
|
<div className="scale-[0.45]">
|
||||||
|
<Loader type="line-scale" active />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@@ -287,9 +315,7 @@ export function StoryContent() {
|
|||||||
<div className="max-h-60 overflow-y-auto">
|
<div className="max-h-60 overflow-y-auto">
|
||||||
{availableGenerations.length === 0 ? (
|
{availableGenerations.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
{searchQuery
|
{searchQuery ? 'No matching generations found' : 'No available generations'}
|
||||||
? 'No matching generations found'
|
|
||||||
: 'No available generations'}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
availableGenerations.map((gen) => (
|
availableGenerations.map((gen) => (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function useGenerationProgress() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
|
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
|
||||||
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
|
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
|
||||||
|
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
|
||||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||||
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||||
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
|
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
|
||||||
@@ -66,12 +67,36 @@ export function useGenerationProgress() {
|
|||||||
// Refresh history to pick up the completed generation
|
// Refresh history to pick up the completed generation
|
||||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
toast({
|
// If this generation was queued for a story, add it now
|
||||||
title: 'Generation complete!',
|
const storyId = removePendingStoryAdd(id);
|
||||||
description: data.duration
|
if (storyId) {
|
||||||
? `Audio generated (${data.duration.toFixed(2)}s)`
|
apiClient
|
||||||
: 'Audio generated',
|
.addStoryItem(storyId, { generation_id: id })
|
||||||
});
|
.then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', storyId] });
|
||||||
|
toast({
|
||||||
|
title: 'Added to story',
|
||||||
|
description: data.duration
|
||||||
|
? `Audio generated (${data.duration.toFixed(2)}s) and added to story`
|
||||||
|
: 'Audio generated and added to story',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast({
|
||||||
|
title: 'Generation complete',
|
||||||
|
description: 'Audio generated but failed to add to story',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Generation complete!',
|
||||||
|
description: data.duration
|
||||||
|
? `Audio generated (${data.duration.toFixed(2)}s)`
|
||||||
|
: 'Audio generated',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-play if enabled and nothing is currently playing
|
// Auto-play if enabled and nothing is currently playing
|
||||||
if (autoplayRef.current && !isPlayingRef.current) {
|
if (autoplayRef.current && !isPlayingRef.current) {
|
||||||
@@ -82,6 +107,7 @@ export function useGenerationProgress() {
|
|||||||
source.close();
|
source.close();
|
||||||
currentSources.delete(id);
|
currentSources.delete(id);
|
||||||
removePendingGeneration(id);
|
removePendingGeneration(id);
|
||||||
|
removePendingStoryAdd(id);
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
@@ -114,5 +140,12 @@ export function useGenerationProgress() {
|
|||||||
}
|
}
|
||||||
currentSources.clear();
|
currentSources.clear();
|
||||||
};
|
};
|
||||||
}, [pendingIds, removePendingGeneration, queryClient, toast, setAudioWithAutoPlay]);
|
}, [
|
||||||
|
pendingIds,
|
||||||
|
removePendingGeneration,
|
||||||
|
removePendingStoryAdd,
|
||||||
|
queryClient,
|
||||||
|
toast,
|
||||||
|
setAudioWithAutoPlay,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,23 @@ interface GenerationState {
|
|||||||
pendingGenerationIds: Set<string>;
|
pendingGenerationIds: Set<string>;
|
||||||
/** Whether any generation is in progress (derived convenience) */
|
/** Whether any generation is in progress (derived convenience) */
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
|
/** Map of generationId → storyId for deferred story additions */
|
||||||
|
pendingStoryAdds: Map<string, string>;
|
||||||
addPendingGeneration: (id: string) => void;
|
addPendingGeneration: (id: string) => void;
|
||||||
removePendingGeneration: (id: string) => void;
|
removePendingGeneration: (id: string) => void;
|
||||||
|
addPendingStoryAdd: (generationId: string, storyId: string) => void;
|
||||||
|
removePendingStoryAdd: (generationId: string) => string | undefined;
|
||||||
/** Legacy setter for backward compat with useRestoreActiveTasks */
|
/** Legacy setter for backward compat with useRestoreActiveTasks */
|
||||||
setIsGenerating: (generating: boolean) => void;
|
setIsGenerating: (generating: boolean) => void;
|
||||||
setActiveGenerationId: (id: string | null) => void;
|
setActiveGenerationId: (id: string | null) => void;
|
||||||
activeGenerationId: string | null;
|
activeGenerationId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGenerationStore = create<GenerationState>((set) => ({
|
export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||||
pendingGenerationIds: new Set(),
|
pendingGenerationIds: new Set(),
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
activeGenerationId: null,
|
activeGenerationId: null,
|
||||||
|
pendingStoryAdds: new Map(),
|
||||||
|
|
||||||
addPendingGeneration: (id) =>
|
addPendingGeneration: (id) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -32,6 +37,25 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
|||||||
return { pendingGenerationIds: next, isGenerating: next.size > 0 };
|
return { pendingGenerationIds: next, isGenerating: next.size > 0 };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
addPendingStoryAdd: (generationId, storyId) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.pendingStoryAdds);
|
||||||
|
next.set(generationId, storyId);
|
||||||
|
return { pendingStoryAdds: next };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removePendingStoryAdd: (generationId) => {
|
||||||
|
const storyId = get().pendingStoryAdds.get(generationId);
|
||||||
|
if (storyId) {
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.pendingStoryAdds);
|
||||||
|
next.delete(generationId);
|
||||||
|
return { pendingStoryAdds: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return storyId;
|
||||||
|
},
|
||||||
|
|
||||||
setIsGenerating: (generating) => set({ isGenerating: generating }),
|
setIsGenerating: (generating) => set({ isGenerating: generating }),
|
||||||
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
|
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
+6
-6
@@ -270,11 +270,14 @@ async def add_item_to_story(
|
|||||||
generation_created_at=generation.created_at,
|
generation_created_at=generation.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get track from data or default to 0
|
||||||
|
track = data.track if data.track is not None else 0
|
||||||
|
|
||||||
# Calculate start_time_ms if not provided
|
# Calculate start_time_ms if not provided
|
||||||
if data.start_time_ms is not None:
|
if data.start_time_ms is not None:
|
||||||
start_time_ms = data.start_time_ms
|
start_time_ms = data.start_time_ms
|
||||||
else:
|
else:
|
||||||
# Find the maximum end time (start_time_ms + duration_ms) of existing items
|
# Find the maximum end time on the target track only
|
||||||
existing_items = db.query(
|
existing_items = db.query(
|
||||||
DBStoryItem,
|
DBStoryItem,
|
||||||
DBGeneration
|
DBGeneration
|
||||||
@@ -282,11 +285,11 @@ async def add_item_to_story(
|
|||||||
DBGeneration,
|
DBGeneration,
|
||||||
DBStoryItem.generation_id == DBGeneration.id
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
).filter(
|
).filter(
|
||||||
DBStoryItem.story_id == story_id
|
DBStoryItem.story_id == story_id,
|
||||||
|
DBStoryItem.track == track,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if not existing_items:
|
if not existing_items:
|
||||||
# First item starts at 0
|
|
||||||
start_time_ms = 0
|
start_time_ms = 0
|
||||||
else:
|
else:
|
||||||
max_end_time_ms = 0
|
max_end_time_ms = 0
|
||||||
@@ -297,9 +300,6 @@ async def add_item_to_story(
|
|||||||
# Add 200ms gap after the last item
|
# Add 200ms gap after the last item
|
||||||
start_time_ms = max_end_time_ms + 200
|
start_time_ms = max_end_time_ms + 200
|
||||||
|
|
||||||
# Get track from data or default to 0
|
|
||||||
track = data.track if data.track is not None else 0
|
|
||||||
|
|
||||||
# Create item
|
# Create item
|
||||||
item = DBStoryItem(
|
item = DBStoryItem(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
|||||||
Reference in New Issue
Block a user