diff --git a/components/CreatePanel.tsx b/components/CreatePanel.tsx index 6ecd102..b9a2a8f 100644 --- a/components/CreatePanel.tsx +++ b/components/CreatePanel.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2 } from 'lucide-react'; +import { Sparkles, ChevronDown, Settings2, Trash2, Music2, Sliders, Dices, Hash, RefreshCw, Plus, Upload, Play, Pause, Loader2, AlertTriangle, CheckCircle2, ExternalLink } from 'lucide-react'; import { GenerationParams, Song } from '../types'; import { useAuth } from '../context/AuthContext'; import { useI18n } from '../context/I18nContext'; @@ -237,6 +237,11 @@ export const CreatePanel: React.FC = ({ // Available models fetched from backend const [fetchedModels, setFetchedModels] = useState<{ name: string; is_active: boolean; is_preloaded: boolean }[]>([]); + // The SFT DiT model name — required for repaint mode + const SFT_MODEL_NAME = 'acestep-v15-sft'; + // The SFT model GGUF file to download when not present (Q8_0 is the default quality tier) + const SFT_MODEL_FILE = 'acestep-v15-sft-Q8_0.gguf'; + // Fallback model list when backend is unavailable const availableModels = useMemo(() => { if (fetchedModels.length > 0) { @@ -244,7 +249,7 @@ export const CreatePanel: React.FC = ({ } return [ { id: 'acestep-v15-base', name: 'acestep-v15-base' }, - { id: 'acestep-v15-sft', name: 'acestep-v15-sft' }, + { id: SFT_MODEL_NAME, name: SFT_MODEL_NAME }, { id: 'acestep-v15-turbo', name: 'acestep-v15-turbo' }, { id: 'acestep-v15-turbo-shift1', name: 'acestep-v15-turbo-shift1' }, { id: 'acestep-v15-turbo-shift3', name: 'acestep-v15-turbo-shift3' }, @@ -270,6 +275,16 @@ export const CreatePanel: React.FC = ({ return modelId.includes('turbo'); }; + // Check if model is an SFT variant (required for repaint) + const isSftModel = (modelId: string): boolean => { + return modelId.includes('sft'); + }; + + // SFT model download/availability state for repaint mode + type SftStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'unavailable'; + const [sftStatus, setSftStatus] = useState('idle'); + const sftSseRef = useRef(null); + const [isUploadingReference, setIsUploadingReference] = useState(false); const [isUploadingSource, setIsUploadingSource] = useState(false); const [isTranscribingReference, setIsTranscribingReference] = useState(false); @@ -575,6 +590,90 @@ export const CreatePanel: React.FC = ({ } }, []); + // Check if the SFT model is on disk and download it if needed. + // Called automatically when repaint mode is selected. + const checkAndEnsureSftModel = useCallback(async () => { + setSftStatus('checking'); + try { + const statusRes = await fetch('/api/models/status'); + if (!statusRes.ok) { setSftStatus('unavailable'); return; } + const statusData = await statusRes.json(); + const onDisk: string[] = statusData.onDisk || []; + const hasSft = onDisk.some((f: string) => f.startsWith(SFT_MODEL_NAME)); + + if (hasSft) { + setSftStatus('available'); + return; + } + + // SFT model not on disk — trigger download if authenticated + if (!token) { setSftStatus('unavailable'); return; } + + setSftStatus('downloading'); + // Enqueue download of the Q8_0 SFT DiT model + await fetch('/api/models/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ files: [SFT_MODEL_FILE] }), + }); + + // Subscribe to SSE stream to detect when download completes + if (sftSseRef.current) sftSseRef.current.close(); + const es = new EventSource('/api/models/download/stream'); + sftSseRef.current = es; + const onDone = (e: MessageEvent) => { + try { + const data = JSON.parse(e.data); + if (data.filename?.startsWith(SFT_MODEL_NAME)) { + setSftStatus('available'); + es.close(); + sftSseRef.current = null; + } + } catch { /* ignore */ } + }; + const onError = () => { + setSftStatus('unavailable'); + es.close(); + sftSseRef.current = null; + }; + es.addEventListener('done', onDone); + es.addEventListener('error', onError); + } catch { + setSftStatus('unavailable'); + } + }, [token]); + + // Auto-switch to SFT model when repaint mode is selected and back to previous model otherwise + const prevTaskTypeRef = useRef(taskType); + const prevModelBeforeRepaintRef = useRef(null); + useEffect(() => { + const prevTaskType = prevTaskTypeRef.current; + prevTaskTypeRef.current = taskType; + + if (taskType === 'repaint') { + // Entering repaint mode: switch to SFT model if not already on one + if (!isSftModel(selectedModel)) { + prevModelBeforeRepaintRef.current = selectedModel; + setSelectedModel(SFT_MODEL_NAME); + localStorage.setItem('ace-model', SFT_MODEL_NAME); + } + // Check/download SFT model + void checkAndEnsureSftModel(); + } else if (prevTaskType === 'repaint') { + // Leaving repaint mode: restore previous model if it was switched + if (sftSseRef.current) { sftSseRef.current.close(); sftSseRef.current = null; } + setSftStatus('idle'); + if (prevModelBeforeRepaintRef.current && isSftModel(selectedModel)) { + setSelectedModel(prevModelBeforeRepaintRef.current); + localStorage.setItem('ace-model', prevModelBeforeRepaintRef.current); + prevModelBeforeRepaintRef.current = null; + } + } + }, [taskType, checkAndEnsureSftModel]); + + // Clean up SSE on unmount + useEffect(() => () => { sftSseRef.current?.close(); }, []); + useEffect(() => { const loadModelsAndLimits = async () => { await refreshModels(); @@ -919,6 +1018,35 @@ export const CreatePanel: React.FC = ({ return `${minutes}:${String(seconds).padStart(2, '0')}`; }; + /** Clear the source audio and reset task type if it was cover/repaint. */ + const handleClearSourceAudio = () => { + setSourceAudioUrl(''); + setSourceAudioTitle(''); + setSourcePlaying(false); + setSourceTime(0); + setSourceDuration(0); + if (taskType === 'cover' || taskType === 'repaint') setTaskType('text2music'); + }; + + /** + * Returns a green overlay element indicating the repaint region on the seekbar. + * Rendered only when taskType === 'repaint' and sourceDuration > 0. + */ + const renderRepaintRegionOverlay = () => { + if (taskType !== 'repaint' || sourceDuration <= 0) return null; + const regionStart = Math.max(0, repaintingStart >= 0 ? repaintingStart : 0); + const regionEnd = Math.min(sourceDuration, repaintingEnd >= 0 ? repaintingEnd : sourceDuration); + return ( +
+ ); + }; + const toggleAudio = (target: 'reference' | 'source') => { const audio = target === 'reference' ? referenceAudioRef.current : sourceAudioRef.current; if (!audio) return; @@ -1280,6 +1408,221 @@ export const CreatePanel: React.FC = ({
+ {/* Source Audio — Cover / Repaint (Simple Mode) */} +
+
+
+ + {t('cover')} / {t('repaintMode')} + + + optional + +
+
+
+ {/* Source audio mini-player */} + {sourceAudioUrl && ( +
+ +
+
+ {sourceAudioTitle || getAudioLabel(sourceAudioUrl)} +
+
+ {formatTime(sourceTime)} +
{ + if (sourceAudioRef.current && sourceDuration > 0) { + const rect = e.currentTarget.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + sourceAudioRef.current.currentTime = percent * sourceDuration; + } + }} + > + {/* Repaint region overlay */} + {renderRepaintRegionOverlay()} +
+
+
+
+ {formatTime(sourceDuration)} +
+
+ +
+ )} + + {/* Cover / Repaint mode controls — shown when source audio is loaded */} + {sourceAudioUrl && ( +
+ {/* Mode toggle */} +
+ + +
+ + {/* Mode description */} +

+ {taskType === 'repaint' ? t('repaintModeDescription') : t('coverModeDescription')} +

+ + {/* Cover strength slider (cover mode only) */} + {taskType !== 'repaint' && ( +
+ + setAudioCoverStrength(Number(e.target.value))} + className="flex-1 h-1.5 accent-emerald-500" + /> + {audioCoverStrength.toFixed(2)} +
+ )} + + {/* Repaint time range (repaint mode only) */} + {taskType === 'repaint' && ( +
+
+ + 0 ? sourceDuration : undefined} + placeholder={t('repaintStartPlaceholder')} + value={repaintingStart >= 0 ? repaintingStart : ''} + onChange={(e) => setRepaintingStart(e.target.value === '' ? -1 : Number(e.target.value))} + className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-emerald-500 dark:focus:border-emerald-500 transition-colors" + /> +
+
+ + 0 ? sourceDuration : undefined} + placeholder={t('repaintEndPlaceholder')} + value={repaintingEnd >= 0 ? repaintingEnd : ''} + onChange={(e) => setRepaintingEnd(e.target.value === '' ? -1 : Number(e.target.value))} + className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-emerald-500 dark:focus:border-emerald-500 transition-colors" + /> +
+
+ )} + + {/* SFT model status banner (repaint only) */} + {taskType === 'repaint' && sftStatus !== 'idle' && ( +
+ {sftStatus === 'available' && } + {(sftStatus === 'downloading' || sftStatus === 'checking') && } + {sftStatus === 'unavailable' && } + + {sftStatus === 'available' && t('sftModelReady')} + {sftStatus === 'checking' && t('sftModelRequired')} + {sftStatus === 'downloading' && t('sftModelDownloading')} + {sftStatus === 'unavailable' && t('sftModelNotFound')} + + {sftStatus === 'unavailable' && ( + { e.preventDefault(); window.history.pushState({}, '', '/models'); window.dispatchEvent(new PopStateEvent('popstate')); }} + className="flex items-center gap-0.5 underline underline-offset-2" + > + Models + + )} +
+ )} +
+ )} + + {/* Upload / Library buttons */} +
+ + +
+
+
+ {/* Quick Settings (Simple Mode) */}

@@ -1486,7 +1829,7 @@ export const CreatePanel: React.FC = ({
{formatTime(sourceTime)}
{ if (sourceAudioRef.current && sourceDuration > 0) { const rect = e.currentTarget.getBoundingClientRect(); @@ -1495,6 +1838,8 @@ export const CreatePanel: React.FC = ({ } }} > + {/* Repaint region overlay */} + {renderRepaintRegionOverlay()}
= ({
)} + {/* Cover / Repaint mode toggle (shown when source audio is loaded) */} + {audioTab === 'source' && sourceAudioUrl && ( +
+ {/* Mode toggle: Cover vs Repaint */} +
+ + +
+ + {/* Mode description */} +

+ {taskType === 'repaint' ? t('repaintModeDescription') : t('coverModeDescription')} +

+ + {/* Cover strength slider (only in cover mode) */} + {taskType !== 'repaint' && ( +
+ + setAudioCoverStrength(Number(e.target.value))} + className="flex-1 h-1.5 accent-emerald-500" + /> + {audioCoverStrength.toFixed(2)} +
+ )} + + {/* Repaint time range (only in repaint mode) */} + {taskType === 'repaint' && ( +
+
+ + 0 ? sourceDuration : undefined} + placeholder={t('repaintStartPlaceholder')} + value={repaintingStart >= 0 ? repaintingStart : ''} + onChange={(e) => setRepaintingStart(e.target.value === '' ? -1 : Number(e.target.value))} + className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-emerald-500 dark:focus:border-emerald-500 transition-colors" + /> +
+
+ + 0 ? sourceDuration : undefined} + placeholder={t('repaintEndPlaceholder')} + value={repaintingEnd >= 0 ? repaintingEnd : ''} + onChange={(e) => setRepaintingEnd(e.target.value === '' ? -1 : Number(e.target.value))} + className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg px-2 py-1.5 text-xs text-zinc-900 dark:text-white focus:outline-none focus:border-emerald-500 dark:focus:border-emerald-500 transition-colors" + /> +
+
+ )} + + {/* SFT model status banner (shown when repaint mode active) */} + {taskType === 'repaint' && sftStatus !== 'idle' && ( +
+ {sftStatus === 'available' && } + {(sftStatus === 'downloading' || sftStatus === 'checking') && } + {sftStatus === 'unavailable' && } + + {sftStatus === 'available' && t('sftModelReady')} + {sftStatus === 'checking' && t('sftModelRequired')} + {sftStatus === 'downloading' && t('sftModelDownloading')} + {sftStatus === 'unavailable' && t('sftModelNotFound')} + + {sftStatus === 'unavailable' && ( + { e.preventDefault(); window.history.pushState({}, '', '/models'); window.dispatchEvent(new PopStateEvent('popstate')); }} + className="flex items-center gap-0.5 underline underline-offset-2" + > + Models + + )} +
+ )} +
+ )} + {/* Action buttons */}