mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
Add version control to track editor, restyle story list
- Story items can be pinned to a specific generation version via toolbar dropdown (shows when clip is selected and has >1 version) - version_id column on story_items with migration, validated against the generation's versions before saving - Split/duplicate preserve the source clip's pinned version - Export and playback resolve version-specific audio paths - Extracted _build_item_detail helper in stories.py (DRY cleanup) - Story list restyled from rounded cards to flat rows with rounded hover/active states, gradient header fade, and dynamic bottom padding that accounts for track editor + generate box
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -29,7 +29,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useStories, useCreateStory, useUpdateStory, useDeleteStory } from '@/lib/hooks/useStories';
|
||||
import {
|
||||
useCreateStory,
|
||||
useDeleteStory,
|
||||
useStories,
|
||||
useStory,
|
||||
useUpdateStory,
|
||||
} from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
@@ -38,6 +44,8 @@ export function StoryList() {
|
||||
const { data: stories, isLoading } = useStories();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const { data: selectedStory } = useStory(selectedStoryId);
|
||||
const createStory = useCreateStory();
|
||||
const updateStory = useUpdateStory();
|
||||
const deleteStory = useDeleteStory();
|
||||
@@ -170,20 +178,29 @@ export function StoryList() {
|
||||
}
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
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 className="h-full flex flex-col relative overflow-hidden">
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Story List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||
{/* Scrollable Story List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
||||
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
@@ -191,75 +208,68 @@ export function StoryList() {
|
||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
storyList.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer',
|
||||
selectedStoryId === story.id && 'bg-muted border-primary',
|
||||
)}
|
||||
aria-label={
|
||||
selectedStoryId === story.id
|
||||
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.`
|
||||
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.`
|
||||
}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedStoryId(story.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="font-medium truncate">{story.name}</h3>
|
||||
{story.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
{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 className="space-y-0.5">
|
||||
{storyList.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedStoryId(story.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 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>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${story.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(story.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${story.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(story.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
GalleryVerticalEnd,
|
||||
GripHorizontal,
|
||||
Minus,
|
||||
Pause,
|
||||
@@ -12,6 +14,12 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
@@ -19,6 +27,7 @@ import {
|
||||
useDuplicateStoryItem,
|
||||
useMoveStoryItem,
|
||||
useRemoveStoryItem,
|
||||
useSetStoryItemVersion,
|
||||
useSplitStoryItem,
|
||||
useTrimStoryItem,
|
||||
} from '@/lib/hooks/useStories';
|
||||
@@ -28,12 +37,14 @@ import { useStoryStore } from '@/stores/storyStore';
|
||||
// Clip waveform component with trim support
|
||||
function ClipWaveform({
|
||||
generationId,
|
||||
versionId,
|
||||
width,
|
||||
trimStartMs,
|
||||
trimEndMs,
|
||||
duration,
|
||||
}: {
|
||||
generationId: string;
|
||||
versionId?: string;
|
||||
width: number;
|
||||
trimStartMs: number;
|
||||
trimEndMs: number;
|
||||
@@ -79,7 +90,9 @@ function ClipWaveform({
|
||||
|
||||
wavesurferRef.current = wavesurfer;
|
||||
|
||||
const audioUrl = apiClient.getAudioUrl(generationId);
|
||||
const audioUrl = versionId
|
||||
? apiClient.getVersionAudioUrl(versionId)
|
||||
: apiClient.getAudioUrl(generationId);
|
||||
wavesurfer.load(audioUrl).catch(() => {
|
||||
// Ignore load errors
|
||||
});
|
||||
@@ -88,7 +101,7 @@ function ClipWaveform({
|
||||
wavesurfer.destroy();
|
||||
wavesurferRef.current = null;
|
||||
};
|
||||
}, [generationId, fullWaveformWidth]);
|
||||
}, [generationId, versionId, fullWaveformWidth]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full opacity-60 overflow-hidden">
|
||||
@@ -135,12 +148,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const splitItem = useSplitStoryItem();
|
||||
const duplicateItem = useDuplicateStoryItem();
|
||||
const removeItem = useRemoveStoryItem();
|
||||
const setItemVersion = useSetStoryItemVersion();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Selection state
|
||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
||||
|
||||
// Selected clip item (for version picker)
|
||||
const selectedItem = useMemo(
|
||||
() => (selectedClipId ? items.find((i) => i.id === selectedClipId) : undefined),
|
||||
[selectedClipId, items],
|
||||
);
|
||||
const selectedItemVersions = selectedItem?.versions;
|
||||
const hasMultipleVersions = selectedItemVersions && selectedItemVersions.length > 1;
|
||||
|
||||
// Determine which version label is active for the selected clip
|
||||
const activeVersionLabel = useMemo(() => {
|
||||
if (!selectedItem || !selectedItemVersions) return null;
|
||||
// If the item has a pinned version_id, find its label
|
||||
if (selectedItem.version_id) {
|
||||
const pinned = selectedItemVersions.find((v) => v.id === selectedItem.version_id);
|
||||
return pinned?.label ?? null;
|
||||
}
|
||||
// Otherwise use the generation's default version
|
||||
const defaultVersion = selectedItemVersions.find((v) => v.is_default);
|
||||
return defaultVersion?.label ?? null;
|
||||
}, [selectedItem, selectedItemVersions]);
|
||||
|
||||
const handleSetVersion = useCallback(
|
||||
(versionId: string | null) => {
|
||||
if (!selectedClipId) return;
|
||||
setItemVersion.mutate(
|
||||
{
|
||||
storyId,
|
||||
itemId: selectedClipId,
|
||||
data: { version_id: versionId },
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to set version',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[selectedClipId, storyId, setItemVersion, toast],
|
||||
);
|
||||
|
||||
// Trim state
|
||||
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
||||
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
||||
@@ -788,6 +846,49 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{hasMultipleVersions && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
title="Change version/take"
|
||||
>
|
||||
<GalleryVerticalEnd className="h-3.5 w-3.5" />
|
||||
<span className="max-w-[80px] truncate">
|
||||
{activeVersionLabel ?? 'default'}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[160px]">
|
||||
{selectedItemVersions.map((version) => {
|
||||
const isActive = selectedItem?.version_id
|
||||
? version.id === selectedItem.version_id
|
||||
: version.is_default;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={version.id}
|
||||
onClick={() => handleSetVersion(version.id)}
|
||||
className="gap-2 text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn('h-3 w-3', isActive ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="truncate">{version.label}</span>
|
||||
{version.effects_chain && version.effects_chain.length > 0 && (
|
||||
<span className="text-muted-foreground ml-auto text-[10px]">
|
||||
{version.effects_chain.length} fx
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -958,6 +1059,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
<div className="absolute inset-0 top-3">
|
||||
<ClipWaveform
|
||||
generationId={item.generation_id}
|
||||
versionId={item.version_id}
|
||||
width={clipWidth}
|
||||
trimStartMs={displayTrimStart}
|
||||
trimEndMs={displayTrimEnd}
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
StoryItemReorder,
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryItemVersionUpdate,
|
||||
StoryResponse,
|
||||
TranscriptionResponse,
|
||||
VoiceProfileCreate,
|
||||
@@ -588,6 +589,17 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setStoryItemVersion(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemVersionUpdate,
|
||||
): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/version`, {
|
||||
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);
|
||||
|
||||
@@ -222,6 +222,7 @@ export interface StoryItemDetail {
|
||||
id: string;
|
||||
story_id: string;
|
||||
generation_id: string;
|
||||
version_id?: string;
|
||||
start_time_ms: number;
|
||||
track: number;
|
||||
trim_start_ms: number;
|
||||
@@ -236,6 +237,12 @@ export interface StoryItemDetail {
|
||||
seed?: number;
|
||||
instruct?: string;
|
||||
generation_created_at: string;
|
||||
versions?: GenerationVersionResponse[];
|
||||
active_version_id?: string;
|
||||
}
|
||||
|
||||
export interface StoryItemVersionUpdate {
|
||||
version_id: string | null;
|
||||
}
|
||||
|
||||
export interface StoryDetailResponse {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
||||
import type {
|
||||
StoryCreate,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemCreate,
|
||||
StoryItemMove,
|
||||
StoryItemReorder,
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryItemVersionUpdate,
|
||||
} from '@/lib/api/types';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useStories() {
|
||||
@@ -109,8 +118,15 @@ export function useMoveStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
|
||||
apiClient.moveStoryItem(storyId, itemId, data),
|
||||
mutationFn: ({
|
||||
storyId,
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
storyId: string;
|
||||
itemId: string;
|
||||
data: StoryItemMove;
|
||||
}) => apiClient.moveStoryItem(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
@@ -122,8 +138,15 @@ export function useTrimStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
|
||||
apiClient.trimStoryItem(storyId, itemId, data),
|
||||
mutationFn: ({
|
||||
storyId,
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
storyId: string;
|
||||
itemId: string;
|
||||
data: StoryItemTrim;
|
||||
}) => apiClient.trimStoryItem(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
@@ -135,8 +158,15 @@ export function useSplitStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
|
||||
apiClient.splitStoryItem(storyId, itemId, data),
|
||||
mutationFn: ({
|
||||
storyId,
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
storyId: string;
|
||||
itemId: string;
|
||||
data: StoryItemSplit;
|
||||
}) => apiClient.splitStoryItem(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
@@ -157,6 +187,26 @@ export function useDuplicateStoryItem() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetStoryItemVersion() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
storyId,
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
storyId: string;
|
||||
itemId: string;
|
||||
data: StoryItemVersionUpdate;
|
||||
}) => apiClient.setStoryItemVersion(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportStoryAudio() {
|
||||
const platform = usePlatform();
|
||||
|
||||
@@ -165,7 +215,10 @@ export function useExportStoryAudio() {
|
||||
const blob = await apiClient.exportStoryAudio(storyId);
|
||||
|
||||
// Create safe filename
|
||||
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const safeName = storyName
|
||||
.substring(0, 50)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${safeName || 'story'}.wav`;
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
|
||||
@@ -70,6 +70,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Resolve the audio buffer key and URL for an item.
|
||||
// When a version_id is pinned, use that version's audio; otherwise use the generation default.
|
||||
const getAudioKey = (item: StoryItemDetail) =>
|
||||
item.version_id ? `v:${item.version_id}` : item.generation_id;
|
||||
|
||||
const getAudioUrlForItem = (item: StoryItemDetail) =>
|
||||
item.version_id
|
||||
? apiClient.getVersionAudioUrl(item.version_id)
|
||||
: apiClient.getAudioUrl(item.generation_id);
|
||||
|
||||
// Preload audio files as AudioBuffers
|
||||
useEffect(() => {
|
||||
if (!items || items.length === 0) {
|
||||
@@ -78,12 +88,12 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = new Set(items.map((item) => item.generation_id));
|
||||
const currentKeys = new Set(items.map(getAudioKey));
|
||||
const audioContext = getAudioContext();
|
||||
|
||||
// Remove buffers for items that no longer exist
|
||||
for (const [id] of audioBuffersRef.current) {
|
||||
if (!currentIds.has(id)) {
|
||||
if (!currentKeys.has(id)) {
|
||||
audioBuffersRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -91,24 +101,25 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
// Preload audio for new items
|
||||
const preloadPromises: Promise<void>[] = [];
|
||||
for (const item of items) {
|
||||
if (!audioBuffersRef.current.has(item.generation_id)) {
|
||||
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
||||
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
|
||||
const key = getAudioKey(item);
|
||||
if (!audioBuffersRef.current.has(key)) {
|
||||
const audioUrl = getAudioUrlForItem(item);
|
||||
console.log('[StoryPlayback] Preloading audio buffer:', key);
|
||||
|
||||
const preloadPromise = fetch(audioUrl)
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||
.then((audioBuffer) => {
|
||||
audioBuffersRef.current.set(item.generation_id, audioBuffer);
|
||||
audioBuffersRef.current.set(key, audioBuffer);
|
||||
console.log(
|
||||
'[StoryPlayback] Preloaded buffer:',
|
||||
item.generation_id,
|
||||
key,
|
||||
'duration:',
|
||||
audioBuffer.duration,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err);
|
||||
console.error('[StoryPlayback] Failed to preload audio:', key, err);
|
||||
});
|
||||
|
||||
preloadPromises.push(preloadPromise);
|
||||
@@ -216,15 +227,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
// Schedule new sources for items that should be playing
|
||||
for (const item of shouldBePlaying) {
|
||||
if (!activeSourcesRef.current.has(item.id)) {
|
||||
const buffer = audioBuffersRef.current.get(item.generation_id);
|
||||
const bufferKey = getAudioKey(item);
|
||||
const buffer = audioBuffersRef.current.get(bufferKey);
|
||||
if (!buffer) {
|
||||
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
||||
console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate when this item should start in AudioContext time
|
||||
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
||||
|
||||
|
||||
// Calculate effective duration and trim offsets
|
||||
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
||||
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
||||
|
||||
@@ -76,6 +76,7 @@ class StoryItem(Base):
|
||||
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)
|
||||
version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) # Pin to specific version, null = use generation default
|
||||
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
|
||||
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
|
||||
trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
|
||||
@@ -376,6 +377,16 @@ def _run_migrations(engine):
|
||||
conn.commit()
|
||||
print("Added sort_order column to effect_presets")
|
||||
|
||||
# Migration: Add version_id column to story_items table
|
||||
if 'story_items' in inspector.get_table_names():
|
||||
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||
if 'version_id' not in columns:
|
||||
print("Migrating story_items: adding version_id column")
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE story_items ADD COLUMN version_id VARCHAR"))
|
||||
conn.commit()
|
||||
print("Added version_id column to story_items")
|
||||
|
||||
if 'generations' in inspector.get_table_names():
|
||||
columns = {col['name'] for col in inspector.get_columns('generations')}
|
||||
if 'is_favorited' not in columns:
|
||||
|
||||
@@ -1644,6 +1644,20 @@ async def duplicate_story_item(
|
||||
return item
|
||||
|
||||
|
||||
@app.put("/stories/{story_id}/items/{item_id}/version", response_model=models.StoryItemDetail)
|
||||
async def set_story_item_version(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
data: models.StoryItemVersionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Pin a story item to a specific generation version."""
|
||||
item = await stories.set_story_item_version(story_id, item_id, data, db)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Story item or version not found")
|
||||
return item
|
||||
|
||||
|
||||
@app.get("/stories/{story_id}/export-audio")
|
||||
async def export_story_audio(
|
||||
story_id: str,
|
||||
|
||||
@@ -276,6 +276,7 @@ class StoryItemDetail(BaseModel):
|
||||
id: str
|
||||
story_id: str
|
||||
generation_id: str
|
||||
version_id: Optional[str] = None
|
||||
start_time_ms: int
|
||||
track: int = 0
|
||||
trim_start_ms: int = 0
|
||||
@@ -291,6 +292,9 @@ class StoryItemDetail(BaseModel):
|
||||
seed: Optional[int]
|
||||
instruct: Optional[str]
|
||||
generation_created_at: datetime
|
||||
# Versions available for this generation
|
||||
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||
active_version_id: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -349,6 +353,11 @@ class StoryItemSplit(BaseModel):
|
||||
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
|
||||
|
||||
|
||||
class StoryItemVersionUpdate(BaseModel):
|
||||
"""Request model for setting a story item's pinned version."""
|
||||
version_id: Optional[str] = None # null = use generation default
|
||||
|
||||
|
||||
# ============================================
|
||||
# Effects & Versions
|
||||
# ============================================
|
||||
|
||||
+119
-177
@@ -20,12 +20,55 @@ from .models import (
|
||||
StoryItemMove,
|
||||
StoryItemTrim,
|
||||
StoryItemSplit,
|
||||
StoryItemVersionUpdate,
|
||||
)
|
||||
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||
from .history import _get_versions_for_generation
|
||||
from .utils.audio import load_audio, save_audio
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _build_item_detail(
|
||||
item: DBStoryItem,
|
||||
generation: DBGeneration,
|
||||
profile_name: str,
|
||||
db: Session,
|
||||
) -> StoryItemDetail:
|
||||
"""Build a StoryItemDetail with version info from a story item and its generation."""
|
||||
versions, active_version_id = _get_versions_for_generation(generation.id, db)
|
||||
|
||||
# Resolve the audio path: if version_id is set, use that version's audio
|
||||
audio_path = generation.audio_path
|
||||
if item.version_id and versions:
|
||||
for v in versions:
|
||||
if v.id == item.version_id:
|
||||
audio_path = v.audio_path
|
||||
break
|
||||
|
||||
return StoryItemDetail(
|
||||
id=item.id,
|
||||
story_id=item.story_id,
|
||||
generation_id=item.generation_id,
|
||||
version_id=getattr(item, 'version_id', None),
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile_name,
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
versions=versions,
|
||||
active_version_id=active_version_id,
|
||||
)
|
||||
|
||||
|
||||
async def create_story(
|
||||
data: StoryCreate,
|
||||
db: Session,
|
||||
@@ -125,26 +168,7 @@ async def get_story(
|
||||
# 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,
|
||||
track=item.track,
|
||||
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile_name,
|
||||
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)
|
||||
item_details.append(_build_item_detail(item, generation, profile_name, db))
|
||||
|
||||
response = StoryDetailResponse.model_validate(story)
|
||||
response.items = item_details
|
||||
@@ -250,25 +274,7 @@ async def add_item_to_story(
|
||||
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,
|
||||
track=existing.track,
|
||||
trim_start_ms=getattr(existing, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(existing, 'trim_end_ms', 0),
|
||||
created_at=existing.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
return _build_item_detail(existing, generation, profile.name if profile else "Unknown", db)
|
||||
|
||||
# Get track from data or default to 0
|
||||
track = data.track if data.track is not None else 0
|
||||
@@ -321,25 +327,7 @@ async def add_item_to_story(
|
||||
# Get profile name
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||
|
||||
return StoryItemDetail(
|
||||
id=item.id,
|
||||
story_id=item.story_id,
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
|
||||
|
||||
|
||||
async def move_story_item(
|
||||
@@ -388,25 +376,7 @@ async def move_story_item(
|
||||
# Get profile name
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||
|
||||
return StoryItemDetail(
|
||||
id=item.id,
|
||||
story_id=item.story_id,
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
|
||||
|
||||
|
||||
async def remove_item_from_story(
|
||||
@@ -495,25 +465,7 @@ async def trim_story_item(
|
||||
# Get profile name
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||
|
||||
return StoryItemDetail(
|
||||
id=item.id,
|
||||
story_id=item.story_id,
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=item.trim_start_ms,
|
||||
trim_end_ms=item.trim_end_ms,
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
|
||||
|
||||
|
||||
async def split_story_item(
|
||||
@@ -568,6 +520,7 @@ async def split_story_item(
|
||||
id=str(uuid.uuid4()),
|
||||
story_id=story_id,
|
||||
generation_id=item.generation_id, # Same generation, different trim
|
||||
version_id=getattr(item, 'version_id', None), # Preserve pinned version
|
||||
start_time_ms=item.start_time_ms + data.split_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=absolute_split_ms,
|
||||
@@ -590,48 +543,10 @@ async def split_story_item(
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||
profile_name = profile.name if profile else "Unknown"
|
||||
|
||||
# Build response items
|
||||
original_item_detail = StoryItemDetail(
|
||||
id=item.id,
|
||||
story_id=item.story_id,
|
||||
generation_id=item.generation_id,
|
||||
start_time_ms=item.start_time_ms,
|
||||
track=item.track,
|
||||
trim_start_ms=item.trim_start_ms,
|
||||
trim_end_ms=item.trim_end_ms,
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile_name,
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
|
||||
new_item_detail = StoryItemDetail(
|
||||
id=new_item.id,
|
||||
story_id=new_item.story_id,
|
||||
generation_id=new_item.generation_id,
|
||||
start_time_ms=new_item.start_time_ms,
|
||||
track=new_item.track,
|
||||
trim_start_ms=new_item.trim_start_ms,
|
||||
trim_end_ms=new_item.trim_end_ms,
|
||||
created_at=new_item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile_name,
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
|
||||
return [original_item_detail, new_item_detail]
|
||||
return [
|
||||
_build_item_detail(item, generation, profile_name, db),
|
||||
_build_item_detail(new_item, generation, profile_name, db),
|
||||
]
|
||||
|
||||
|
||||
async def duplicate_story_item(
|
||||
@@ -674,6 +589,7 @@ async def duplicate_story_item(
|
||||
id=str(uuid.uuid4()),
|
||||
story_id=story_id,
|
||||
generation_id=original_item.generation_id, # Same generation as original
|
||||
version_id=getattr(original_item, 'version_id', None), # Preserve pinned version
|
||||
start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap
|
||||
track=original_item.track,
|
||||
trim_start_ms=current_trim_start,
|
||||
@@ -694,25 +610,7 @@ async def duplicate_story_item(
|
||||
# Get profile name
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||
|
||||
return StoryItemDetail(
|
||||
id=new_item.id,
|
||||
story_id=new_item.story_id,
|
||||
generation_id=new_item.generation_id,
|
||||
start_time_ms=new_item.start_time_ms,
|
||||
track=new_item.track,
|
||||
trim_start_ms=new_item.trim_start_ms,
|
||||
trim_end_ms=new_item.trim_end_ms,
|
||||
created_at=new_item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile.name if profile else "Unknown",
|
||||
text=generation.text,
|
||||
language=generation.language,
|
||||
audio_path=generation.audio_path,
|
||||
duration=generation.duration,
|
||||
seed=generation.seed,
|
||||
instruct=generation.instruct,
|
||||
generation_created_at=generation.created_at,
|
||||
)
|
||||
return _build_item_detail(new_item, generation, profile.name if profile else "Unknown", db)
|
||||
|
||||
|
||||
async def update_story_item_times(
|
||||
@@ -813,25 +711,7 @@ async def reorder_story_items(
|
||||
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,
|
||||
track=item.track,
|
||||
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||
created_at=item.created_at,
|
||||
profile_id=generation.profile_id,
|
||||
profile_name=profile_name,
|
||||
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,
|
||||
))
|
||||
updated_items.append(_build_item_detail(item, generation, profile_name, db))
|
||||
|
||||
# Update story updated_at
|
||||
story.updated_at = datetime.utcnow()
|
||||
@@ -840,6 +720,60 @@ async def reorder_story_items(
|
||||
return updated_items
|
||||
|
||||
|
||||
async def set_story_item_version(
|
||||
story_id: str,
|
||||
item_id: str,
|
||||
data: StoryItemVersionUpdate,
|
||||
db: Session,
|
||||
) -> Optional[StoryItemDetail]:
|
||||
"""
|
||||
Pin a story item to a specific generation version.
|
||||
|
||||
Args:
|
||||
story_id: Story ID
|
||||
item_id: Story item ID
|
||||
data: Version update data (version_id or null for default)
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Updated item detail or None if not found
|
||||
"""
|
||||
item = db.query(DBStoryItem).filter_by(
|
||||
id=item_id,
|
||||
story_id=story_id,
|
||||
).first()
|
||||
if not item:
|
||||
return None
|
||||
|
||||
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||
if not generation:
|
||||
return None
|
||||
|
||||
# Validate version_id belongs to this generation if provided
|
||||
if data.version_id:
|
||||
from .database import GenerationVersion as DBGenerationVersion
|
||||
version = db.query(DBGenerationVersion).filter_by(
|
||||
id=data.version_id,
|
||||
generation_id=item.generation_id,
|
||||
).first()
|
||||
if not version:
|
||||
return None
|
||||
|
||||
item.version_id = data.version_id
|
||||
|
||||
# Update story updated_at
|
||||
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||
if story:
|
||||
story.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||
|
||||
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
|
||||
|
||||
|
||||
async def export_story_audio(
|
||||
story_id: str,
|
||||
db: Session,
|
||||
@@ -877,7 +811,15 @@ async def export_story_audio(
|
||||
sample_rate = 24000 # Default sample rate
|
||||
|
||||
for item, generation in items:
|
||||
audio_path = Path(generation.audio_path)
|
||||
# Resolve audio path: use pinned version if set, otherwise generation default
|
||||
resolved_audio_path = generation.audio_path
|
||||
if getattr(item, 'version_id', None):
|
||||
from .database import GenerationVersion as DBGenerationVersion
|
||||
version = db.query(DBGenerationVersion).filter_by(id=item.version_id).first()
|
||||
if version:
|
||||
resolved_audio_path = version.audio_path
|
||||
|
||||
audio_path = Path(resolved_audio_path)
|
||||
if not audio_path.exists():
|
||||
continue
|
||||
|
||||
|
||||
Reference in New Issue
Block a user