diff --git a/app/src/components/StoriesTab/StoryList.tsx b/app/src/components/StoriesTab/StoryList.tsx index a39a806d..64719257 100644 --- a/app/src/components/StoriesTab/StoryList.tsx +++ b/app/src/components/StoriesTab/StoryList.tsx @@ -1,4 +1,4 @@ -import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; +import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { AlertDialog, @@ -29,7 +29,13 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; -import { useStories, useCreateStory, useUpdateStory, useDeleteStory } from '@/lib/hooks/useStories'; +import { + useCreateStory, + useDeleteStory, + useStories, + useStory, + useUpdateStory, +} from '@/lib/hooks/useStories'; import { cn } from '@/lib/utils/cn'; import { formatDate } from '@/lib/utils/format'; import { useStoryStore } from '@/stores/storyStore'; @@ -38,6 +44,8 @@ export function StoryList() { const { data: stories, isLoading } = useStories(); const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId); + const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); + const { data: selectedStory } = useStory(selectedStoryId); const createStory = useCreateStory(); const updateStory = useUpdateStory(); const deleteStory = useDeleteStory(); @@ -170,20 +178,29 @@ export function StoryList() { } const storyList = stories || []; + const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0; return ( -
- {/* Header */} -
-

Stories

- +
+ {/* Scroll Mask */} +
+ + {/* Fixed Header */} +
+
+

Stories

+ +
- {/* Story List */} -
+ {/* Scrollable Story List */} +
{storyList.length === 0 ? (
@@ -191,75 +208,68 @@ export function StoryList() {

Create your first story to get started

) : ( - storyList.map((story) => ( -
setSelectedStoryId(story.id)} - onKeyDown={(e) => { - if (e.target !== e.currentTarget) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setSelectedStoryId(story.id); - } - }} - > -
-
-

{story.name}

- {story.description && ( -

- {story.description} -

- )} -
- - {story.item_count} {story.item_count === 1 ? 'item' : 'items'} - - - {formatDate(story.updated_at)} +
+ {storyList.map((story) => ( +
setSelectedStoryId(story.id)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedStoryId(story.id); + } + }} + > +
+
+

{story.name}

+
+ + {story.item_count} {story.item_count === 1 ? 'item' : 'items'} + + · + {formatDate(story.updated_at)} +
+ + + + + + handleEditClick(story)}> + + Edit + + handleDeleteClick(story.id)} + className="text-destructive focus:text-destructive" + > + + Delete + + +
- - - - - - handleEditClick(story)}> - - Edit - - handleDeleteClick(story.id)} - className="text-destructive focus:text-destructive" - > - - Delete - - -
-
- )) + ))} +
)}
diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 71e33cdd..ef20bf95 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -1,5 +1,7 @@ import { + Check, Copy, + GalleryVerticalEnd, GripHorizontal, Minus, Pause, @@ -12,6 +14,12 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { StoryItemDetail } from '@/lib/api/types'; @@ -19,6 +27,7 @@ import { useDuplicateStoryItem, useMoveStoryItem, useRemoveStoryItem, + useSetStoryItemVersion, useSplitStoryItem, useTrimStoryItem, } from '@/lib/hooks/useStories'; @@ -28,12 +37,14 @@ import { useStoryStore } from '@/stores/storyStore'; // Clip waveform component with trim support function ClipWaveform({ generationId, + versionId, width, trimStartMs, trimEndMs, duration, }: { generationId: string; + versionId?: string; width: number; trimStartMs: number; trimEndMs: number; @@ -79,7 +90,9 @@ function ClipWaveform({ wavesurferRef.current = wavesurfer; - const audioUrl = apiClient.getAudioUrl(generationId); + const audioUrl = versionId + ? apiClient.getVersionAudioUrl(versionId) + : apiClient.getAudioUrl(generationId); wavesurfer.load(audioUrl).catch(() => { // Ignore load errors }); @@ -88,7 +101,7 @@ function ClipWaveform({ wavesurfer.destroy(); wavesurferRef.current = null; }; - }, [generationId, fullWaveformWidth]); + }, [generationId, versionId, fullWaveformWidth]); return (
@@ -135,12 +148,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const splitItem = useSplitStoryItem(); const duplicateItem = useDuplicateStoryItem(); const removeItem = useRemoveStoryItem(); + const setItemVersion = useSetStoryItemVersion(); const { toast } = useToast(); // Selection state const selectedClipId = useStoryStore((state) => state.selectedClipId); const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId); + // Selected clip item (for version picker) + const selectedItem = useMemo( + () => (selectedClipId ? items.find((i) => i.id === selectedClipId) : undefined), + [selectedClipId, items], + ); + const selectedItemVersions = selectedItem?.versions; + const hasMultipleVersions = selectedItemVersions && selectedItemVersions.length > 1; + + // Determine which version label is active for the selected clip + const activeVersionLabel = useMemo(() => { + if (!selectedItem || !selectedItemVersions) return null; + // If the item has a pinned version_id, find its label + if (selectedItem.version_id) { + const pinned = selectedItemVersions.find((v) => v.id === selectedItem.version_id); + return pinned?.label ?? null; + } + // Otherwise use the generation's default version + const defaultVersion = selectedItemVersions.find((v) => v.is_default); + return defaultVersion?.label ?? null; + }, [selectedItem, selectedItemVersions]); + + const handleSetVersion = useCallback( + (versionId: string | null) => { + if (!selectedClipId) return; + setItemVersion.mutate( + { + storyId, + itemId: selectedClipId, + data: { version_id: versionId }, + }, + { + onError: (error) => { + toast({ + title: 'Failed to set version', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }); + }, + }, + ); + }, + [selectedClipId, storyId, setItemVersion, toast], + ); + // Trim state const [trimmingItem, setTrimmingItem] = useState(null); const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null); @@ -788,6 +846,49 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { > + {hasMultipleVersions && ( + <> +
+ + + + + + {selectedItemVersions.map((version) => { + const isActive = selectedItem?.version_id + ? version.id === selectedItem.version_id + : version.is_default; + return ( + handleSetVersion(version.id)} + className="gap-2 text-xs" + > + + {version.label} + {version.effects_chain && version.effects_chain.length > 0 && ( + + {version.effects_chain.length} fx + + )} + + ); + })} + + + + )}
)} @@ -958,6 +1059,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{ + return this.request(`/stories/${storyId}/items/${itemId}/version`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + async exportStoryAudio(storyId: string): Promise { const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`; const response = await fetch(url); diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index 5a00c2f7..225d0532 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -222,6 +222,7 @@ export interface StoryItemDetail { id: string; story_id: string; generation_id: string; + version_id?: string; start_time_ms: number; track: number; trim_start_ms: number; @@ -236,6 +237,12 @@ export interface StoryItemDetail { seed?: number; instruct?: string; generation_created_at: string; + versions?: GenerationVersionResponse[]; + active_version_id?: string; +} + +export interface StoryItemVersionUpdate { + version_id: string | null; } export interface StoryDetailResponse { diff --git a/app/src/lib/hooks/useStories.ts b/app/src/lib/hooks/useStories.ts index 2b35f381..ffc5aee3 100644 --- a/app/src/lib/hooks/useStories.ts +++ b/app/src/lib/hooks/useStories.ts @@ -1,6 +1,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; -import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types'; +import type { + StoryCreate, + StoryItemBatchUpdate, + StoryItemCreate, + StoryItemMove, + StoryItemReorder, + StoryItemSplit, + StoryItemTrim, + StoryItemVersionUpdate, +} from '@/lib/api/types'; import { usePlatform } from '@/platform/PlatformContext'; export function useStories() { @@ -109,8 +118,15 @@ export function useMoveStoryItem() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) => - apiClient.moveStoryItem(storyId, itemId, data), + mutationFn: ({ + storyId, + itemId, + data, + }: { + storyId: string; + itemId: string; + data: StoryItemMove; + }) => apiClient.moveStoryItem(storyId, itemId, data), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); @@ -122,8 +138,15 @@ export function useTrimStoryItem() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) => - apiClient.trimStoryItem(storyId, itemId, data), + mutationFn: ({ + storyId, + itemId, + data, + }: { + storyId: string; + itemId: string; + data: StoryItemTrim; + }) => apiClient.trimStoryItem(storyId, itemId, data), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); @@ -135,8 +158,15 @@ export function useSplitStoryItem() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) => - apiClient.splitStoryItem(storyId, itemId, data), + mutationFn: ({ + storyId, + itemId, + data, + }: { + storyId: string; + itemId: string; + data: StoryItemSplit; + }) => apiClient.splitStoryItem(storyId, itemId, data), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['stories'] }); queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); @@ -157,6 +187,26 @@ export function useDuplicateStoryItem() { }); } +export function useSetStoryItemVersion() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + storyId, + itemId, + data, + }: { + storyId: string; + itemId: string; + data: StoryItemVersionUpdate; + }) => apiClient.setStoryItemVersion(storyId, itemId, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['stories'] }); + queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); + }, + }); +} + export function useExportStoryAudio() { const platform = usePlatform(); @@ -165,7 +215,10 @@ export function useExportStoryAudio() { const blob = await apiClient.exportStoryAudio(storyId); // Create safe filename - const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase(); + const safeName = storyName + .substring(0, 50) + .replace(/[^a-z0-9]/gi, '-') + .toLowerCase(); const filename = `${safeName || 'story'}.wav`; await platform.filesystem.saveFile(filename, blob, [ diff --git a/app/src/lib/hooks/useStoryPlayback.ts b/app/src/lib/hooks/useStoryPlayback.ts index f9678cdc..12cff59f 100644 --- a/app/src/lib/hooks/useStoryPlayback.ts +++ b/app/src/lib/hooks/useStoryPlayback.ts @@ -70,6 +70,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) { } }, []); + // Resolve the audio buffer key and URL for an item. + // When a version_id is pinned, use that version's audio; otherwise use the generation default. + const getAudioKey = (item: StoryItemDetail) => + item.version_id ? `v:${item.version_id}` : item.generation_id; + + const getAudioUrlForItem = (item: StoryItemDetail) => + item.version_id + ? apiClient.getVersionAudioUrl(item.version_id) + : apiClient.getAudioUrl(item.generation_id); + // Preload audio files as AudioBuffers useEffect(() => { if (!items || items.length === 0) { @@ -78,12 +88,12 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) { return; } - const currentIds = new Set(items.map((item) => item.generation_id)); + const currentKeys = new Set(items.map(getAudioKey)); const audioContext = getAudioContext(); // Remove buffers for items that no longer exist for (const [id] of audioBuffersRef.current) { - if (!currentIds.has(id)) { + if (!currentKeys.has(id)) { audioBuffersRef.current.delete(id); } } @@ -91,24 +101,25 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) { // Preload audio for new items const preloadPromises: Promise[] = []; for (const item of items) { - if (!audioBuffersRef.current.has(item.generation_id)) { - const audioUrl = apiClient.getAudioUrl(item.generation_id); - console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id); + const key = getAudioKey(item); + if (!audioBuffersRef.current.has(key)) { + const audioUrl = getAudioUrlForItem(item); + console.log('[StoryPlayback] Preloading audio buffer:', key); const preloadPromise = fetch(audioUrl) .then((response) => response.arrayBuffer()) .then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer)) .then((audioBuffer) => { - audioBuffersRef.current.set(item.generation_id, audioBuffer); + audioBuffersRef.current.set(key, audioBuffer); console.log( '[StoryPlayback] Preloaded buffer:', - item.generation_id, + key, 'duration:', audioBuffer.duration, ); }) .catch((err) => { - console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err); + console.error('[StoryPlayback] Failed to preload audio:', key, err); }); preloadPromises.push(preloadPromise); @@ -216,15 +227,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) { // Schedule new sources for items that should be playing for (const item of shouldBePlaying) { if (!activeSourcesRef.current.has(item.id)) { - const buffer = audioBuffersRef.current.get(item.generation_id); + const bufferKey = getAudioKey(item); + const buffer = audioBuffersRef.current.get(bufferKey); if (!buffer) { - console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id); + console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey); continue; } // Calculate when this item should start in AudioContext time const itemStartContextTime = storyTimeToContextTime(item.start_time_ms); - + // Calculate effective duration and trim offsets const trimStartSec = (item.trim_start_ms || 0) / 1000; const trimEndSec = (item.trim_end_ms || 0) / 1000; diff --git a/backend/database.py b/backend/database.py index 92332d71..d0a1c5b6 100644 --- a/backend/database.py +++ b/backend/database.py @@ -76,6 +76,7 @@ class StoryItem(Base): id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) story_id = Column(String, ForeignKey("stories.id"), nullable=False) generation_id = Column(String, ForeignKey("generations.id"), nullable=False) + version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) # Pin to specific version, null = use generation default start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start track = Column(Integer, nullable=False, default=0) # Track number (0 = main track) trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start @@ -376,6 +377,16 @@ def _run_migrations(engine): conn.commit() print("Added sort_order column to effect_presets") + # Migration: Add version_id column to story_items table + if 'story_items' in inspector.get_table_names(): + columns = {col['name'] for col in inspector.get_columns('story_items')} + if 'version_id' not in columns: + print("Migrating story_items: adding version_id column") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE story_items ADD COLUMN version_id VARCHAR")) + conn.commit() + print("Added version_id column to story_items") + if 'generations' in inspector.get_table_names(): columns = {col['name'] for col in inspector.get_columns('generations')} if 'is_favorited' not in columns: diff --git a/backend/main.py b/backend/main.py index 98970e51..8a71ea0e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1644,6 +1644,20 @@ async def duplicate_story_item( return item +@app.put("/stories/{story_id}/items/{item_id}/version", response_model=models.StoryItemDetail) +async def set_story_item_version( + story_id: str, + item_id: str, + data: models.StoryItemVersionUpdate, + db: Session = Depends(get_db), +): + """Pin a story item to a specific generation version.""" + item = await stories.set_story_item_version(story_id, item_id, data, db) + if item is None: + raise HTTPException(status_code=404, detail="Story item or version not found") + return item + + @app.get("/stories/{story_id}/export-audio") async def export_story_audio( story_id: str, diff --git a/backend/models.py b/backend/models.py index b411000d..1c9216dd 100644 --- a/backend/models.py +++ b/backend/models.py @@ -276,6 +276,7 @@ class StoryItemDetail(BaseModel): id: str story_id: str generation_id: str + version_id: Optional[str] = None start_time_ms: int track: int = 0 trim_start_ms: int = 0 @@ -291,6 +292,9 @@ class StoryItemDetail(BaseModel): seed: Optional[int] instruct: Optional[str] generation_created_at: datetime + # Versions available for this generation + versions: Optional[List["GenerationVersionResponse"]] = None + active_version_id: Optional[str] = None class Config: from_attributes = True @@ -349,6 +353,11 @@ class StoryItemSplit(BaseModel): split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start) +class StoryItemVersionUpdate(BaseModel): + """Request model for setting a story item's pinned version.""" + version_id: Optional[str] = None # null = use generation default + + # ============================================ # Effects & Versions # ============================================ diff --git a/backend/stories.py b/backend/stories.py index f63710c7..8d59bc17 100644 --- a/backend/stories.py +++ b/backend/stories.py @@ -20,12 +20,55 @@ from .models import ( StoryItemMove, StoryItemTrim, StoryItemSplit, + StoryItemVersionUpdate, ) from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile +from .history import _get_versions_for_generation from .utils.audio import load_audio, save_audio import numpy as np +def _build_item_detail( + item: DBStoryItem, + generation: DBGeneration, + profile_name: str, + db: Session, +) -> StoryItemDetail: + """Build a StoryItemDetail with version info from a story item and its generation.""" + versions, active_version_id = _get_versions_for_generation(generation.id, db) + + # Resolve the audio path: if version_id is set, use that version's audio + audio_path = generation.audio_path + if item.version_id and versions: + for v in versions: + if v.id == item.version_id: + audio_path = v.audio_path + break + + return StoryItemDetail( + id=item.id, + story_id=item.story_id, + generation_id=item.generation_id, + version_id=getattr(item, 'version_id', None), + start_time_ms=item.start_time_ms, + track=item.track, + trim_start_ms=getattr(item, 'trim_start_ms', 0), + trim_end_ms=getattr(item, 'trim_end_ms', 0), + created_at=item.created_at, + profile_id=generation.profile_id, + profile_name=profile_name, + text=generation.text, + language=generation.language, + audio_path=audio_path, + duration=generation.duration, + seed=generation.seed, + instruct=generation.instruct, + generation_created_at=generation.created_at, + versions=versions, + active_version_id=active_version_id, + ) + + async def create_story( data: StoryCreate, db: Session, @@ -125,26 +168,7 @@ async def get_story( # Build item details item_details = [] for item, generation, profile_name in items: - item_detail = StoryItemDetail( - id=item.id, - story_id=item.story_id, - generation_id=item.generation_id, - start_time_ms=item.start_time_ms, - track=item.track, - trim_start_ms=getattr(item, 'trim_start_ms', 0), - trim_end_ms=getattr(item, 'trim_end_ms', 0), - created_at=item.created_at, - profile_id=generation.profile_id, - profile_name=profile_name, - text=generation.text, - language=generation.language, - audio_path=generation.audio_path, - duration=generation.duration, - seed=generation.seed, - instruct=generation.instruct, - generation_created_at=generation.created_at, - ) - item_details.append(item_detail) + item_details.append(_build_item_detail(item, generation, profile_name, db)) response = StoryDetailResponse.model_validate(story) response.items = item_details @@ -250,25 +274,7 @@ async def add_item_to_story( if existing: # Return existing item profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() - return StoryItemDetail( - id=existing.id, - story_id=existing.story_id, - generation_id=existing.generation_id, - start_time_ms=existing.start_time_ms, - track=existing.track, - trim_start_ms=getattr(existing, 'trim_start_ms', 0), - trim_end_ms=getattr(existing, 'trim_end_ms', 0), - created_at=existing.created_at, - profile_id=generation.profile_id, - profile_name=profile.name if profile else "Unknown", - text=generation.text, - language=generation.language, - audio_path=generation.audio_path, - duration=generation.duration, - seed=generation.seed, - instruct=generation.instruct, - generation_created_at=generation.created_at, - ) + return _build_item_detail(existing, generation, profile.name if profile else "Unknown", db) # Get track from data or default to 0 track = data.track if data.track is not None else 0 @@ -321,25 +327,7 @@ async def add_item_to_story( # Get profile name profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() - return StoryItemDetail( - id=item.id, - story_id=item.story_id, - generation_id=item.generation_id, - start_time_ms=item.start_time_ms, - track=item.track, - trim_start_ms=getattr(item, 'trim_start_ms', 0), - trim_end_ms=getattr(item, 'trim_end_ms', 0), - created_at=item.created_at, - profile_id=generation.profile_id, - profile_name=profile.name if profile else "Unknown", - text=generation.text, - language=generation.language, - audio_path=generation.audio_path, - duration=generation.duration, - seed=generation.seed, - instruct=generation.instruct, - generation_created_at=generation.created_at, - ) + return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) async def move_story_item( @@ -388,25 +376,7 @@ async def move_story_item( # Get profile name profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() - return StoryItemDetail( - id=item.id, - story_id=item.story_id, - generation_id=item.generation_id, - start_time_ms=item.start_time_ms, - track=item.track, - trim_start_ms=getattr(item, 'trim_start_ms', 0), - trim_end_ms=getattr(item, 'trim_end_ms', 0), - created_at=item.created_at, - profile_id=generation.profile_id, - profile_name=profile.name if profile else "Unknown", - text=generation.text, - language=generation.language, - audio_path=generation.audio_path, - duration=generation.duration, - seed=generation.seed, - instruct=generation.instruct, - generation_created_at=generation.created_at, - ) + return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) async def remove_item_from_story( @@ -495,25 +465,7 @@ async def trim_story_item( # Get profile name profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() - return StoryItemDetail( - id=item.id, - story_id=item.story_id, - generation_id=item.generation_id, - start_time_ms=item.start_time_ms, - track=item.track, - trim_start_ms=item.trim_start_ms, - trim_end_ms=item.trim_end_ms, - created_at=item.created_at, - profile_id=generation.profile_id, - profile_name=profile.name if profile else "Unknown", - text=generation.text, - language=generation.language, - audio_path=generation.audio_path, - duration=generation.duration, - seed=generation.seed, - instruct=generation.instruct, - generation_created_at=generation.created_at, - ) + return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) async def split_story_item( @@ -568,6 +520,7 @@ async def split_story_item( id=str(uuid.uuid4()), story_id=story_id, generation_id=item.generation_id, # Same generation, different trim + version_id=getattr(item, 'version_id', None), # Preserve pinned version start_time_ms=item.start_time_ms + data.split_time_ms, track=item.track, trim_start_ms=absolute_split_ms, @@ -590,48 +543,10 @@ async def split_story_item( profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() profile_name = profile.name if profile else "Unknown" - # Build response items - original_item_detail = StoryItemDetail( - id=item.id, - story_id=item.story_id, - generation_id=item.generation_id, - start_time_ms=item.start_time_ms, - track=item.track, - trim_start_ms=item.trim_start_ms, - trim_end_ms=item.trim_end_ms, - created_at=item.created_at, - profile_id=generation.profile_id, - profile_name=profile_name, - text=generation.text, - language=generation.language, - audio_path=generation.audio_path, - duration=generation.duration, - seed=generation.seed, - instruct=generation.instruct, - generation_created_at=generation.created_at, - ) - - new_item_detail = StoryItemDetail( - id=new_item.id, - story_id=new_item.story_id, - generation_id=new_item.generation_id, - start_time_ms=new_item.start_time_ms, - track=new_item.track, - trim_start_ms=new_item.trim_start_ms, - trim_end_ms=new_item.trim_end_ms, - created_at=new_item.created_at, - profile_id=generation.profile_id, - profile_name=profile_name, - text=generation.text, - language=generation.language, - audio_path=generation.audio_path, - duration=generation.duration, - seed=generation.seed, - instruct=generation.instruct, - generation_created_at=generation.created_at, - ) - - return [original_item_detail, new_item_detail] + return [ + _build_item_detail(item, generation, profile_name, db), + _build_item_detail(new_item, generation, profile_name, db), + ] async def duplicate_story_item( @@ -674,6 +589,7 @@ async def duplicate_story_item( id=str(uuid.uuid4()), story_id=story_id, generation_id=original_item.generation_id, # Same generation as original + version_id=getattr(original_item, 'version_id', None), # Preserve pinned version start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap track=original_item.track, trim_start_ms=current_trim_start, @@ -694,25 +610,7 @@ async def duplicate_story_item( # Get profile name profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() - return StoryItemDetail( - id=new_item.id, - story_id=new_item.story_id, - generation_id=new_item.generation_id, - start_time_ms=new_item.start_time_ms, - track=new_item.track, - trim_start_ms=new_item.trim_start_ms, - trim_end_ms=new_item.trim_end_ms, - created_at=new_item.created_at, - profile_id=generation.profile_id, - profile_name=profile.name if profile else "Unknown", - text=generation.text, - language=generation.language, - audio_path=generation.audio_path, - duration=generation.duration, - seed=generation.seed, - instruct=generation.instruct, - generation_created_at=generation.created_at, - ) + return _build_item_detail(new_item, generation, profile.name if profile else "Unknown", db) async def update_story_item_times( @@ -813,25 +711,7 @@ async def reorder_story_items( current_time_ms += duration_ms + gap_ms # Build the response item - updated_items.append(StoryItemDetail( - id=item.id, - story_id=item.story_id, - generation_id=item.generation_id, - start_time_ms=item.start_time_ms, - track=item.track, - trim_start_ms=getattr(item, 'trim_start_ms', 0), - trim_end_ms=getattr(item, 'trim_end_ms', 0), - created_at=item.created_at, - profile_id=generation.profile_id, - profile_name=profile_name, - text=generation.text, - language=generation.language, - audio_path=generation.audio_path, - duration=generation.duration, - seed=generation.seed, - instruct=generation.instruct, - generation_created_at=generation.created_at, - )) + updated_items.append(_build_item_detail(item, generation, profile_name, db)) # Update story updated_at story.updated_at = datetime.utcnow() @@ -840,6 +720,60 @@ async def reorder_story_items( return updated_items +async def set_story_item_version( + story_id: str, + item_id: str, + data: StoryItemVersionUpdate, + db: Session, +) -> Optional[StoryItemDetail]: + """ + Pin a story item to a specific generation version. + + Args: + story_id: Story ID + item_id: Story item ID + data: Version update data (version_id or null for default) + db: Database session + + Returns: + Updated item detail or None if not found + """ + item = db.query(DBStoryItem).filter_by( + id=item_id, + story_id=story_id, + ).first() + if not item: + return None + + generation = db.query(DBGeneration).filter_by(id=item.generation_id).first() + if not generation: + return None + + # Validate version_id belongs to this generation if provided + if data.version_id: + from .database import GenerationVersion as DBGenerationVersion + version = db.query(DBGenerationVersion).filter_by( + id=data.version_id, + generation_id=item.generation_id, + ).first() + if not version: + return None + + item.version_id = data.version_id + + # Update story updated_at + story = db.query(DBStory).filter_by(id=story_id).first() + if story: + story.updated_at = datetime.utcnow() + + db.commit() + db.refresh(item) + + profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() + + return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) + + async def export_story_audio( story_id: str, db: Session, @@ -877,7 +811,15 @@ async def export_story_audio( sample_rate = 24000 # Default sample rate for item, generation in items: - audio_path = Path(generation.audio_path) + # Resolve audio path: use pinned version if set, otherwise generation default + resolved_audio_path = generation.audio_path + if getattr(item, 'version_id', None): + from .database import GenerationVersion as DBGenerationVersion + version = db.query(DBGenerationVersion).filter_by(id=item.version_id).first() + if version: + resolved_audio_path = version.audio_path + + audio_path = Path(resolved_audio_path) if not audio_path.exists(): continue