mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-04 09:35:16 -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 { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -29,7 +29,13 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
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 { cn } from '@/lib/utils/cn';
|
||||||
import { formatDate } from '@/lib/utils/format';
|
import { formatDate } from '@/lib/utils/format';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
@@ -38,6 +44,8 @@ export function StoryList() {
|
|||||||
const { data: stories, isLoading } = useStories();
|
const { data: stories, isLoading } = useStories();
|
||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||||
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
const { data: selectedStory } = useStory(selectedStoryId);
|
||||||
const createStory = useCreateStory();
|
const createStory = useCreateStory();
|
||||||
const updateStory = useUpdateStory();
|
const updateStory = useUpdateStory();
|
||||||
const deleteStory = useDeleteStory();
|
const deleteStory = useDeleteStory();
|
||||||
@@ -170,10 +178,15 @@ export function StoryList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storyList = stories || [];
|
const storyList = stories || [];
|
||||||
|
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="h-full flex flex-col relative overflow-hidden">
|
||||||
{/* Header */}
|
{/* 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">
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
<h2 className="text-2xl font-bold">Stories</h2>
|
<h2 className="text-2xl font-bold">Stories</h2>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||||
@@ -181,9 +194,13 @@ export function StoryList() {
|
|||||||
New Story
|
New Story
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Story List */}
|
{/* Scrollable Story List */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
<div
|
||||||
|
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
||||||
|
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||||
|
>
|
||||||
{storyList.length === 0 ? (
|
{storyList.length === 0 ? (
|
||||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
<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" />
|
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
@@ -191,20 +208,17 @@ export function StoryList() {
|
|||||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
storyList.map((story) => (
|
<div className="space-y-0.5">
|
||||||
|
{storyList.map((story) => (
|
||||||
<div
|
<div
|
||||||
key={story.id}
|
key={story.id}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer',
|
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||||
selectedStoryId === story.id && 'bg-muted border-primary',
|
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
||||||
)}
|
)}
|
||||||
aria-label={
|
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
|
||||||
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}
|
aria-pressed={selectedStoryId === story.id}
|
||||||
onClick={() => setSelectedStoryId(story.id)}
|
onClick={() => setSelectedStoryId(story.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -217,17 +231,12 @@ export function StoryList() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
<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">
|
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||||
<h3 className="font-medium truncate">{story.name}</h3>
|
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||||
{story.description && (
|
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||||
<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>
|
<span>
|
||||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<span>·</span>
|
||||||
<span>{formatDate(story.updated_at)}</span>
|
<span>{formatDate(story.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,11 +245,11 @@ export function StoryList() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
aria-label={`Actions for ${story.name}`}
|
aria-label={`Actions for ${story.name}`}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -259,7 +268,8 @@ export function StoryList() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
|
GalleryVerticalEnd,
|
||||||
GripHorizontal,
|
GripHorizontal,
|
||||||
Minus,
|
Minus,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -12,6 +14,12 @@ import {
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
@@ -19,6 +27,7 @@ import {
|
|||||||
useDuplicateStoryItem,
|
useDuplicateStoryItem,
|
||||||
useMoveStoryItem,
|
useMoveStoryItem,
|
||||||
useRemoveStoryItem,
|
useRemoveStoryItem,
|
||||||
|
useSetStoryItemVersion,
|
||||||
useSplitStoryItem,
|
useSplitStoryItem,
|
||||||
useTrimStoryItem,
|
useTrimStoryItem,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
@@ -28,12 +37,14 @@ import { useStoryStore } from '@/stores/storyStore';
|
|||||||
// Clip waveform component with trim support
|
// Clip waveform component with trim support
|
||||||
function ClipWaveform({
|
function ClipWaveform({
|
||||||
generationId,
|
generationId,
|
||||||
|
versionId,
|
||||||
width,
|
width,
|
||||||
trimStartMs,
|
trimStartMs,
|
||||||
trimEndMs,
|
trimEndMs,
|
||||||
duration,
|
duration,
|
||||||
}: {
|
}: {
|
||||||
generationId: string;
|
generationId: string;
|
||||||
|
versionId?: string;
|
||||||
width: number;
|
width: number;
|
||||||
trimStartMs: number;
|
trimStartMs: number;
|
||||||
trimEndMs: number;
|
trimEndMs: number;
|
||||||
@@ -79,7 +90,9 @@ function ClipWaveform({
|
|||||||
|
|
||||||
wavesurferRef.current = wavesurfer;
|
wavesurferRef.current = wavesurfer;
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(generationId);
|
const audioUrl = versionId
|
||||||
|
? apiClient.getVersionAudioUrl(versionId)
|
||||||
|
: apiClient.getAudioUrl(generationId);
|
||||||
wavesurfer.load(audioUrl).catch(() => {
|
wavesurfer.load(audioUrl).catch(() => {
|
||||||
// Ignore load errors
|
// Ignore load errors
|
||||||
});
|
});
|
||||||
@@ -88,7 +101,7 @@ function ClipWaveform({
|
|||||||
wavesurfer.destroy();
|
wavesurfer.destroy();
|
||||||
wavesurferRef.current = null;
|
wavesurferRef.current = null;
|
||||||
};
|
};
|
||||||
}, [generationId, fullWaveformWidth]);
|
}, [generationId, versionId, fullWaveformWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full opacity-60 overflow-hidden">
|
<div className="w-full h-full opacity-60 overflow-hidden">
|
||||||
@@ -135,12 +148,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const splitItem = useSplitStoryItem();
|
const splitItem = useSplitStoryItem();
|
||||||
const duplicateItem = useDuplicateStoryItem();
|
const duplicateItem = useDuplicateStoryItem();
|
||||||
const removeItem = useRemoveStoryItem();
|
const removeItem = useRemoveStoryItem();
|
||||||
|
const setItemVersion = useSetStoryItemVersion();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||||
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
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
|
// Trim state
|
||||||
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
||||||
const [trimSide, setTrimSide] = useState<'start' | 'end' | 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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -958,6 +1059,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
<div className="absolute inset-0 top-3">
|
<div className="absolute inset-0 top-3">
|
||||||
<ClipWaveform
|
<ClipWaveform
|
||||||
generationId={item.generation_id}
|
generationId={item.generation_id}
|
||||||
|
versionId={item.version_id}
|
||||||
width={clipWidth}
|
width={clipWidth}
|
||||||
trimStartMs={displayTrimStart}
|
trimStartMs={displayTrimStart}
|
||||||
trimEndMs={displayTrimEnd}
|
trimEndMs={displayTrimEnd}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
StoryItemReorder,
|
StoryItemReorder,
|
||||||
StoryItemSplit,
|
StoryItemSplit,
|
||||||
StoryItemTrim,
|
StoryItemTrim,
|
||||||
|
StoryItemVersionUpdate,
|
||||||
StoryResponse,
|
StoryResponse,
|
||||||
TranscriptionResponse,
|
TranscriptionResponse,
|
||||||
VoiceProfileCreate,
|
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> {
|
async exportStoryAudio(storyId: string): Promise<Blob> {
|
||||||
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export interface StoryItemDetail {
|
|||||||
id: string;
|
id: string;
|
||||||
story_id: string;
|
story_id: string;
|
||||||
generation_id: string;
|
generation_id: string;
|
||||||
|
version_id?: string;
|
||||||
start_time_ms: number;
|
start_time_ms: number;
|
||||||
track: number;
|
track: number;
|
||||||
trim_start_ms: number;
|
trim_start_ms: number;
|
||||||
@@ -236,6 +237,12 @@ export interface StoryItemDetail {
|
|||||||
seed?: number;
|
seed?: number;
|
||||||
instruct?: string;
|
instruct?: string;
|
||||||
generation_created_at: string;
|
generation_created_at: string;
|
||||||
|
versions?: GenerationVersionResponse[];
|
||||||
|
active_version_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemVersionUpdate {
|
||||||
|
version_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoryDetailResponse {
|
export interface StoryDetailResponse {
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
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';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function useStories() {
|
export function useStories() {
|
||||||
@@ -109,8 +118,15 @@ export function useMoveStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
|
mutationFn: ({
|
||||||
apiClient.moveStoryItem(storyId, itemId, data),
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemMove;
|
||||||
|
}) => apiClient.moveStoryItem(storyId, itemId, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
@@ -122,8 +138,15 @@ export function useTrimStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
|
mutationFn: ({
|
||||||
apiClient.trimStoryItem(storyId, itemId, data),
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemTrim;
|
||||||
|
}) => apiClient.trimStoryItem(storyId, itemId, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
@@ -135,8 +158,15 @@ export function useSplitStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
|
mutationFn: ({
|
||||||
apiClient.splitStoryItem(storyId, itemId, data),
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemSplit;
|
||||||
|
}) => apiClient.splitStoryItem(storyId, itemId, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
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() {
|
export function useExportStoryAudio() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
|
||||||
@@ -165,7 +215,10 @@ export function useExportStoryAudio() {
|
|||||||
const blob = await apiClient.exportStoryAudio(storyId);
|
const blob = await apiClient.exportStoryAudio(storyId);
|
||||||
|
|
||||||
// Create safe filename
|
// 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`;
|
const filename = `${safeName || 'story'}.wav`;
|
||||||
|
|
||||||
await platform.filesystem.saveFile(filename, blob, [
|
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
|
// Preload audio files as AudioBuffers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
@@ -78,12 +88,12 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIds = new Set(items.map((item) => item.generation_id));
|
const currentKeys = new Set(items.map(getAudioKey));
|
||||||
const audioContext = getAudioContext();
|
const audioContext = getAudioContext();
|
||||||
|
|
||||||
// Remove buffers for items that no longer exist
|
// Remove buffers for items that no longer exist
|
||||||
for (const [id] of audioBuffersRef.current) {
|
for (const [id] of audioBuffersRef.current) {
|
||||||
if (!currentIds.has(id)) {
|
if (!currentKeys.has(id)) {
|
||||||
audioBuffersRef.current.delete(id);
|
audioBuffersRef.current.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,24 +101,25 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
// Preload audio for new items
|
// Preload audio for new items
|
||||||
const preloadPromises: Promise<void>[] = [];
|
const preloadPromises: Promise<void>[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!audioBuffersRef.current.has(item.generation_id)) {
|
const key = getAudioKey(item);
|
||||||
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
if (!audioBuffersRef.current.has(key)) {
|
||||||
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
|
const audioUrl = getAudioUrlForItem(item);
|
||||||
|
console.log('[StoryPlayback] Preloading audio buffer:', key);
|
||||||
|
|
||||||
const preloadPromise = fetch(audioUrl)
|
const preloadPromise = fetch(audioUrl)
|
||||||
.then((response) => response.arrayBuffer())
|
.then((response) => response.arrayBuffer())
|
||||||
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||||
.then((audioBuffer) => {
|
.then((audioBuffer) => {
|
||||||
audioBuffersRef.current.set(item.generation_id, audioBuffer);
|
audioBuffersRef.current.set(key, audioBuffer);
|
||||||
console.log(
|
console.log(
|
||||||
'[StoryPlayback] Preloaded buffer:',
|
'[StoryPlayback] Preloaded buffer:',
|
||||||
item.generation_id,
|
key,
|
||||||
'duration:',
|
'duration:',
|
||||||
audioBuffer.duration,
|
audioBuffer.duration,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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);
|
preloadPromises.push(preloadPromise);
|
||||||
@@ -216,9 +227,10 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
// Schedule new sources for items that should be playing
|
// Schedule new sources for items that should be playing
|
||||||
for (const item of shouldBePlaying) {
|
for (const item of shouldBePlaying) {
|
||||||
if (!activeSourcesRef.current.has(item.id)) {
|
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) {
|
if (!buffer) {
|
||||||
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class StoryItem(Base):
|
|||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
story_id = Column(String, ForeignKey("stories.id"), nullable=False)
|
story_id = Column(String, ForeignKey("stories.id"), nullable=False)
|
||||||
generation_id = Column(String, ForeignKey("generations.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
|
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)
|
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
|
||||||
trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
|
trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
|
||||||
@@ -376,6 +377,16 @@ def _run_migrations(engine):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
print("Added sort_order column to effect_presets")
|
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():
|
if 'generations' in inspector.get_table_names():
|
||||||
columns = {col['name'] for col in inspector.get_columns('generations')}
|
columns = {col['name'] for col in inspector.get_columns('generations')}
|
||||||
if 'is_favorited' not in columns:
|
if 'is_favorited' not in columns:
|
||||||
|
|||||||
@@ -1644,6 +1644,20 @@ async def duplicate_story_item(
|
|||||||
return 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")
|
@app.get("/stories/{story_id}/export-audio")
|
||||||
async def export_story_audio(
|
async def export_story_audio(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ class StoryItemDetail(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
story_id: str
|
story_id: str
|
||||||
generation_id: str
|
generation_id: str
|
||||||
|
version_id: Optional[str] = None
|
||||||
start_time_ms: int
|
start_time_ms: int
|
||||||
track: int = 0
|
track: int = 0
|
||||||
trim_start_ms: int = 0
|
trim_start_ms: int = 0
|
||||||
@@ -291,6 +292,9 @@ class StoryItemDetail(BaseModel):
|
|||||||
seed: Optional[int]
|
seed: Optional[int]
|
||||||
instruct: Optional[str]
|
instruct: Optional[str]
|
||||||
generation_created_at: datetime
|
generation_created_at: datetime
|
||||||
|
# Versions available for this generation
|
||||||
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
|
active_version_id: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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)
|
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
|
# Effects & Versions
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
+119
-177
@@ -20,12 +20,55 @@ from .models import (
|
|||||||
StoryItemMove,
|
StoryItemMove,
|
||||||
StoryItemTrim,
|
StoryItemTrim,
|
||||||
StoryItemSplit,
|
StoryItemSplit,
|
||||||
|
StoryItemVersionUpdate,
|
||||||
)
|
)
|
||||||
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
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
|
from .utils.audio import load_audio, save_audio
|
||||||
import numpy as np
|
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(
|
async def create_story(
|
||||||
data: StoryCreate,
|
data: StoryCreate,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -125,26 +168,7 @@ async def get_story(
|
|||||||
# Build item details
|
# Build item details
|
||||||
item_details = []
|
item_details = []
|
||||||
for item, generation, profile_name in items:
|
for item, generation, profile_name in items:
|
||||||
item_detail = StoryItemDetail(
|
item_details.append(_build_item_detail(item, generation, profile_name, db))
|
||||||
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)
|
|
||||||
|
|
||||||
response = StoryDetailResponse.model_validate(story)
|
response = StoryDetailResponse.model_validate(story)
|
||||||
response.items = item_details
|
response.items = item_details
|
||||||
@@ -250,25 +274,7 @@ async def add_item_to_story(
|
|||||||
if existing:
|
if existing:
|
||||||
# Return existing item
|
# Return existing item
|
||||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
return StoryItemDetail(
|
return _build_item_detail(existing, generation, profile.name if profile else "Unknown", db)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get track from data or default to 0
|
# Get track from data or default to 0
|
||||||
track = data.track if data.track is not None else 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
|
# Get profile name
|
||||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
return StoryItemDetail(
|
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def move_story_item(
|
async def move_story_item(
|
||||||
@@ -388,25 +376,7 @@ async def move_story_item(
|
|||||||
# Get profile name
|
# Get profile name
|
||||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
return StoryItemDetail(
|
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_item_from_story(
|
async def remove_item_from_story(
|
||||||
@@ -495,25 +465,7 @@ async def trim_story_item(
|
|||||||
# Get profile name
|
# Get profile name
|
||||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
return StoryItemDetail(
|
return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
|
||||||
id=item.id,
|
|
||||||
story_id=item.story_id,
|
|
||||||
generation_id=item.generation_id,
|
|
||||||
start_time_ms=item.start_time_ms,
|
|
||||||
track=item.track,
|
|
||||||
trim_start_ms=item.trim_start_ms,
|
|
||||||
trim_end_ms=item.trim_end_ms,
|
|
||||||
created_at=item.created_at,
|
|
||||||
profile_id=generation.profile_id,
|
|
||||||
profile_name=profile.name if profile else "Unknown",
|
|
||||||
text=generation.text,
|
|
||||||
language=generation.language,
|
|
||||||
audio_path=generation.audio_path,
|
|
||||||
duration=generation.duration,
|
|
||||||
seed=generation.seed,
|
|
||||||
instruct=generation.instruct,
|
|
||||||
generation_created_at=generation.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def split_story_item(
|
async def split_story_item(
|
||||||
@@ -568,6 +520,7 @@ async def split_story_item(
|
|||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
story_id=story_id,
|
story_id=story_id,
|
||||||
generation_id=item.generation_id, # Same generation, different trim
|
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,
|
start_time_ms=item.start_time_ms + data.split_time_ms,
|
||||||
track=item.track,
|
track=item.track,
|
||||||
trim_start_ms=absolute_split_ms,
|
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 = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
profile_name = profile.name if profile else "Unknown"
|
profile_name = profile.name if profile else "Unknown"
|
||||||
|
|
||||||
# Build response items
|
return [
|
||||||
original_item_detail = StoryItemDetail(
|
_build_item_detail(item, generation, profile_name, db),
|
||||||
id=item.id,
|
_build_item_detail(new_item, generation, profile_name, db),
|
||||||
story_id=item.story_id,
|
]
|
||||||
generation_id=item.generation_id,
|
|
||||||
start_time_ms=item.start_time_ms,
|
|
||||||
track=item.track,
|
|
||||||
trim_start_ms=item.trim_start_ms,
|
|
||||||
trim_end_ms=item.trim_end_ms,
|
|
||||||
created_at=item.created_at,
|
|
||||||
profile_id=generation.profile_id,
|
|
||||||
profile_name=profile_name,
|
|
||||||
text=generation.text,
|
|
||||||
language=generation.language,
|
|
||||||
audio_path=generation.audio_path,
|
|
||||||
duration=generation.duration,
|
|
||||||
seed=generation.seed,
|
|
||||||
instruct=generation.instruct,
|
|
||||||
generation_created_at=generation.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
new_item_detail = StoryItemDetail(
|
|
||||||
id=new_item.id,
|
|
||||||
story_id=new_item.story_id,
|
|
||||||
generation_id=new_item.generation_id,
|
|
||||||
start_time_ms=new_item.start_time_ms,
|
|
||||||
track=new_item.track,
|
|
||||||
trim_start_ms=new_item.trim_start_ms,
|
|
||||||
trim_end_ms=new_item.trim_end_ms,
|
|
||||||
created_at=new_item.created_at,
|
|
||||||
profile_id=generation.profile_id,
|
|
||||||
profile_name=profile_name,
|
|
||||||
text=generation.text,
|
|
||||||
language=generation.language,
|
|
||||||
audio_path=generation.audio_path,
|
|
||||||
duration=generation.duration,
|
|
||||||
seed=generation.seed,
|
|
||||||
instruct=generation.instruct,
|
|
||||||
generation_created_at=generation.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
return [original_item_detail, new_item_detail]
|
|
||||||
|
|
||||||
|
|
||||||
async def duplicate_story_item(
|
async def duplicate_story_item(
|
||||||
@@ -674,6 +589,7 @@ async def duplicate_story_item(
|
|||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
story_id=story_id,
|
story_id=story_id,
|
||||||
generation_id=original_item.generation_id, # Same generation as original
|
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
|
start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap
|
||||||
track=original_item.track,
|
track=original_item.track,
|
||||||
trim_start_ms=current_trim_start,
|
trim_start_ms=current_trim_start,
|
||||||
@@ -694,25 +610,7 @@ async def duplicate_story_item(
|
|||||||
# Get profile name
|
# Get profile name
|
||||||
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
return StoryItemDetail(
|
return _build_item_detail(new_item, generation, profile.name if profile else "Unknown", db)
|
||||||
id=new_item.id,
|
|
||||||
story_id=new_item.story_id,
|
|
||||||
generation_id=new_item.generation_id,
|
|
||||||
start_time_ms=new_item.start_time_ms,
|
|
||||||
track=new_item.track,
|
|
||||||
trim_start_ms=new_item.trim_start_ms,
|
|
||||||
trim_end_ms=new_item.trim_end_ms,
|
|
||||||
created_at=new_item.created_at,
|
|
||||||
profile_id=generation.profile_id,
|
|
||||||
profile_name=profile.name if profile else "Unknown",
|
|
||||||
text=generation.text,
|
|
||||||
language=generation.language,
|
|
||||||
audio_path=generation.audio_path,
|
|
||||||
duration=generation.duration,
|
|
||||||
seed=generation.seed,
|
|
||||||
instruct=generation.instruct,
|
|
||||||
generation_created_at=generation.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_story_item_times(
|
async def update_story_item_times(
|
||||||
@@ -813,25 +711,7 @@ async def reorder_story_items(
|
|||||||
current_time_ms += duration_ms + gap_ms
|
current_time_ms += duration_ms + gap_ms
|
||||||
|
|
||||||
# Build the response item
|
# Build the response item
|
||||||
updated_items.append(StoryItemDetail(
|
updated_items.append(_build_item_detail(item, generation, profile_name, db))
|
||||||
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,
|
|
||||||
))
|
|
||||||
|
|
||||||
# Update story updated_at
|
# Update story updated_at
|
||||||
story.updated_at = datetime.utcnow()
|
story.updated_at = datetime.utcnow()
|
||||||
@@ -840,6 +720,60 @@ async def reorder_story_items(
|
|||||||
return updated_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(
|
async def export_story_audio(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -877,7 +811,15 @@ async def export_story_audio(
|
|||||||
sample_rate = 24000 # Default sample rate
|
sample_rate = 24000 # Default sample rate
|
||||||
|
|
||||||
for item, generation in items:
|
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():
|
if not audio_path.exists():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user