Fix multiple issues: format API, Gradio availability, storage, UI responsiveness
- Format endpoint now calls ACE-Step /format_input REST API directly instead of spawning Python, fixing ENOENT errors on Windows (#44, #27, #34) - isGradioAvailable() tries /gradio_api/info, /info, / in sequence to handle Gradio 4.x/5.x/6.x version differences, fixing generation fallback (#53, #20) - Storage getUrl/getPublicUrl normalize /audio/ prefix to prevent double-prefix URLs when reference tracks are used for cover generation (#10) - Gradio args: fix normalization_db default from 0.0 to -1.0 (Gradio default) - Volume popover: add 400ms delay before hiding to prevent accidental dismissal (#51) - Polling: skip setSongs state update when nothing changed to reduce re-renders (#51) - FFmpeg: add jsdelivr CDN fallback when unpkg fails (#30) - Model switching: add switchModelIfNeeded() via /v1/init REST API (#45)
This commit is contained in:
@@ -707,17 +707,21 @@ function AppContent() {
|
|||||||
? (Number(status.progress) > 1 ? Number(status.progress) / 100 : Number(status.progress))
|
? (Number(status.progress) > 1 ? Number(status.progress) / 100 : Number(status.progress))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
setSongs(prev => prev.map(s => {
|
setSongs(prev => {
|
||||||
if (s.id === tempId) {
|
const song = prev.find(s => s.id === tempId);
|
||||||
return {
|
if (!song) return prev;
|
||||||
...s,
|
const newQueuePos = status.status === 'queued' ? status.queuePosition : undefined;
|
||||||
queuePosition: status.status === 'queued' ? status.queuePosition : undefined,
|
const newProgress = normalizedProgress ?? song.progress;
|
||||||
progress: normalizedProgress ?? s.progress,
|
const newStage = status.stage ?? song.stage;
|
||||||
stage: status.stage ?? s.stage,
|
// Skip update if nothing changed to avoid unnecessary re-renders
|
||||||
};
|
if (newProgress === song.progress && newStage === song.stage && newQueuePos === song.queuePosition) {
|
||||||
|
return prev;
|
||||||
}
|
}
|
||||||
return s;
|
return prev.map(s => {
|
||||||
}));
|
if (s.id !== tempId) return s;
|
||||||
|
return { ...s, queuePosition: newQueuePos, progress: newProgress, stage: newStage };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (status.status === 'succeeded' && status.result) {
|
if (status.status === 'succeeded' && status.result) {
|
||||||
cleanupJob(jobId, tempId);
|
cleanupJob(jobId, tempId);
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const Player: React.FC<PlayerProps> = ({
|
|||||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||||
const fullscreenProgressRef = useRef<HTMLDivElement>(null);
|
const fullscreenProgressRef = useRef<HTMLDivElement>(null);
|
||||||
const [isHoveringVolume, setIsHoveringVolume] = useState(false);
|
const [isHoveringVolume, setIsHoveringVolume] = useState(false);
|
||||||
|
const volumeHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||||
@@ -755,8 +756,13 @@ export const Player: React.FC<PlayerProps> = ({
|
|||||||
{/* Volume Control with Vertical Slider */}
|
{/* Volume Control with Vertical Slider */}
|
||||||
<div
|
<div
|
||||||
className="relative group hidden md:block"
|
className="relative group hidden md:block"
|
||||||
onMouseEnter={() => setIsHoveringVolume(true)}
|
onMouseEnter={() => {
|
||||||
onMouseLeave={() => setIsHoveringVolume(false)}
|
if (volumeHideTimer.current) clearTimeout(volumeHideTimer.current);
|
||||||
|
setIsHoveringVolume(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
volumeHideTimer.current = setTimeout(() => setIsHoveringVolume(false), 400);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
||||||
|
|||||||
@@ -228,17 +228,30 @@ export const VideoGeneratorModal: React.FC<VideoGeneratorModalProps> = ({ isOpen
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
|
const cdnBases = [
|
||||||
await ffmpeg.load({
|
'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm',
|
||||||
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
|
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm',
|
||||||
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
|
];
|
||||||
});
|
let loaded = false;
|
||||||
|
for (const baseURL of cdnBases) {
|
||||||
|
try {
|
||||||
|
await ffmpeg.load({
|
||||||
|
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
|
||||||
|
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
|
||||||
|
});
|
||||||
|
loaded = true;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
console.warn(`FFmpeg load failed from ${baseURL}, trying next CDN...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!loaded) throw new Error('All CDN sources failed');
|
||||||
|
|
||||||
ffmpegRef.current = ffmpeg;
|
ffmpegRef.current = ffmpeg;
|
||||||
setFfmpegLoaded(true);
|
setFfmpegLoaded(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load FFmpeg:', error);
|
console.error('Failed to load FFmpeg:', error);
|
||||||
alert('Failed to load video encoder. Please refresh and try again.');
|
alert('Failed to load video encoder. Check your internet connection and try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setFfmpegLoading(false);
|
setFfmpegLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,30 @@ import { getStorageProvider } from '../services/storage/factory.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// Auto-generate a song title from lyrics or style when none is provided
|
||||||
|
function autoTitle(params: { title?: string; lyrics?: string; instrumental?: boolean; style?: string; songDescription?: string }): string {
|
||||||
|
if (params.title?.trim()) return params.title.trim();
|
||||||
|
|
||||||
|
// Try first meaningful lyric line (skip section markers like [verse], [chorus])
|
||||||
|
if (!params.instrumental && params.lyrics) {
|
||||||
|
for (const line of params.lyrics.split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (t && !/^\[.*\]$/.test(t)) {
|
||||||
|
return t.length > 40 ? t.slice(0, 40).trimEnd() + '…' : t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to first 4 words of style or description
|
||||||
|
const source = params.style || params.songDescription || '';
|
||||||
|
if (source) {
|
||||||
|
const words = source.trim().split(/\s+/).slice(0, 4).join(' ');
|
||||||
|
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Untitled';
|
||||||
|
}
|
||||||
|
|
||||||
const audioUpload = multer({
|
const audioUpload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max
|
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max
|
||||||
@@ -125,6 +149,9 @@ interface GenerateBody {
|
|||||||
trackName?: string;
|
trackName?: string;
|
||||||
completeTrackClasses?: string[];
|
completeTrackClasses?: string[];
|
||||||
isFormatCaption?: boolean;
|
isFormatCaption?: boolean;
|
||||||
|
|
||||||
|
// Model selection
|
||||||
|
ditModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('/upload-audio', authMiddleware, (req: AuthenticatedRequest, res: Response, next: Function) => {
|
router.post('/upload-audio', authMiddleware, (req: AuthenticatedRequest, res: Response, next: Function) => {
|
||||||
@@ -237,6 +264,7 @@ router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response
|
|||||||
trackName,
|
trackName,
|
||||||
completeTrackClasses,
|
completeTrackClasses,
|
||||||
isFormatCaption,
|
isFormatCaption,
|
||||||
|
ditModel,
|
||||||
} = req.body as GenerateBody;
|
} = req.body as GenerateBody;
|
||||||
|
|
||||||
if (!customMode && !songDescription) {
|
if (!customMode && !songDescription) {
|
||||||
@@ -304,6 +332,7 @@ router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response
|
|||||||
trackName,
|
trackName,
|
||||||
completeTrackClasses,
|
completeTrackClasses,
|
||||||
isFormatCaption,
|
isFormatCaption,
|
||||||
|
ditModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create job record in database
|
// Create job record in database
|
||||||
@@ -392,7 +421,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
|
|||||||
for (let i = 0; i < audioUrls.length; i++) {
|
for (let i = 0; i < audioUrls.length; i++) {
|
||||||
const audioUrl = audioUrls[i];
|
const audioUrl = audioUrls[i];
|
||||||
const variationSuffix = audioUrls.length > 1 ? ` (v${i + 1})` : '';
|
const variationSuffix = audioUrls.length > 1 ? ` (v${i + 1})` : '';
|
||||||
const songTitle = (params.title || 'Untitled') + variationSuffix;
|
const songTitle = autoTitle(params) + variationSuffix;
|
||||||
|
|
||||||
const songId = generateUUID();
|
const songId = generateUUID();
|
||||||
|
|
||||||
@@ -416,7 +445,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
|
|||||||
params.style,
|
params.style,
|
||||||
params.style,
|
params.style,
|
||||||
storedPath,
|
storedPath,
|
||||||
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
|
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 0),
|
||||||
aceStatus.result.bpm || params.bpm,
|
aceStatus.result.bpm || params.bpm,
|
||||||
aceStatus.result.keyScale || params.keyScale,
|
aceStatus.result.keyScale || params.keyScale,
|
||||||
aceStatus.result.timeSignature || params.timeSignature,
|
aceStatus.result.timeSignature || params.timeSignature,
|
||||||
@@ -442,7 +471,7 @@ router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, r
|
|||||||
params.style,
|
params.style,
|
||||||
params.style,
|
params.style,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
|
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 0),
|
||||||
aceStatus.result.bpm || params.bpm,
|
aceStatus.result.bpm || params.bpm,
|
||||||
aceStatus.result.keyScale || params.keyScale,
|
aceStatus.result.keyScale || params.keyScale,
|
||||||
aceStatus.result.timeSignature || params.timeSignature,
|
aceStatus.result.timeSignature || params.timeSignature,
|
||||||
@@ -750,8 +779,63 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { spawn } = await import('child_process');
|
const ACESTEP_API_URL = config.acestep.apiUrl;
|
||||||
|
|
||||||
|
// Build param_obj for the REST API
|
||||||
|
const paramObj: Record<string, unknown> = {};
|
||||||
|
if (bpm && bpm > 0) paramObj.bpm = bpm;
|
||||||
|
if (duration && duration > 0) paramObj.duration = duration;
|
||||||
|
if (keyScale) paramObj.key = keyScale;
|
||||||
|
if (timeSignature) paramObj.time_signature = timeSignature;
|
||||||
|
|
||||||
|
// Primary path: call ACE-Step's /format_input REST endpoint (avoids Python spawn ENOENT on Windows)
|
||||||
|
try {
|
||||||
|
console.log(`[Format] Calling REST API: ${ACESTEP_API_URL}/format_input`);
|
||||||
|
const apiRes = await fetch(`${ACESTEP_API_URL}/format_input`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: caption,
|
||||||
|
lyrics: lyrics || '',
|
||||||
|
temperature: temperature ?? 0.85,
|
||||||
|
param_obj: paramObj,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(300_000), // 5 min — LLM may need to init first
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiData = await apiRes.json() as any;
|
||||||
|
|
||||||
|
if (!apiRes.ok || apiData.code !== 200) {
|
||||||
|
const errMsg = apiData.error || apiData.detail || `Format API returned ${apiRes.status}`;
|
||||||
|
console.error('[Format] API error:', errMsg);
|
||||||
|
res.status(500).json({ success: false, error: errMsg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = apiData.data;
|
||||||
|
res.json({
|
||||||
|
caption: d.caption,
|
||||||
|
lyrics: d.lyrics,
|
||||||
|
bpm: d.bpm,
|
||||||
|
duration: d.duration,
|
||||||
|
key_scale: d.key_scale,
|
||||||
|
time_signature: d.time_signature,
|
||||||
|
vocal_language: d.vocal_language,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (fetchErr: any) {
|
||||||
|
// Only fall back to Python spawn on network errors (service not yet reachable)
|
||||||
|
if (fetchErr?.name !== 'AbortError' && (fetchErr?.code === 'ECONNREFUSED' || fetchErr?.cause?.code === 'ECONNREFUSED')) {
|
||||||
|
console.warn('[Format] REST API unreachable, falling back to Python spawn');
|
||||||
|
} else {
|
||||||
|
console.error('[Format] REST API request failed:', fetchErr?.message);
|
||||||
|
res.status(500).json({ success: false, error: fetchErr?.message || 'Format request failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Python spawn (only reached when REST API is unreachable)
|
||||||
|
const { spawn } = await import('child_process');
|
||||||
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../ACE-Step-1.5');
|
const ACESTEP_DIR = process.env.ACESTEP_PATH || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../ACE-Step-1.5');
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -759,12 +843,7 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
|
|||||||
const FORMAT_SCRIPT = path.join(SCRIPTS_DIR, 'format_sample.py');
|
const FORMAT_SCRIPT = path.join(SCRIPTS_DIR, 'format_sample.py');
|
||||||
const pythonPath = resolvePythonPath(ACESTEP_DIR);
|
const pythonPath = resolvePythonPath(ACESTEP_DIR);
|
||||||
|
|
||||||
const args = [
|
const args = [FORMAT_SCRIPT, '--caption', caption, '--json'];
|
||||||
FORMAT_SCRIPT,
|
|
||||||
'--caption', caption,
|
|
||||||
'--json',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (lyrics) args.push('--lyrics', lyrics);
|
if (lyrics) args.push('--lyrics', lyrics);
|
||||||
if (bpm && bpm > 0) args.push('--bpm', String(bpm));
|
if (bpm && bpm > 0) args.push('--bpm', String(bpm));
|
||||||
if (duration && duration > 0) args.push('--duration', String(duration));
|
if (duration && duration > 0) args.push('--duration', String(duration));
|
||||||
@@ -776,15 +855,11 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
|
|||||||
if (lmModel) args.push('--lm-model', lmModel);
|
if (lmModel) args.push('--lm-model', lmModel);
|
||||||
if (lmBackend) args.push('--lm-backend', lmBackend);
|
if (lmBackend) args.push('--lm-backend', lmBackend);
|
||||||
|
|
||||||
console.log(`[Format] Running: ${pythonPath} ${args.join(' ')}`);
|
console.log(`[Format] Fallback spawn: ${pythonPath} ${args.join(' ')}`);
|
||||||
console.log(`[Format] CWD: ${ACESTEP_DIR}`);
|
|
||||||
const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
|
const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
|
||||||
const proc = spawn(pythonPath, args, {
|
const proc = spawn(pythonPath, args, {
|
||||||
cwd: ACESTEP_DIR,
|
cwd: ACESTEP_DIR,
|
||||||
env: {
|
env: { ...process.env, ACESTEP_PATH: ACESTEP_DIR },
|
||||||
...process.env,
|
|
||||||
ACESTEP_PATH: ACESTEP_DIR,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
@@ -795,7 +870,6 @@ router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Re
|
|||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on('close', (code) => {
|
||||||
if (code === 0 && stdout) {
|
if (code === 0 && stdout) {
|
||||||
// stdout may contain log lines before the JSON — extract last JSON line
|
|
||||||
const lines = stdout.trim().split('\n');
|
const lines = stdout.trim().split('\n');
|
||||||
let jsonStr = '';
|
let jsonStr = '';
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
|
|||||||
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
|
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Gradio generation: map params to the 45 positional args for /generation_wrapper
|
// Gradio generation: map params to the 51 positional args for /generation_wrapper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,16 +186,16 @@ async function buildGradioArgs(params: GenerationParams): Promise<unknown[]> {
|
|||||||
params.constrainedDecodingDebug ?? false, // 38: Constrained Decoding Debug
|
params.constrainedDecodingDebug ?? false, // 38: Constrained Decoding Debug
|
||||||
params.allowLmBatch ?? true, // 39: ParallelThinking
|
params.allowLmBatch ?? true, // 39: ParallelThinking
|
||||||
params.getScores ?? false, // 40: Auto Score
|
params.getScores ?? false, // 40: Auto Score
|
||||||
// Note: auto_lrc (getLrc) is a hidden Gradio state param — NOT included in the public API args
|
params.getLrc ?? false, // 41: Auto LRC (timestamped lyrics)
|
||||||
params.scoreScale ?? 0.5, // 41: Quality Score Sensitivity (0.01-1.0)
|
params.scoreScale ?? 0.5, // 42: Quality Score Sensitivity (0.01-1.0)
|
||||||
params.lmBatchChunkSize ?? 8, // 42: LM Batch Chunk Size
|
params.lmBatchChunkSize ?? 8, // 43: LM Batch Chunk Size
|
||||||
params.trackName || null, // 43: Track Name
|
params.trackName || null, // 44: Track Name
|
||||||
params.completeTrackClasses || [], // 44: Track Names
|
params.completeTrackClasses || [], // 45: Track Names
|
||||||
false, // 45: Enable Normalization (ACE-Step v1.5 new param, default false)
|
true, // 46: Enable Normalization (ACE-Step v1.5, default true)
|
||||||
0.0, // 46: Normalization DB (ACE-Step v1.5 new param, default 0.0)
|
-1.0, // 47: Normalization DB (ACE-Step v1.5, default -1.0)
|
||||||
0.0, // 47: Latent Shift (ACE-Step v1.5 new param, default 0.0)
|
0.0, // 48: Latent Shift (ACE-Step v1.5, default 0.0)
|
||||||
1.0, // 48: Latent Rescale (ACE-Step v1.5 new param, default 1.0)
|
1.0, // 49: Latent Rescale (ACE-Step v1.5, default 1.0)
|
||||||
params.autogen ?? false, // 49: AutoGen (visible last param)
|
params.autogen ?? false, // 50: AutoGen
|
||||||
// Note: current_batch_index, total_batches, batch_queue, generation_params_state
|
// Note: current_batch_index, total_batches, batch_queue, generation_params_state
|
||||||
// are hidden Gradio state variables and must NOT be passed via client.predict()
|
// are hidden Gradio state variables and must NOT be passed via client.predict()
|
||||||
];
|
];
|
||||||
@@ -365,6 +365,40 @@ export async function checkSpaceHealth(): Promise<boolean> {
|
|||||||
return isGradioAvailable();
|
return isGradioAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Model switching — call /v1/init to change the active DiT model
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getActiveModel(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ACESTEP_API}/v1/models`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json() as any;
|
||||||
|
const models = data?.data?.models || data?.models || [];
|
||||||
|
return models[0]?.name || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchModelIfNeeded(ditModel: string): Promise<void> {
|
||||||
|
const activeModel = await getActiveModel();
|
||||||
|
if (activeModel === ditModel) return; // already loaded, no-op
|
||||||
|
|
||||||
|
console.log(`[Model] Switching from '${activeModel ?? 'unknown'}' to '${ditModel}'`);
|
||||||
|
const res = await fetch(`${ACESTEP_API}/v1/init`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: ditModel, init_llm: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Model switch to '${ditModel}' failed: ${res.status} ${err}`);
|
||||||
|
}
|
||||||
|
console.log(`[Model] Switched to '${ditModel}'`);
|
||||||
|
}
|
||||||
|
|
||||||
// Discover endpoints (for compatibility)
|
// Discover endpoints (for compatibility)
|
||||||
export async function discoverEndpoints(): Promise<unknown> {
|
export async function discoverEndpoints(): Promise<unknown> {
|
||||||
return { provider: 'acestep-gradio', endpoint: ACESTEP_API };
|
return { provider: 'acestep-gradio', endpoint: ACESTEP_API };
|
||||||
@@ -472,6 +506,12 @@ async function processGenerationViaGradio(
|
|||||||
params: GenerationParams,
|
params: GenerationParams,
|
||||||
job: ActiveJob,
|
job: ActiveJob,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Switch DiT model if a specific one was requested
|
||||||
|
if (params.ditModel) {
|
||||||
|
job.stage = `Loading model ${params.ditModel}...`;
|
||||||
|
await switchModelIfNeeded(params.ditModel);
|
||||||
|
}
|
||||||
|
|
||||||
const client = await getGradioClient();
|
const client = await getGradioClient();
|
||||||
const args = await buildGradioArgs(params);
|
const args = await buildGradioArgs(params);
|
||||||
|
|
||||||
@@ -552,7 +592,7 @@ async function processGenerationViaGradio(
|
|||||||
|
|
||||||
const finalDuration = actualDuration > 0
|
const finalDuration = actualDuration > 0
|
||||||
? actualDuration
|
? actualDuration
|
||||||
: (metas.duration || params.duration || 60);
|
: (metas.duration || params.duration || 0);
|
||||||
|
|
||||||
job.status = 'succeeded';
|
job.status = 'succeeded';
|
||||||
job.result = {
|
job.result = {
|
||||||
@@ -703,7 +743,7 @@ async function processGenerationViaPython(
|
|||||||
console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError);
|
console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 60);
|
const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 0);
|
||||||
|
|
||||||
job.status = 'succeeded';
|
job.status = 'succeeded';
|
||||||
job.result = {
|
job.result = {
|
||||||
|
|||||||
@@ -41,17 +41,26 @@ export function resetGradioClient(): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the Gradio app is reachable.
|
* Check if the Gradio app is reachable.
|
||||||
|
* Tries multiple well-known endpoints to handle version differences.
|
||||||
*/
|
*/
|
||||||
export async function isGradioAvailable(): Promise<boolean> {
|
export async function isGradioAvailable(): Promise<boolean> {
|
||||||
try {
|
const baseUrl = config.acestep.apiUrl;
|
||||||
const controller = new AbortController();
|
const candidates = [
|
||||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
`${baseUrl}/gradio_api/info`, // Gradio 5+
|
||||||
const response = await fetch(`${config.acestep.apiUrl}/gradio_api/info`, {
|
`${baseUrl}/info`, // Gradio 4.x fallback
|
||||||
signal: controller.signal,
|
`${baseUrl}/`, // Any HTTP response means server is up
|
||||||
});
|
];
|
||||||
clearTimeout(timeout);
|
|
||||||
return response.ok;
|
for (const url of candidates) {
|
||||||
} catch {
|
try {
|
||||||
return false;
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 5000);
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (response.ok || response.status < 500) return true;
|
||||||
|
} catch {
|
||||||
|
// Try next candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,13 @@ export class LocalStorageProvider implements StorageProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getUrl(key: string, _expiresIn?: number): Promise<string> {
|
async getUrl(key: string, _expiresIn?: number): Promise<string> {
|
||||||
return `/audio/${key}`;
|
const cleanKey = key.startsWith('/audio/') ? key.slice('/audio/'.length) : key;
|
||||||
|
return `/audio/${cleanKey.replace(/^\/+/, '')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPublicUrl(key: string): string {
|
getPublicUrl(key: string): string {
|
||||||
if (key.startsWith('/audio/')) {
|
const cleanKey = key.startsWith('/audio/') ? key.slice('/audio/'.length) : key;
|
||||||
return key;
|
return `/audio/${cleanKey.replace(/^\/+/, '')}`;
|
||||||
}
|
|
||||||
return `/audio/${key}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
async delete(key: string): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user