diff --git a/components/StandaloneShell.js b/components/StandaloneShell.js index 731b7c1ad..1158a9063 100644 --- a/components/StandaloneShell.js +++ b/components/StandaloneShell.js @@ -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,65 @@ 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); + const [generationCounts, setGenerationCounts] = useState({}); + + 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) => { @@ -354,6 +422,37 @@ export default function StandaloneShell() { pushNotification({ type: 'error', tabId, label: tab?.label || tabId, message }); }, [pushNotification]); + const makeGenerationStartCallback = useCallback((tabId) => () => { + setGenerationCounts((previous) => ({ + ...previous, + [tabId]: (previous[tabId] || 0) + 1, + })); + }, []); + + const makeGenerationEndCallback = useCallback((tabId) => () => { + setGenerationCounts((previous) => { + const currentCount = previous[tabId] || 0; + if (currentCount <= 1) { + const next = { ...previous }; + delete next[tabId]; + return next; + } + + return { + ...previous, + [tabId]: currentCount - 1, + }; + }); + }, []); + + const activeGenerations = TABS + .filter((tab) => generationCounts[tab.id] > 0) + .map((tab) => ({ + tabId: tab.id, + label: tab.label, + count: generationCounts[tab.id], + })); + // Popstate event listener to sync tab state with URL on back/forward navigation useEffect(() => { const handlePopState = () => { @@ -368,10 +467,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) { @@ -821,112 +925,173 @@ export default function StandaloneShell() { {/* Studio Content */}
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
{activeTab === 'design-agent' && ( - + )}
- +
- {/* ── Global Generation Notification Stack ── */} - {notifications.length > 0 && ( + {/* Global generation activity and notification stack */} + {(activeGenerations.length > 0 || notifications.length > 0) && (
+ {activeGenerations.map((generation) => ( +
+ + +

+ {generation.label} is generating + {generation.count > 1 ? ` (${generation.count})` : ''} +

+
+ ))} + {notifications.map((notif) => (
- {/* Icon */} -
{notif.type === 'success' ? ( - + ) : ( - + )} -
+ - {/* Body */} -
-

+

+

{notif.label} - - {notif.type === 'success' ? ' · Generation complete' : ' · Generation failed'} + + {notif.type === 'success' ? ' - Generation complete' : ' - Generation failed'}

{notif.type === 'error' && notif.message && ( -

+

{notif.message}

)} + {notif.type === 'success' && ( +

+ Your result is ready. +

+ )} {notif.type === 'success' && ( )}
- {/* Dismiss */}
))} diff --git a/packages/Open-AI-Design-Agent b/packages/Open-AI-Design-Agent index e179fe1a6..ebc0ce765 160000 --- a/packages/Open-AI-Design-Agent +++ b/packages/Open-AI-Design-Agent @@ -1 +1 @@ -Subproject commit e179fe1a6c47b26ee6afac9128155d9f259a5d14 +Subproject commit ebc0ce7650baad0d13797ccd471c883e78be3161 diff --git a/packages/Vibe-Workflow b/packages/Vibe-Workflow index 4fed75125..c65ce897e 160000 --- a/packages/Vibe-Workflow +++ b/packages/Vibe-Workflow @@ -1 +1 @@ -Subproject commit 4fed75125da0c9bb9d94ad18f0b7746ed6531a9f +Subproject commit c65ce897e82bf73659c2725528c8492cd609d831 diff --git a/packages/studio/src/components/AiInfluencerStudio.jsx b/packages/studio/src/components/AiInfluencerStudio.jsx index ef6c871d3..8010e0537 100644 --- a/packages/studio/src/components/AiInfluencerStudio.jsx +++ b/packages/studio/src/components/AiInfluencerStudio.jsx @@ -4,6 +4,9 @@ import { useState, useCallback } from "react"; import toast, { Toaster } from "react-hot-toast"; import { generateImage } from "../muapi.js"; import { formatErrorMessage } from "../utils/formatError.js"; +import MobileGenerationActions, { + GenerationCopyButtons, +} from "./MobileGenerationActions.jsx"; const CDN = "https://cdn.muapi.ai/influencer"; @@ -332,7 +335,15 @@ function HoverPill({ label, img, onClick }) { } // ─── Main Component ───────────────────────────────────────────────────────── -export default function AiInfluencerStudio({ apiKey, onGenerate, isGenerating: externalIsGenerating }) { +export default function AiInfluencerStudio({ + apiKey, + onGenerate, + onGenerationStart, + onGenerationEnd, + onGenerationComplete, + onGenerationError, + isGenerating: externalIsGenerating, +}) { const [activeTab, setActiveTab] = useState("face"); const [selectedOptions, setSelectedOptions] = useState(() => { @@ -389,6 +400,7 @@ export default function AiInfluencerStudio({ apiKey, onGenerate, isGenerating: e // ── Generate ────────────────────────────────────────────────────────────── const handleGenerate = async () => { if (isGenerating) return; + onGenerationStart?.(); setIsGeneratingInternal(true); setErrorMsg(""); @@ -406,13 +418,22 @@ export default function AiInfluencerStudio({ apiKey, onGenerate, isGenerating: e } if (res?.url) { setCurrentResult(res.url); - setHistory((prev) => [{ url: res.url, ts: Date.now() }, ...prev]); + setHistory((prev) => [{ url: res.url, prompt, 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); + onGenerationEnd?.(); } }; @@ -722,7 +743,14 @@ export default function AiInfluencerStudio({ apiKey, onGenerate, isGenerating: e > {`Character {/* Download on hover */} -
+
+
+ +
+ downloadImg(item.url), + }, + ]} + /> {/* Index badge */}
#{history.length - idx} diff --git a/packages/studio/src/components/AudioStudio.jsx b/packages/studio/src/components/AudioStudio.jsx index 13987dbe2..aa6bf7c6c 100644 --- a/packages/studio/src/components/AudioStudio.jsx +++ b/packages/studio/src/components/AudioStudio.jsx @@ -477,6 +477,8 @@ function PremiumAudioPlayer({ url, title }) { // --------------------------------------------------------------------------- export default function AudioStudio({ apiKey, + onGenerationStart, + onGenerationEnd, onGenerationComplete, onGenerationError, historyItems, @@ -639,6 +641,7 @@ export default function AudioStudio({ } } + onGenerationStart?.(); setIsGenerating(true); setGenerateError(null); @@ -683,10 +686,11 @@ 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); + onGenerationEnd?.(); } }; diff --git a/packages/studio/src/components/CinemaStudio.jsx b/packages/studio/src/components/CinemaStudio.jsx index cdca0f0e9..c5e8aa6e9 100644 --- a/packages/studio/src/components/CinemaStudio.jsx +++ b/packages/studio/src/components/CinemaStudio.jsx @@ -3,6 +3,9 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { generateImage, uploadFile } from "../muapi.js"; import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js"; +import MobileGenerationActions, { + CopyContentIcon, +} from "./MobileGenerationActions.jsx"; import { PromptAspectRatioIcon, PromptAction, @@ -44,6 +47,48 @@ const LENS_MAP = { "Clinical Sharp Prime": "ultra-sharp clinical prime lens", }; +async function fetchImageAsPngBlob(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Image request failed with status ${response.status}.`); + } + + const sourceBlob = await response.blob(); + if (sourceBlob.type === "image/png") return sourceBlob; + + const objectUrl = URL.createObjectURL(sourceBlob); + try { + const image = await new Promise((resolve, reject) => { + const element = new Image(); + element.onload = () => resolve(element); + element.onerror = () => reject(new Error("Could not decode the image.")); + element.src = objectUrl; + }); + + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Could not create an image clipboard canvas."); + } + + context.drawImage(image, 0, 0); + return await new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => + blob + ? resolve(blob) + : reject(new Error("Could not convert the image to PNG.")), + "image/png", + ); + }); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + const FOCAL_PERSPECTIVE = { 8: "ultra-wide perspective", 14: "wide-angle perspective", @@ -483,6 +528,8 @@ function CameraControlsOverlay({ export default function CinemaStudio({ apiKey, + onGenerationStart, + onGenerationEnd, onGenerationComplete, onGenerationError, historyItems, @@ -515,6 +562,7 @@ export default function CinemaStudio({ const imageInputRef = useRef(null); const [activeHistoryIndex, setactiveHistoryIndex] = useState(null); const [copiedPromptIndex, setCopiedPromptIndex] = useState(null); + const [copiedImageIndex, setCopiedImageIndex] = useState(null); // ── Internal history state (used when historyItems prop is not provided) ── const [internalHistory, setInternalHistory] = useState([]); @@ -605,6 +653,7 @@ export default function CinemaStudio({ const basePrompt = settings.prompt.trim(); if (!basePrompt || isGenerating) return; + onGenerationStart?.(); setIsGenerating(true); const finalPrompt = buildNanoBananaPrompt( @@ -663,6 +712,7 @@ export default function CinemaStudio({ onGenerationError?.(e.message?.slice(0, 120) || "Cinema generation failed"); } finally { setIsGenerating(false); + onGenerationEnd?.(); } }, [ settings, @@ -670,6 +720,9 @@ export default function CinemaStudio({ apiKey, isGenerating, onGenerationComplete, + onGenerationEnd, + onGenerationError, + onGenerationStart, historyItems, ]); @@ -717,23 +770,37 @@ export default function CinemaStudio({ [onGenerationError], ); - // ── Load history item ── - const loadHistoryItem = (entry, idx) => { - if (entry.settings) { - setSettings((prev) => ({ - ...prev, - camera: entry.settings.camera ?? prev.camera, - lens: entry.settings.lens ?? prev.lens, - focal: entry.settings.focal ?? prev.focal, - aperture: entry.settings.aperture ?? prev.aperture, - aspect_ratio: entry.settings.aspect_ratio ?? prev.aspect_ratio, - prompt: entry.settings.prompt ?? prev.prompt, - })); - if (entry.settings.resolution) setResolution(entry.settings.resolution); + const handleCopyImage = useCallback( + async (url, index) => { + if (!url) return; - } - setCanvasUrl(entry.url); - }; + try { + if ( + !window.isSecureContext || + !navigator.clipboard?.write || + typeof window.ClipboardItem === "undefined" + ) { + throw new Error("Image clipboard access requires HTTPS or localhost."); + } + + await navigator.clipboard.write([ + new window.ClipboardItem({ + "image/png": fetchImageAsPngBlob(url), + }), + ]); + setCopiedImageIndex(index); + window.setTimeout(() => { + setCopiedImageIndex((current) => (current === index ? null : current)); + }, 1600); + } catch (error) { + console.error("Failed to copy the image:", error); + onGenerationError?.( + "Could not copy the image. Image copy requires HTTPS or localhost.", + ); + } + }, + [onGenerationError], + ); const resetToPrompt = () => { setCanvasUrl(null); @@ -755,7 +822,7 @@ export default function CinemaStudio({
loadHistoryItem(entry, idx)} + onClick={() => setFullscreenUrl(entry.url)} > {/* Overlay actions */} -
+
+
+ + handleCopyPrompt(entry.settings?.prompt, idx), + }, + { + kind: "image", + label: "Copy image", + onSelect: () => handleCopyImage(entry.url, idx), + }, + { + kind: "download", + label: "Download", + onSelect: async () => { + try { + const response = await fetch(entry.url); + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = blobUrl; + anchor.download = `cinema-shot-${entry.id || idx}.jpg`; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(blobUrl); + } catch { + window.open(entry.url, "_blank"); + } + }, + }, + { + kind: "delete", + label: "Delete", + danger: true, + onSelect: () => { + if (confirm("Are you sure you want to delete this generated item?")) { + setInternalHistory((prev) => prev.filter((_, i) => i !== idx)); + } + }, + }, + ]} + /> {/* Details */}
- +

- {copiedPromptIndex === idx ? "Prompt copied" : ""} + {copiedPromptIndex === idx + ? "Prompt copied" + : copiedImageIndex === idx + ? "Image copied" + : ""}
diff --git a/packages/studio/src/components/ClippingStudio.jsx b/packages/studio/src/components/ClippingStudio.jsx index b3b609178..79ead61f6 100644 --- a/packages/studio/src/components/ClippingStudio.jsx +++ b/packages/studio/src/components/ClippingStudio.jsx @@ -5,6 +5,9 @@ import toast, { Toaster } from "react-hot-toast"; import { runClipping, uploadFile } from "../muapi.js"; import { formatErrorMessage } from "../utils/formatError.js"; import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js"; +import MobileGenerationActions, { + GenerationCopyButtons, +} from "./MobileGenerationActions.jsx"; import { PROMPT_CONTROL_LABEL_CLASS, PROMPT_MEDIA_PREVIEW_CLASS, @@ -23,6 +26,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 +85,104 @@ const CopyIcon = () => ( ); +const ErrorToast = ({ toastInstance, message }) => ( +
+ + + + {message} + +
+); + +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) => ( + + ), + 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"; @@ -81,6 +201,8 @@ const getAspectClass = (ar) => { // --------------------------------------------------------------------------- export default function ClippingStudio({ apiKey, + onGenerationStart, + onGenerationEnd, onGenerationComplete, onGenerationError, droppedFiles, @@ -209,8 +331,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 +347,7 @@ export default function ClippingStudio({ }) .catch(err => { setVideoUploading(false); - alert(`Failed to upload dropped file: ${err.message}`); + showVideoUploadError(err); }); } onFilesHandled?.(); @@ -286,8 +408,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 +422,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); @@ -318,6 +441,7 @@ export default function ClippingStudio({ return; } + onGenerationStart?.(); setIsGenerating(true); setGenerateError(null); setResult(null); @@ -374,10 +498,14 @@ 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); + onGenerationEnd?.(); } }; @@ -467,17 +595,17 @@ export default function ClippingStudio({ {history.map((entry, idx) => (
handleSelectHistory(entry)} + className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer" >
diff --git a/packages/studio/src/components/MarketingStudio.jsx b/packages/studio/src/components/MarketingStudio.jsx index 6c61e74b2..8143127eb 100644 --- a/packages/studio/src/components/MarketingStudio.jsx +++ b/packages/studio/src/components/MarketingStudio.jsx @@ -3,6 +3,9 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { uploadFile, generateMarketingStudioAd } from "../muapi.js"; import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js"; +import MobileGenerationActions, { + GenerationCopyButtons, +} from "./MobileGenerationActions.jsx"; import { PROMPT_CONTROL_LABEL_CLASS, PromptAspectRatioIcon, @@ -268,7 +271,16 @@ function SimpleDropdown({ isOpen, title, options, selected, onSelect, onClose }) // ── Main Component ─────────────────────────────────────────────────────────── -export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled, onGenerationComplete, onGenerationError, historyItems }) { +export default function MarketingStudio({ + apiKey, + droppedFiles, + onFilesHandled, + onGenerationStart, + onGenerationEnd, + onGenerationComplete, + onGenerationError, + historyItems, +}) { const LEGACY_PERSIST_KEY = "hg_marketing_studio_persistent"; const PERSIST_KEY = scopedPersistKey(LEGACY_PERSIST_KEY, apiKey); useEffect(() => { @@ -372,6 +384,7 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled, if (!prompt.trim()) return alert("Please enter an ad script."); if (!productImage) return alert("Please upload a product image."); + onGenerationStart?.(); setIsGenerating(true); try { const result = await generateMarketingStudioAd(apiKey, { @@ -401,6 +414,7 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled, onGenerationError?.(err.message?.slice(0, 120) || "Marketing generation failed"); } finally { setIsGenerating(false); + onGenerationEnd?.(); } }; @@ -415,32 +429,23 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled, {history.length > 0 ? (
{history.map(entry => ( -
+
setFullscreenUrl(entry.url)} + className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col cursor-pointer" + >
diff --git a/packages/studio/src/components/VideoStudio.jsx b/packages/studio/src/components/VideoStudio.jsx index c1b55c69a..dd4770087 100644 --- a/packages/studio/src/components/VideoStudio.jsx +++ b/packages/studio/src/components/VideoStudio.jsx @@ -6,6 +6,9 @@ import { generateVideo, generateI2V, processV2V, uploadFile } from "../muapi.js" import { formatErrorMessage } from "../utils/formatError.js"; import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js"; import DrawModal from "./DrawModal.jsx"; +import MobileGenerationActions, { + GenerationCopyButtons, +} from "./MobileGenerationActions.jsx"; import { t2vModels, i2vModels, @@ -404,6 +407,8 @@ function ModelDropdown({ imageMode, selectedModel, onSelect, onClose }) { export default function VideoStudio({ apiKey, + onGenerationStart, + onGenerationEnd, onGenerationComplete, onGenerationError, historyItems, @@ -1099,6 +1104,7 @@ export default function VideoStudio({ } } + onGenerationStart?.(); setGenerating(true); setGenerateError(null); @@ -1256,10 +1262,11 @@ 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); + onGenerationEnd?.(); } }, [ apiKey, @@ -1282,6 +1289,9 @@ export default function VideoStudio({ addToLocalHistory, showVideoInCanvas, onGenerationComplete, + onGenerationEnd, + onGenerationError, + onGenerationStart, ]); // ── reset to prompt bar ─────────────────────────────────────────────────── @@ -1361,12 +1371,12 @@ export default function VideoStudio({ return (
setFullscreenUrl(entry.url)} >
diff --git a/packages/studio/src/components/WorkflowStudio.jsx b/packages/studio/src/components/WorkflowStudio.jsx index 17f618932..82917fb78 100644 --- a/packages/studio/src/components/WorkflowStudio.jsx +++ b/packages/studio/src/components/WorkflowStudio.jsx @@ -125,7 +125,15 @@ function WorkflowCard({ workflow, onClick, activeTab, onRename, onDelete }) { ); } -export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggleHeader }) { +export default function WorkflowStudio({ + apiKey, + isHeaderVisible = true, + onToggleHeader, + onGenerationStart, + onGenerationEnd, + onGenerationComplete, + onGenerationError, +}) { const params = useParams(); const router = useRouter(); const slug = params?.slug || []; @@ -398,6 +406,7 @@ export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggl e.preventDefault(); if (isExecuting) return; + onGenerationStart?.(); setIsExecuting(true); setError(null); setResult(null); @@ -414,11 +423,18 @@ 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); + onGenerationEnd?.(); } }; @@ -815,6 +831,10 @@ 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 }} + onGenerationStart={onGenerationStart} + onGenerationEnd={onGenerationEnd} + onGenerationComplete={onGenerationComplete} + onGenerationError={onGenerationError} /> ) : (
diff --git a/packages/studio/src/components/WorkflowUI.jsx b/packages/studio/src/components/WorkflowUI.jsx index 39651cee8..b3e73b3c8 100644 --- a/packages/studio/src/components/WorkflowUI.jsx +++ b/packages/studio/src/components/WorkflowUI.jsx @@ -6,7 +6,15 @@ import "reactflow/dist/style.css"; import "react-toastify/dist/ReactToastify.css"; -const WorkflowUI = ({ workflowId, initialNodeSchemas, initialWorkflowData }) => { +const WorkflowUI = ({ + workflowId, + initialNodeSchemas, + initialWorkflowData, + onGenerationStart, + onGenerationEnd, + onGenerationComplete, + onGenerationError, +}) => { useEffect(() => { sessionStorage.setItem("fromWorkflowBuilder", "true"); }, []); @@ -18,6 +26,10 @@ const WorkflowUI = ({ workflowId, initialNodeSchemas, initialWorkflowData }) => initialNodeSchemas={initialNodeSchemas} initialWorkflowData={initialWorkflowData} costType="dollars" + onGenerationStart={onGenerationStart} + onGenerationEnd={onGenerationEnd} + onGenerationComplete={onGenerationComplete} + onGenerationError={onGenerationError} />
);