import { forwardRef, useCallback, useImperativeHandle, useRef, useState, } from "react"; import { FolderOpenIcon, ImageIcon, MusicIcon, PlayIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react"; import { DynamicIcon } from "@storyteller/icons"; import { toast } from "@storyteller/ui-toaster"; import { UploaderStates } from "@storyteller/common"; import type { UploadMediaFn } from "@storyteller/api"; import type { RefAudio, RefImage } from "../promptStore"; import { AUDIO_FILE_ACCEPT, AUDIO_FILE_TYPE_ERROR, isAudioFile, } from "./audioFiles"; /** Lets the prompt box push dropped/pasted files through the same upload * path the row's own file inputs use. */ export interface AudioReferenceRowHandle { addAudioFiles: (files: File[]) => Promise; addImageFile: (file: File) => Promise; } export interface AudioReferenceRowProps { referenceAudios: RefAudio[]; onReferenceAudiosChange: (audios: RefAudio[]) => void; maxAudioCount: number; maxAudioRefDuration: number; uploadAudio?: UploadMediaFn; // Opens the caller's audio library picker (adds a "From library" button). onPickAudioFromLibrary?: () => void; // Whether the audio reference is required (remix/sample source). audioRequired?: boolean; // Optional single-image reference section (Seed Audio). imageSupported?: boolean; referenceImages?: RefImage[]; onReferenceImagesChange?: (images: RefImage[]) => void; uploadImage?: UploadMediaFn; className?: string; } // Reference row for the audio promptbox: upload an audio track (the // remix/sample source or Seed Audio refs), plus an optional image reference // for models that support one. Rendered inside the glass card, above the // prompt textarea. export const AudioReferenceRow = forwardRef< AudioReferenceRowHandle, AudioReferenceRowProps >(function AudioReferenceRow( { referenceAudios, onReferenceAudiosChange, maxAudioCount, maxAudioRefDuration, uploadAudio, onPickAudioFromLibrary, audioRequired = false, imageSupported = false, referenceImages = [], onReferenceImagesChange, uploadImage, className = "", }, ref, ) { const audioInputRef = useRef(null); const imageInputRef = useRef(null); const [isUploadingAudio, setIsUploadingAudio] = useState(false); const [isUploadingImage, setIsUploadingImage] = useState(false); // Latest-value refs so async upload callbacks append to fresh state. const referenceAudiosRef = useRef(referenceAudios); referenceAudiosRef.current = referenceAudios; const referenceImagesRef = useRef(referenceImages); referenceImagesRef.current = referenceImages; const processAudioFiles = async (files: File[]) => { if (files.length === 0) return; const audioFiles = files.filter(isAudioFile); if (audioFiles.length < files.length) { toast.error(AUDIO_FILE_TYPE_ERROR); } const availableSlots = Math.max(0, maxAudioCount - referenceAudios.length); const filesToProcess = audioFiles.slice(0, availableSlots); for (const file of filesToProcess) { const duration = await getAudioFileDuration(file); const currentTotal = referenceAudiosRef.current.reduce( (sum, audio) => sum + audio.duration, 0, ); if (currentTotal + duration > maxAudioRefDuration) { toast.error( `Total audio duration cannot exceed ${maxAudioRefDuration}s`, ); break; } if (uploadAudio) { setIsUploadingAudio(true); await uploadAudio({ title: `reference-audio-${Math.random().toString(36).substring(2, 15)}`, assetFile: file, progressCallback: (newState) => { if (newState.status === UploaderStates.success && newState.data) { const refAudio: RefAudio = { id: Math.random().toString(36).substring(7), url: URL.createObjectURL(file), file, mediaToken: newState.data, duration, }; setIsUploadingAudio(false); onReferenceAudiosChange([ ...referenceAudiosRef.current, refAudio, ]); } else if ( newState.status === UploaderStates.assetError || newState.status === UploaderStates.imageCreateError ) { setIsUploadingAudio(false); toast.error("Audio upload failed. Please try again."); } }, }); } } }; const handleAudioFileUpload = async ( event: React.ChangeEvent, ) => { await processAudioFiles(Array.from(event.target.files || [])); if (audioInputRef.current) audioInputRef.current.value = ""; }; const processImageFile = async (file: File) => { if (!uploadImage) return; setIsUploadingImage(true); await uploadImage({ title: `reference-image-${Math.random().toString(36).substring(2, 15)}`, assetFile: file, progressCallback: (newState) => { if (newState.status === UploaderStates.success && newState.data) { const refImage: RefImage = { id: Math.random().toString(36).substring(7), url: URL.createObjectURL(file), file, mediaToken: newState.data, }; setIsUploadingImage(false); onReferenceImagesChange?.([refImage]); } else if ( newState.status === UploaderStates.assetError || newState.status === UploaderStates.imageCreateError ) { setIsUploadingImage(false); toast.error("Image upload failed. Please try again."); } }, }); }; const handleImageFileUpload = async ( event: React.ChangeEvent, ) => { const file = (event.target.files || [])[0]; if (file) await processImageFile(file); if (imageInputRef.current) imageInputRef.current.value = ""; }; useImperativeHandle( ref, () => ({ addAudioFiles: processAudioFiles, addImageFile: processImageFile, }), // Recreated each render so the handle always closes over fresh props // (slot counts, current refs) rather than mount-time values. ); const removeAudio = (id: string) => { onReferenceAudiosChange(referenceAudios.filter((a) => a.id !== id)); if (audioInputRef.current) audioInputRef.current.value = ""; }; return (
1} onChange={handleAudioFileUpload} /> {imageSupported && ( )}
Audio Track{" "} ({referenceAudios.length}/{maxAudioCount}) {audioRequired && referenceAudios.length === 0 && ( required )}
{referenceAudios.map((audio, index) => ( ))} {referenceAudios.length < maxAudioCount && ( <> {onPickAudioFromLibrary && ( )} )} {imageSupported && ( <>
Image
{referenceImages.map((image) => (
Reference
))} {referenceImages.length === 0 && ( )} )}
); }); function AudioRefTile({ audio, index, onRemove, }: { audio: RefAudio; index: number; onRemove: (id: string) => void; }) { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const togglePlay = useCallback(() => { if (isPlaying) { audioRef.current?.pause(); audioRef.current = null; setIsPlaying(false); return; } const element = new Audio(audio.url); element.onended = () => { audioRef.current = null; setIsPlaying(false); }; audioRef.current = element; void element.play().catch(() => setIsPlaying(false)); setIsPlaying(true); }, [audio.url, isPlaying]); return (
Audio {index + 1} · {audio.duration}s
); } function getAudioFileDuration(file: File): Promise { return new Promise((resolve) => { const audio = document.createElement("audio"); audio.preload = "metadata"; audio.onloadedmetadata = () => { URL.revokeObjectURL(audio.src); resolve(Math.round(audio.duration)); }; audio.onerror = () => { URL.revokeObjectURL(audio.src); resolve(0); }; audio.src = URL.createObjectURL(file); }); }