From 7ca2f280e7043324783b591da0fa5ab6cee774c0 Mon Sep 17 00:00:00 2001 From: Anil Matcha Date: Tue, 10 Mar 2026 10:17:44 +0530 Subject: [PATCH] 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 --- src/components/ImageStudio.js | 69 +++++++++++++++++++++++++++++----- src/components/VideoStudio.js | 70 ++++++++++++++++++++++++++++++----- src/lib/muapi.js | 17 +++++++-- src/lib/pendingJobs.js | 33 +++++++++++++++++ 4 files changed, 167 insertions(+), 22 deletions(-) create mode 100644 src/lib/pendingJobs.js diff --git a/src/components/ImageStudio.js b/src/components/ImageStudio.js index 62f2f66..ef7c7dd 100644 --- a/src/components/ImageStudio.js +++ b/src/components/ImageStudio.js @@ -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 = ` `; + 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 = ` 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 ✨`; } }; diff --git a/src/components/VideoStudio.js b/src/components/VideoStudio.js index 457c1bf..33089f9 100644 --- a/src/components/VideoStudio.js +++ b/src/components/VideoStudio.js @@ -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 = ` `; + 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 = ` 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 ✨`; } }; diff --git a/src/lib/muapi.js b/src/lib/muapi.js index 7080576..2cf1632 100644 --- a/src/lib/muapi.js +++ b/src/lib/muapi.js @@ -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 }; diff --git a/src/lib/pendingJobs.js b/src/lib/pendingJobs.js new file mode 100644 index 0000000..aebae52 --- /dev/null +++ b/src/lib/pendingJobs.js @@ -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 []; + } +}