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