@@ -1,11 +1,12 @@ -import axios from 'axios' +import axios, { AxiosResponse } from 'axios' import { IShortModel } from '#/entities/model-entity' +import { MessageSend, sendMediaMessage } from '#/entities/message' import { getApiUrl } from '#/shared/lib/constants' -export async function getAudio(token?: string): Promise { +async function fetchMlModelsByCategory(category: string, token?: string): Promise { try { - const { data } = await axios.get(getApiUrl() + '/ml_models/?category=audio', { + const { data } = await axios.get(getApiUrl() + `/ml_models/?category=${encodeURIComponent(category)}`, { headers: { Authorization: `Bearer ${token}`, }, @@ -15,3 +16,97 @@ export async function getAudio(token?: string): Promise { return [] } } + +export async function getAudio(token?: string): Promise { + return fetchMlModelsByCategory('audio', token) +} + +export async function getVoiceCloneModels(token?: string): Promise { + return fetchMlModelsByCategory('voice', token) +} + +export interface MediaVoiceItem { + uid: string + title: string + file: string +} + +export async function getMediaVoices(token?: string): Promise { + try { + const { data } = await axios.get(getApiUrl() + '/api/media/voices/?kind=voice', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +export async function getMediaPresets(token?: string): Promise { + try { + const { data } = await axios.get(getApiUrl() + '/api/media/presets/?kind=voice', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +export async function getVoicesAndPresets(token?: string): Promise<{ voices: MediaVoiceItem[]; presets: MediaVoiceItem[] }> { + const [voices, presets] = await Promise.all([getMediaVoices(token), getMediaPresets(token)]) + return { voices, presets } +} + +/** POST /api/media/voices/ — загрузить голос (file обязателен: mp3, ogg, wav; title опционален) */ +export async function createMediaVoice(file: File, token?: string, title?: string): Promise> { + const formData = new FormData() + formData.append('file', file) + if (title != null && title !== '') { + formData.append('title', title) + } + + return axios.post(getApiUrl() + '/api/media/voices/', formData, { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} + +/** DELETE /api/media/voices/{voice_id}/ */ +export async function deleteMediaVoice(voiceId: string, token?: string): Promise> { + return axios.delete(getApiUrl() + `/api/media/voices/${encodeURIComponent(voiceId)}/`, { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} + +/** PATCH /api/media/voices/{voice_id}/ — сменить title */ +export async function patchMediaVoiceTitle(voiceId: string, title: string, token?: string): Promise> { + return axios.patch( + getApiUrl() + `/api/media/voices/${encodeURIComponent(voiceId)}/`, + { title }, + { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } + ) +} + +/** POST /media/voice/{model} — генерация (тот же FormData/JSON, что у остальных media POST) */ +export function postVoiceCloneGeneration(model: string | null, dataForSend: MessageSend | FormData, token?: string) { + return sendMediaMessage(model, 'voice', dataForSend, token) +} @@ -4,9 +4,12 @@ import { Message, MessageSend } from '../types' import { getApiUrl } from '#/shared/lib/constants' - - -export async function sendMediaMessage(model: string | null, modelType: 'video' | 'image' | 'audio', dataForSend: MessageSend | FormData, token?: string) { +export async function sendMediaMessage( + model: string | null, + modelType: 'video' | 'image' | 'audio' | 'voice', + dataForSend: MessageSend | FormData, + token?: string +) { const HeaderDataType = dataForSend instanceof FormData ? 'multipart/form-data' : 'application/json' return await axios.post(getApiUrl() + `/media/${modelType}/${model}`, dataForSend, { @@ -19,7 +22,7 @@ export async function sendMediaMessage(model: string | null, modelType: 'vide }) } -export async function getImagesBySlug(slug: string, token: string, type:'image' | 'video' | 'audio', offset?: number, limit = 10) { +export async function getImagesBySlug(slug: string, token: string, type: 'image' | 'video' | 'audio' | 'voice', offset?: number, limit = 10) { return await axios.get(getApiUrl() + `/media/${type}/${slug}?limit=${limit}&offset=${offset}`, { validateStatus: (status) => status < 500, headers: { @@ -13,12 +13,23 @@ import styles from './card.module.scss' export interface AudioModelCardProps extends IShortModel { accessed_models: string[] | null + /** Базовый сегмент URL списка моделей (например audio, voice-cloning) */ + modelsBasePath?: string } -export function AudioModelCard({ description, image, title, slug, accessed_models, blocked, tags }: AudioModelCardProps) { +export function AudioModelCard({ + description, + image, + title, + slug, + accessed_models, + blocked, + tags, + modelsBasePath = 'audio', +}: AudioModelCardProps) { const link = useMemo(() => { - return accessed_models && !accessed_models.includes(slug) ? '/subscription' : `audio/${slug}` - }, [accessed_models]) + return accessed_models && !accessed_models.includes(slug) ? '/subscription' : `${modelsBasePath}/${slug}` + }, [accessed_models, slug, modelsBasePath]) return ( @@ -10,7 +10,7 @@ import { formDataHelper } from '#/widgets/messages' export function useCreateMediaMessage( showError: (message: string) => void, type: string, - modelType: 'video' | 'image' | 'audio', + modelType: 'video' | 'image' | 'audio' | 'voice', device: Device, setMessages: Dispatch>, mobileScrollContainer: RefObject @@ -9,7 +9,7 @@ import { getImagesBySlug, Message } from '#/entities/message' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { Device } from '#/shared/lib/types/entities' -export function useMediaBotPagination(deviceType: Device, type:'image' | 'video' | 'audio') { +export function useMediaBotPagination(deviceType: Device, type: 'image' | 'video' | 'audio' | 'voice') { const refScrollMobile = useRef(null) const refScrollDesktop = useRef(null) const mobileScrollContainer = useRef(null) @@ -4,3 +4,4 @@ export const ERROR_REPORT = 'error-report' export const BUSINESS_ERROR_REPORT = 'business-error-report' export const LOW_BALANCE_OFFER = 'low-balance-offer' export const SUBSCRIPTION_CHANGE_NOTIFICATION = 'subscription-change-notification' +export const PLATE_ADD_VOICE = 'plate-add-voice' @@ -66,7 +66,7 @@ &__content { background-color: var(--new-ui-main-color); - border-radius: 42px; + border-radius: 16px; position: relative; overflow: hidden; height: fit-content; @@ -20,6 +20,8 @@ export interface PlatesTemplateProps { animationClass?: string animationBehaviorClass?: string headerClassName?: string + /** Перекрывает фон контента плашки (например `#151518`) */ + contentBackgroundColor?: string variant?: Variant } @@ -35,6 +37,7 @@ export default function PlatesTemplate({ animationBehaviorClass = styles['template__animation-behavior'], closeModal = () => {}, headerClassName, + contentBackgroundColor, variant = 'primary', }: PlatesTemplateProps) { const { setModal, getModal } = usePlatesStore() @@ -64,6 +67,7 @@ export default function PlatesTemplate({ modal.state ? '' : animationBehaviorClass, styles[`template__content_${variant}`] )} + style={contentBackgroundColor ? { backgroundColor: contentBackgroundColor } : undefined} onClick={(e) => e.stopPropagation()} > + ) +} @@ -0,0 +1 @@ +export { AddVoiceCta } from './add-voice-cta' @@ -0,0 +1,171 @@ +.container { + padding-bottom: 0 !important; +} + +.modal { + padding: 56px 24px 24px 24px; + min-width: 450px; + min-height: 300px; + width: 648px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.upload { + border: 1px dashed #a4aab5; + width: 100%; + padding: 32px 24px; + background-color: #1d1d2180; + border-radius: 16px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 16px; + transition: + border-color 0.2s ease, + background-color 0.2s ease; + + &_dragging { + border-color: #8280ff; + background-color: rgba(130, 128, 255, 0.08); + } +} + +.visuallyHidden { + position: absolute; + visibility: hidden; +} + +.uploadIcon { + width: 56px; + height: 56px; +} + +.title { + font-size: 18px; + font-style: bold; + font-weight: 700; + color: #a4aab5; +} + +.hint { + margin: 0; + font-size: 14px; + color: #80808e; +} + +.hintAccent { + font-weight: 600; +} + +.fileName { + margin: 0; + font-size: 13px; + color: #a4aab5; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.previewBlock { + width: 100%; + display: flex; + flex-direction: column; + gap: 14px; +} + +.playerWrap { + width: 100%; + position: relative; + display: flex; + align-items: center; + border-radius: 15px; + background-color: #1e1e20; + padding: 12px; + box-sizing: border-box; +} + + + +.actions { + display: flex; + gap: 12px; + width: 100%; + margin-top: 16px; +} + +.actionBtn { + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + height: 38px; + border: 1px solid #8280FF; +} + +.uploadFromMicBtn { + display: flex; + align-items: center; + justify-content: center; + max-width: 400px; + border-radius: 8px; + height: 38px; + border: 1px solid #8280ff; +} + +.micPanel { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + max-width: 440px; + margin-top: 4px; +} + +.micRow { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + width: 100%; +} + +.micSelect { + flex: 1; + min-width: 0; + height: 38px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid #5a5a5a; + background: #2a2a2a; + color: #e0e0e0; + font-size: 14px; + font-family: inherit; + cursor: pointer; +} + +.micStartBtn { + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + height: 38px; + border: 1px solid #8280ff; +} + +.micHint { + margin: 0; + font-size: 13px; + color: #80808e; +} + +.or { + margin: 0; + font-size: 14px; + color: #80808e; +} \ No newline at end of file @@ -0,0 +1,480 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useSession } from 'next-auth/react' + +import { createMediaVoice } from '#/entities/audio-model' +import { getModalById, PLATE_ADD_VOICE, PlateTemplate } from '#/features/modals' +import { c, getDeviceType, getOs } from '#/shared/lib/helpers' +import { useDeviceType } from '#/shared/lib/hooks' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { CommonButton } from '#/shared/ui/button' +import { CommonInput } from '#/shared/ui/common-input' +import { AudioPlayer } from '#/widgets/messages/ui/audio-player' + +import styles from './add-voice-plate.module.scss' + +const MAX_BYTES = 10 * 1024 * 1024 +const FILE_ACCEPT = '.mp3,.wav,audio/mpeg,audio/wav' +const PREVIEW_PLAYER_UID = 'add-voice-plate-preview' +const DEFAULT_VOICE_TITLE = 'Аудиозапись 1' + +function defaultTitleFromFile(file: File): string { + const base = file.name.replace(/\.[^/.]+$/, '').trim() + return base || DEFAULT_VOICE_TITLE +} + +type AddVoicePlateProps = { + onSuccess: () => Promise | void +} + +function validateAudioFile(file: File): string | null { + if (file.size > MAX_BYTES) { + return 'Размер файла не больше 10 МБ' + } + const ext = file.name.split('.').pop()?.toLowerCase() + if (ext !== 'mp3' && ext !== 'wav' && ext !== 'ogg' && ext !== 'weba') { + return 'Допустимы только файлы .mp3, .wav, .ogg, .weba' + } + return null +} + +function audioMime(file: File): string { + if (file.type) return file.type + const ext = file.name.split('.').pop()?.toLowerCase() + if (ext === 'wav') return 'audio/wav' + if (ext === 'ogg') return 'audio/ogg' + if (ext === 'webm' || ext === 'weba') return 'audio/webm' + return 'audio/mpeg' +} + +function pickRecordingMime(): string { + if (typeof MediaRecorder === 'undefined') return '' + const candidates = ['audio/ogg;codecs=opus', 'audio/webm;codecs=opus', 'audio/webm'] + for (const m of candidates) { + if (MediaRecorder.isTypeSupported(m)) return m + } + return '' +} + +export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { + /** Главный экран модалки: выбор источника или превью перед обучением */ + const ModalView = { + PickSource: 'pick_source', + Preview: 'preview', + } as const + + /** Подэкран микрофона внутри PickSource (панель открыта по кнопке «Записать…») */ + const MicPanelPhase = { + Closed: 'closed', + LoadingList: 'loading_list', + NoMicrophones: 'no_microphones', + Ready: 'ready', + Recording: 'recording', + } as const + + const modal = getModalById(PLATE_ADD_VOICE) + const { data: session } = useSession() + const { showMessage } = useShowDataStore() + + const deviceType = getDeviceType() + const deviceOs = getOs() + const { desktop } = useDeviceType(deviceType, deviceOs) + + const fileInputRef = useRef(null) + const dragDepthRef = useRef(0) + const mediaRecorderRef = useRef(null) + const mediaStreamRef = useRef(null) + + const [isDragging, setIsDragging] = useState(false) + const [pickedFile, setPickedFile] = useState(null) + const [voiceTitle, setVoiceTitle] = useState('') + const [training, setTraining] = useState(false) + const [micPanelOpen, setMicPanelOpen] = useState(false) + const [micDevices, setMicDevices] = useState([]) + const [micDevicesLoading, setMicDevicesLoading] = useState(false) + const [selectedMicId, setSelectedMicId] = useState('') + const [isRecording, setIsRecording] = useState(false) + + const modalView = pickedFile ? ModalView.Preview : ModalView.PickSource + + const micPanelPhase = useMemo(() => { + if (!micPanelOpen) return MicPanelPhase.Closed + if (micDevicesLoading) return MicPanelPhase.LoadingList + if (micDevices.length === 0) return MicPanelPhase.NoMicrophones + if (isRecording) return MicPanelPhase.Recording + return MicPanelPhase.Ready + }, [micPanelOpen, micDevicesLoading, micDevices.length, isRecording]) + + const showMicInvite = micPanelPhase === MicPanelPhase.Closed + const showMicPanel = micPanelPhase !== MicPanelPhase.Closed + const showMicDeviceRow = micPanelPhase === MicPanelPhase.Ready || micPanelPhase === MicPanelPhase.Recording + + const previewUrl = useMemo(() => (pickedFile ? URL.createObjectURL(pickedFile) : null), [pickedFile]) + + useEffect(() => { + if (!previewUrl) return + return () => { + URL.revokeObjectURL(previewUrl) + } + }, [previewUrl]) + + const applyFile = useCallback( + (file: File) => { + const err = validateAudioFile(file) + if (err) { + showMessage(err) + return + } + setPickedFile(file) + }, + [showMessage] + ) + + const stopMediaStream = useCallback(() => { + mediaStreamRef.current?.getTracks().forEach((t) => t.stop()) + mediaStreamRef.current = null + }, []) + + const discardRecording = useCallback(() => { + const rec = mediaRecorderRef.current + if (rec && rec.state !== 'inactive') { + rec.onstop = null + rec.stop() + } + mediaRecorderRef.current = null + stopMediaStream() + setIsRecording(false) + }, [stopMediaStream]) + + const resetFile = useCallback(() => { + discardRecording() + setPickedFile(null) + setVoiceTitle('') + setMicPanelOpen(false) + setMicDevices([]) + setSelectedMicId('') + }, [discardRecording]) + + const refreshMicDevices = useCallback(async () => { + if (!navigator.mediaDevices?.enumerateDevices) { + showMessage('Браузер не поддерживает выбор микрофона') + return + } + setMicDevicesLoading(true) + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + stream.getTracks().forEach((t) => t.stop()) + const list = await navigator.mediaDevices.enumerateDevices() + const inputs = list.filter((d) => d.kind === 'audioinput') + setMicDevices(inputs) + setSelectedMicId((prev) => { + if (prev && inputs.some((i) => i.deviceId === prev)) return prev + return inputs[0]?.deviceId ?? '' + }) + if (!inputs.length) { + showMessage('Не найдено ни одного микрофона') + } + } catch { + showMessage('Нет доступа к микрофону') + setMicDevices([]) + setSelectedMicId('') + } finally { + setMicDevicesLoading(false) + } + }, [showMessage]) + + const onUploadFromMic = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + setMicPanelOpen(true) + void refreshMicDevices() + }, + [refreshMicDevices] + ) + + const onMicStartStop = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + if (isRecording) { + mediaRecorderRef.current?.stop() + return + } + const mime = pickRecordingMime() + if (!mime) { + showMessage('Запись в этом браузере недоступна') + return + } + try { + const constraints: MediaStreamConstraints = { + audio: selectedMicId ? { deviceId: { exact: selectedMicId } } : true, + } + const stream = await navigator.mediaDevices.getUserMedia(constraints) + mediaStreamRef.current = stream + const rec = new MediaRecorder(stream, { mimeType: mime }) + mediaRecorderRef.current = rec + const chunks: BlobPart[] = [] + rec.ondataavailable = (ev) => { + if (ev.data.size) chunks.push(ev.data) + } + rec.onstop = () => { + const blob = new Blob(chunks, { type: mime }) + mediaRecorderRef.current = null + stopMediaStream() + setIsRecording(false) + /* бэкенд ожидает аудио WebM под расширением .weba */ + const ext = mime.includes('ogg') ? 'ogg' : 'weba' + const file = new File([blob], `voice-record.${ext}`, { type: mime }) + applyFile(file) + setMicPanelOpen(false) + setMicDevices([]) + } + rec.start() + setIsRecording(true) + } catch { + showMessage('Не удалось начать запись') + discardRecording() + } + }, + [applyFile, discardRecording, isRecording, selectedMicId, showMessage, stopMediaStream] + ) + + useEffect(() => { + return () => { + discardRecording() + } + }, [discardRecording]) + + useEffect(() => { + if (!pickedFile) return + setVoiceTitle(defaultTitleFromFile(pickedFile)) + }, [pickedFile]) + + const openFileDialog = () => { + fileInputRef.current?.click() + } + + const onFileInputChange = (e: React.ChangeEvent) => { + const f = e.target.files?.[0] + if (f) applyFile(f) + e.target.value = '' + } + + const onDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragDepthRef.current += 1 + setIsDragging(true) + } + + const onDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragDepthRef.current -= 1 + if (dragDepthRef.current <= 0) { + dragDepthRef.current = 0 + setIsDragging(false) + } + } + + const onDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const onDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragDepthRef.current = 0 + setIsDragging(false) + const f = e.dataTransfer.files?.[0] + if (f) applyFile(f) + } + + const onTrain = async () => { + if (!pickedFile || !session?.access) { + if (!session?.access) showMessage('Нет авторизации') + return + } + setTraining(true) + try { + const titleForApi = voiceTitle.trim() || DEFAULT_VOICE_TITLE + const res = await createMediaVoice(pickedFile, session.access, titleForApi) + if (res.status >= 400) { + const data = res.data as { detail?: unknown } + const detail = data?.detail + const msg = + typeof detail === 'string' + ? detail + : Array.isArray(detail) + ? detail.map((d) => (typeof d === 'object' && d && 'msg' in d ? String((d as { msg: string }).msg) : String(d))).join(', ') + : 'Не удалось загрузить голос' + showMessage(msg) + return + } + await onSuccess() + showMessage('Голос добавлен', 'success') + modal.setState(false) + resetFile() + } catch { + showMessage('Не удалось загрузить голос') + } finally { + setTraining(false) + } + } + + return ( + {}} + > +
+ {modalView === ModalView.PickSource ? ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + openFileDialog() + } + }} + > + +
+ + + +
+

+ Загрузите примеры голоса
для клонирования +

+

+ аудиофайлы в формате .mp3 или{' '} + .wav, не более{' '} + 10MB каждый +

+ {showMicInvite ? ( + <> +

e.stopPropagation()}> + или +

+ + Записать аудио с микрофона + + + ) : null} + {showMicPanel ? ( +
e.stopPropagation()}> + {micPanelPhase === MicPanelPhase.LoadingList ? ( +

Загрузка списка микрофонов…

+ ) : null} + {micPanelPhase === MicPanelPhase.NoMicrophones ? ( +

Нет доступных микрофонов. Проверьте разрешения браузера.

+ ) : null} + {showMicDeviceRow ? ( +
+ + void onMicStartStop(ev)} + > + {micPanelPhase === MicPanelPhase.Recording ? 'Остановить' : 'Начать'} + +
+ ) : null} +
+ ) : null} +
+ ) : ( +
+ setVoiceTitle(e.target.value)} + /> +
+ + + Ваш браузер не поддерживает аудио. + +
+
+ + Попробовать ещё + + void onTrain()} + > + Обучить > + +
+
+ )} +
+
+ ) +} @@ -0,0 +1 @@ +export { AddVoicePlate } from './add-voice-plate' @@ -0,0 +1 @@ +export { VoiceCloneSelect } from './voice-clone-select' @@ -0,0 +1,61 @@ +.subheader { + background-color: #151518; + color: #a4aab5; + font-weight: 600; + font-size: 12px; + line-height: 32px; +} + +.hiddenAudio { + display: none; +} + +/* min-height синхронизирован с VOICE_SELECT_ROW_HEIGHT в voice-clone-select.tsx */ +.menuItemRow { + display: flex; + align-items: center; + gap: 12px; + min-height: 58px; + padding-block: 0; + background-color: transparent; + overflow: hidden; +} + +.menuItemRowAlt { + background-color: #1d1d21; +} + +/* ширина/высота = VOICE_PREVIEW_PLAY_SIZE в voice-clone-select.tsx */ +.menuPlaySlot { + flex-shrink: 0; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; +} + +.rowActions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0; + margin-left: 2px; +} + +.titleInput { + flex: 1; + min-width: 0; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #5a5a5a; + background: #2a2a2a; + color: #e0e0e0; + font-size: 15px; + line-height: 1.2; + outline: none; + + &:focus { + border-color: #827fff; + } +} @@ -0,0 +1,481 @@ +import { Pause, PlayArrow } from '@mui/icons-material' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' +import { + Box, + IconButton, + ListSubheader, + MenuItem, + Select, + SelectChangeEvent, + Stack, + Typography, +} from '@mui/material' +import type { MenuProps } from '@mui/material/Menu' +import Image from 'next/image' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { deleteMediaVoice, MediaVoiceItem, patchMediaVoiceTitle } from '#/entities/audio-model' +import { baseColor } from '#/shared/lib/constants/colors' +import { c } from '#/shared/lib/helpers' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { TooltipCustom } from '#/shared' + +import styles from './voice-clone-select.module.scss' + +const VOICE_TITLE_DISPLAY_MAX = 15 + +function formatVoiceSelectLabel(full: string): { text: string; tooltip?: string } { + if (full.length <= VOICE_TITLE_DISPLAY_MAX) return { text: full } + return { text: `${full.slice(0, VOICE_TITLE_DISPLAY_MAX)}…`, tooltip: full } +} + +function VoiceSelectLabel({ full }: { full: string }) { + const { text, tooltip } = formatVoiceSelectLabel(full) + const typo = ( + + {text} + + ) + if (!tooltip) return typo + return ( + + {typo} + + ) +} + +/** Иконки из /public (как в api-keys, account) */ +const VOICE_ICON_EDIT = '/svg/account/pencil.svg' +const VOICE_ICON_SAVE = '/svg/tic.svg' +const VOICE_ICON_DELETE = '/svg/main_menu/trash.svg' + +/** Высота поля селекта и строк меню (px) */ +const VOICE_SELECT_ROW_HEIGHT = 58 + +/** Круглая кнопка превью (px), без класса плеера 42px */ +const VOICE_PREVIEW_PLAY_SIZE = 26 + +const previewPlayIconButtonSx = { + flexShrink: 0, + width: VOICE_PREVIEW_PLAY_SIZE, + height: VOICE_PREVIEW_PLAY_SIZE, + minWidth: VOICE_PREVIEW_PLAY_SIZE, + padding: 0, + borderRadius: '50%', + backgroundColor: baseColor, + color: '#fff', + '&:hover': { + backgroundColor: '#6d6be0', + }, +} as const + +const previewPlayIconSx = { fontSize: 14 } as const + +const voiceActionIconButtonSx = { + flexShrink: 0, + width: 32, + height: 32, + minWidth: 32, + padding: 0, + borderRadius: '8px', + color: '#8280ff', + '&:hover': { + backgroundColor: 'rgba(130, 128, 255, 0.12)', + }, + '&.Mui-disabled': { + opacity: 0.45, + }, +} as const + +const selectSx = { + boxShadow: 'none', + borderRadius: '13px', + backgroundColor: '#4B4B4B', + border: '2px solid #40404E;', + color: '#A6A5A5', + '.MuiOutlinedInput-root': { + minHeight: VOICE_SELECT_ROW_HEIGHT, + height: VOICE_SELECT_ROW_HEIGHT, + }, + '.MuiSelect-select': { + display: 'flex', + alignItems: 'center', + minHeight: VOICE_SELECT_ROW_HEIGHT, + height: '100%', + paddingTop: 0, + paddingBottom: 0, + boxSizing: 'border-box', + }, + '.MuiSelect-icon': { + color: '#A6A5A5', + }, + '&& fieldset': { + border: '0px solid transparent', + }, + '&.Mui-focused': { + border: '2px solid #40404E;', + borderColor: baseColor, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none', + }, + }, + '&:hover': { + '&& fieldset': { + border: '0px solid transparent', + }, + }, +} as const + +/** Как в Autocomplete listbox / прочих меню: ограничение высоты + overflow; скроллбар — globals `.smallScroll` (см. voice-cloning-model, image-model). */ +const VOICE_SELECT_MENU_LIST_MAX_HEIGHT = 'min(45vh, 400px)' + +const VOICE_SELECT_MENU_PROPS: Partial = { + transitionDuration: 0, + PaperProps: { + sx: { + backgroundColor: '#151518', + borderRadius: '8px', + marginTop: '8px', + boxShadow: '0px 0px 4px rgba(0, 0, 0, 0.04), 0px 4px 32px rgba(0, 0, 0, 0.16)', + }, + }, + MenuListProps: { + className: 'smallScroll', + sx: { + color: '#A6A5A5', + backgroundColor: '#151518', + maxHeight: VOICE_SELECT_MENU_LIST_MAX_HEIGHT, + overflowY: 'auto', + scrollbarGutter: 'stable', + '& .MuiMenuItem-root': { + minHeight: VOICE_SELECT_ROW_HEIGHT, + }, + }, + }, +} + +type Props = { + voices: MediaVoiceItem[] + presets: MediaVoiceItem[] + value: string + loading: boolean + onSelect: (item: MediaVoiceItem) => void + /** Токен для PATCH/DELETE голосов; без него кнопки не показываются */ + accessToken?: string + onVoiceListChanged?: () => void + /** После удаления — сбросить выбранный uid, если удалён он */ + onDeletedVoice?: (uid: string) => void +} + +export function VoiceCloneSelect({ + voices, + presets, + value, + loading, + onSelect, + accessToken, + onVoiceListChanged, + onDeletedVoice, +}: Props) { + const { showMessage } = useShowDataStore() + const all = [...voices, ...presets] + const audioRef = useRef(null) + const [playingUid, setPlayingUid] = useState(null) + const [editingUid, setEditingUid] = useState(null) + const [editDraft, setEditDraft] = useState('') + const [mutatingUid, setMutatingUid] = useState(null) + + const stopPreview = useCallback(() => { + const el = audioRef.current + if (el) { + el.pause() + } + setPlayingUid(null) + }, []) + + const togglePreview = useCallback( + (item: MediaVoiceItem) => { + const el = audioRef.current + if (!el || !item.file?.trim()) return + + if (playingUid === item.uid) { + el.pause() + setPlayingUid(null) + return + } + + el.pause() + el.src = item.file + el.currentTime = 0 + const p = el.play() + if (p !== undefined) { + p.then(() => setPlayingUid(item.uid)).catch(() => setPlayingUid(null)) + } + }, + [playingUid] + ) + + useEffect(() => { + const el = audioRef.current + if (!el) return + const onEnded = () => setPlayingUid(null) + el.addEventListener('ended', onEnded) + return () => { + el.removeEventListener('ended', onEnded) + } + }, []) + + useEffect( + () => () => { + audioRef.current?.pause() + }, + [] + ) + + const onChange = (e: SelectChangeEvent) => { + const uid = e.target.value + const item = all.find((v) => v.uid === uid) ?? null + if (item) { + onSelect(item) + } + } + + const closeMenuExtras = useCallback(() => { + stopPreview() + setEditingUid(null) + setEditDraft('') + }, [stopPreview]) + + const commitRename = useCallback( + async (item: MediaVoiceItem) => { + if (!accessToken) return + const trimmed = editDraft.trim() + if (!trimmed) { + showMessage('Введите название') + return + } + setMutatingUid(item.uid) + try { + const res = await patchMediaVoiceTitle(item.uid, trimmed, accessToken) + if (res.status >= 400) { + showMessage('Не удалось изменить название') + return + } + setEditingUid(null) + setEditDraft('') + onVoiceListChanged?.() + showMessage('Название изменено', 'success') + } finally { + setMutatingUid(null) + } + }, + [accessToken, editDraft, onVoiceListChanged, showMessage] + ) + + const removeVoice = useCallback( + async (item: MediaVoiceItem) => { + if (!accessToken) return + if (!window.confirm('Удалить этот голос?')) return + setMutatingUid(item.uid) + try { + const res = await deleteMediaVoice(item.uid, accessToken) + if (res.status >= 400) { + showMessage('Не удалось удалить голос') + return + } + if (editingUid === item.uid) { + setEditingUid(null) + setEditDraft('') + } + onDeletedVoice?.(item.uid) + onVoiceListChanged?.() + showMessage('Голос удалён', 'success') + } finally { + setMutatingUid(null) + } + }, + [accessToken, editingUid, onDeletedVoice, onVoiceListChanged, showMessage] + ) + + const flatDisabled = !loading && all.length === 0 + + const renderVoiceMenuItem = (item: MediaVoiceItem, optionIndex: number, isUserVoice: boolean) => { + const hasPreview = Boolean(item.file?.trim()) + const isEditing = editingUid === item.uid + const busy = mutatingUid === item.uid + const showActions = isUserVoice && Boolean(accessToken) + + return ( + + {hasPreview ? ( + { + e.stopPropagation() + togglePreview(item) + }} + onMouseDown={(e) => e.stopPropagation()} + aria-label={playingUid === item.uid ? 'Пауза' : 'Прослушать пример'} + > + {playingUid === item.uid ? : } + + ) : ( + + )} + {isEditing ? ( + setEditDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + void commitRename(item) + } + }} + aria-label='Название голоса' + autoFocus + /> + ) : ( + + + + )} + {showActions ? ( + + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + if (isEditing) { + void commitRename(item) + } else { + setEditingUid(item.uid) + setEditDraft(item.title) + } + }} + aria-label={isEditing ? 'Сохранить' : 'Изменить'} + > + {isEditing ? ( + + ) : ( + + )} + + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + void removeVoice(item) + }} + aria-label='Удалить голос' + > + + + + ) : null} + + ) + } + + return ( + + + ) +} @@ -0,0 +1,2 @@ +export { default } from './voice-cloning-model' +export { default as VoiceCloningModelPage } from './voice-cloning-model' @@ -0,0 +1,181 @@ +.container { + width: 100%; + border-radius: 15px; + height: 100%; + position: absolute; + bottom: 0; + left: 0; + background-color: var(--new-ui-main-color); + opacity: 0; + z-index: -1; + transition: all 0.3s ease-in-out; + + &_active { + opacity: 1; + z-index: 100; + } +} + +.staticTabsWrapper { + display: flex; + align-items: center; + width: calc(24.5% + 2px); + margin-left: auto; + margin-right: calc(3%); + margin-top: 0; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 22px; + margin: 14px 0; + + > *:first-child { + min-width: 200px; + } + + @media screen and (max-width: 768px) { + align-items: flex-start; + width: 100%; + max-width: 100%; + gap: 4px; + overflow-x: hidden; + } +} +@keyframes fadein { + 0% { + opacity: 0; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.icon { +} + + +.tags { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + + @media screen and (max-width: 768px) { + overflow-x: auto; + overflow-y: hidden; + width: 100%; + min-width: 0; + max-width: 100%; + align-self: stretch; + -webkit-overflow-scrolling: touch; + } +} + +.tag { + display: flex; + align-items: center; + justify-content: center; + background-color: white; + border-radius: 100px; + width: 50px; + height: 50px; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.1); + transition: width 0.3s ease-in-out; + + @media screen and (max-width: 768px) { + width: 150px; + height: 40px; + padding: 12px; + transition: none; + flex-shrink: 0; + + .tag__text { + opacity: 1; + position: static; + display: block; + } + } + + &__icon { + color: var(--air-color); + } + + &:hover { + padding: 12px; + width: 200px; + + @media screen and (max-width: 768px) { + width: 150px; + + .tag__icon { + animation: none; + } + + .tag__text { + animation: none; + } + } + + @media screen and (min-width: 769px) { + .tag__icon { + animation: fadein 0.6s ease-in-out; + } + + .tag__text { + opacity: 1; + position: static; + animation: fadein 0.6s ease-in-out; + display: block; + } + } + } + + &__text { + position: absolute; + color: var(--air-color); + font-weight: 500; + padding-left: 10px; + font-size: 14px; + opacity: 0; + display: none; + min-width: max-content !important; + } +} + +.blocked { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: #c32528; + padding: 20px; + gap: 10px; + position: absolute; + + left: 0; + + width: 100%; + border-radius: 15px; + top: 0; + + @media screen and (max-width: 768px) { + top: unset; + // bottom: -22px; + z-index: 1; + border-radius: 15px; + } + + &__text { + color: white !important; + font-size: 16px; + font-weight: 600; + } +} + @@ -0,0 +1,579 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Box, Collapse, Stack, Typography } from '@mui/material' +import Head from 'next/head' +import { useRouter } from 'next/router' +import { useSession } from 'next-auth/react' + +import styles from './voice-cloning-model.module.scss' + +import { ChatSelect } from '#/app/components/chat_select' +import { ResetFilters } from '#/app/components/filters/reset_filters' +import BlockedSvg from '#/assets/svg/blocked.svg?react' +import { useImageBot } from '#/entities/model-entity/model/use-image-bot' +import BotParamsMap from '#/features/bot-params/bot-params-map' +import { useCreateMediaMessage } from '#/features/create-media-message' +import { useImagesBotFilters } from '#/features/image-bot-filters' +import { useImagesUniqInput } from '#/features/image-bot-input' +import { useMediaBotPagination } from '#/features/image-bot-pagination' +import { ModelInput } from '#/features/model-input' +import { usePredictPrice } from '#/features/predict-price/model/use-predict-price' +import Title from '#/features/title/title' +import { getVoicesAndPresets, MediaVoiceItem } from '#/entities/audio-model' +import { MessageSend } from '#/entities/message' +import { NextPageWithLayout } from '#/pages/_app' +import { DrawerCustom, Loader } from '#/shared' +import { c, getDeviceType, getOs } from '#/shared/lib/helpers' +import { useProgressLoader } from '#/shared/lib/hooks/use-progress-loader' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { SvgIcon } from '#/shared/ui/svg' +import ProgressLoader from '#/widgets/loaders/progress-loader-props' +import { AudioMessagesList } from '#/widgets/messages/ui/audio-messages-list' +import { ModelApiView, StaticTabs } from '#/widgets/model-api-view' +import { useDeviceType } from '#/shared/lib/hooks' + +import { AddVoiceCta } from '../add-voice-cta' +import { AddVoicePlate } from '../add-voice-plate' +import { VoiceCloneSelect } from '../voice-clone-select' + +const VoiceCloningModelPage: NextPageWithLayout = () => { + const { query, push } = useRouter() + const { data: session } = useSession() + const { showMessage } = useShowDataStore() + + const deviceType = getDeviceType() + const deviceOs = getOs() + const { desktop } = useDeviceType(deviceType, deviceOs) + + const [scope, setScope] = useState<'playground' | 'api'>('playground') + const [prompt, setPrompt] = useState('') + const [selectedVoiceUid, setSelectedVoiceUid] = useState('') + const [voices, setVoices] = useState([]) + const [presets, setPresets] = useState([]) + const [voicesLoading, setVoicesLoading] = useState(false) + + const { botParams, version, modelType, fetchBotParams, resetParams, setDefaultParams, setVersion } = useImageBot(query.slug as string) + const { openFiltersMobile, setOpenFiltersMobile, params, setParams, includeParams } = useImagesBotFilters() + const { refScrollMobile, refScrollDesktop, mobileScrollContainer, onObserverMounted, setMessages, fetchMessages, loading, offset, messages } = + useMediaBotPagination(deviceType, 'voice') + const { createImage, isComplete, createLoading } = useCreateMediaMessage( + showMessage, + modelType, + 'voice', + deviceType, + setMessages, + mobileScrollContainer + ) + + const createWithVoice = useCallback( + (dataForSend: MessageSend) => { + return createImage({ + ...dataForSend, + info: { + ...(typeof dataForSend.info === 'object' && dataForSend.info !== null ? (dataForSend.info as object) : {}), + ...(selectedVoiceUid ? { voice_uid: selectedVoiceUid } : {}), + } as T, + }) + }, + [createImage, selectedVoiceUid] + ) + + const { onCreateImage, onLoadImage, image, setImage } = useImagesUniqInput(version, includeParams, createWithVoice) + + const onVoiceSelect = useCallback((item: MediaVoiceItem) => { + setSelectedVoiceUid(item.uid) + }, []) + + const refetchVoicesAndPresets = useCallback(async () => { + if (!session?.access) return + setVoicesLoading(true) + try { + const { voices: v, presets: p } = await getVoicesAndPresets(session.access) + setVoices(v) + setPresets(p) + } finally { + setVoicesLoading(false) + } + }, [session?.access]) + + const allVoiceOptions = useMemo(() => [...voices, ...presets], [voices, presets]) + + useEffect(() => { + if (voicesLoading || allVoiceOptions.length === 0) return + const valid = Boolean(selectedVoiceUid && allVoiceOptions.some((v) => v.uid === selectedVoiceUid)) + if (!valid) { + onVoiceSelect(allVoiceOptions[0]) + } + }, [voicesLoading, allVoiceOptions, selectedVoiceUid, onVoiceSelect]) + + const { progress, isVisible: isProgressVisible } = useProgressLoader({ + isLoading: createLoading, + duration: 150000, // 150 секунд для создания аудио + }) + + async function onFetch() { + const result = await fetchBotParams() + + if (typeof result === 'string' || !result) { + showMessage(result || 'Произошла ошибка') + return push('/404') + } + } + + useEffect(() => { + if (!session) return + onFetch() + onObserverMounted() + }, [session?.access]) + + useEffect(() => { + if (!session?.access) return + let cancelled = false + setVoicesLoading(true) + getVoicesAndPresets(session.access) + .then(({ voices: v, presets: p }) => { + if (!cancelled) { + setVoices(v) + setPresets(p) + } + }) + .finally(() => { + if (!cancelled) setVoicesLoading(false) + }) + return () => { + cancelled = true + } + }, [session?.access]) + + // Подготавливаем данные для API вкладки + // Фильтруем параметры - оставляем только актуальные для текущей версии + const filteredParams = useMemo(() => { + if (!botParams?.parameters) { + return {} + } + + // Получаем параметры, актуальные для текущей версии + const validParams = botParams.parameters.filter((param) => { + // Если у параметра нет версий - актуален для всех + if (param.versions.length === 0) return true + + // Если нет выбранной версии - показываем все параметры + if (!version) return true + + // Проверяем, актуален ли параметр для текущей версии + return param.versions.includes(version) + }) + + // Создаем объект: если есть значение в Redux - берем его, иначе - дефолтное + return validParams.reduce( + (acc, param) => ({ + ...acc, + // @ts-ignore + [param.key]: includeParams?.[param.key] ?? param.values.default, + }), + {} + ) + }, [botParams?.parameters, version, includeParams]) + + const showFileExample = useMemo(() => { + if (!botParams?.inputs) return false + + // Проверяем, есть ли типы кроме 'text', которые доступны для текущей версии + return botParams.inputs.some((input) => { + // Пропускаем текстовый тип + if (input.type === 'text') return false + + // Если у input нет версий - актуален для всех + if (input.versions.length === 0) return true + + // Если нет выбранной версии - показываем все inputs + if (!version) return true + + // Проверяем, актуален ли input для текущей версии + return input.versions.includes(version) + }) + }, [botParams?.inputs, version]) + + const predictPriceInfo = useMemo( + () => ({ ...(includeParams || {}), ...(version ? { version } : {}) }), + [includeParams, version] + ) + + const predictedPrice = usePredictPrice({ + modelSlug: modelType, + content: prompt, + fileExists: !!image, + info: predictPriceInfo, + token: session?.access, + enabled: !!modelType && !!session?.access && scope === 'playground', + }) + + return ( + <> + + {botParams ? botParams?.title : 'Загрузка...'} + +
+ + <div className={styles.tags}> + {botParams && + botParams.tags.map((tag, index) => ( + <div key={index} className={styles.tag}> + <SvgIcon width={23} height={23} url={tag.icon} className={styles.tag__icon} /> + + <span className={styles.tag__text}>{tag.title}</span> + </div> + ))} + </div> + {desktop && ( + <Stack className={styles.staticTabsWrapper}> + <StaticTabs scope={scope} setScope={setScope} /> + </Stack>)} + </div> + + {!desktop && ( + <Stack sx={{ display: 'flex', alignItems: 'center', width: '100%', marginTop: '10px' }}> + <StaticTabs scope={scope} setScope={setScope} /> + </Stack> + )} + + <Box + display={'flex'} + justifyContent='space-between' + alignItems='start' + flexDirection={desktop ? 'row' : 'column-reverse'} + sx={{ + marginBottom: desktop ? 0 : 2, + width: desktop ? '97%' : '100%', + marginTop: desktop ? 3 : '15px', + }} + > + <Box + sx={{ + width: desktop ? '73%' : '100%', + marginLeft: 0, + display: 'flex', + flexDirection: desktop ? 'column' : 'column-reverse', + }} + > + {desktop ? ( + <> + <Stack + alignItems='center' + sx={{ + marginBottom: '15px', + position: 'relative', + zIndex: 1, + }} + > + {botParams && ( + <ModelInput + currentVersion={version} + styles={'images'} + input_types={botParams.inputs} + image={image} + value={prompt} + onValueChange={(value: string) => setPrompt(value)} + desktop={desktop} + blocked={scope === 'playground' ? botParams.blocked : true} + loading={createLoading} + imageLoad={onLoadImage} + sendMessage={onCreateImage} + unpinImage={() => setImage(null)} + viewMobileSettings={() => setOpenFiltersMobile(true)} + predictedPrice={predictedPrice} + /> + )} + <Box sx={{ width: '100%', marginTop: '10px' }}> + {isProgressVisible && ( + <ProgressLoader progress={progress} height={15} title='Создание аудио...' showPercentage={true} /> + )} + </Box> + {botParams?.blocked && ( + <div className={styles.blocked}> + <BlockedSvg width={16} height={16} color='white' /> + <span className={c(styles.blocked__text)}>Модель недоступна</span> + </div> + )} + </Stack> + <Box + className={'bg-color-block border-radius-main'} + sx={{ + padding: '30px', + position: 'relative', + }} + > + <Box sx={{ display: scope === 'api' ? 'block' : 'none' }}> + <ModelApiView + version={version || ''} + slug={botParams?.slug || ''} + APIModel='voice' + modelParams={filteredParams} + showFileExample={showFileExample} + /> + </Box> + <Box sx={{ display: scope === 'playground' ? 'block' : 'none' }}> + <AudioMessagesList device={deviceType} audios={messages} getMessagesPagination={fetchMessages} onPromptClick={(content) => setPrompt(content)} /> + </Box> + <Box + sx={{ + position: 'absolute', + bottom: '0', + visibility: 'hidden', + height: '800px', + width: '100%', + }} + ref={refScrollDesktop} + ></Box> + </Box> + </> + ) : ( + <Box className={'bg-color-block border-radius-main'} sx={{ position: 'relative' }}> + <div className={c(styles.container, loading && offset.current === 0 && styles.container_active)}> + <div + style={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }} + > + <Loader /> + </div> + </div> + <div style={{ position: 'relative', borderRadius: '13px' }}> + <Box + sx={{ + padding: '30px', + height: `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 23.5px'})`, + overflowY: 'scroll', + overflowX: 'hidden', + position: 'relative', + }} + ref={mobileScrollContainer} + className={'smallScroll'} + > + <Box sx={{ display: scope === 'playground' ? 'block' : 'none' }}> + <div style={{ position: 'absolute', top: 300 }} ref={refScrollMobile}></div> + <AudioMessagesList device={deviceType} audios={messages} getMessagesPagination={fetchMessages} onPromptClick={(content) => setPrompt(content)} /> + </Box> + + <Box sx={{ display: scope === 'api' ? 'block' : 'none' }}> + <ModelApiView + version={version || ''} + slug={botParams?.slug || ''} + APIModel='voice' + modelParams={filteredParams} + showFileExample={showFileExample} + /> + </Box> + </Box> + + <Stack alignItems='center' sx={{ zIndex: 10, position: 'relative', margin: 1.25 }}> + {botParams && ( + <ModelInput + currentVersion={version} + styles={'images'} + input_types={botParams.inputs} + image={image} + blocked={scope === 'playground' ? botParams.blocked : true} + value={prompt} + onValueChange={(value: string) => setPrompt(value)} + desktop={desktop} + loading={createLoading} + imageLoad={onLoadImage} + sendMessage={onCreateImage} + unpinImage={() => setImage(null)} + viewMobileSettings={() => setOpenFiltersMobile(true)} + predictedPrice={predictedPrice} + /> + )} + {isProgressVisible && ( + <ProgressLoader progress={progress} height={15} title='Создание аудио...' showPercentage={true} /> + )} + {botParams?.blocked && ( + <div className={styles.blocked}> + <BlockedSvg width={21} height={21} color='white' /> + <span className={c(styles.blocked__text)}>Модель недоступна</span> + </div> + )} + </Stack> + </div> + </Box> + )} + </Box> + + <Stack spacing={2} sx={{ width: desktop ? '25.5%' : '100%', paddingBottom: desktop ? '' : '15px' }}> + {desktop && ( + <Stack spacing={2} className='pd-30 bg-color-block border-radius-main' sx={{ height: 'auto' }}> + {botParams?.versions && botParams.versions.length !== 0 ? ( + <> + <Typography + sx={{ + color: '#A4AAB5', + fontWeight: '600', + fontSize: '14px', + letterSpacing: '0.1px', + }} + > + ВЕРСИИ + </Typography> + <ChatSelect + setDefaultParams={setDefaultParams} + value={version} + list={botParams.versions} + setValue={setVersion} + /> + </> + ) : ( + <></> + )} + <AddVoiceCta disabled={!session?.access} /> + <VoiceCloneSelect + voices={voices} + presets={presets} + value={selectedVoiceUid} + loading={voicesLoading} + onSelect={onVoiceSelect} + accessToken={session?.access} + onVoiceListChanged={refetchVoicesAndPresets} + onDeletedVoice={(uid) => { + if (selectedVoiceUid === uid) setSelectedVoiceUid('') + }} + /> + {botParams && botParams.parameters?.length > 0 && ( + <Box + display={'flex'} + alignItems={'center'} + gap={'5px'} + sx={{ cursor: 'pointer' }} + onClick={() => { + setParams(!params) + }} + > + <Typography + sx={{ + color: '#A4AAB5', + fontWeight: '600', + fontSize: '14px', + letterSpacing: '0.1px', + }} + > + ПАРАМЕТРЫ + </Typography> + <svg + width='14' + height='20' + viewBox='0 0 21 13' + fill='none' + xmlns='http://www.w3.org/2000/svg' + className={`${params ? 'rotate-180' : 'rotate-0'}`} + > + <path + fillRule='evenodd' + clipRule='evenodd' + d='M0.614851 0.615358C1.00866 0.221668 1.54271 0.000505666 2.09955 0.000505642C2.6564 0.000505617 3.19044 0.221668 3.58425 0.615357L10.4996 7.53066L17.4149 0.615357C17.8109 0.232825 18.3414 0.0211567 18.892 0.0259414C19.4426 0.0307261 19.9693 0.25158 20.3587 0.640937C20.748 1.03029 20.9689 1.557 20.9737 2.10761C20.9784 2.65823 20.7668 3.18869 20.3842 3.58476L11.9843 11.9848C11.5904 12.3784 11.0564 12.5996 10.4996 12.5996C9.94271 12.5996 9.40866 12.3784 9.01485 11.9848L0.614851 3.58476C0.221162 3.19095 -4.3461e-07 2.6569 -4.5895e-07 2.10006C-4.8329e-07 1.54321 0.221162 1.00917 0.614851 0.615358Z' + fill='#7f7df3' + /> + </svg> + </Box> + )} + + {botParams && botParams.parameters?.length > 0 ? ( + <Collapse in={params} orientation='vertical' collapsedSize={0}> + <BotParamsMap currentVersion={version} params={botParams?.parameters} /> + <ResetFilters desktop={desktop} closeDrawer={() => setOpenFiltersMobile(false)} reset={resetParams} /> + </Collapse> + ) : ( + <Typography + sx={{ + color: '#6e6e6e', + fontSize: '15px', + fontWeight: '500', + }} + > + Параметры отсутствуют + </Typography> + )} + </Stack> + )} + </Stack> + <DrawerCustom open={openFiltersMobile} onClose={() => setOpenFiltersMobile(false)}> + <Stack spacing={1} padding={2.4}> + {botParams?.versions && botParams.versions.length !== 0 ? ( + <> + <Typography + sx={{ + color: '#A4AAB5', + fontWeight: '600', + fontSize: '14px', + letterSpacing: '0.1px', + margin: '20px 0px 0px !important', + }} + > + ВЕРСИИ + </Typography> + <ChatSelect + setDefaultParams={setDefaultParams} + value={version} + list={botParams.versions} + setValue={setVersion} + /> + </> + ) : ( + <></> + )} + <Box sx={{ marginTop: '12px' }}> + <AddVoiceCta disabled={!session?.access} /> + <VoiceCloneSelect + voices={voices} + presets={presets} + value={selectedVoiceUid} + loading={voicesLoading} + onSelect={onVoiceSelect} + accessToken={session?.access} + onVoiceListChanged={refetchVoicesAndPresets} + onDeletedVoice={(uid) => { + if (selectedVoiceUid === uid) setSelectedVoiceUid('') + }} + /> + </Box> + {botParams && botParams.parameters?.length > 0 ? ( + <> + <Typography + sx={{ + color: '#A4AAB5', + fontWeight: '600', + fontSize: '14px', + letterSpacing: '0.1px', + margin: '20px 0px 0px !important', + }} + > + ПАРАМЕТРЫ + </Typography> + <BotParamsMap currentVersion={version} params={botParams?.parameters} /> + <ResetFilters closeDrawer={() => setOpenFiltersMobile(false)} desktop={desktop} reset={resetParams} /> + </> + ) : ( + <Typography + sx={{ + color: '#6e6e6e', + fontSize: '15px', + fontWeight: '500', + }} + > + Параметры отсутствуют + </Typography> + )} + </Stack> + </DrawerCustom> + </Box> + <AddVoicePlate onSuccess={refetchVoicesAndPresets} /> + </> + ) +} + +export default VoiceCloningModelPage @@ -0,0 +1,2 @@ +export { default } from './voice-cloning-models' +export { VoiceCloningModelsPage } from './voice-cloning-models' @@ -0,0 +1,31 @@ +.apiButton { + margin-left: 15px; + margin-top: 25px; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + + align-items: flex-start; + justify-content: flex-start; + justify-items: stretch; + + @media screen and (max-width: 1000px) { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + } + gap: 20px; + padding-top: 40px; + + margin-right: 60px; + + @media screen and (min-width: 1820px) { + margin-right: 240px; + grid-template-columns: repeat(auto-fit, 360px); + } + + @media screen and (max-width: 1000px) { + margin-right: 0px; + } +} + @@ -0,0 +1,72 @@ +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Box, Typography } from '@mui/material' +import CircularProgress from '@mui/material/CircularProgress' +import { useSession } from 'next-auth/react' + +import { useAppSelector } from '#/app/store/store' +import { getVoiceCloneModels } from '#/entities/audio-model' +import { AudioModelCard, IShortModel } from '#/entities/model-entity' +import { NextPageWithLayout } from '#/pages/_app' + +import styles from './voice-cloning-models.module.scss' +import { getDeviceType, getOs } from '#/shared/lib/helpers' +import { useDeviceType } from '#/shared/lib/hooks' + +export const VoiceCloningModelsPage: NextPageWithLayout = () => { + const [models, setModels] = useState<IShortModel[] | null>(null) + const deviceType = getDeviceType() + const deviceOs = getOs() + const { desktop } = useDeviceType(deviceType, deviceOs) + + const { data, status } = useSession() + const payment_plan = useAppSelector((state) => state.user.payment_plan) + + useEffect(() => { + if (!data) return + + getVoiceCloneModels(data.access).then((res) => setModels(res)).catch(() => { + setModels([]) + }) + }, [status]) + + return ( + <> + <Box + sx={{ + display: 'flex', + alignItems: 'center', + }} + > + <Typography sx={{ fontSize: 24, fontWeight: 'bold', marginTop: '25px' }}>Клонирование голоса</Typography> + </Box> + <Box className={styles.cards} sx={{ position: 'relative', marginRight: desktop ? '60px' : '0px' }}> + {models ? ( + models.map((item, index) => ( + <AudioModelCard + accessed_models={payment_plan.plan.accessed_models} + modelsBasePath='voice-cloning' + key={item.slug || item.title || index} + {...item} + /> + )) + ) : ( + <CircularProgress + size={50} + thickness={3} + sx={{ + color: '#7F7DF3', + position: 'absolute', + top: '40%', + left: 0, + right: 0, + margin: '0 auto', + }} + /> + )} + </Box> + </> + ) +} + +export default VoiceCloningModelsPage @@ -0,0 +1,2 @@ +export { default as VoiceCloningModelsPage } from './voice-cloning-models' +export { default as VoiceCloningModelPage } from './voice-cloning-model' @@ -0,0 +1 @@ +export * from './ui' @@ -80,6 +80,10 @@ transform: translateX(-50%); z-index: 1000; pointer-events: auto; + /* мост по hover: между кнопкой и вертикальным слайдером иначе pointer «просвечивает» и срабатывает mouseleave */ + padding-bottom: 52px; + margin-bottom: -52px; + box-sizing: content-box; } .volumeSlider { @@ -275,7 +275,11 @@ const AudioPlayerBase = ( </Box> {device === 'desktop' && ( - <Box className={styles.volumeContainer} onMouseEnter={() => setVolumeHovered(true)}> + <Box + className={styles.volumeContainer} + onMouseEnter={() => setVolumeHovered(true)} + onMouseLeave={() => setVolumeHovered(false)} + > <IconButton onClick={toggleMute} className={styles.volumeButton} @@ -286,7 +290,7 @@ const AudioPlayerBase = ( </IconButton> {volumeHovered && ( - <Box onMouseEnter={() => setVolumeHovered(true)} className={styles.volumeSliderWrapper}> + <Box className={styles.volumeSliderWrapper}> <Slider size='small' orientation='vertical' @@ -7,10 +7,10 @@ export interface IScope { export interface IComponentProps { currentVersion: string modelSlug: string - modelsType: 'text' | 'image' | 'video' | 'audio' + modelsType: 'text' | 'image' | 'video' | 'audio' | 'voice' showFileExample: boolean modelParams: Record<string, string | number | number[] | boolean> } export type TScope = 'chat-bots' | 'api' -export type TApiModel = 'text' | 'image' | 'video' | 'audio' +export type TApiModel = 'text' | 'image' | 'video' | 'audio' | 'voice' @@ -8,7 +8,7 @@ interface IProps { scopeTitle: string currentVersion: string modelSlug: string - modelsType: 'text' | 'image' | 'video' | 'audio' + modelsType: 'text' | 'image' | 'video' | 'audio' | 'voice' showFileExample: boolean modelParams: Record<string, string | number | number[] | boolean> } @@ -63,6 +63,12 @@ export const menuListMiddle = [ icon: '/svg/side-menu/audio', activeList: ['audio'], }, + { + title: 'Клонирование голоса', + link: '/voice-cloning', + icon: '/svg/side-menu/audio', + activeList: ['voice-cloning'], + }, ] const drawerWidth = 240