mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-08-31 01:40:10 +08:00
Unify generation notifications across Studio
This commit is contained in:
committed by
jaiprasad04
parent
5517c1f8d7
commit
53464e8e3f
+147
-41
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback } 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';
|
||||
@@ -238,6 +238,35 @@ const getNavigationCategory = (tabId) => (
|
||||
);
|
||||
|
||||
const STORAGE_KEY = 'muapi_key';
|
||||
const NOTIFICATIONS_STORAGE_KEY = 'open_gen_notifications_v1';
|
||||
const MAX_VISIBLE_NOTIFICATIONS = 3;
|
||||
|
||||
const loadStoredNotifications = () => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = JSON.parse(window.sessionStorage.getItem(NOTIFICATIONS_STORAGE_KEY) || '[]');
|
||||
const now = Date.now();
|
||||
return Array.isArray(stored)
|
||||
? stored.filter((notification) => notification.expiresAt > now).slice(0, MAX_VISIBLE_NOTIFICATIONS)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const persistNotifications = (notifications) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
NOTIFICATIONS_STORAGE_KEY,
|
||||
JSON.stringify(notifications),
|
||||
);
|
||||
} catch {
|
||||
// Notification persistence is optional; rendering still works without storage.
|
||||
}
|
||||
};
|
||||
|
||||
export default function StandaloneShell() {
|
||||
const params = useParams();
|
||||
@@ -327,26 +356,64 @@ export default function StandaloneShell() {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [droppedFiles, setDroppedFiles] = useState(null);
|
||||
|
||||
// ── Global Generation Notifications ────────────────────────────────────────
|
||||
// Global generation notifications remain mounted while users switch studios.
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const activeTabRef = useRef(null);
|
||||
useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]);
|
||||
const [notificationsHydrated, setNotificationsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setNotifications(loadStoredNotifications());
|
||||
setNotificationsHydrated(true);
|
||||
}, []);
|
||||
|
||||
const pushNotification = useCallback((notif) => {
|
||||
const now = Date.now();
|
||||
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 ttl = 12000;
|
||||
const entry = { ...notif, id, expiresAt: now + ttl };
|
||||
setNotifications((previous) => {
|
||||
const next = [
|
||||
...previous.filter((notification) => notification.expiresAt > now),
|
||||
entry,
|
||||
].slice(-MAX_VISIBLE_NOTIFICATIONS);
|
||||
persistNotifications(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dismissNotification = useCallback((id) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
setNotifications((previous) => {
|
||||
const next = previous.filter((notification) => notification.id !== id);
|
||||
persistNotifications(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notificationsHydrated) return;
|
||||
|
||||
persistNotifications(notifications);
|
||||
}, [notifications, notificationsHydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notifications.length === 0) return undefined;
|
||||
|
||||
const nextExpiry = Math.min(...notifications.map((notification) => notification.expiresAt));
|
||||
const timer = window.setTimeout(() => {
|
||||
const now = Date.now();
|
||||
setNotifications((previous) => previous.filter((notification) => notification.expiresAt > now));
|
||||
}, Math.max(0, nextExpiry - Date.now()));
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [notifications]);
|
||||
|
||||
const makeSuccessCallback = useCallback((tabId) => (data) => {
|
||||
const tab = TABS.find(t => t.id === tabId);
|
||||
pushNotification({ type: 'success', tabId, label: tab?.label || tabId, data });
|
||||
pushNotification({
|
||||
type: 'success',
|
||||
tabId,
|
||||
label: tab?.label || tabId,
|
||||
resultUrl: data?.url || null,
|
||||
});
|
||||
}, [pushNotification]);
|
||||
|
||||
const makeErrorCallback = useCallback((tabId) => (message) => {
|
||||
@@ -368,10 +435,15 @@ export default function StandaloneShell() {
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const handleTabChange = (tabId) => {
|
||||
const handleTabChange = useCallback((tabId) => {
|
||||
window.history.pushState(null, '', `/studio/${tabId}`);
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenNotification = useCallback((notification) => {
|
||||
handleTabChange(notification.tabId);
|
||||
dismissNotification(notification.id);
|
||||
}, [dismissNotification, handleTabChange]);
|
||||
|
||||
const handleTabClick = (e, tabId) => {
|
||||
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
|
||||
@@ -848,85 +920,119 @@ export default function StandaloneShell() {
|
||||
<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} />
|
||||
<WorkflowStudio
|
||||
apiKey={apiKey}
|
||||
isHeaderVisible={isHeaderVisible}
|
||||
onToggleHeader={setIsHeaderVisible}
|
||||
onGenerationComplete={makeSuccessCallback('workflows')}
|
||||
onGenerationError={makeErrorCallback('workflows')}
|
||||
/>
|
||||
</div>
|
||||
<div className={activeTab === 'agents' ? "h-full w-full" : "hidden"}>
|
||||
<AgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
|
||||
</div>
|
||||
<div className={activeTab === 'design-agent' ? "h-full w-full" : "hidden"}>
|
||||
{activeTab === 'design-agent' && (
|
||||
<DesignAgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
|
||||
<DesignAgentStudio
|
||||
apiKey={apiKey}
|
||||
isHeaderVisible={isHeaderVisible}
|
||||
onToggleHeader={setIsHeaderVisible}
|
||||
onGenerationComplete={makeSuccessCallback('design-agent')}
|
||||
onGenerationError={makeErrorCallback('design-agent')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={activeTab === 'apps' ? "h-full w-full" : "hidden"}>
|
||||
<AppsStudio apiKey={apiKey} />
|
||||
</div>
|
||||
<div className={activeTab === 'ai-influencer' ? "h-full w-full" : "hidden"}>
|
||||
<AiInfluencerStudio apiKey={apiKey} />
|
||||
<AiInfluencerStudio
|
||||
apiKey={apiKey}
|
||||
onGenerationComplete={makeSuccessCallback('ai-influencer')}
|
||||
onGenerationError={makeErrorCallback('ai-influencer')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Global Generation Notification Stack ── */}
|
||||
{/* 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' }}
|
||||
aria-label="Generation notifications"
|
||||
className="fixed bottom-5 right-5 z-[200] flex w-[340px] max-w-[calc(100vw-32px)] flex-col gap-2 pointer-events-none"
|
||||
data-testid="global-notification-stack"
|
||||
>
|
||||
{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"
|
||||
role={notif.type === 'error' ? 'alert' : 'status'}
|
||||
data-notification-type={notif.type}
|
||||
data-notification-tab={notif.tabId}
|
||||
className="pointer-events-auto flex items-start gap-3 rounded-xl border bg-[#0d0d0f] px-3.5 py-3 text-[13px] text-zinc-100 shadow-[0_16px_48px_rgba(0,0,0,0.65)]"
|
||||
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)' }}
|
||||
<span
|
||||
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border ${
|
||||
notif.type === 'success'
|
||||
? 'border-cyan-400/35 bg-cyan-400/10 text-cyan-300'
|
||||
: 'border-red-500/35 bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{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="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m5 12 4 4L19 6" />
|
||||
</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>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v6" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[12px] font-bold text-white/90 leading-tight">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold leading-5 text-zinc-100">
|
||||
{notif.label}
|
||||
<span className="font-normal text-white/50">
|
||||
{notif.type === 'success' ? ' · Generation complete' : ' · Generation failed'}
|
||||
<span className="font-normal text-zinc-400">
|
||||
{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}>
|
||||
<p className="mt-0.5 line-clamp-2 text-[12px] font-medium leading-4 text-red-300/85" title={notif.message}>
|
||||
{notif.message}
|
||||
</p>
|
||||
)}
|
||||
{notif.type === 'success' && (
|
||||
<p className="mt-0.5 text-[12px] leading-4 text-zinc-400">
|
||||
Your result is ready.
|
||||
</p>
|
||||
)}
|
||||
{notif.type === 'success' && (
|
||||
<button
|
||||
onClick={() => { handleTabChange(notif.tabId); dismissNotification(notif.id); }}
|
||||
className="mt-1.5 text-[11px] font-bold text-[#22d3ee] hover:underline"
|
||||
type="button"
|
||||
onClick={() => handleOpenNotification(notif)}
|
||||
className="mt-1.5 text-[11px] font-bold text-cyan-300 transition-colors hover:text-cyan-100"
|
||||
aria-label={`Open ${notif.label} result`}
|
||||
>
|
||||
Open →
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss */}
|
||||
<button
|
||||
type="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"
|
||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-white/5 hover:text-zinc-200 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
×
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Submodule packages/Open-AI-Design-Agent updated: e179fe1a6c...d225535728
+1
-1
Submodule packages/Vibe-Workflow updated: 4fed75125d...c074ceac43
@@ -332,7 +332,13 @@ function HoverPill({ label, img, onClick }) {
|
||||
}
|
||||
|
||||
// ─── Main Component ─────────────────────────────────────────────────────────
|
||||
export default function AiInfluencerStudio({ apiKey, onGenerate, isGenerating: externalIsGenerating }) {
|
||||
export default function AiInfluencerStudio({
|
||||
apiKey,
|
||||
onGenerate,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
isGenerating: externalIsGenerating,
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState("face");
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState(() => {
|
||||
@@ -408,9 +414,17 @@ export default function AiInfluencerStudio({ apiKey, onGenerate, isGenerating: e
|
||||
setCurrentResult(res.url);
|
||||
setHistory((prev) => [{ url: res.url, ts: Date.now() }, ...prev]);
|
||||
setSelectedHistoryIdx(0);
|
||||
onGenerationComplete?.({
|
||||
url: res.url,
|
||||
model: INFLUENCER_MODEL,
|
||||
prompt,
|
||||
type: "image",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(formatErrorMessage(err, "Generation failed. Please try again."));
|
||||
const message = formatErrorMessage(err, "Generation failed. Please try again.");
|
||||
if (onGenerationError) onGenerationError(message);
|
||||
else toast.error(message);
|
||||
} finally {
|
||||
setIsGeneratingInternal(false);
|
||||
}
|
||||
|
||||
@@ -683,8 +683,8 @@ export default function AudioStudio({
|
||||
} catch (e) {
|
||||
console.error("[AudioStudio]", e);
|
||||
const errMsg = formatErrorMessage(e, "Audio generation failed");
|
||||
toast.error(errMsg);
|
||||
onGenerationError?.(errMsg);
|
||||
if (onGenerationError) onGenerationError(errMsg);
|
||||
else toast.error(errMsg);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,25 @@ import {
|
||||
promptMediaButtonClassName,
|
||||
} from "./prompt/PromptComposer.jsx";
|
||||
|
||||
const MAX_VIDEO_SIZE_MB = 100;
|
||||
const MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024;
|
||||
const CLIPPING_TOASTER_ID = "clipping-studio";
|
||||
const VIDEO_TOO_LARGE_FOR_MODE_MESSAGE =
|
||||
"The file is too large for this mode. Compress or trim the video, then upload a smaller file.";
|
||||
const MAX_VISIBLE_ERROR_TOASTS = 3;
|
||||
const ERROR_TOAST_DURATION_MS = 7000;
|
||||
const activeErrorToastIds = [];
|
||||
|
||||
const forgetErrorToast = (toastId) => {
|
||||
const index = activeErrorToastIds.indexOf(toastId);
|
||||
if (index !== -1) activeErrorToastIds.splice(index, 1);
|
||||
};
|
||||
|
||||
const dismissErrorToast = (toastId) => {
|
||||
forgetErrorToast(toastId);
|
||||
toast.dismiss(toastId, CLIPPING_TOASTER_ID);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline SVG Icons
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,6 +82,104 @@ const CopyIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ErrorToast = ({ toastInstance, message }) => (
|
||||
<div
|
||||
className={`pointer-events-auto flex w-[340px] max-w-[calc(100vw-32px)] items-start gap-3 rounded-xl border border-red-500/35 bg-[#0d0d0f] px-3.5 py-3 text-[13px] text-zinc-100 shadow-[0_16px_48px_rgba(0,0,0,0.65)] transition-all duration-200 ${
|
||||
toastInstance.visible ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
|
||||
}`}
|
||||
role="alert"
|
||||
>
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-red-500/35 bg-red-500/10 text-red-400">
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v6" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 py-1 font-medium leading-5">{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissErrorToast(toastInstance.id)}
|
||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-white/5 hover:text-zinc-200 focus:outline-none focus:ring-1 focus:ring-red-400/60"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const showErrorToast = (message) => {
|
||||
const options = {
|
||||
duration: ERROR_TOAST_DURATION_MS,
|
||||
position: "bottom-right",
|
||||
toasterId: CLIPPING_TOASTER_ID,
|
||||
};
|
||||
|
||||
while (activeErrorToastIds.length >= MAX_VISIBLE_ERROR_TOASTS) {
|
||||
const oldestToastId = activeErrorToastIds.shift();
|
||||
toast.remove(oldestToastId, CLIPPING_TOASTER_ID);
|
||||
}
|
||||
|
||||
const toastId = toast.custom(
|
||||
(toastInstance) => (
|
||||
<ErrorToast
|
||||
toastInstance={toastInstance}
|
||||
message={message}
|
||||
/>
|
||||
),
|
||||
options,
|
||||
);
|
||||
|
||||
activeErrorToastIds.push(toastId);
|
||||
setTimeout(
|
||||
() => forgetErrorToast(toastId),
|
||||
ERROR_TOAST_DURATION_MS + 1000,
|
||||
);
|
||||
};
|
||||
|
||||
const showVideoSizeLimitToast = () => {
|
||||
showErrorToast(`Video exceeds ${MAX_VIDEO_SIZE_MB}MB limit.`);
|
||||
};
|
||||
|
||||
const isFileSizeError = (error) => {
|
||||
const message = String(error?.message || error || "");
|
||||
return /(?:\b413\b|payload too large|request entity too large|file(?: size)? (?:is )?too large|file is too heavy|exceeds?.*(?:size|limit)|слишком (?:больш|тяж)|превышает.*(?:размер|лимит))/i.test(message);
|
||||
};
|
||||
|
||||
const showVideoUploadError = (error) => {
|
||||
if (isFileSizeError(error)) {
|
||||
showErrorToast(VIDEO_TOO_LARGE_FOR_MODE_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = formatErrorMessage(
|
||||
error,
|
||||
"Video upload failed. Please try again.",
|
||||
);
|
||||
showErrorToast(message);
|
||||
};
|
||||
|
||||
const getAspectClass = (ar) => {
|
||||
switch (ar) {
|
||||
case "16:9": return "aspect-video";
|
||||
@@ -209,8 +326,8 @@ export default function ClippingStudio({
|
||||
const videoFiles = droppedFiles.filter(f => f.type.startsWith('video/'));
|
||||
if (videoFiles.length > 0) {
|
||||
const file = videoFiles[0];
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
alert("Video exceeds 100MB limit.");
|
||||
if (file.size > MAX_VIDEO_SIZE_BYTES) {
|
||||
showVideoSizeLimitToast();
|
||||
onFilesHandled?.();
|
||||
return;
|
||||
}
|
||||
@@ -225,7 +342,7 @@ export default function ClippingStudio({
|
||||
})
|
||||
.catch(err => {
|
||||
setVideoUploading(false);
|
||||
alert(`Failed to upload dropped file: ${err.message}`);
|
||||
showVideoUploadError(err);
|
||||
});
|
||||
}
|
||||
onFilesHandled?.();
|
||||
@@ -286,8 +403,9 @@ export default function ClippingStudio({
|
||||
const handleVideoFileChange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
alert("Video exceeds 100MB limit.");
|
||||
if (file.size > MAX_VIDEO_SIZE_BYTES) {
|
||||
showVideoSizeLimitToast();
|
||||
if (videoFileInputRef.current) videoFileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
setVideoUploading(true);
|
||||
@@ -299,7 +417,7 @@ export default function ClippingStudio({
|
||||
setVideoUrl(url);
|
||||
} catch (err) {
|
||||
console.error("[ClippingStudio] Video upload failed:", err);
|
||||
alert(`Video upload failed: ${err.message}`);
|
||||
showVideoUploadError(err);
|
||||
} finally {
|
||||
setVideoUploading(false);
|
||||
setVideoProgress(0);
|
||||
@@ -374,8 +492,11 @@ export default function ClippingStudio({
|
||||
} catch (err) {
|
||||
console.error("[ClippingStudio] Error generating clips:", err);
|
||||
const errMsg = formatErrorMessage(err, "Failed to process AI clipping.");
|
||||
toast.error(errMsg);
|
||||
onGenerationError?.(errMsg);
|
||||
const notificationMessage = isFileSizeError(err)
|
||||
? VIDEO_TOO_LARGE_FOR_MODE_MESSAGE
|
||||
: errMsg;
|
||||
if (onGenerationError) onGenerationError(notificationMessage);
|
||||
else showErrorToast(notificationMessage);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
@@ -988,7 +1109,34 @@ export default function ClippingStudio({
|
||||
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
|
||||
}
|
||||
`}</style>
|
||||
<Toaster position="top-right" containerStyle={{ zIndex: 99999 }} toastOptions={{ duration: 5000, style: { background: '#18181b', color: '#ffffff', border: '1px solid rgba(255,255,255,0.15)', fontSize: '13px', borderRadius: '12px', boxShadow: '0 10px 30px rgba(0,0,0,0.6)', maxWidth: '440px', wordBreak: 'break-word', whiteSpace: 'pre-wrap', padding: '12px 16px' } }} />
|
||||
<Toaster
|
||||
toasterId={CLIPPING_TOASTER_ID}
|
||||
position="bottom-right"
|
||||
reverseOrder={false}
|
||||
gutter={8}
|
||||
containerStyle={{ zIndex: 99999, right: 20, bottom: 20 }}
|
||||
toastOptions={{
|
||||
duration: 6000,
|
||||
style: {
|
||||
background: "#0d0d0f",
|
||||
color: "#f4f4f5",
|
||||
border: "1px solid rgba(239,68,68,0.35)",
|
||||
fontSize: "13px",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 16px 48px rgba(0,0,0,0.65)",
|
||||
maxWidth: "380px",
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
padding: "12px 14px",
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: "#f87171",
|
||||
secondary: "#0d0d0f",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ import { CreativeCanvas } from 'design-agent';
|
||||
|
||||
import { getUserBalance } from '../muapi';
|
||||
|
||||
export default function DesignAgentStudio({ apiKey, isHeaderVisible, onToggleHeader }) {
|
||||
export default function DesignAgentStudio({
|
||||
apiKey,
|
||||
isHeaderVisible,
|
||||
onToggleHeader,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
}) {
|
||||
const [userData, setUserData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,6 +44,8 @@ export default function DesignAgentStudio({ apiKey, isHeaderVisible, onToggleHea
|
||||
theme="dark"
|
||||
onToggleHeader={onToggleHeader}
|
||||
isHeaderVisible={isHeaderVisible}
|
||||
onGenerationComplete={onGenerationComplete}
|
||||
onGenerationError={onGenerationError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1272,8 +1272,8 @@ export default function ImageStudio({
|
||||
} catch (e) {
|
||||
console.error("[ImageStudio] Generation failed:", e);
|
||||
const errMsg = formatErrorMessage(e, "Image generation failed");
|
||||
toast.error(errMsg);
|
||||
onGenerationError?.(errMsg);
|
||||
if (onGenerationError) onGenerationError(errMsg);
|
||||
else toast.error(errMsg);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
|
||||
@@ -691,8 +691,8 @@ export default function LipSyncStudio({
|
||||
} catch (e) {
|
||||
console.error("[LipSyncStudio]", e);
|
||||
const errMsg = formatErrorMessage(e, "Lip sync generation failed");
|
||||
toast.error(errMsg);
|
||||
onGenerationError?.(errMsg);
|
||||
if (onGenerationError) onGenerationError(errMsg);
|
||||
else toast.error(errMsg);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -765,8 +765,8 @@ export default function RecastStudio({
|
||||
} catch (e) {
|
||||
console.error("[RecastStudio]", e);
|
||||
const errMsg = formatErrorMessage(e, "Body swap generation failed");
|
||||
toast.error(errMsg);
|
||||
onGenerationError?.(errMsg);
|
||||
if (onGenerationError) onGenerationError(errMsg);
|
||||
else toast.error(errMsg);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -193,14 +193,15 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
|
||||
if (isStaleEdit) {
|
||||
console.warn("[VibeMotionStudio] Remix unavailable:", raw.slice(0, 120));
|
||||
const msg = "This generation can't be remixed — the animation code wasn't saved server-side. Generate a new motion graphic first, then remix that result.";
|
||||
toast.error(msg);
|
||||
if (onGenerationError) onGenerationError(msg);
|
||||
else toast.error(msg);
|
||||
setEditMode(false);
|
||||
setEditSourceId(null);
|
||||
} else {
|
||||
console.error("[VibeMotionStudio]", err);
|
||||
const errMsg = formatErrorMessage(raw || err, "Vibe Motion generation failed");
|
||||
toast.error(errMsg);
|
||||
onGenerationError?.(errMsg);
|
||||
if (onGenerationError) onGenerationError(errMsg);
|
||||
else toast.error(errMsg);
|
||||
}
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
|
||||
@@ -1256,8 +1256,8 @@ export default function VideoStudio({
|
||||
hadError = true;
|
||||
console.error("[VideoStudio]", e);
|
||||
const errMsg = formatErrorMessage(e, "Video generation failed");
|
||||
toast.error(errMsg);
|
||||
onGenerationError?.(errMsg);
|
||||
if (onGenerationError) onGenerationError(errMsg);
|
||||
else toast.error(errMsg);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,13 @@ function WorkflowCard({ workflow, onClick, activeTab, onRename, onDelete }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggleHeader }) {
|
||||
export default function WorkflowStudio({
|
||||
apiKey,
|
||||
isHeaderVisible = true,
|
||||
onToggleHeader,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
}) {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const slug = params?.slug || [];
|
||||
@@ -414,9 +420,15 @@ export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggl
|
||||
|
||||
const data = await executeWorkflow(apiKey, selectedWorkflow.id, inputs);
|
||||
setResult(data);
|
||||
onGenerationComplete?.({
|
||||
url: data?.url || data?.output?.url || data?.outputs?.[0]?.url || null,
|
||||
type: "workflow",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Execution failed:", err);
|
||||
setError(err.message || "Execution failed");
|
||||
const message = err.message || "Execution failed";
|
||||
setError(message);
|
||||
onGenerationError?.(message);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
@@ -815,6 +827,8 @@ export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggl
|
||||
// Inject ID to prevent builder from assuming this is a new unsaved flow
|
||||
workflow_id: selectedWorkflow?.id
|
||||
}}
|
||||
onGenerationComplete={onGenerationComplete}
|
||||
onGenerationError={onGenerationError}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
|
||||
@@ -6,7 +6,13 @@ import "reactflow/dist/style.css";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
|
||||
|
||||
const WorkflowUI = ({ workflowId, initialNodeSchemas, initialWorkflowData }) => {
|
||||
const WorkflowUI = ({
|
||||
workflowId,
|
||||
initialNodeSchemas,
|
||||
initialWorkflowData,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem("fromWorkflowBuilder", "true");
|
||||
}, []);
|
||||
@@ -18,6 +24,8 @@ const WorkflowUI = ({ workflowId, initialNodeSchemas, initialWorkflowData }) =>
|
||||
initialNodeSchemas={initialNodeSchemas}
|
||||
initialWorkflowData={initialWorkflowData}
|
||||
costType="dollars"
|
||||
onGenerationComplete={onGenerationComplete}
|
||||
onGenerationError={onGenerationError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user