mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-04 01:25:18 -07:00
Implement story management features and update dependencies
- Introduced story management functionality, including creating, listing, and managing story items. - Added new components for story display and interaction, including StoriesTab, StoryList, and StoryContent. - Integrated drag-and-drop functionality for reordering story items using @dnd-kit. - Updated dependencies for @dnd-kit packages to enhance drag-and-drop capabilities. - Bumped version for @voicebox/app, @voicebox/landing, @voicebox/tauri, and @voicebox/web to 0.1.5. - Enhanced audio playback features to support story mode with auto-play functionality. - Improved error handling and user feedback through toast notifications in story-related actions.
This commit is contained in:
@@ -13,6 +13,9 @@
|
|||||||
"check": "biome check --write src"
|
"check": "biome check --write src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
|
|||||||
@@ -402,6 +402,11 @@ export function AudioPlayer() {
|
|||||||
wavesurfer.play();
|
wavesurfer.play();
|
||||||
} else {
|
} else {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
// Trigger finish callback if set
|
||||||
|
const onFinish = usePlayerStore.getState().onFinish;
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -653,6 +658,29 @@ export function AudioPlayer() {
|
|||||||
clearRestartFlag();
|
clearRestartFlag();
|
||||||
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
||||||
|
|
||||||
|
// Handle shouldAutoPlay flag - for story mode auto-advance
|
||||||
|
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
||||||
|
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wavesurfer = wavesurferRef.current;
|
||||||
|
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play the newly loaded audio
|
||||||
|
debug.log('Auto-playing next track in story mode');
|
||||||
|
wavesurfer.seekTo(0);
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to auto-play:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the auto-play flag
|
||||||
|
clearAutoPlayFlag();
|
||||||
|
}, [shouldAutoPlay, duration, setIsPlaying, clearAutoPlayFlag]);
|
||||||
|
|
||||||
// Handle loop - WaveSurfer handles this via the 'finish' event
|
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||||
|
|
||||||
const handlePlayPause = async () => {
|
const handlePlayPause = async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Loader2, Sparkles } from 'lucide-react';
|
import { Loader2, MessageSquare, Sparkles } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useMatchRoute } from '@tanstack/react-router';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
import {
|
import {
|
||||||
@@ -13,22 +14,55 @@ import {
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
||||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
|
import { useStory, useAddStoryItem } from '@/lib/hooks/useStories';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
interface FloatingGenerateBoxProps {
|
interface FloatingGenerateBoxProps {
|
||||||
isPlayerOpen: boolean;
|
isPlayerOpen: boolean;
|
||||||
|
showVoiceSelector?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) {
|
export function FloatingGenerateBox({ isPlayerOpen, showVoiceSelector = false }: FloatingGenerateBoxProps) {
|
||||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
|
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||||
|
const { data: profiles } = useProfiles();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isInstructMode, setIsInstructMode] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const matchRoute = useMatchRoute();
|
||||||
|
const isStoriesRoute = matchRoute({ to: '/stories' });
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
|
const addStoryItem = useAddStoryItem();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { form, handleSubmit, isPending } = useGenerationForm({
|
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||||
onSuccess: () => {
|
onSuccess: async (generationId) => {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
|
// If on stories route and a story is selected, add generation to story
|
||||||
|
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||||
|
try {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,7 +103,12 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="fixed left-[calc(5rem+2rem)] right-auto w-[calc((100%-5rem-4rem)/2-1rem)]"
|
className={cn(
|
||||||
|
'fixed right-auto',
|
||||||
|
isStoriesRoute
|
||||||
|
? 'left-[calc(5rem+2rem+((100%-5rem-4rem)/2)+1.5rem)] w-[calc((100%-5rem-4rem)/2-1rem)]'
|
||||||
|
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
bottom: isPlayerOpen ? 'calc(7rem + 1.5rem)' : '1.5rem',
|
bottom: isPlayerOpen ? 'calc(7rem + 1.5rem)' : '1.5rem',
|
||||||
}}
|
}}
|
||||||
@@ -85,15 +124,24 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
|
{isInstructMode && (
|
||||||
|
<span className="text-xs text-accent font-medium mb-1 block">
|
||||||
|
Delivery instructions:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="text"
|
name={isInstructMode ? 'instruct' : 'text'}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={
|
placeholder={
|
||||||
selectedProfile
|
isInstructMode
|
||||||
|
? 'Add delivery instructions...'
|
||||||
|
: isStoriesRoute && currentStory
|
||||||
|
? `Generate speech for "${currentStory.name}"...`
|
||||||
|
: selectedProfile
|
||||||
? `Generate speech using ${selectedProfile.name}...`
|
? `Generate speech using ${selectedProfile.name}...`
|
||||||
: 'Select a voice profile above...'
|
: 'Select a voice profile above...'
|
||||||
}
|
}
|
||||||
@@ -114,10 +162,11 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="relative shrink-0">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || !selectedProfileId}
|
disabled={isPending || !selectedProfileId}
|
||||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 shrink-0 transition-all duration-200"
|
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||||
size="icon"
|
size="icon"
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
@@ -126,6 +175,30 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||||
|
className={`h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200 ${
|
||||||
|
isInstructMode ? 'text-accent' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -137,6 +210,24 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
|
|||||||
className=" mt-3"
|
className=" mt-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{showVoiceSelector && (
|
||||||
|
<Select
|
||||||
|
value={selectedProfileId || ''}
|
||||||
|
onValueChange={(value) => setSelectedProfileId(value || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all flex-1">
|
||||||
|
<SelectValue placeholder="Select a voice..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{profiles?.map((profile) => (
|
||||||
|
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||||
|
{profile.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="language"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||||
import { Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
@@ -11,6 +11,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
||||||
|
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
|
||||||
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
||||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
|
import { StoryContent } from './StoryContent';
|
||||||
|
import { StoryList } from './StoryList';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
|
export function StoriesTab() {
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
|
||||||
|
{/* Left Column - Story List */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<StoryList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Story Content */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<StoryContent />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Generate Box */}
|
||||||
|
<FloatingGenerateBox isPlayerOpen={!!audioUrl} showVoiceSelector />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
|
interface StoryChatItemProps {
|
||||||
|
item: StoryItemDetail;
|
||||||
|
storyId: string;
|
||||||
|
index: number;
|
||||||
|
onRemove: () => void;
|
||||||
|
currentTimeMs: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
isDragging?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoryChatItem({
|
||||||
|
item,
|
||||||
|
onRemove,
|
||||||
|
currentTimeMs,
|
||||||
|
isPlaying,
|
||||||
|
dragHandleProps,
|
||||||
|
isDragging,
|
||||||
|
}: StoryChatItemProps) {
|
||||||
|
const seek = useStoryStore((state) => state.seek);
|
||||||
|
|
||||||
|
// Check if this item is currently playing based on timecode
|
||||||
|
const itemStartMs = item.start_time_ms;
|
||||||
|
const itemEndMs = item.start_time_ms + item.duration * 1000;
|
||||||
|
const isCurrentlyPlaying = isPlaying && currentTimeMs >= itemStartMs && currentTimeMs < itemEndMs;
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
// Seek to the start of this item
|
||||||
|
seek(itemStartMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const milliseconds = Math.floor((ms % 1000) / 100);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 p-4 rounded-lg border transition-colors',
|
||||||
|
isCurrentlyPlaying && 'bg-muted/70 border-primary',
|
||||||
|
!isCurrentlyPlaying && 'hover:bg-muted/50',
|
||||||
|
isDragging && 'opacity-50 shadow-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
{dragHandleProps && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
{...dragHandleProps}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voice Icon */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Mic className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-medium text-sm">{item.profile_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.language}</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums ml-auto">
|
||||||
|
{formatTime(itemStartMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
// Don't trigger play if clicking on textarea or if text is selected
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePlay();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={item.text}
|
||||||
|
className="flex-1 resize-none text-sm text-muted-foreground select-text pointer-events-none bg-card"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handlePlay}>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Play from here
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove from Story
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortable wrapper component
|
||||||
|
export function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: props.item.generation_id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
<StoryChatItem
|
||||||
|
{...props}
|
||||||
|
dragHandleProps={listeners}
|
||||||
|
isDragging={isDragging}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { Download, Pause, Play } from 'lucide-react';
|
||||||
|
import { useMemo, useRef } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { useStory, useRemoveStoryItem, useExportStoryAudio, useReorderStoryItems } from '@/lib/hooks/useStories';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
|
|
||||||
|
export function StoryContent() {
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||||
|
const removeItem = useRemoveStoryItem();
|
||||||
|
const reorderItems = useReorderStoryItems();
|
||||||
|
const exportAudio = useExportStoryAudio();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
|
||||||
|
// Drag and drop sensors
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Playback state
|
||||||
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
|
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||||
|
const totalDurationMs = useStoryStore((state) => state.totalDurationMs);
|
||||||
|
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||||
|
const play = useStoryStore((state) => state.play);
|
||||||
|
const pause = useStoryStore((state) => state.pause);
|
||||||
|
const stop = useStoryStore((state) => state.stop);
|
||||||
|
const seek = useStoryStore((state) => state.seek);
|
||||||
|
|
||||||
|
// Use playback hook
|
||||||
|
useStoryPlayback(story?.items);
|
||||||
|
|
||||||
|
// Sort items by start_time_ms
|
||||||
|
const sortedItems = useMemo(() => {
|
||||||
|
if (!story?.items) return [];
|
||||||
|
return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
||||||
|
}, [story?.items]);
|
||||||
|
|
||||||
|
const handleRemoveItem = (generationId: string) => {
|
||||||
|
if (!story) return;
|
||||||
|
|
||||||
|
removeItem.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
generationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to remove item',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!story || !over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = sortedItems.findIndex((item) => item.generation_id === active.id);
|
||||||
|
const newIndex = sortedItems.findIndex((item) => item.generation_id === over.id);
|
||||||
|
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
// Calculate the new order
|
||||||
|
const newOrder = arrayMove(sortedItems, oldIndex, newIndex);
|
||||||
|
const generationIds = newOrder.map((item) => item.generation_id);
|
||||||
|
|
||||||
|
// Send reorder request to backend
|
||||||
|
reorderItems.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
data: { generation_ids: generationIds },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to reorder items',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!story || story.items.length === 0) return;
|
||||||
|
|
||||||
|
if (isPlaying && playbackStoryId === story.id) {
|
||||||
|
pause();
|
||||||
|
} else {
|
||||||
|
play(story.id, sortedItems);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (value: number[]) => {
|
||||||
|
seek(value[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const milliseconds = Math.floor((ms % 1000) / 100);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportAudio = () => {
|
||||||
|
if (!story) return;
|
||||||
|
|
||||||
|
exportAudio.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
storyName: story.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to export audio',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedStoryId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-medium mb-2">Select a story</p>
|
||||||
|
<p className="text-sm">Choose a story from the list to view its content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading story...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!story) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-medium mb-2">Story not found</p>
|
||||||
|
<p className="text-sm">The selected story could not be loaded</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 mb-4 px-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||||
|
{story.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{story.items.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePlayPause}>
|
||||||
|
{isPlaying && playbackStoryId === story.id ? (
|
||||||
|
<>
|
||||||
|
<Pause className="mr-2 h-4 w-4" />
|
||||||
|
Pause
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Play
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isPlaying && playbackStoryId === story.id && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleStop}>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportAudio} disabled={exportAudio.isPending}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export Audio
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
{story.items.length > 0 && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums min-w-16">
|
||||||
|
{formatTime(currentTimeMs)}
|
||||||
|
</span>
|
||||||
|
<Slider
|
||||||
|
value={[currentTimeMs]}
|
||||||
|
max={totalDurationMs || 1}
|
||||||
|
step={10}
|
||||||
|
onValueChange={handleSeek}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isPlaying && playbackStoryId !== story.id}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums min-w-16">
|
||||||
|
{formatTime(totalDurationMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-h-0 overflow-y-auto space-y-3',
|
||||||
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sortedItems.length === 0 ? (
|
||||||
|
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||||
|
<p className="text-sm">No items in this story</p>
|
||||||
|
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortedItems.map((item) => item.generation_id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedItems.map((item, index) => (
|
||||||
|
<SortableStoryChatItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
storyId={story.id}
|
||||||
|
index={index}
|
||||||
|
onRemove={() => handleRemoveItem(item.generation_id)}
|
||||||
|
currentTimeMs={currentTimeMs}
|
||||||
|
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { Plus, BookOpen } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { useStories, useCreateStory } from '@/lib/hooks/useStories';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { formatDate } from '@/lib/utils/format';
|
||||||
|
|
||||||
|
export function StoryList() {
|
||||||
|
const { data: stories, isLoading } = useStories();
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||||
|
const createStory = useCreateStory();
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [newStoryName, setNewStoryName] = useState('');
|
||||||
|
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleCreateStory = () => {
|
||||||
|
if (!newStoryName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Name required',
|
||||||
|
description: 'Please enter a story name',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createStory.mutate(
|
||||||
|
{
|
||||||
|
name: newStoryName.trim(),
|
||||||
|
description: newStoryDescription.trim() || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (story) => {
|
||||||
|
setSelectedStoryId(story.id);
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
setNewStoryName('');
|
||||||
|
setNewStoryDescription('');
|
||||||
|
toast({
|
||||||
|
title: 'Story created',
|
||||||
|
description: `"${story.name}" has been created`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to create story',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading stories...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storyList = stories || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<h2 className="text-2xl font-bold">Stories</h2>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Story
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story List */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||||
|
{storyList.length === 0 ? (
|
||||||
|
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||||
|
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p className="text-sm">No stories yet</p>
|
||||||
|
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
storyList.map((story) => (
|
||||||
|
<div
|
||||||
|
key={story.id}
|
||||||
|
className={cn(
|
||||||
|
'p-4 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors',
|
||||||
|
selectedStoryId === story.id && 'bg-muted border-primary',
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedStoryId(story.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium truncate">{story.name}</h3>
|
||||||
|
{story.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{story.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||||
|
<span>{story.item_count} {story.item_count === 1 ? 'item' : 'items'}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDate(story.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Story Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Story</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new story to organize your voice generations into conversations.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="story-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="story-name"
|
||||||
|
placeholder="My Story"
|
||||||
|
value={newStoryName}
|
||||||
|
onChange={(e) => setNewStoryName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateStory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="story-description">Description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="story-description"
|
||||||
|
placeholder="A conversation between..."
|
||||||
|
value={newStoryDescription}
|
||||||
|
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
|
||||||
|
{createStory.isPending ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,13 @@ import type {
|
|||||||
ModelStatusListResponse,
|
ModelStatusListResponse,
|
||||||
ModelDownloadRequest,
|
ModelDownloadRequest,
|
||||||
ActiveTasksResponse,
|
ActiveTasksResponse,
|
||||||
|
StoryCreate,
|
||||||
|
StoryResponse,
|
||||||
|
StoryDetailResponse,
|
||||||
|
StoryItemCreate,
|
||||||
|
StoryItemDetail,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
|
StoryItemReorder,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -361,6 +368,76 @@ class ApiClient {
|
|||||||
body: JSON.stringify({ channel_ids: channelIds }),
|
body: JSON.stringify({ channel_ids: channelIds }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stories
|
||||||
|
async listStories(): Promise<StoryResponse[]> {
|
||||||
|
return this.request<StoryResponse[]>('/stories');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStory(data: StoryCreate): Promise<StoryResponse> {
|
||||||
|
return this.request<StoryResponse>('/stories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStory(storyId: string): Promise<StoryDetailResponse> {
|
||||||
|
return this.request<StoryDetailResponse>(`/stories/${storyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStory(storyId: string, data: StoryCreate): Promise<StoryResponse> {
|
||||||
|
return this.request<StoryResponse>(`/stories/${storyId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStory(storyId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/stories/${storyId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addStoryItem(storyId: string, data: StoryItemCreate): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeStoryItem(storyId: string, generationId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/stories/${storyId}/items/${generationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStoryItemTimes(storyId: string, data: StoryItemBatchUpdate): Promise<void> {
|
||||||
|
await this.request<void>(`/stories/${storyId}/items/times`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reorderStoryItems(storyId: string, data: StoryItemReorder): Promise<StoryItemDetail[]> {
|
||||||
|
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportStoryAudio(storyId: string): Promise<Blob> {
|
||||||
|
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@@ -123,3 +123,61 @@ export interface ActiveTasksResponse {
|
|||||||
downloads: ActiveDownloadTask[];
|
downloads: ActiveDownloadTask[];
|
||||||
generations: ActiveGenerationTask[];
|
generations: ActiveGenerationTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoryCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemDetail {
|
||||||
|
id: string;
|
||||||
|
story_id: string;
|
||||||
|
generation_id: string;
|
||||||
|
start_time_ms: number;
|
||||||
|
created_at: string;
|
||||||
|
profile_id: string;
|
||||||
|
profile_name: string;
|
||||||
|
text: string;
|
||||||
|
language: string;
|
||||||
|
audio_path: string;
|
||||||
|
duration: number;
|
||||||
|
seed?: number;
|
||||||
|
instruct?: string;
|
||||||
|
generation_created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryDetailResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
items: StoryItemDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemCreate {
|
||||||
|
generation_id: string;
|
||||||
|
start_time_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemUpdateTime {
|
||||||
|
generation_id: string;
|
||||||
|
start_time_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemBatchUpdate {
|
||||||
|
updates: StoryItemUpdateTime[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemReorder {
|
||||||
|
generation_ids: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const generationSchema = z.object({
|
|||||||
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||||
|
|
||||||
interface UseGenerationFormOptions {
|
interface UseGenerationFormOptions {
|
||||||
onSuccess?: () => void;
|
onSuccess?: (generationId: string) => void;
|
||||||
defaultValues?: Partial<GenerationFormValues>;
|
defaultValues?: Partial<GenerationFormValues>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
options.onSuccess?.();
|
options.onSuccess?.(result.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Generation failed',
|
title: 'Generation failed',
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder } from '@/lib/api/types';
|
||||||
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
|
||||||
|
export function useStories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['stories'],
|
||||||
|
queryFn: () => apiClient.listStories(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStory(storyId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['stories', storyId],
|
||||||
|
queryFn: () => apiClient.getStory(storyId!),
|
||||||
|
enabled: !!storyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateStory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: StoryCreate) => apiClient.createStory(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateStory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryCreate }) =>
|
||||||
|
apiClient.updateStory(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteStory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (storyId: string) => apiClient.deleteStory(storyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemCreate }) =>
|
||||||
|
apiClient.addStoryItem(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, generationId }: { storyId: string; generationId: string }) =>
|
||||||
|
apiClient.removeStoryItem(storyId, generationId),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateStoryItemTimes() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemBatchUpdate }) =>
|
||||||
|
apiClient.updateStoryItemTimes(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReorderStoryItems() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemReorder }) =>
|
||||||
|
apiClient.reorderStoryItems(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExportStoryAudio() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
||||||
|
const blob = await apiClient.exportStoryAudio(storyId);
|
||||||
|
|
||||||
|
// Create safe filename
|
||||||
|
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||||
|
const filename = `${safeName || 'story'}.wav`;
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
// Use Tauri's native save dialog
|
||||||
|
try {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const filePath = await save({
|
||||||
|
defaultPath: filename,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: 'Audio File',
|
||||||
|
extensions: ['wav'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
// Write file using Tauri's filesystem API
|
||||||
|
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||||
|
// Fall back to browser download if Tauri dialog fails
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Browser: trigger download
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing timecode-based story playback.
|
||||||
|
* Uses a single audio element for reliable playback.
|
||||||
|
*/
|
||||||
|
export function useStoryPlayback(_items: StoryItemDetail[] | undefined) {
|
||||||
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
|
const playbackItems = useStoryStore((state) => state.playbackItems);
|
||||||
|
const tick = useStoryStore((state) => state.tick);
|
||||||
|
|
||||||
|
// Single audio element for playback
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const currentItemIdRef = useRef<string | null>(null);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
const lastTimeRef = useRef<number>(Date.now());
|
||||||
|
|
||||||
|
// Get or create audio element
|
||||||
|
const getAudio = useCallback(() => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
audioRef.current = new Audio();
|
||||||
|
audioRef.current.preload = 'auto';
|
||||||
|
}
|
||||||
|
return audioRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Find the item that should be playing at a given time
|
||||||
|
const findActiveItem = useCallback((timeMs: number, items: StoryItemDetail[]): StoryItemDetail | null => {
|
||||||
|
for (const item of items) {
|
||||||
|
const itemStart = item.start_time_ms;
|
||||||
|
const itemEnd = item.start_time_ms + item.duration * 1000;
|
||||||
|
if (timeMs >= itemStart && timeMs < itemEnd) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Find the next item after a given time
|
||||||
|
const findNextItem = useCallback((timeMs: number, items: StoryItemDetail[]): StoryItemDetail | null => {
|
||||||
|
const sorted = [...items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
||||||
|
for (const item of sorted) {
|
||||||
|
if (item.start_time_ms > timeMs) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.src = '';
|
||||||
|
}
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Main playback effect
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = getAudio();
|
||||||
|
|
||||||
|
if (!isPlaying || !playbackItems || playbackItems.length === 0) {
|
||||||
|
console.log('[StoryPlayback] Stopping playback');
|
||||||
|
audio.pause();
|
||||||
|
currentItemIdRef.current = null;
|
||||||
|
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = playbackItems; // Capture for closure
|
||||||
|
console.log('[StoryPlayback] Starting playback');
|
||||||
|
|
||||||
|
const playItem = (item: StoryItemDetail, offsetMs: number = 0) => {
|
||||||
|
console.log('[StoryPlayback] Playing item:', item.generation_id, 'offset:', offsetMs);
|
||||||
|
currentItemIdRef.current = item.generation_id;
|
||||||
|
|
||||||
|
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
||||||
|
audio.src = audioUrl;
|
||||||
|
|
||||||
|
audio.onloadedmetadata = () => {
|
||||||
|
const offsetSeconds = Math.max(0, offsetMs / 1000);
|
||||||
|
audio.currentTime = offsetSeconds;
|
||||||
|
audio.play().catch(err => {
|
||||||
|
console.error('[StoryPlayback] Play failed:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.onerror = (e) => {
|
||||||
|
console.error('[StoryPlayback] Audio error:', e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// When this audio ends, advance the clock and check for next item
|
||||||
|
audio.onended = () => {
|
||||||
|
console.log('[StoryPlayback] Audio ended');
|
||||||
|
const state = useStoryStore.getState();
|
||||||
|
if (!state.isPlaying || !state.playbackItems) return;
|
||||||
|
|
||||||
|
// Find what's next
|
||||||
|
const nextItem = findNextItem(state.currentTimeMs, state.playbackItems);
|
||||||
|
if (nextItem) {
|
||||||
|
// Jump to next item's start
|
||||||
|
useStoryStore.setState({ currentTimeMs: nextItem.start_time_ms });
|
||||||
|
playItem(nextItem, 0);
|
||||||
|
} else {
|
||||||
|
// No more items
|
||||||
|
console.log('[StoryPlayback] Story complete');
|
||||||
|
useStoryStore.getState().stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Animation frame for updating the clock
|
||||||
|
const updateClock = () => {
|
||||||
|
if (!useStoryStore.getState().isPlaying) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const deltaMs = now - lastTimeRef.current;
|
||||||
|
lastTimeRef.current = now;
|
||||||
|
|
||||||
|
// Update master clock
|
||||||
|
tick(deltaMs);
|
||||||
|
|
||||||
|
const currentTime = useStoryStore.getState().currentTimeMs;
|
||||||
|
const totalDuration = useStoryStore.getState().totalDurationMs;
|
||||||
|
|
||||||
|
// Check if we need to start playing a different item
|
||||||
|
const activeItem = findActiveItem(currentTime, items);
|
||||||
|
|
||||||
|
if (activeItem && currentItemIdRef.current !== activeItem.generation_id) {
|
||||||
|
// Need to switch to a different item
|
||||||
|
const offset = currentTime - activeItem.start_time_ms;
|
||||||
|
playItem(activeItem, offset);
|
||||||
|
} else if (!activeItem && currentItemIdRef.current) {
|
||||||
|
// We're in a gap between items, pause audio
|
||||||
|
audio.pause();
|
||||||
|
currentItemIdRef.current = null;
|
||||||
|
|
||||||
|
// Check if there's a next item to wait for
|
||||||
|
const nextItem = findNextItem(currentTime, items);
|
||||||
|
if (!nextItem && currentTime >= totalDuration) {
|
||||||
|
console.log('[StoryPlayback] Reached end');
|
||||||
|
useStoryStore.getState().stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue loop
|
||||||
|
animationFrameRef.current = requestAnimationFrame(updateClock);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start with the first item
|
||||||
|
const currentTime = useStoryStore.getState().currentTimeMs;
|
||||||
|
const activeItem = findActiveItem(currentTime, items);
|
||||||
|
|
||||||
|
if (activeItem) {
|
||||||
|
const offset = currentTime - activeItem.start_time_ms;
|
||||||
|
playItem(activeItem, offset);
|
||||||
|
} else {
|
||||||
|
// Maybe we're before all items start, find the first one
|
||||||
|
const firstItem = [...items].sort((a, b) => a.start_time_ms - b.start_time_ms)[0];
|
||||||
|
if (firstItem && currentTime < firstItem.start_time_ms) {
|
||||||
|
// Wait for the first item
|
||||||
|
console.log('[StoryPlayback] Waiting for first item at', firstItem.start_time_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start clock
|
||||||
|
lastTimeRef.current = Date.now();
|
||||||
|
animationFrameRef.current = requestAnimationFrame(updateClock);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.onended = null;
|
||||||
|
audio.onloadedmetadata = null;
|
||||||
|
audio.onerror = null;
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, playbackItems, getAudio, findActiveItem, findNextItem, tick]);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { MainEditor } from '@/components/MainEditor/MainEditor';
|
|||||||
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
||||||
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
||||||
import { Sidebar } from '@/components/Sidebar';
|
import { Sidebar } from '@/components/Sidebar';
|
||||||
|
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
@@ -77,6 +78,13 @@ const indexRoute = createRoute({
|
|||||||
component: MainEditor,
|
component: MainEditor,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stories route
|
||||||
|
const storiesRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/stories',
|
||||||
|
component: StoriesTab,
|
||||||
|
});
|
||||||
|
|
||||||
// Voices route
|
// Voices route
|
||||||
const voicesRoute = createRoute({
|
const voicesRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@@ -108,6 +116,7 @@ const serverRoute = createRoute({
|
|||||||
// Route tree
|
// Route tree
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
|
storiesRoute,
|
||||||
voicesRoute,
|
voicesRoute,
|
||||||
audioRoute,
|
audioRoute,
|
||||||
modelsRoute,
|
modelsRoute,
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ interface PlayerState {
|
|||||||
volume: number;
|
volume: number;
|
||||||
isLooping: boolean;
|
isLooping: boolean;
|
||||||
shouldRestart: boolean;
|
shouldRestart: boolean;
|
||||||
|
shouldAutoPlay: boolean;
|
||||||
|
onFinish: (() => void) | null;
|
||||||
|
|
||||||
setAudio: (url: string, id: string, profileId: string | null, title?: string) => void;
|
setAudio: (url: string, id: string, profileId: string | null, title?: string) => void;
|
||||||
|
setAudioWithAutoPlay: (url: string, id: string, profileId: string | null, title?: string) => void;
|
||||||
setIsPlaying: (playing: boolean) => void;
|
setIsPlaying: (playing: boolean) => void;
|
||||||
setCurrentTime: (time: number) => void;
|
setCurrentTime: (time: number) => void;
|
||||||
setDuration: (duration: number) => void;
|
setDuration: (duration: number) => void;
|
||||||
@@ -20,6 +23,8 @@ interface PlayerState {
|
|||||||
toggleLoop: () => void;
|
toggleLoop: () => void;
|
||||||
restartCurrentAudio: () => void;
|
restartCurrentAudio: () => void;
|
||||||
clearRestartFlag: () => void;
|
clearRestartFlag: () => void;
|
||||||
|
clearAutoPlayFlag: () => void;
|
||||||
|
setOnFinish: (callback: (() => void) | null) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +39,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
volume: 1,
|
volume: 1,
|
||||||
isLooping: false,
|
isLooping: false,
|
||||||
shouldRestart: false,
|
shouldRestart: false,
|
||||||
|
shouldAutoPlay: false,
|
||||||
|
onFinish: null,
|
||||||
|
|
||||||
setAudio: (url, id, profileId, title) =>
|
setAudio: (url, id, profileId, title) =>
|
||||||
set({
|
set({
|
||||||
@@ -44,6 +51,18 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
shouldRestart: false,
|
shouldRestart: false,
|
||||||
|
shouldAutoPlay: false,
|
||||||
|
}),
|
||||||
|
setAudioWithAutoPlay: (url, id, profileId, title) =>
|
||||||
|
set({
|
||||||
|
audioUrl: url,
|
||||||
|
audioId: id,
|
||||||
|
profileId: profileId || null,
|
||||||
|
title: title || null,
|
||||||
|
currentTime: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
shouldRestart: false,
|
||||||
|
shouldAutoPlay: true,
|
||||||
}),
|
}),
|
||||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||||
setCurrentTime: (time) => set({ currentTime: time }),
|
setCurrentTime: (time) => set({ currentTime: time }),
|
||||||
@@ -52,6 +71,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
toggleLoop: () => set((state) => ({ isLooping: !state.isLooping })),
|
toggleLoop: () => set((state) => ({ isLooping: !state.isLooping })),
|
||||||
restartCurrentAudio: () => set({ shouldRestart: true }),
|
restartCurrentAudio: () => set({ shouldRestart: true }),
|
||||||
clearRestartFlag: () => set({ shouldRestart: false }),
|
clearRestartFlag: () => set({ shouldRestart: false }),
|
||||||
|
clearAutoPlayFlag: () => set({ shouldAutoPlay: false }),
|
||||||
|
setOnFinish: (callback) => set({ onFinish: callback }),
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
audioUrl: null,
|
audioUrl: null,
|
||||||
@@ -63,5 +84,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
isLooping: false,
|
isLooping: false,
|
||||||
shouldRestart: false,
|
shouldRestart: false,
|
||||||
|
shouldAutoPlay: false,
|
||||||
|
onFinish: null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
|
||||||
|
interface StoryPlaybackState {
|
||||||
|
// Selection
|
||||||
|
selectedStoryId: string | null;
|
||||||
|
setSelectedStoryId: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Playback state
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTimeMs: number;
|
||||||
|
totalDurationMs: number;
|
||||||
|
playbackStoryId: string | null;
|
||||||
|
playbackItems: StoryItemDetail[] | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
play: (storyId: string, items: StoryItemDetail[]) => void;
|
||||||
|
pause: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
seek: (timeMs: number) => void;
|
||||||
|
tick: (deltaMs: number) => void; // Called by animation frame
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
||||||
|
// Selection
|
||||||
|
selectedStoryId: null,
|
||||||
|
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
|
||||||
|
|
||||||
|
// Playback state
|
||||||
|
isPlaying: false,
|
||||||
|
currentTimeMs: 0,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
playbackStoryId: null,
|
||||||
|
playbackItems: null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
play: (storyId, items) => {
|
||||||
|
// Calculate total duration from items
|
||||||
|
const maxEndTimeMs = Math.max(
|
||||||
|
...items.map((item) => item.start_time_ms + item.duration * 1000),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the minimum start time (first item)
|
||||||
|
const minStartTimeMs = Math.min(
|
||||||
|
...items.map((item) => item.start_time_ms),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// If resuming the same story, keep position; otherwise start at first item
|
||||||
|
const currentState = get();
|
||||||
|
const shouldResume = currentState.playbackStoryId === storyId && currentState.currentTimeMs > 0;
|
||||||
|
const startTimeMs = shouldResume ? currentState.currentTimeMs : minStartTimeMs;
|
||||||
|
|
||||||
|
console.log('[StoryStore] Play called:', {
|
||||||
|
storyId,
|
||||||
|
itemCount: items.length,
|
||||||
|
items: items.map(i => ({ id: i.generation_id, start: i.start_time_ms, duration: i.duration })),
|
||||||
|
maxEndTimeMs,
|
||||||
|
minStartTimeMs,
|
||||||
|
startTimeMs,
|
||||||
|
shouldResume,
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
isPlaying: true,
|
||||||
|
playbackStoryId: storyId,
|
||||||
|
playbackItems: items,
|
||||||
|
totalDurationMs: maxEndTimeMs,
|
||||||
|
currentTimeMs: startTimeMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
pause: () => {
|
||||||
|
set({ isPlaying: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
stop: () => {
|
||||||
|
set({
|
||||||
|
isPlaying: false,
|
||||||
|
currentTimeMs: 0,
|
||||||
|
playbackStoryId: null,
|
||||||
|
playbackItems: null,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
seek: (timeMs) => {
|
||||||
|
const state = get();
|
||||||
|
const clampedTime = Math.max(0, Math.min(timeMs, state.totalDurationMs));
|
||||||
|
set({ currentTimeMs: clampedTime });
|
||||||
|
},
|
||||||
|
|
||||||
|
tick: (deltaMs) => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.isPlaying || !state.playbackItems) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTime = state.currentTimeMs + deltaMs;
|
||||||
|
const clampedTime = Math.min(newTime, state.totalDurationMs);
|
||||||
|
|
||||||
|
// Auto-stop when reaching the end
|
||||||
|
if (clampedTime >= state.totalDurationMs) {
|
||||||
|
set({
|
||||||
|
currentTimeMs: state.totalDurationMs,
|
||||||
|
isPlaying: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({ currentTimeMs: clampedTime });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -51,6 +51,28 @@ class Generation(Base):
|
|||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Story(Base):
|
||||||
|
"""Story database model."""
|
||||||
|
__tablename__ = "stories"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItem(Base):
|
||||||
|
"""Story item database model (links generations to stories)."""
|
||||||
|
__tablename__ = "story_items"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
story_id = Column(String, ForeignKey("stories.id"), nullable=False)
|
||||||
|
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
||||||
|
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base):
|
||||||
"""Audio studio project database model."""
|
"""Audio studio project database model."""
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
@@ -108,6 +130,10 @@ def init_db():
|
|||||||
)
|
)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
# Run migrations before creating tables
|
||||||
|
_run_migrations(engine)
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
# Create default channel if it doesn't exist
|
# Create default channel if it doesn't exist
|
||||||
@@ -136,6 +162,91 @@ def init_db():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_migrations(engine):
|
||||||
|
"""Run database migrations."""
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
inspector = inspect(engine)
|
||||||
|
|
||||||
|
# Check if story_items table exists
|
||||||
|
if 'story_items' not in inspector.get_table_names():
|
||||||
|
return # Table doesn't exist yet, will be created fresh
|
||||||
|
|
||||||
|
# Get columns in story_items table
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||||
|
|
||||||
|
# Migration: Remove position column and ensure start_time_ms exists
|
||||||
|
# SQLite doesn't support DROP COLUMN easily, so we recreate the table
|
||||||
|
if 'position' in columns:
|
||||||
|
print("Migrating story_items: removing position column, using start_time_ms")
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check if start_time_ms already exists
|
||||||
|
has_start_time = 'start_time_ms' in columns
|
||||||
|
|
||||||
|
if not has_start_time:
|
||||||
|
# First, add the new column temporarily
|
||||||
|
conn.execute(text("ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"))
|
||||||
|
|
||||||
|
# Calculate timecodes from position ordering
|
||||||
|
result = conn.execute(text("""
|
||||||
|
SELECT si.id, si.story_id, si.position, g.duration
|
||||||
|
FROM story_items si
|
||||||
|
JOIN generations g ON si.generation_id = g.id
|
||||||
|
ORDER BY si.story_id, si.position
|
||||||
|
"""))
|
||||||
|
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
current_story_id = None
|
||||||
|
current_time_ms = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
item_id, story_id, position, duration = row
|
||||||
|
|
||||||
|
if story_id != current_story_id:
|
||||||
|
current_story_id = story_id
|
||||||
|
current_time_ms = 0
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
text("UPDATE story_items SET start_time_ms = :time WHERE id = :id"),
|
||||||
|
{"time": current_time_ms, "id": item_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
current_time_ms += int(duration * 1000) + 200
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Now recreate the table without the position column
|
||||||
|
# 1. Create new table
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE story_items_new (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
story_id VARCHAR NOT NULL,
|
||||||
|
generation_id VARCHAR NOT NULL,
|
||||||
|
start_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
FOREIGN KEY (story_id) REFERENCES stories(id),
|
||||||
|
FOREIGN KEY (generation_id) REFERENCES generations(id)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 2. Copy data
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, created_at)
|
||||||
|
SELECT id, story_id, generation_id, start_time_ms, created_at FROM story_items
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 3. Drop old table
|
||||||
|
conn.execute(text("DROP TABLE story_items"))
|
||||||
|
|
||||||
|
# 4. Rename new table
|
||||||
|
conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items"))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migrated story_items table to use start_time_ms (removed position column)")
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""Get database session (generator for dependency injection)."""
|
"""Get database session (generator for dependency injection)."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|||||||
+149
-1
@@ -19,7 +19,7 @@ import io
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels
|
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories
|
||||||
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
from .utils.progress import get_progress_manager
|
from .utils.progress import get_progress_manager
|
||||||
from .utils.tasks import get_task_manager
|
from .utils.tasks import get_task_manager
|
||||||
@@ -698,6 +698,154 @@ async def transcribe_audio(
|
|||||||
Path(tmp_path).unlink(missing_ok=True)
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# STORY ENDPOINTS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@app.get("/stories", response_model=List[models.StoryResponse])
|
||||||
|
async def list_stories(db: Session = Depends(get_db)):
|
||||||
|
"""List all stories."""
|
||||||
|
return await stories.list_stories(db)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/stories", response_model=models.StoryResponse)
|
||||||
|
async def create_story(
|
||||||
|
data: models.StoryCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new story."""
|
||||||
|
try:
|
||||||
|
return await stories.create_story(data, db)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stories/{story_id}", response_model=models.StoryDetailResponse)
|
||||||
|
async def get_story(
|
||||||
|
story_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a story with all its items."""
|
||||||
|
story = await stories.get_story(story_id, db)
|
||||||
|
if not story:
|
||||||
|
raise HTTPException(status_code=404, detail="Story not found")
|
||||||
|
return story
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}", response_model=models.StoryResponse)
|
||||||
|
async def update_story(
|
||||||
|
story_id: str,
|
||||||
|
data: models.StoryCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a story."""
|
||||||
|
story = await stories.update_story(story_id, data, db)
|
||||||
|
if not story:
|
||||||
|
raise HTTPException(status_code=404, detail="Story not found")
|
||||||
|
return story
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/stories/{story_id}")
|
||||||
|
async def delete_story(
|
||||||
|
story_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a story."""
|
||||||
|
success = await stories.delete_story(story_id, db)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Story not found")
|
||||||
|
return {"message": "Story deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/stories/{story_id}/items", response_model=models.StoryItemDetail)
|
||||||
|
async def add_story_item(
|
||||||
|
story_id: str,
|
||||||
|
data: models.StoryItemCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Add a generation to a story."""
|
||||||
|
item = await stories.add_item_to_story(story_id, data, db)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Story or generation not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/stories/{story_id}/items/{generation_id}")
|
||||||
|
async def remove_story_item(
|
||||||
|
story_id: str,
|
||||||
|
generation_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)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found")
|
||||||
|
return {"message": "Item removed successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}/items/times")
|
||||||
|
async def update_story_item_times(
|
||||||
|
story_id: str,
|
||||||
|
data: models.StoryItemBatchUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update story item timecodes."""
|
||||||
|
success = await stories.update_story_item_times(story_id, data, db)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid timecode update request")
|
||||||
|
return {"message": "Item timecodes updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}/items/reorder", response_model=List[models.StoryItemDetail])
|
||||||
|
async def reorder_story_items(
|
||||||
|
story_id: str,
|
||||||
|
data: models.StoryItemReorder,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reorder story items and recalculate timecodes."""
|
||||||
|
items = await stories.reorder_story_items(story_id, data.generation_ids, db)
|
||||||
|
if items is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid reorder request - ensure all generation IDs belong to this story")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/stories/{story_id}/export-audio")
|
||||||
|
async def export_story_audio(
|
||||||
|
story_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Export story as single mixed audio file with timecode-based mixing."""
|
||||||
|
try:
|
||||||
|
# Get story to create filename
|
||||||
|
story = db.query(database.Story).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
raise HTTPException(status_code=404, detail="Story not found")
|
||||||
|
|
||||||
|
# Export audio
|
||||||
|
audio_bytes = await stories.export_story_audio(story_id, db)
|
||||||
|
if not audio_bytes:
|
||||||
|
raise HTTPException(status_code=400, detail="Story has no audio items")
|
||||||
|
|
||||||
|
# Create safe filename
|
||||||
|
safe_name = "".join(c for c in story.name if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||||
|
if not safe_name:
|
||||||
|
safe_name = "story"
|
||||||
|
filename = f"{safe_name}.wav"
|
||||||
|
|
||||||
|
# Return as streaming response
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(audio_bytes),
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# FILE SERVING
|
# FILE SERVING
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -193,3 +193,79 @@ class ChannelVoiceAssignment(BaseModel):
|
|||||||
class ProfileChannelAssignment(BaseModel):
|
class ProfileChannelAssignment(BaseModel):
|
||||||
"""Request model for assigning channels to a profile."""
|
"""Request model for assigning channels to a profile."""
|
||||||
channel_ids: List[str]
|
channel_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class StoryCreate(BaseModel):
|
||||||
|
"""Request model for creating a story."""
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryResponse(BaseModel):
|
||||||
|
"""Response model for story (list view)."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
item_count: int = 0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemDetail(BaseModel):
|
||||||
|
"""Detail model for story item with generation info."""
|
||||||
|
id: str
|
||||||
|
story_id: str
|
||||||
|
generation_id: str
|
||||||
|
start_time_ms: int
|
||||||
|
created_at: datetime
|
||||||
|
# Generation details
|
||||||
|
profile_id: str
|
||||||
|
profile_name: str
|
||||||
|
text: str
|
||||||
|
language: str
|
||||||
|
audio_path: str
|
||||||
|
duration: float
|
||||||
|
seed: Optional[int]
|
||||||
|
instruct: Optional[str]
|
||||||
|
generation_created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StoryDetailResponse(BaseModel):
|
||||||
|
"""Response model for story with items."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
items: List[StoryItemDetail] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemCreate(BaseModel):
|
||||||
|
"""Request model for adding a generation to a story."""
|
||||||
|
generation_id: str
|
||||||
|
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemUpdateTime(BaseModel):
|
||||||
|
"""Request model for updating a story item's timecode."""
|
||||||
|
generation_id: str
|
||||||
|
start_time_ms: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemBatchUpdate(BaseModel):
|
||||||
|
"""Request model for batch updating story item timecodes."""
|
||||||
|
updates: List[StoryItemUpdateTime]
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemReorder(BaseModel):
|
||||||
|
"""Request model for reordering story items."""
|
||||||
|
generation_ids: List[str] = Field(..., min_length=1)
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
"""
|
||||||
|
Story management module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
StoryCreate,
|
||||||
|
StoryResponse,
|
||||||
|
StoryDetailResponse,
|
||||||
|
StoryItemDetail,
|
||||||
|
StoryItemCreate,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
|
)
|
||||||
|
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
|
from .utils.audio import load_audio, save_audio
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
async def create_story(
|
||||||
|
data: StoryCreate,
|
||||||
|
db: Session,
|
||||||
|
) -> StoryResponse:
|
||||||
|
"""
|
||||||
|
Create a new story.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Story creation data
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created story
|
||||||
|
"""
|
||||||
|
db_story = DBStory(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(db_story)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_story)
|
||||||
|
|
||||||
|
# Get item count
|
||||||
|
item_count = db.query(func.count(DBStoryItem.id)).filter(
|
||||||
|
DBStoryItem.story_id == db_story.id
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
response = StoryResponse.model_validate(db_story)
|
||||||
|
response.item_count = item_count
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def list_stories(
|
||||||
|
db: Session,
|
||||||
|
) -> List[StoryResponse]:
|
||||||
|
"""
|
||||||
|
List all stories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of stories with item counts
|
||||||
|
"""
|
||||||
|
stories = db.query(DBStory).order_by(DBStory.updated_at.desc()).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for story in stories:
|
||||||
|
item_count = db.query(func.count(DBStoryItem.id)).filter(
|
||||||
|
DBStoryItem.story_id == story.id
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
response = StoryResponse.model_validate(story)
|
||||||
|
response.item_count = item_count
|
||||||
|
result.append(response)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_story(
|
||||||
|
story_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryDetailResponse]:
|
||||||
|
"""
|
||||||
|
Get a story with all its items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Story with items or None if not found
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get all items ordered by start_time_ms
|
||||||
|
items = db.query(
|
||||||
|
DBStoryItem,
|
||||||
|
DBGeneration,
|
||||||
|
DBVoiceProfile.name.label('profile_name')
|
||||||
|
).join(
|
||||||
|
DBGeneration,
|
||||||
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
|
).join(
|
||||||
|
DBVoiceProfile,
|
||||||
|
DBGeneration.profile_id == DBVoiceProfile.id
|
||||||
|
).filter(
|
||||||
|
DBStoryItem.story_id == story_id
|
||||||
|
).order_by(DBStoryItem.start_time_ms).all()
|
||||||
|
|
||||||
|
# Build item details
|
||||||
|
item_details = []
|
||||||
|
for item, generation, profile_name in items:
|
||||||
|
item_detail = StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_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,
|
||||||
|
)
|
||||||
|
item_details.append(item_detail)
|
||||||
|
|
||||||
|
response = StoryDetailResponse.model_validate(story)
|
||||||
|
response.items = item_details
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def update_story(
|
||||||
|
story_id: str,
|
||||||
|
data: StoryCreate,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryResponse]:
|
||||||
|
"""
|
||||||
|
Update a story.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
data: Update data
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated story or None if not found
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
story.name = data.name
|
||||||
|
story.description = data.description
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(story)
|
||||||
|
|
||||||
|
# Get item count
|
||||||
|
item_count = db.query(func.count(DBStoryItem.id)).filter(
|
||||||
|
DBStoryItem.story_id == story.id
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
response = StoryResponse.model_validate(story)
|
||||||
|
response.item_count = item_count
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_story(
|
||||||
|
story_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a story and all its items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Delete all items
|
||||||
|
db.query(DBStoryItem).filter_by(story_id=story_id).delete()
|
||||||
|
|
||||||
|
# Delete story
|
||||||
|
db.delete(story)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def add_item_to_story(
|
||||||
|
story_id: str,
|
||||||
|
data: StoryItemCreate,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryItemDetail]:
|
||||||
|
"""
|
||||||
|
Add a generation to a story.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
data: Item creation data
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created item detail or None if story/generation not found
|
||||||
|
"""
|
||||||
|
# Verify story exists
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify generation exists
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=data.generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if generation is already in story
|
||||||
|
existing = db.query(DBStoryItem).filter_by(
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=data.generation_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
# Return existing item
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
return StoryItemDetail(
|
||||||
|
id=existing.id,
|
||||||
|
story_id=existing.story_id,
|
||||||
|
generation_id=existing.generation_id,
|
||||||
|
start_time_ms=existing.start_time_ms,
|
||||||
|
created_at=existing.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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate start_time_ms if not provided
|
||||||
|
if data.start_time_ms is not None:
|
||||||
|
start_time_ms = data.start_time_ms
|
||||||
|
else:
|
||||||
|
# Find the maximum end time (start_time_ms + duration_ms) of existing items
|
||||||
|
existing_items = db.query(
|
||||||
|
DBStoryItem,
|
||||||
|
DBGeneration
|
||||||
|
).join(
|
||||||
|
DBGeneration,
|
||||||
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
|
).filter(
|
||||||
|
DBStoryItem.story_id == story_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not existing_items:
|
||||||
|
# First item starts at 0
|
||||||
|
start_time_ms = 0
|
||||||
|
else:
|
||||||
|
max_end_time_ms = 0
|
||||||
|
for item, gen in existing_items:
|
||||||
|
item_end_ms = item.start_time_ms + int(gen.duration * 1000)
|
||||||
|
max_end_time_ms = max(max_end_time_ms, item_end_ms)
|
||||||
|
|
||||||
|
# Add 200ms gap after the last item
|
||||||
|
start_time_ms = max_end_time_ms + 200
|
||||||
|
|
||||||
|
# Create item
|
||||||
|
item = DBStoryItem(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=data.generation_id,
|
||||||
|
start_time_ms=start_time_ms,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(item)
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
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,
|
||||||
|
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 remove_item_from_story(
|
||||||
|
story_id: str,
|
||||||
|
generation_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Remove a generation from a story.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
generation_id: Generation ID to remove
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if removed, False if not found
|
||||||
|
"""
|
||||||
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=generation_id
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Delete item
|
||||||
|
db.delete(item)
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def update_story_item_times(
|
||||||
|
story_id: str,
|
||||||
|
data: StoryItemBatchUpdate,
|
||||||
|
db: Session,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update story item timecodes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
data: Batch update data with timecodes
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if updated, False if story not found or invalid
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get all items for this story
|
||||||
|
items = db.query(DBStoryItem).filter_by(story_id=story_id).all()
|
||||||
|
item_map = {item.generation_id: item for item in items}
|
||||||
|
|
||||||
|
# Verify all generation IDs belong to this story and update timecodes
|
||||||
|
for update in data.updates:
|
||||||
|
if update.generation_id not in item_map:
|
||||||
|
return False
|
||||||
|
item_map[update.generation_id].start_time_ms = update.start_time_ms
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def reorder_story_items(
|
||||||
|
story_id: str,
|
||||||
|
generation_ids: List[str],
|
||||||
|
db: Session,
|
||||||
|
gap_ms: int = 200,
|
||||||
|
) -> Optional[List[StoryItemDetail]]:
|
||||||
|
"""
|
||||||
|
Reorder story items and recalculate timecodes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
generation_ids: List of generation IDs in the desired order
|
||||||
|
db: Database session
|
||||||
|
gap_ms: Gap in milliseconds between items (default 200ms)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated list of story items with new timecodes, or None if invalid
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get all items for this story with their generation data
|
||||||
|
items_with_gen = db.query(
|
||||||
|
DBStoryItem,
|
||||||
|
DBGeneration,
|
||||||
|
DBVoiceProfile.name.label('profile_name')
|
||||||
|
).join(
|
||||||
|
DBGeneration,
|
||||||
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
|
).join(
|
||||||
|
DBVoiceProfile,
|
||||||
|
DBGeneration.profile_id == DBVoiceProfile.id
|
||||||
|
).filter(
|
||||||
|
DBStoryItem.story_id == story_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Create maps for quick lookup
|
||||||
|
item_map = {item.generation_id: (item, gen, profile_name) for item, gen, profile_name in items_with_gen}
|
||||||
|
|
||||||
|
# Verify all generation IDs belong to this story
|
||||||
|
if set(generation_ids) != set(item_map.keys()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Recalculate timecodes based on new order
|
||||||
|
current_time_ms = 0
|
||||||
|
updated_items = []
|
||||||
|
|
||||||
|
for gen_id in generation_ids:
|
||||||
|
item, generation, profile_name = item_map[gen_id]
|
||||||
|
|
||||||
|
# Update the item's start time
|
||||||
|
item.start_time_ms = current_time_ms
|
||||||
|
|
||||||
|
# Calculate the duration in ms
|
||||||
|
duration_ms = int(generation.duration * 1000)
|
||||||
|
|
||||||
|
# Move to next position (current end + gap)
|
||||||
|
current_time_ms += duration_ms + gap_ms
|
||||||
|
|
||||||
|
# Build the response item
|
||||||
|
updated_items.append(StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_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,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return updated_items
|
||||||
|
|
||||||
|
|
||||||
|
async def export_story_audio(
|
||||||
|
story_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[bytes]:
|
||||||
|
"""
|
||||||
|
Export story as single mixed audio file with timecode-based mixing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Audio file bytes or None if story not found
|
||||||
|
"""
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if not story:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get all items ordered by start_time_ms
|
||||||
|
items = db.query(
|
||||||
|
DBStoryItem,
|
||||||
|
DBGeneration
|
||||||
|
).join(
|
||||||
|
DBGeneration,
|
||||||
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
|
).filter(
|
||||||
|
DBStoryItem.story_id == story_id
|
||||||
|
).order_by(DBStoryItem.start_time_ms).all()
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Load all audio files and calculate total duration
|
||||||
|
audio_data = []
|
||||||
|
sample_rate = 24000 # Default sample rate
|
||||||
|
|
||||||
|
for item, generation in items:
|
||||||
|
audio_path = Path(generation.audio_path)
|
||||||
|
if not audio_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
|
||||||
|
sample_rate = sr # Use actual sample rate from first file
|
||||||
|
|
||||||
|
# Store audio with its timecode info
|
||||||
|
start_time_ms = item.start_time_ms
|
||||||
|
duration_ms = int(generation.duration * 1000)
|
||||||
|
|
||||||
|
audio_data.append({
|
||||||
|
'audio': audio,
|
||||||
|
'start_time_ms': start_time_ms,
|
||||||
|
'duration_ms': duration_ms,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# Skip files that can't be loaded
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not audio_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate total duration: max(start_time_ms + duration_ms)
|
||||||
|
max_end_time_ms = max(
|
||||||
|
(data['start_time_ms'] + data['duration_ms'] for data in audio_data),
|
||||||
|
default=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to samples
|
||||||
|
total_samples = int((max_end_time_ms / 1000.0) * sample_rate)
|
||||||
|
|
||||||
|
# Create output buffer initialized to zeros
|
||||||
|
final_audio = np.zeros(total_samples, dtype=np.float32)
|
||||||
|
|
||||||
|
# Mix each audio segment at its timecode position
|
||||||
|
for data in audio_data:
|
||||||
|
audio = data['audio']
|
||||||
|
start_time_ms = data['start_time_ms']
|
||||||
|
|
||||||
|
# Calculate start sample index
|
||||||
|
start_sample = int((start_time_ms / 1000.0) * sample_rate)
|
||||||
|
|
||||||
|
# Ensure we don't exceed buffer bounds
|
||||||
|
audio_length = len(audio)
|
||||||
|
end_sample = min(start_sample + audio_length, total_samples)
|
||||||
|
|
||||||
|
if start_sample < total_samples:
|
||||||
|
# Trim audio if it extends beyond buffer
|
||||||
|
audio_to_mix = audio[:end_sample - start_sample]
|
||||||
|
|
||||||
|
# Mix: add audio to existing buffer (overlapping audio will sum)
|
||||||
|
# Normalize to prevent clipping (simple approach: divide by max)
|
||||||
|
final_audio[start_sample:end_sample] += audio_to_mix
|
||||||
|
|
||||||
|
# Normalize to prevent clipping
|
||||||
|
max_val = np.abs(final_audio).max()
|
||||||
|
if max_val > 1.0:
|
||||||
|
final_audio = final_audio / max_val
|
||||||
|
|
||||||
|
# Save to temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
save_audio(final_audio, tmp_path, sample_rate)
|
||||||
|
|
||||||
|
# Read file bytes
|
||||||
|
with open(tmp_path, 'rb') as f:
|
||||||
|
audio_bytes = f.read()
|
||||||
|
|
||||||
|
return audio_bytes
|
||||||
|
finally:
|
||||||
|
# Clean up temp file
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
@@ -13,8 +13,11 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
@@ -64,7 +67,7 @@
|
|||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -89,7 +92,7 @@
|
|||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
@@ -108,7 +111,7 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
@@ -189,6 +192,14 @@
|
|||||||
|
|
||||||
"@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
|
"@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/accessibility": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||||
|
|
||||||
|
"@dnd-kit/core": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||||
|
|
||||||
|
"@dnd-kit/sortable": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||||
|
|
||||||
|
"@dnd-kit/utilities": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user