feat: persistent background generation with toast notifications across all studios

- Web app (StandaloneShell): added global notification stack at bottom-right
  - Success toast with teal border, checkmark, studio name, and 'Open' button
  - Error toast with red border, error message text, auto-dismiss
  - Wired onGenerationComplete + onGenerationError into all 9 studio components

- Studios: added onGenerationError callback to all studios
  - VibeMotionStudio + MarketingStudio: added both callbacks (were missing)
  - All others: added onGenerationError to catch blocks
  - Replaced alert() with onGenerationError in CinemaStudio and MarketingStudio

- Desktop app (src/main.js): fixed router to use show/hide instead of
  innerHTML='' so studio components stay alive during tab switches,
  preserving in-progress generation across navigation
This commit is contained in:
Jaya Prasad Kavuru
2026-07-15 14:03:41 +05:30
parent 8b007297f1
commit 32d9dcc0c0
11 changed files with 174 additions and 42 deletions
+110 -10
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { ImageStudio, VideoStudio, ClippingStudio, VibeMotionStudio, LipSyncStudio, RecastStudio, CinemaStudio, AudioStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, AiInfluencerStudio, getUserBalance } from 'studio';
@@ -80,6 +80,32 @@ export default function StandaloneShell() {
const [isDragging, setIsDragging] = useState(false);
const [droppedFiles, setDroppedFiles] = useState(null);
// ── Global Generation Notifications ────────────────────────────────────────
const [notifications, setNotifications] = useState([]);
const activeTabRef = useRef(null);
useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]);
const pushNotification = useCallback((notif) => {
const id = `notif-${Date.now()}-${Math.random()}`;
const entry = { ...notif, id };
setNotifications(prev => [entry, ...prev].slice(0, 5));
const ttl = notif.type === 'success' ? 8000 : 6000;
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), ttl);
}, []);
const dismissNotification = useCallback((id) => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
const makeSuccessCallback = useCallback((tabId) => (data) => {
const tab = TABS.find(t => t.id === tabId);
pushNotification({ type: 'success', tabId, label: tab?.label || tabId, data });
}, [pushNotification]);
const makeErrorCallback = useCallback((tabId) => (message) => {
const tab = TABS.find(t => t.id === tabId);
pushNotification({ type: 'error', tabId, label: tab?.label || tabId, message });
}, [pushNotification]);
// Popstate event listener to sync tab state with URL on back/forward navigation
useEffect(() => {
@@ -364,31 +390,31 @@ export default function StandaloneShell() {
{/* Studio Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className={activeTab === 'image' ? "h-full w-full" : "hidden"}>
<ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
<ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('image')} onGenerationError={makeErrorCallback('image')} />
</div>
<div className={activeTab === 'video' ? "h-full w-full" : "hidden"}>
<VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
<VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('video')} onGenerationError={makeErrorCallback('video')} />
</div>
<div className={activeTab === 'clipping' ? "h-full w-full" : "hidden"}>
<ClippingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
<ClippingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('clipping')} onGenerationError={makeErrorCallback('clipping')} />
</div>
<div className={activeTab === 'vibe-motion' ? "h-full w-full" : "hidden"}>
<VibeMotionStudio apiKey={apiKey} />
<VibeMotionStudio apiKey={apiKey} onGenerationComplete={makeSuccessCallback('vibe-motion')} onGenerationError={makeErrorCallback('vibe-motion')} />
</div>
<div className={activeTab === 'lipsync' ? "h-full w-full" : "hidden"}>
<LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
<LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('lipsync')} onGenerationError={makeErrorCallback('lipsync')} />
</div>
<div className={activeTab === 'body-swap' ? "h-full w-full" : "hidden"}>
<RecastStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
<RecastStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('body-swap')} onGenerationError={makeErrorCallback('body-swap')} />
</div>
<div className={activeTab === 'cinema' ? "h-full w-full" : "hidden"}>
<CinemaStudio apiKey={apiKey} />
<CinemaStudio apiKey={apiKey} onGenerationComplete={makeSuccessCallback('cinema')} onGenerationError={makeErrorCallback('cinema')} />
</div>
<div className={activeTab === 'audio' ? "h-full w-full" : "hidden"}>
<AudioStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
<AudioStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('audio')} onGenerationError={makeErrorCallback('audio')} />
</div>
<div className={activeTab === 'marketing' ? "h-full w-full" : "hidden"}>
<MarketingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
<MarketingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('marketing')} onGenerationError={makeErrorCallback('marketing')} />
</div>
<div className={activeTab === 'workflows' ? "h-full w-full" : "hidden"}>
<WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
@@ -409,6 +435,80 @@ export default function StandaloneShell() {
</div>
</div>
{/* ── Global Generation Notification Stack ── */}
{notifications.length > 0 && (
<div
aria-live="polite"
className="fixed bottom-6 right-6 z-[200] flex flex-col gap-3 pointer-events-none"
style={{ maxWidth: '360px' }}
>
{notifications.map((notif) => (
<div
key={notif.id}
className="pointer-events-auto flex items-start gap-3 bg-[#0e0e10] border rounded-xl px-4 py-3 shadow-2xl shadow-black/60"
style={{
borderColor: notif.type === 'success' ? 'rgba(34,211,238,0.35)' : 'rgba(239,68,68,0.35)',
borderLeftWidth: '3px',
borderLeftColor: notif.type === 'success' ? '#22d3ee' : '#ef4444',
animation: 'slideInRight 280ms cubic-bezier(0.16,1,0.3,1) forwards',
}}
>
{/* Icon */}
<div
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center mt-0.5"
style={{ background: notif.type === 'success' ? 'rgba(34,211,238,0.12)' : 'rgba(239,68,68,0.12)' }}
>
{notif.type === 'success' ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" strokeWidth="3"><polyline points="20 6 9 17 4 12" /></svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="3"><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></svg>
)}
</div>
{/* Body */}
<div className="flex-1 min-w-0">
<p className="text-[12px] font-bold text-white/90 leading-tight">
{notif.label}
<span className="font-normal text-white/50">
{notif.type === 'success' ? ' · Generation complete' : ' · Generation failed'}
</span>
</p>
{notif.type === 'error' && notif.message && (
<p className="text-[11px] text-red-400/80 mt-0.5 leading-snug truncate" title={notif.message}>
{notif.message}
</p>
)}
{notif.type === 'success' && (
<button
onClick={() => { handleTabChange(notif.tabId); dismissNotification(notif.id); }}
className="mt-1.5 text-[11px] font-bold text-[#22d3ee] hover:underline"
>
Open
</button>
)}
</div>
{/* Dismiss */}
<button
onClick={() => dismissNotification(notif.id)}
className="flex-shrink-0 text-white/30 hover:text-white/70 transition-colors text-lg leading-none mt-0.5"
aria-label="Dismiss"
>
×
</button>
</div>
))}
</div>
)}
{/* Keyframe for toast slide-in */}
<style>{`
@keyframes slideInRight {
from { transform: translateX(110%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`}</style>
{/* Settings Modal */}
{showSettings && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in-up">
@@ -475,6 +475,7 @@ function PremiumAudioPlayer({ url, title }) {
export default function AudioStudio({
apiKey,
onGenerationComplete,
onGenerationError,
historyItems,
droppedFiles,
onFilesHandled,
@@ -661,6 +662,7 @@ export default function AudioStudio({
} catch (e) {
console.error("[AudioStudio]", e);
setGenerateError(e.message?.slice(0, 100) ?? "Audio generation failed");
onGenerationError?.(e.message?.slice(0, 120) || "Audio generation failed");
} finally {
setIsGenerating(false);
}
@@ -444,6 +444,7 @@ function CameraControlsOverlay({
export default function CinemaStudio({
apiKey,
onGenerationComplete,
onGenerationError,
historyItems,
}) {
const PERSIST_KEY = "hg_cinema_studio_persistent";
@@ -632,7 +633,7 @@ export default function CinemaStudio({
}
} catch (e) {
console.error(e);
alert("Generation Failed: " + e.message);
onGenerationError?.(e.message?.slice(0, 120) || "Cinema generation failed");
} finally {
setIsGenerating(false);
}
@@ -90,6 +90,7 @@ const getAspectClass = (ar) => {
export default function ClippingStudio({
apiKey,
onGenerationComplete,
onGenerationError,
droppedFiles,
onFilesHandled,
}) {
@@ -397,6 +398,7 @@ export default function ClippingStudio({
} catch (err) {
console.error("[ClippingStudio] Error generating clips:", err);
setGenerateError(err.message || "Failed to process AI clipping.");
onGenerationError?.(err.message?.slice(0, 120) || "AI Clipping failed");
} finally {
setIsGenerating(false);
}
@@ -811,6 +811,7 @@ function SimpleDropdown({ title, options, selected, onSelect, onClose }) {
export default function ImageStudio({
apiKey,
onGenerationComplete,
onGenerationError,
historyItems,
droppedFiles,
onFilesHandled,
@@ -1176,6 +1177,7 @@ export default function ImageStudio({
console.error("[ImageStudio] Generation failed:", e);
setGenerateError(e.message.slice(0, 80));
setTimeout(() => setGenerateError(null), 4000);
onGenerationError?.(e.message?.slice(0, 120) || "Image generation failed");
} finally {
setGenerating(false);
}
@@ -318,6 +318,7 @@ const VideoIcon = ({
export default function LipSyncStudio({
apiKey,
onGenerationComplete,
onGenerationError,
historyItems,
droppedFiles,
onFilesHandled,
@@ -692,6 +693,7 @@ export default function LipSyncStudio({
console.error("[LipSyncStudio]", e);
setGenerateError(e.message?.slice(0, 80) ?? "Unknown error");
setTimeout(() => setGenerateError(null), 4000);
onGenerationError?.(e.message?.slice(0, 120) || "Lip sync generation failed");
} finally {
setIsGenerating(false);
}
@@ -250,7 +250,7 @@ function SimpleDropdown({ isOpen, title, options, selected, onSelect, onClose })
// ── Main Component ───────────────────────────────────────────────────────────
export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled }) {
export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled, onGenerationComplete, onGenerationError }) {
const PERSIST_KEY = "hg_marketing_studio_persistent";
const [prompt, setPrompt] = useState("");
@@ -369,9 +369,10 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled }
};
setHistory(prev => [entry, ...prev]);
setFullscreenUrl(result.url);
onGenerationComplete?.({ url: result.url, type: "video" });
}
} catch (err) {
alert("Generation failed: " + err.message);
onGenerationError?.(err.message?.slice(0, 120) || "Marketing generation failed");
} finally {
setIsGenerating(false);
}
@@ -388,6 +388,7 @@ const ImageIcon = ({
export default function RecastStudio({
apiKey,
onGenerationComplete,
onGenerationError,
historyItems,
droppedFiles,
onFilesHandled,
@@ -745,6 +746,7 @@ export default function RecastStudio({
console.error("[RecastStudio]", e);
setGenerateError(e.message?.slice(0, 80) ?? "Unknown error");
setTimeout(() => setGenerateError(null), 4000);
onGenerationError?.(e.message?.slice(0, 120) || "Body swap generation failed");
} finally {
setIsGenerating(false);
}
@@ -47,7 +47,7 @@ function DropdownItem({ label, selected, onClick }) {
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function VibeMotionStudio({ apiKey }) {
export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGenerationError }) {
const PERSIST_KEY = "hg_vibe_motion_studio_persistent";
// ── Params ────────────────────────────────────────────────────────────────
@@ -160,6 +160,7 @@ export default function VibeMotionStudio({ apiKey }) {
const next = [entry, ...history].slice(0, 30);
saveHistory(next);
onGenerationComplete?.({ url: videoUrl, type: "video" });
} catch (err) {
// Detect the backend's "animation code not saved" limitation
const raw = err.message || "";
@@ -183,6 +184,7 @@ export default function VibeMotionStudio({ apiKey }) {
setGenerateError(raw.slice(0, 120) || "Generation failed");
}
setTimeout(() => setGenerateError(null), 10000);
onGenerationError?.(err.message?.slice(0, 120) || "Vibe Motion generation failed");
} finally {
setGenerating(false);
stopTimer();
@@ -410,6 +410,7 @@ function ControlBtn({ icon, label, onClick, style }) {
export default function VideoStudio({
apiKey,
onGenerationComplete,
onGenerationError,
historyItems,
droppedFiles,
onFilesHandled,
@@ -1287,6 +1288,7 @@ export default function VideoStudio({
console.error("[VideoStudio]", e);
setGenerateError(e.message?.slice(0, 80) || "Generation failed");
setTimeout(() => setGenerateError(null), 4000);
onGenerationError?.(e.message?.slice(0, 120) || "Video generation failed");
} finally {
setGenerating(false);
}
+44 -28
View File
@@ -5,37 +5,53 @@ import { ImageStudio } from './components/ImageStudio.js';
const app = document.querySelector('#app');
let contentArea;
// Router
// Keep all mounted page nodes so async generation survives tab switches
const mountedPages = {};
// Router — show/hide instead of destroy
function navigate(page) {
if (!contentArea) return;
contentArea.innerHTML = '';
if (page === 'image') {
contentArea.appendChild(ImageStudio());
} else if (page === 'video') {
import('./components/VideoStudio.js').then(({ VideoStudio }) => {
contentArea.appendChild(VideoStudio());
});
} else if (page === 'cinema') {
import('./components/CinemaStudio.js').then(({ CinemaStudio }) => {
contentArea.appendChild(CinemaStudio());
});
} else if (page === 'lipsync') {
import('./components/LipSyncStudio.js').then(({ LipSyncStudio }) => {
contentArea.appendChild(LipSyncStudio());
});
} else if (page === 'workflows') {
import('./components/WorkflowStudio.js').then(({ WorkflowStudio }) => {
contentArea.appendChild(WorkflowStudio());
});
} else if (page === 'agents') {
import('./components/AgentStudio.js').then(({ AgentStudio }) => {
contentArea.appendChild(AgentStudio());
});
} else if (page === 'mcp-cli') {
import('./components/McpCliStudio.js').then(({ McpCliStudio }) => {
contentArea.appendChild(McpCliStudio());
});
// Hide all existing pages
Object.values(mountedPages).forEach(node => { node.style.display = 'none'; });
if (mountedPages[page]) {
// Already mounted — just show it again
mountedPages[page].style.display = '';
} else {
// First visit — create a wrapper and mount the studio into it
const wrapper = document.createElement('div');
wrapper.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;';
contentArea.appendChild(wrapper);
mountedPages[page] = wrapper;
if (page === 'image') {
wrapper.appendChild(ImageStudio());
} else if (page === 'video') {
import('./components/VideoStudio.js').then(({ VideoStudio }) => {
wrapper.appendChild(VideoStudio());
});
} else if (page === 'cinema') {
import('./components/CinemaStudio.js').then(({ CinemaStudio }) => {
wrapper.appendChild(CinemaStudio());
});
} else if (page === 'lipsync') {
import('./components/LipSyncStudio.js').then(({ LipSyncStudio }) => {
wrapper.appendChild(LipSyncStudio());
});
} else if (page === 'workflows') {
import('./components/WorkflowStudio.js').then(({ WorkflowStudio }) => {
wrapper.appendChild(WorkflowStudio());
});
} else if (page === 'agents') {
import('./components/AgentStudio.js').then(({ AgentStudio }) => {
wrapper.appendChild(AgentStudio());
});
} else if (page === 'mcp-cli') {
import('./components/McpCliStudio.js').then(({ McpCliStudio }) => {
wrapper.appendChild(McpCliStudio());
});
}
}
}