mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 06:40:38 -07:00
feat(stories): import external audio into the timeline (drag-drop + picker)
You can now drop a music file onto the story content area or pick one through the new "Import audio" button in the add-clip popover. Both call POST /generate/import which writes the file to data/generations/<id>.<ext>, probes duration via librosa, and inserts a Generation row pointing at a singleton "Imported Audio" profile (created lazily on first import). The existing addStoryItem flow takes over from there — the timeline doesn't care that the row didn't come out of TTS. Engine field on the row is "import"; it's surfaced on StoryItemDetail so the chat list shows a music icon instead of the (missing) profile avatar and both the dropdown and the track-editor toolbar hide the Regenerate action — there's nothing to regenerate. Accepted formats: wav/mp3/flac/ogg/m4a/aac/webm, capped at 200 MB. Translation keys added across en/ja/zh-CN/zh-TW.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Play, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { GripVertical, Mic, MoreHorizontal, Music, Play, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -85,7 +85,9 @@ export function StoryChatItem({
|
||||
{/* Voice Avatar */}
|
||||
<div className="shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||
{!avatarError ? (
|
||||
{item.engine === 'import' ? (
|
||||
<Music className="h-5 w-5 text-muted-foreground" />
|
||||
) : !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import { Download, Music, Plus, Upload } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Loader from 'react-loaders';
|
||||
@@ -47,8 +47,12 @@ export function StoryContent() {
|
||||
const addStoryItem = useAddStoryItem();
|
||||
const { toast } = useToast();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
// Add generation popover state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -229,6 +233,33 @@ export function StoryContent() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleImportAudio = async (file: File) => {
|
||||
if (!story) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const generation = await apiClient.importAudio(file);
|
||||
await addStoryItem.mutateAsync({
|
||||
storyId: story.id,
|
||||
data: { generation_id: generation.id },
|
||||
});
|
||||
setIsAddOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('storyContent.toast.importFailed'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFiles = async (files: FileList | File[]) => {
|
||||
for (const file of Array.from(files)) {
|
||||
await handleImportAudio(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGeneration = (generationId: string) => {
|
||||
if (!story) return;
|
||||
|
||||
@@ -284,7 +315,49 @@ export function StoryContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 relative overflow-hidden">
|
||||
<div
|
||||
className="flex flex-col h-full min-h-0 relative overflow-hidden"
|
||||
onDragEnter={(e) => {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDraggingFile(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDraggingFile(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!e.dataTransfer?.files?.length) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDraggingFile(false);
|
||||
handleImportFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="audio/*,.wav,.mp3,.flac,.ogg,.m4a,.aac,.webm"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) handleImportFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{isDraggingFile && (
|
||||
<div className="absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-accent/10 border-2 border-dashed border-accent rounded-lg m-4">
|
||||
<div className="flex flex-col items-center gap-2 text-accent">
|
||||
<Music className="h-8 w-8" />
|
||||
<span className="text-sm font-medium">{t('storyContent.dropToImport')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
@@ -329,13 +402,23 @@ export function StoryContent() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="p-2 border-b">
|
||||
<div className="p-2 border-b space-y-2">
|
||||
<Input
|
||||
placeholder={t('storyContent.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
disabled={isImporting}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{isImporting ? t('storyContent.importing') : t('storyContent.importAudio')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{availableGenerations.length === 0 ? (
|
||||
@@ -414,7 +497,11 @@ export function StoryContent() {
|
||||
storyId={story.id}
|
||||
index={index}
|
||||
onRemove={() => handleRemoveItem(item.id)}
|
||||
onRegenerate={() => handleRegenerate(item.generation_id)}
|
||||
onRegenerate={
|
||||
item.engine === 'import'
|
||||
? undefined
|
||||
: () => handleRegenerate(item.generation_id)
|
||||
}
|
||||
currentTimeMs={currentTimeMs}
|
||||
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||
/>
|
||||
|
||||
@@ -1016,16 +1016,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleRegenerate}
|
||||
title="Regenerate"
|
||||
aria-label="Regenerate clip"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
{selectedItem?.engine !== 'import' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleRegenerate}
|
||||
title="Regenerate"
|
||||
aria-label="Regenerate clip"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasMultipleVersions && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
@@ -632,12 +632,16 @@
|
||||
"regenerate": "Regenerate",
|
||||
"removeFromStory": "Remove from Story"
|
||||
},
|
||||
"importAudio": "Import audio…",
|
||||
"importing": "Importing…",
|
||||
"dropToImport": "Drop audio to import",
|
||||
"toast": {
|
||||
"removeFailed": "Failed to remove item",
|
||||
"reorderFailed": "Failed to reorder items",
|
||||
"exportFailed": "Failed to export audio",
|
||||
"addFailed": "Failed to add generation",
|
||||
"regenerateFailed": "Failed to regenerate"
|
||||
"regenerateFailed": "Failed to regenerate",
|
||||
"importFailed": "Failed to import audio"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -632,12 +632,16 @@
|
||||
"regenerate": "再生成",
|
||||
"removeFromStory": "ストーリーから削除"
|
||||
},
|
||||
"importAudio": "オーディオをインポート…",
|
||||
"importing": "インポート中…",
|
||||
"dropToImport": "ドロップしてオーディオをインポート",
|
||||
"toast": {
|
||||
"removeFailed": "項目の削除に失敗しました",
|
||||
"reorderFailed": "項目の並び替えに失敗しました",
|
||||
"exportFailed": "オーディオのエクスポートに失敗しました",
|
||||
"addFailed": "生成の追加に失敗しました",
|
||||
"regenerateFailed": "再生成に失敗しました"
|
||||
"regenerateFailed": "再生成に失敗しました",
|
||||
"importFailed": "オーディオのインポートに失敗しました"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -632,12 +632,16 @@
|
||||
"regenerate": "重新生成",
|
||||
"removeFromStory": "从故事中移除"
|
||||
},
|
||||
"importAudio": "导入音频…",
|
||||
"importing": "正在导入…",
|
||||
"dropToImport": "拖放以导入音频",
|
||||
"toast": {
|
||||
"removeFailed": "移除项目失败",
|
||||
"reorderFailed": "重新排序项目失败",
|
||||
"exportFailed": "导出音频失败",
|
||||
"addFailed": "添加生成失败",
|
||||
"regenerateFailed": "重新生成失败"
|
||||
"regenerateFailed": "重新生成失败",
|
||||
"importFailed": "导入音频失败"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -632,12 +632,16 @@
|
||||
"regenerate": "重新生成",
|
||||
"removeFromStory": "從故事中移除"
|
||||
},
|
||||
"importAudio": "匯入音訊…",
|
||||
"importing": "匯入中…",
|
||||
"dropToImport": "拖放以匯入音訊",
|
||||
"toast": {
|
||||
"removeFailed": "移除項目失敗",
|
||||
"reorderFailed": "重新排序項目失敗",
|
||||
"exportFailed": "匯出音訊失敗",
|
||||
"addFailed": "新增生成失敗",
|
||||
"regenerateFailed": "重新生成失敗"
|
||||
"regenerateFailed": "重新生成失敗",
|
||||
"importFailed": "匯入音訊失敗"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
@@ -272,6 +272,20 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async importAudio(file: File): Promise<GenerationResponse> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`${this.getBaseUrl()}/generate/import`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => res.statusText);
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
|
||||
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -391,6 +391,7 @@ export interface StoryItemDetail {
|
||||
duration: number;
|
||||
seed?: number;
|
||||
instruct?: string;
|
||||
engine?: string;
|
||||
generation_created_at: string;
|
||||
versions?: GenerationVersionResponse[];
|
||||
active_version_id?: string;
|
||||
|
||||
Reference in New Issue
Block a user