mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
Add version control to track editor, restyle story list
- Story items can be pinned to a specific generation version via toolbar dropdown (shows when clip is selected and has >1 version) - version_id column on story_items with migration, validated against the generation's versions before saving - Split/duplicate preserve the source clip's pinned version - Export and playback resolve version-specific audio paths - Extracted _build_item_detail helper in stories.py (DRY cleanup) - Story list restyled from rounded cards to flat rows with rounded hover/active states, gradient header fade, and dynamic bottom padding that accounts for track editor + generate box
This commit is contained in:
@@ -27,6 +27,7 @@ import type {
|
||||
StoryItemReorder,
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryItemVersionUpdate,
|
||||
StoryResponse,
|
||||
TranscriptionResponse,
|
||||
VoiceProfileCreate,
|
||||
@@ -588,6 +589,17 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setStoryItemVersion(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemVersionUpdate,
|
||||
): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/version`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async exportStoryAudio(storyId: string): Promise<Blob> {
|
||||
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||
const response = await fetch(url);
|
||||
|
||||
@@ -222,6 +222,7 @@ export interface StoryItemDetail {
|
||||
id: string;
|
||||
story_id: string;
|
||||
generation_id: string;
|
||||
version_id?: string;
|
||||
start_time_ms: number;
|
||||
track: number;
|
||||
trim_start_ms: number;
|
||||
@@ -236,6 +237,12 @@ export interface StoryItemDetail {
|
||||
seed?: number;
|
||||
instruct?: string;
|
||||
generation_created_at: string;
|
||||
versions?: GenerationVersionResponse[];
|
||||
active_version_id?: string;
|
||||
}
|
||||
|
||||
export interface StoryItemVersionUpdate {
|
||||
version_id: string | null;
|
||||
}
|
||||
|
||||
export interface StoryDetailResponse {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
||||
import type {
|
||||
StoryCreate,
|
||||
StoryItemBatchUpdate,
|
||||
StoryItemCreate,
|
||||
StoryItemMove,
|
||||
StoryItemReorder,
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryItemVersionUpdate,
|
||||
} from '@/lib/api/types';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function useStories() {
|
||||
@@ -109,8 +118,15 @@ export function useMoveStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
|
||||
apiClient.moveStoryItem(storyId, itemId, data),
|
||||
mutationFn: ({
|
||||
storyId,
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
storyId: string;
|
||||
itemId: string;
|
||||
data: StoryItemMove;
|
||||
}) => apiClient.moveStoryItem(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
@@ -122,8 +138,15 @@ export function useTrimStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
|
||||
apiClient.trimStoryItem(storyId, itemId, data),
|
||||
mutationFn: ({
|
||||
storyId,
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
storyId: string;
|
||||
itemId: string;
|
||||
data: StoryItemTrim;
|
||||
}) => apiClient.trimStoryItem(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
@@ -135,8 +158,15 @@ export function useSplitStoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
|
||||
apiClient.splitStoryItem(storyId, itemId, data),
|
||||
mutationFn: ({
|
||||
storyId,
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
storyId: string;
|
||||
itemId: string;
|
||||
data: StoryItemSplit;
|
||||
}) => apiClient.splitStoryItem(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
@@ -157,6 +187,26 @@ export function useDuplicateStoryItem() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetStoryItemVersion() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
storyId,
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
storyId: string;
|
||||
itemId: string;
|
||||
data: StoryItemVersionUpdate;
|
||||
}) => apiClient.setStoryItemVersion(storyId, itemId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportStoryAudio() {
|
||||
const platform = usePlatform();
|
||||
|
||||
@@ -165,7 +215,10 @@ export function useExportStoryAudio() {
|
||||
const blob = await apiClient.exportStoryAudio(storyId);
|
||||
|
||||
// Create safe filename
|
||||
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
const safeName = storyName
|
||||
.substring(0, 50)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${safeName || 'story'}.wav`;
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
|
||||
@@ -70,6 +70,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Resolve the audio buffer key and URL for an item.
|
||||
// When a version_id is pinned, use that version's audio; otherwise use the generation default.
|
||||
const getAudioKey = (item: StoryItemDetail) =>
|
||||
item.version_id ? `v:${item.version_id}` : item.generation_id;
|
||||
|
||||
const getAudioUrlForItem = (item: StoryItemDetail) =>
|
||||
item.version_id
|
||||
? apiClient.getVersionAudioUrl(item.version_id)
|
||||
: apiClient.getAudioUrl(item.generation_id);
|
||||
|
||||
// Preload audio files as AudioBuffers
|
||||
useEffect(() => {
|
||||
if (!items || items.length === 0) {
|
||||
@@ -78,12 +88,12 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = new Set(items.map((item) => item.generation_id));
|
||||
const currentKeys = new Set(items.map(getAudioKey));
|
||||
const audioContext = getAudioContext();
|
||||
|
||||
// Remove buffers for items that no longer exist
|
||||
for (const [id] of audioBuffersRef.current) {
|
||||
if (!currentIds.has(id)) {
|
||||
if (!currentKeys.has(id)) {
|
||||
audioBuffersRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -91,24 +101,25 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
// Preload audio for new items
|
||||
const preloadPromises: Promise<void>[] = [];
|
||||
for (const item of items) {
|
||||
if (!audioBuffersRef.current.has(item.generation_id)) {
|
||||
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
||||
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
|
||||
const key = getAudioKey(item);
|
||||
if (!audioBuffersRef.current.has(key)) {
|
||||
const audioUrl = getAudioUrlForItem(item);
|
||||
console.log('[StoryPlayback] Preloading audio buffer:', key);
|
||||
|
||||
const preloadPromise = fetch(audioUrl)
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||
.then((audioBuffer) => {
|
||||
audioBuffersRef.current.set(item.generation_id, audioBuffer);
|
||||
audioBuffersRef.current.set(key, audioBuffer);
|
||||
console.log(
|
||||
'[StoryPlayback] Preloaded buffer:',
|
||||
item.generation_id,
|
||||
key,
|
||||
'duration:',
|
||||
audioBuffer.duration,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err);
|
||||
console.error('[StoryPlayback] Failed to preload audio:', key, err);
|
||||
});
|
||||
|
||||
preloadPromises.push(preloadPromise);
|
||||
@@ -216,15 +227,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||
// Schedule new sources for items that should be playing
|
||||
for (const item of shouldBePlaying) {
|
||||
if (!activeSourcesRef.current.has(item.id)) {
|
||||
const buffer = audioBuffersRef.current.get(item.generation_id);
|
||||
const bufferKey = getAudioKey(item);
|
||||
const buffer = audioBuffersRef.current.get(bufferKey);
|
||||
if (!buffer) {
|
||||
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
||||
console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate when this item should start in AudioContext time
|
||||
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
||||
|
||||
|
||||
// Calculate effective duration and trim offsets
|
||||
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
||||
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
||||
|
||||
Reference in New Issue
Block a user