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"
+ >
setFullscreenUrl(entry.url)}
+ className="w-full aspect-video object-cover hover:opacity-80 transition-opacity"
muted loop onMouseOver={e => e.target.play()} onMouseOut={e => { e.target.pause(); e.target.currentTime = 0; }}
/>
{/* Actions Overlay */}
-
-
+
+
+
+
+ downloadFile(entry.url, `marketing-ad-${entry.id}.mp4`),
+ },
+ {
+ kind: "delete",
+ label: "Delete",
+ danger: true,
+ onSelect: () => {
+ if (confirm("Are you sure you want to delete this generated item?")) {
+ if (!historyItems) {
+ setLocalHistory((prev) =>
+ prev.filter((item) => item.id !== entry.id),
+ );
+ }
+ }
+ },
+ },
+ ]}
+ />
@@ -481,21 +512,6 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled,
{entry.format}
)}
- {entry.prompt && (
-
- )}
))}
diff --git a/packages/studio/src/components/MobileGenerationActions.jsx b/packages/studio/src/components/MobileGenerationActions.jsx
new file mode 100644
index 000000000..136ae6b89
--- /dev/null
+++ b/packages/studio/src/components/MobileGenerationActions.jsx
@@ -0,0 +1,375 @@
+"use client";
+
+import { useState } from "react";
+
+async function getClipboardPngBlob(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);
+ }
+}
+
+async function copyPrompt(prompt) {
+ if (!prompt) return;
+ await navigator.clipboard.writeText(prompt);
+}
+
+async function copyImage(url) {
+ if (!url) return;
+ 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": getClipboardPngBlob(url),
+ }),
+ ]);
+}
+
+export function CopyContentIcon({ kind, size = 19 }) {
+ const isText = kind === "text";
+
+ if (!isText) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function CopiedIcon({ size = 15 }) {
+ return (
+
+ );
+}
+
+export function GenerationCopyButtons({
+ prompt,
+ imageUrl,
+ onCopyError,
+}) {
+ const [copiedKind, setCopiedKind] = useState(null);
+
+ const runCopy = async (event, kind) => {
+ event.stopPropagation();
+
+ try {
+ if (kind === "text") {
+ await copyPrompt(prompt);
+ } else {
+ await copyImage(imageUrl);
+ }
+
+ setCopiedKind(kind);
+ window.setTimeout(() => {
+ setCopiedKind((current) => (current === kind ? null : current));
+ }, 1600);
+ } catch (error) {
+ const contentLabel = kind === "text" ? "the prompt" : "the image";
+ console.error(`Failed to copy ${contentLabel}:`, error);
+ onCopyError?.(
+ kind === "text"
+ ? "Could not copy the prompt to the clipboard."
+ : "Could not copy the image. Image copy requires HTTPS or localhost.",
+ );
+ }
+ };
+
+ return (
+ <>
+ {prompt && (
+
+ )}
+ {imageUrl && (
+
+ )}
+ >
+ );
+}
+
+function ActionIcon({ kind }) {
+ if (kind === "text") {
+ return ;
+ }
+
+ if (kind === "image") {
+ return ;
+ }
+
+ if (kind === "download") {
+ return (
+
+ );
+ }
+
+ if (kind === "delete") {
+ return (
+
+ );
+ }
+
+ if (kind === "extend") {
+ return (
+
+ );
+ }
+
+ if (kind === "remix") {
+ return (
+
+ );
+ }
+
+ if (kind === "copy") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default function MobileGenerationActions({
+ actions = [],
+ prompt,
+ imageUrl,
+ onCopyError,
+}) {
+ const [open, setOpen] = useState(false);
+ const copyActions = [
+ prompt
+ ? {
+ kind: "text",
+ label: "Copy prompt",
+ onSelect: async () => {
+ try {
+ await copyPrompt(prompt);
+ } catch (error) {
+ console.error("Failed to copy the prompt:", error);
+ onCopyError?.("Could not copy the prompt to the clipboard.");
+ }
+ },
+ }
+ : null,
+ imageUrl
+ ? {
+ kind: "image",
+ label: "Copy image",
+ onSelect: async () => {
+ try {
+ await copyImage(imageUrl);
+ } catch (error) {
+ console.error("Failed to copy the image:", error);
+ onCopyError?.(
+ "Could not copy the image. Image copy requires HTTPS or localhost.",
+ );
+ }
+ },
+ }
+ : null,
+ ];
+ const availableActions = [...copyActions, ...actions].filter(Boolean);
+
+ if (availableActions.length === 0) return null;
+
+ const stopCardClick = (event) => {
+ event.stopPropagation();
+ };
+
+ const runAction = (event, action) => {
+ event.stopPropagation();
+ setOpen(false);
+ action.onSelect?.();
+ };
+
+ return (
+
+ {open && (
+
+ );
+}
diff --git a/packages/studio/src/components/RecastStudio.jsx b/packages/studio/src/components/RecastStudio.jsx
index 45930b1f1..1f810372d 100644
--- a/packages/studio/src/components/RecastStudio.jsx
+++ b/packages/studio/src/components/RecastStudio.jsx
@@ -5,6 +5,9 @@ import toast, { Toaster } from "react-hot-toast";
import { processRecast, uploadFile } from "../muapi.js";
import { formatErrorMessage } from "../utils/formatError.js";
import { scopedPersistKey, migrateLegacyPersistKey } from "../persistKey.js";
+import MobileGenerationActions, {
+ GenerationCopyButtons,
+} from "./MobileGenerationActions.jsx";
import {
recastModels,
getRecastModelById,
@@ -407,6 +410,8 @@ const ImageIcon = ({
// ---------------------------------------------------------------------------
export default function RecastStudio({
apiKey,
+ onGenerationStart,
+ onGenerationEnd,
onGenerationComplete,
onGenerationError,
historyItems,
@@ -715,6 +720,7 @@ export default function RecastStudio({
return;
}
+ onGenerationStart?.();
setIsGenerating(true);
setGenerateError(null);
@@ -765,10 +771,11 @@ 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);
+ onGenerationEnd?.();
}
};
@@ -786,12 +793,12 @@ export default function RecastStudio({
{history.map((entry, idx) => (
setFullscreenUrl(entry.url)}
>
setFullscreenUrl(entry.url)}
+ className="w-full aspect-video object-cover bg-black/40 hover:opacity-80 transition-opacity"
controls={false}
loop
muted
@@ -804,23 +811,11 @@ export default function RecastStudio({
/>
{/* Overlay actions */}
-
-
{
- e.stopPropagation();
- setFullscreenUrl(entry.url);
- }}
- className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
- >
-
-
+
+
+
+ downloadFile(entry.url, `bodyswap-${entry.id || idx}.mp4`),
+ },
+ {
+ 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 */}
@@ -865,22 +882,6 @@ export default function RecastStudio({
Body Swap
- {entry.prompt && (
- {
- e.stopPropagation();
- navigator.clipboard.writeText(entry.prompt);
- const btn = e.currentTarget;
- btn.innerText = "Copied!";
- setTimeout(() => { btn.innerText = "Copy"; }, 2000);
- }}
- className="px-2 py-0.5 bg-white/5 hover:bg-primary/20 hover:text-primary rounded text-[10px] font-medium text-white/70 transition-all border border-white/10"
- title="Copy prompt"
- >
- Copy Prompt
-
- )}
diff --git a/packages/studio/src/components/VibeMotionStudio.jsx b/packages/studio/src/components/VibeMotionStudio.jsx
index 242504b6f..11a6c2f0e 100644
--- a/packages/studio/src/components/VibeMotionStudio.jsx
+++ b/packages/studio/src/components/VibeMotionStudio.jsx
@@ -5,6 +5,9 @@ import toast, { Toaster } from "react-hot-toast";
import { runMotionGraphics, runMotionGraphicsEdit } 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,
PromptAspectRatioIcon,
@@ -65,7 +68,13 @@ function DropdownItem({ label, selected, onClick }) {
}
// ── Main Component ────────────────────────────────────────────────────────────
-export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGenerationError }) {
+export default function VibeMotionStudio({
+ apiKey,
+ onGenerationStart,
+ onGenerationEnd,
+ onGenerationComplete,
+ onGenerationError,
+}) {
const LEGACY_PERSIST_KEY = "hg_vibe_motion_studio_persistent";
const PERSIST_KEY = scopedPersistKey(LEGACY_PERSIST_KEY, apiKey);
useEffect(() => {
@@ -140,6 +149,7 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
// ── Generate ──────────────────────────────────────────────────────────────
const handleGenerate = useCallback(async () => {
if (!prompt.trim() || generating) return;
+ onGenerationStart?.();
setGenerating(true);
setGenerateError(null);
startTimer();
@@ -193,20 +203,35 @@ 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);
stopTimer();
+ onGenerationEnd?.();
}
- }, [apiKey, prompt, editMode, editSourceId, aspectRatio, duration, history, saveHistory]);
+ }, [
+ apiKey,
+ prompt,
+ editMode,
+ editSourceId,
+ aspectRatio,
+ duration,
+ history,
+ saveHistory,
+ onGenerationComplete,
+ onGenerationEnd,
+ onGenerationError,
+ onGenerationStart,
+ ]);
const handleKeyDown = (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleGenerate();
@@ -285,13 +310,13 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
{history.map((entry, idx) => (
setFullscreenUrl(entry.url)}
>
{/* Video thumbnail */}
setFullscreenUrl(entry.url)}
+ className="w-full aspect-video object-cover bg-black/40 hover:opacity-80 transition-opacity"
controls={false}
loop
muted
@@ -310,20 +335,11 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
{/* ── Hover overlay actions ── */}
-
-
{ e.stopPropagation(); setFullscreenUrl(entry.url); }}
- className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
- >
-
-
+
+
+
+ downloadFile(entry.url, `motion-${entry.id || idx}.mp4`),
+ },
+ entry.requestId &&
+ entry.canEdit !== false && {
+ kind: "remix",
+ label: "Remix",
+ onSelect: () => {
+ setEditMode(true);
+ setEditSourceId(entry.requestId);
+ setPrompt("");
+ setTimeout(() => textareaRef.current?.focus(), 50);
+ },
+ },
+ {
+ kind: "delete",
+ label: "Delete",
+ danger: true,
+ onSelect: () => {
+ if (confirm("Are you sure you want to delete this generated item?")) {
+ setHistory((prev) => prev.filter((_, i) => i !== idx));
+ }
+ },
+ },
+ ]}
+ />
{/* ── Card footer: prompt + metadata ── */}
@@ -404,22 +453,6 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
)}
- {entry.prompt && (
- {
- e.stopPropagation();
- navigator.clipboard.writeText(entry.prompt);
- const btn = e.currentTarget;
- btn.innerText = "Copied!";
- setTimeout(() => { btn.innerText = "Copy"; }, 2000);
- }}
- className="px-2 py-0.5 bg-white/5 hover:bg-primary/20 hover:text-primary rounded text-[10px] font-medium text-white/70 transition-all border border-white/10"
- title="Copy prompt"
- >
- Copy Prompt
-
- )}
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)}
>
setFullscreenUrl(entry.url)}
+ className="w-full aspect-video object-cover bg-black/40 hover:opacity-80 transition-opacity"
controls={false}
loop
muted
@@ -1379,23 +1389,11 @@ export default function VideoStudio({
/>
{/* Overlay actions */}
-
-
{
- e.stopPropagation();
- setFullscreenUrl(entry.url);
- }}
- className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
- >
-
-
+
+
+
+ downloadFile(entry.url, `video-${entry.id || idx}.mp4`),
+ },
+ isSeedance2 && {
+ kind: "extend",
+ label: "Extend",
+ onSelect: () => {
+ setLastGenerationId(entry.id);
+ handleExtend();
+ },
+ },
+ {
+ kind: "delete",
+ label: "Delete",
+ danger: true,
+ onSelect: () => {
+ if (confirm("Are you sure you want to delete this generated item?")) {
+ setLocalHistory((prev) => prev.filter((_, i) => i !== idx));
+ }
+ },
+ },
+ ]}
+ />
{/* Prompt & Details */}
@@ -1464,22 +1492,6 @@ export default function VideoStudio({
)}
- {entry.prompt && (
- {
- e.stopPropagation();
- navigator.clipboard.writeText(entry.prompt);
- const btn = e.currentTarget;
- btn.innerText = "Copied!";
- setTimeout(() => { btn.innerText = "Copy"; }, 2000);
- }}
- className="px-2 py-0.5 bg-white/5 hover:bg-primary/20 hover:text-primary rounded text-[10px] font-medium text-white/70 transition-all border border-white/10"
- title="Copy prompt"
- >
- Copy Prompt
-
- )}
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}
/>