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:
Jamie Pine
2026-04-25 01:55:49 -07:00
parent 2a937983c5
commit c43f2d45cc
12 changed files with 245 additions and 22 deletions
@@ -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`}
+91 -4
View File
@@ -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" />
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -632,12 +632,16 @@
"regenerate": "再生成",
"removeFromStory": "ストーリーから削除"
},
"importAudio": "オーディオをインポート…",
"importing": "インポート中…",
"dropToImport": "ドロップしてオーディオをインポート",
"toast": {
"removeFailed": "項目の削除に失敗しました",
"reorderFailed": "項目の並び替えに失敗しました",
"exportFailed": "オーディオのエクスポートに失敗しました",
"addFailed": "生成の追加に失敗しました",
"regenerateFailed": "再生成に失敗しました"
"regenerateFailed": "再生成に失敗しました",
"importFailed": "オーディオのインポートに失敗しました"
}
},
"history": {
+5 -1
View File
@@ -632,12 +632,16 @@
"regenerate": "重新生成",
"removeFromStory": "从故事中移除"
},
"importAudio": "导入音频…",
"importing": "正在导入…",
"dropToImport": "拖放以导入音频",
"toast": {
"removeFailed": "移除项目失败",
"reorderFailed": "重新排序项目失败",
"exportFailed": "导出音频失败",
"addFailed": "添加生成失败",
"regenerateFailed": "重新生成失败"
"regenerateFailed": "重新生成失败",
"importFailed": "导入音频失败"
}
},
"history": {
+5 -1
View File
@@ -632,12 +632,16 @@
"regenerate": "重新生成",
"removeFromStory": "從故事中移除"
},
"importAudio": "匯入音訊…",
"importing": "匯入中…",
"dropToImport": "拖放以匯入音訊",
"toast": {
"removeFailed": "移除項目失敗",
"reorderFailed": "重新排序項目失敗",
"exportFailed": "匯出音訊失敗",
"addFailed": "新增生成失敗",
"regenerateFailed": "重新生成失敗"
"regenerateFailed": "重新生成失敗",
"importFailed": "匯入音訊失敗"
}
},
"history": {
+14
View File
@@ -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',
+1
View File
@@ -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;