Improve generation reliability and error handling

- Fix silent failures: catch block no longer overridden by finally; errors now display for 4s before resetting
- Restore hero section on generation failure so the page doesn't look broken
- Extend video polling timeout from 4 min to 30 min (900 attempts)
- Add pending job recovery: save requestId to localStorage on submit, resume polling on studio load with a live banner showing progress
- Add onRequestId callback to all muapi generate methods (generateImage, generateI2I, generateVideo, generateI2V, processV2V)
- New pendingJobs.js utility for CRUD operations on pending jobs in localStorage
This commit is contained in:
Anil Matcha 2026-03-10 10:17:44 +05:30
commit 7ca2f280e7
4 changed files with 167 additions and 22 deletions

View file

@ -6,6 +6,7 @@ import {
} from '../lib/models.js';
import { AuthModal } from './AuthModal.js';
import { createUploadPicker } from './UploadPicker.js';
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
export function ImageStudio() {
const container = document.createElement('div');
@ -503,6 +504,40 @@ export function ImageStudio() {
}
} catch (e) { /* ignore */ }
// --- Resume any pending image generations from a previous session ---
(async () => {
const pending = getPendingJobs('image');
if (!pending.length) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) return; // can't poll without key; jobs remain for next time
const banner = document.createElement('div');
banner.className = 'fixed top-4 left-1/2 -translate-x-1/2 z-[200] bg-[#111] border border-white/10 text-white text-sm px-5 py-3 rounded-2xl shadow-xl flex items-center gap-3';
banner.innerHTML = `<span class="animate-spin text-primary">◌</span> <span class="banner-text">Resuming ${pending.length} pending generation${pending.length > 1 ? 's' : ''}…</span>`;
document.body.appendChild(banner);
let remaining = pending.length;
pending.forEach(async (job) => {
const elapsedAttempts = Math.floor((Date.now() - job.submittedAt) / job.interval);
const attemptsLeft = Math.max(1, job.maxAttempts - elapsedAttempts);
try {
const result = await muapi.pollForResult(job.requestId, apiKey, attemptsLeft, job.interval);
const url = result.outputs?.[0] || result.url || result.output?.url;
if (url) {
addToHistory({ id: job.requestId, url, ...job.historyMeta, timestamp: new Date().toISOString() });
}
} catch (e) {
console.warn('[ImageStudio] Pending job failed on resume:', job.requestId, e.message);
} finally {
removePendingJob(job.requestId);
remaining--;
if (remaining === 0) banner.remove();
else banner.querySelector('.banner-text').textContent = `Resuming ${remaining} pending generation${remaining > 1 ? 's' : ''}`;
}
});
})();
// --- Button Handlers ---
downloadBtn.onclick = () => {
const current = resultImg.src;
@ -570,6 +605,10 @@ export function ImageStudio() {
generateBtn.disabled = true;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
let hadError = false;
let capturedRequestId = null;
const historyMeta = { prompt, model: selectedModel, aspect_ratio: selectedAr };
try {
let res;
const qualityLabel = document.getElementById('quality-btn-label')?.textContent;
@ -578,7 +617,11 @@ export function ImageStudio() {
model: selectedModel,
images_list: uploadedImageUrls,
image_url: uploadedImageUrls[0], // backward compat for single-image models
aspect_ratio: selectedAr
aspect_ratio: selectedAr,
onRequestId: (rid) => {
capturedRequestId = rid;
savePendingJob({ requestId: rid, studioType: 'image', historyMeta, maxAttempts: 60, interval: 2000, submittedAt: Date.now() });
}
};
if (prompt) genParams.prompt = prompt;
const qualityField = getCurrentQualityField(selectedModel);
@ -588,7 +631,11 @@ export function ImageStudio() {
const genParams = {
model: selectedModel,
prompt,
aspect_ratio: selectedAr
aspect_ratio: selectedAr,
onRequestId: (rid) => {
capturedRequestId = rid;
savePendingJob({ requestId: rid, studioType: 'image', historyMeta, maxAttempts: 60, interval: 2000, submittedAt: Date.now() });
}
};
const qualityField = getCurrentQualityField(selectedModel);
if (qualityField && qualityLabel) genParams[qualityField] = qualityLabel;
@ -598,32 +645,34 @@ export function ImageStudio() {
console.log('[ImageStudio] Full response:', res);
if (res && res.url) {
// Add to history
if (capturedRequestId) removePendingJob(capturedRequestId);
addToHistory({
id: res.id || Date.now().toString(),
id: res.id || capturedRequestId || Date.now().toString(),
url: res.url,
prompt: prompt,
model: selectedModel,
aspect_ratio: selectedAr,
timestamp: new Date().toISOString()
});
// Show image
showImageInCanvas(res.url);
} else {
console.error('[ImageStudio] No image URL in response:', res);
throw new Error('No image URL returned by API');
}
} catch (e) {
hadError = true;
if (capturedRequestId) removePendingJob(capturedRequestId);
console.error(e);
generateBtn.innerHTML = `Error: ${e.message.slice(0, 40)}`;
// Restore hero so the page doesn't look broken after a failed generation
hero.classList.remove('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
generateBtn.innerHTML = `Error: ${e.message.slice(0, 60)}`;
setTimeout(() => {
generateBtn.innerHTML = `Generate ✨`;
generateBtn.disabled = false;
}, 3000);
}, 4000);
} finally {
generateBtn.disabled = false;
generateBtn.innerHTML = `Generate ✨`;
// Only reset the label on success; the catch timeout handles the error case
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
}
};

View file

@ -2,6 +2,7 @@ import { muapi } from '../lib/muapi.js';
import { t2vModels, getAspectRatiosForVideoModel, getDurationsForModel, getResolutionsForVideoModel, i2vModels, getAspectRatiosForI2VModel, getDurationsForI2VModel, getResolutionsForI2VModel, v2vModels } from '../lib/models.js';
import { AuthModal } from './AuthModal.js';
import { createUploadPicker } from './UploadPicker.js';
import { savePendingJob, removePendingJob, getPendingJobs } from '../lib/pendingJobs.js';
export function VideoStudio() {
const container = document.createElement('div');
@ -765,6 +766,40 @@ export function VideoStudio() {
}
} catch (e) { /* ignore */ }
// --- Resume any pending video generations from a previous session ---
(async () => {
const pending = getPendingJobs('video');
if (!pending.length) return;
const apiKey = localStorage.getItem('muapi_key');
if (!apiKey) return; // can't poll without key; jobs remain for next time
const banner = document.createElement('div');
banner.className = 'fixed top-4 left-1/2 -translate-x-1/2 z-[200] bg-[#111] border border-white/10 text-white text-sm px-5 py-3 rounded-2xl shadow-xl flex items-center gap-3';
banner.innerHTML = `<span class="animate-spin text-primary">◌</span> <span class="banner-text">Resuming ${pending.length} pending generation${pending.length > 1 ? 's' : ''}…</span>`;
document.body.appendChild(banner);
let remaining = pending.length;
pending.forEach(async (job) => {
const elapsedAttempts = Math.floor((Date.now() - job.submittedAt) / job.interval);
const attemptsLeft = Math.max(1, job.maxAttempts - elapsedAttempts);
try {
const result = await muapi.pollForResult(job.requestId, apiKey, attemptsLeft, job.interval);
const url = result.outputs?.[0] || result.url || result.output?.url;
if (url) {
addToHistory({ id: job.requestId, url, ...job.historyMeta, timestamp: new Date().toISOString() });
}
} catch (e) {
console.warn('[VideoStudio] Pending job failed on resume:', job.requestId, e.message);
} finally {
removePendingJob(job.requestId);
remaining--;
if (remaining === 0) banner.remove();
else banner.querySelector('.banner-text').textContent = `Resuming ${remaining} pending generation${remaining > 1 ? 's' : ''}`;
}
});
})();
// --- Button Handlers ---
downloadBtn.onclick = () => {
const current = resultVideo.src;
@ -858,12 +893,22 @@ export function VideoStudio() {
generateBtn.disabled = true;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> Generating...`;
let hadError = false;
let capturedRequestId = null;
const historyMeta = { prompt, model: selectedModel, aspect_ratio: selectedAr, duration: selectedDuration };
const onRequestId = (rid) => {
capturedRequestId = rid;
savePendingJob({ requestId: rid, studioType: 'video', historyMeta, maxAttempts: 900, interval: 2000, submittedAt: Date.now() });
};
try {
if (v2vMode) {
const res = await muapi.processV2V({ model: selectedModel, video_url: uploadedVideoUrl });
const res = await muapi.processV2V({ model: selectedModel, video_url: uploadedVideoUrl, onRequestId });
console.log('[VideoStudio] V2V response:', res);
if (res && res.url) {
const genId = res.id || res.request_id || Date.now().toString();
if (capturedRequestId) removePendingJob(capturedRequestId);
const genId = res.id || capturedRequestId || Date.now().toString();
lastGenerationId = null;
lastGenerationModel = null;
addToHistory({ id: genId, url: res.url, prompt: '', model: selectedModel, timestamp: new Date().toISOString() });
@ -880,6 +925,7 @@ export function VideoStudio() {
const i2vParams = {
model: selectedModel,
image_url: uploadedImageUrl,
onRequestId,
};
if (prompt) i2vParams.prompt = prompt;
i2vParams.aspect_ratio = selectedAr;
@ -893,7 +939,8 @@ export function VideoStudio() {
console.log('[VideoStudio] I2V response:', res);
if (res && res.url) {
const genId = res.id || res.request_id || Date.now().toString();
if (capturedRequestId) removePendingJob(capturedRequestId);
const genId = res.id || capturedRequestId || Date.now().toString();
if (selectedModel === 'seedance-v2.0-i2v') {
lastGenerationId = genId;
lastGenerationModel = selectedModel;
@ -911,7 +958,7 @@ export function VideoStudio() {
return;
}
const params = { model: selectedModel };
const params = { model: selectedModel, onRequestId };
if (prompt) params.prompt = prompt;
@ -935,7 +982,8 @@ export function VideoStudio() {
console.log('[VideoStudio] Full response:', res);
if (res && res.url) {
const genId = res.id || res.request_id || Date.now().toString();
if (capturedRequestId) removePendingJob(capturedRequestId);
const genId = res.id || capturedRequestId || Date.now().toString();
// Store request_id for seedance-v2.0 models (enables Extend button)
if (selectedModel === 'seedance-v2.0-t2v' || selectedModel === 'seedance-v2.0-i2v') {
lastGenerationId = genId;
@ -960,15 +1008,19 @@ export function VideoStudio() {
throw new Error('No video URL returned by API');
}
} catch (e) {
hadError = true;
if (capturedRequestId) removePendingJob(capturedRequestId);
console.error(e);
generateBtn.innerHTML = `Error: ${e.message.slice(0, 40)}`;
// Restore hero so the page doesn't look broken after a failed generation
hero.classList.remove('opacity-0', 'scale-95', '-translate-y-10', 'pointer-events-none');
generateBtn.innerHTML = `Error: ${e.message.slice(0, 60)}`;
setTimeout(() => {
generateBtn.innerHTML = `Generate ✨`;
generateBtn.disabled = false;
}, 3000);
}, 4000);
} finally {
generateBtn.disabled = false;
generateBtn.innerHTML = `Generate ✨`;
// Only reset the label on success; the catch timeout handles the error case
if (!hadError) generateBtn.innerHTML = `Generate ✨`;
}
};

View file

@ -95,6 +95,9 @@ export class MuapiClient {
return submitData;
}
// Notify caller of requestId so they can persist it before polling begins
if (params.onRequestId) params.onRequestId(requestId);
// Step 2: Poll for results
console.log('[Muapi] Polling for results, request_id:', requestId);
const result = await this.pollForResult(requestId, key);
@ -207,8 +210,10 @@ export class MuapiClient {
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
if (params.onRequestId) params.onRequestId(requestId);
console.log('[Muapi] Polling for video results, request_id:', requestId);
const result = await this.pollForResult(requestId, key, 120, 2000);
const result = await this.pollForResult(requestId, key, 900, 2000);
const videoUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] Video URL:', videoUrl);
@ -277,6 +282,8 @@ export class MuapiClient {
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
if (params.onRequestId) params.onRequestId(requestId);
const result = await this.pollForResult(requestId, key);
const imageUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] I2I Result URL:', imageUrl);
@ -344,7 +351,9 @@ export class MuapiClient {
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
const result = await this.pollForResult(requestId, key, 120, 2000);
if (params.onRequestId) params.onRequestId(requestId);
const result = await this.pollForResult(requestId, key, 900, 2000);
const videoUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] I2V Result URL:', videoUrl);
return { ...result, url: videoUrl };
@ -423,7 +432,9 @@ export class MuapiClient {
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
const result = await this.pollForResult(requestId, key, 120, 2000);
if (params.onRequestId) params.onRequestId(requestId);
const result = await this.pollForResult(requestId, key, 900, 2000);
const videoUrl = result.outputs?.[0] || result.url || result.output?.url;
console.log('[Muapi] V2V Result URL:', videoUrl);
return { ...result, url: videoUrl };

33
src/lib/pendingJobs.js Normal file
View file

@ -0,0 +1,33 @@
const PENDING_KEY = 'muapi_pending_jobs';
export function savePendingJob(job) {
try {
const jobs = getAllPendingJobs().filter(j => j.requestId !== job.requestId);
jobs.push(job);
localStorage.setItem(PENDING_KEY, JSON.stringify(jobs));
} catch (e) {
console.warn('[PendingJobs] Failed to save:', e);
}
}
export function removePendingJob(requestId) {
try {
const jobs = getAllPendingJobs().filter(j => j.requestId !== requestId);
localStorage.setItem(PENDING_KEY, JSON.stringify(jobs));
} catch (e) {
console.warn('[PendingJobs] Failed to remove:', e);
}
}
export function getPendingJobs(studioType) {
const all = getAllPendingJobs();
return studioType ? all.filter(j => j.studioType === studioType) : all;
}
function getAllPendingJobs() {
try {
return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]');
} catch {
return [];
}
}