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:
Jamie Pine
2026-03-14 09:56:27 -07:00
parent e8d54d52d3
commit 899b90202b
10 changed files with 448 additions and 276 deletions
+88 -78
View File
@@ -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,20 +178,29 @@ 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="flex items-center justify-between mb-4 px-1"> <div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
<h2 className="text-2xl font-bold">Stories</h2>
<Button onClick={() => setCreateDialogOpen(true)} size="sm"> {/* Fixed Header */}
<Plus className="mr-2 h-4 w-4" /> <div className="absolute top-0 left-0 right-0 z-20">
New Story <div className="flex items-center justify-between mb-4 px-1">
</Button> <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> </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,75 +208,68 @@ 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">
<div {storyList.map((story) => (
key={story.id} <div
role="button" key={story.id}
tabIndex={0} role="button"
className={cn( tabIndex={0}
'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer', className={cn(
selectedStoryId === story.id && 'bg-muted border-primary', '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={ )}
selectedStoryId === story.id aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
? `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Selected. Press Enter to select.` aria-pressed={selectedStoryId === story.id}
: `Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.` onClick={() => setSelectedStoryId(story.id)}
} onKeyDown={(e) => {
aria-pressed={selectedStoryId === story.id} if (e.target !== e.currentTarget) return;
onClick={() => setSelectedStoryId(story.id)} if (e.key === 'Enter' || e.key === ' ') {
onKeyDown={(e) => { e.preventDefault();
if (e.target !== e.currentTarget) return; setSelectedStoryId(story.id);
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-start justify-between gap-2 w-full min-w-0"> <div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<div className="flex-1 min-w-0 text-left overflow-hidden"> <span>
<h3 className="font-medium truncate">{story.name}</h3> {story.item_count} {story.item_count === 1 ? 'item' : 'items'}
{story.description && ( </span>
<p className="text-sm text-muted-foreground mt-1 truncate"> <span>·</span>
{story.description} <span>{formatDate(story.updated_at)}</span>
</p> </div>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span>
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
</span>
<span>•</span>
<span>{formatDate(story.updated_at)}</span>
</div> </div>
<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> </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>
)} )}
</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}
+12
View File
@@ -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);
+7
View File
@@ -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 {
+61 -8
View File
@@ -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, [
+23 -11
View File
@@ -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,15 +227,16 @@ 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;
} }
// Calculate when this item should start in AudioContext time // Calculate when this item should start in AudioContext time
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms); const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
// Calculate effective duration and trim offsets // Calculate effective duration and trim offsets
const trimStartSec = (item.trim_start_ms || 0) / 1000; const trimStartSec = (item.trim_start_ms || 0) / 1000;
const trimEndSec = (item.trim_end_ms || 0) / 1000; const trimEndSec = (item.trim_end_ms || 0) / 1000;
+11
View File
@@ -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:
+14
View File
@@ -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,
+9
View File
@@ -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
View File
@@ -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