Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements

This commit is contained in:
ajaysi
2026-02-28 20:06:26 +05:30
parent 08a1f4a1d8
commit 4828274cbf
162 changed files with 19489 additions and 4300 deletions

View File

@@ -1,16 +1,17 @@
import React from "react";
import { Stack } from "@mui/material";
import { Stack, alpha } from "@mui/material";
import {
VolumeUp as VolumeUpIcon,
PlayArrow as PlayArrowIcon,
Refresh as RefreshIcon,
Image as ImageIcon,
Videocam as VideocamIcon,
Download as DownloadIcon,
Share as ShareIcon,
PlayArrow as PlayArrowIcon,
Refresh as RefreshIcon,
} from "@mui/icons-material";
import { Scene, Job } from "../types";
import { PrimaryButton, SecondaryButton } from "../ui";
import { Typography } from "@mui/material"; // Import Typography
interface SceneActionButtonsProps {
scene: Scene;
@@ -76,7 +77,26 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
);
}
// Failed - show retry
// Video generation failed - show specific retry for video
if (job?.status === "failed" && !needsAudio && hasAudio) {
return (
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Typography variant="caption" color="error" sx={{ alignSelf: "center", mr: 1 }}>
Video Generation Failed
</Typography>
<SecondaryButton
onClick={() => onVideoRender(scene.id)}
startIcon={<RefreshIcon />}
tooltip="Retry video generation"
sx={{ borderColor: "error.main", color: "error.main" }}
>
Retry Video
</SecondaryButton>
</Stack>
);
}
// Failed (Audio) - show retry
if (job?.status === "failed") {
return (
<Stack direction="row" spacing={1} justifyContent="flex-end">
@@ -85,7 +105,7 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
startIcon={<RefreshIcon />}
tooltip="Retry audio generation"
>
Retry
Retry Audio
</SecondaryButton>
</Stack>
);
@@ -97,40 +117,49 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
return (
<Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap>
{/* Generate Image */}
{/* Generate/Regenerate Image - ALWAYS visible if we have audio */}
<PrimaryButton
onClick={() => onImageGenerate(scene.id)}
disabled={isGeneratingImage || hasImage}
disabled={isGeneratingImage}
loading={isGeneratingImage}
startIcon={<ImageIcon />}
tooltip={
hasImage
? "Image already generated for this scene"
: isGeneratingImage
isGeneratingImage
? "Generating image..."
: hasImage
? "Regenerate image for this scene"
: "Generate image for video (optional)"
}
sx={{ minWidth: 160 }}
sx={{
minWidth: 160,
// Use secondary style if image exists (to de-emphasize), primary if needed
background: hasImage ? alpha("#667eea", 0.1) : undefined,
color: hasImage ? "#667eea" : undefined,
border: hasImage ? "1px solid rgba(102,126,234,0.3)" : undefined,
"&:hover": {
background: hasImage ? alpha("#667eea", 0.2) : undefined,
}
}}
>
{isGeneratingImage ? "Generating..." : hasImage ? "Image Ready" : "Generate Image"}
{isGeneratingImage ? "Generating..." : hasImage ? "Regenerate Image" : "Generate Image"}
</PrimaryButton>
{/* Generate Video */}
{/* Generate Video - ALWAYS visible if we have audio */}
<PrimaryButton
onClick={() => {
onVideoRender(scene.id);
}}
disabled={isBusy || videoInProgress || !hasImage || hasVideo}
disabled={isBusy || videoInProgress || !hasImage}
startIcon={<VideocamIcon />}
tooltip={
hasVideo
? "Video already generated"
: !hasImage
!hasImage
? "Generate an image first to create video"
: videoInProgress
? "A video generation is already running. Please wait..."
: isBusy
? "Another operation in progress"
: hasVideo
? "Regenerate video"
: "Generate video for this scene"
}
sx={{ minWidth: 180 }}
@@ -138,7 +167,7 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
{videoInProgress && isCurrentVideo
? "Generating Video..."
: hasVideo
? "Video Ready"
? "Regenerate Video"
: "Generate Video"}
</PrimaryButton>
@@ -154,36 +183,48 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
)}
{/* Download Audio */}
<SecondaryButton
onClick={() => {
if (!audioUrl) {
onError("Audio URL not found. Please regenerate audio.");
return;
}
onDownloadAudio(audioUrl, scene.title);
}}
startIcon={<DownloadIcon />}
tooltip={hasAudio ? "Download this scene's audio file" : "No audio available. Generate audio first."}
disabled={!hasAudio}
>
Download Audio
</SecondaryButton>
{hasAudio && audioUrl && (
<PrimaryButton
onClick={() => onDownloadAudio(audioUrl, scene.title)}
startIcon={<DownloadIcon />}
tooltip="Download audio file"
sx={{
minWidth: 40,
width: 40,
padding: 0,
background: alpha("#64748b", 0.1),
color: "#64748b",
border: "1px solid rgba(100, 116, 139, 0.2)",
"&:hover": {
background: alpha("#64748b", 0.2),
},
}}
>
{/* Icon only */}
</PrimaryButton>
)}
{/* Share */}
<SecondaryButton
onClick={() => {
if (!audioUrl) {
onError("Audio URL not found. Please regenerate audio.");
return;
}
onShare(audioUrl, scene.title);
}}
startIcon={<ShareIcon />}
tooltip={hasAudio ? "Share this scene's audio" : "No audio available. Generate audio first."}
disabled={!hasAudio}
>
Share
</SecondaryButton>
{hasAudio && audioUrl && (
<PrimaryButton
onClick={() => onShare(audioUrl, scene.title)}
startIcon={<ShareIcon />}
tooltip="Share audio link"
sx={{
minWidth: 40,
width: 40,
padding: 0,
background: alpha("#64748b", 0.1),
color: "#64748b",
border: "1px solid rgba(100, 116, 139, 0.2)",
"&:hover": {
background: alpha("#64748b", 0.2),
},
}}
>
{/* Icon only */}
</PrimaryButton>
)}
</Stack>
);
};

View File

@@ -1,11 +1,13 @@
import React, { useState, useEffect } from "react";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha } from "@mui/material";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha, Modal, IconButton } from "@mui/material";
import {
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
Info as InfoIcon,
OpenInNew as OpenInNewIcon,
Videocam as VideocamIcon,
Close as CloseIcon,
ZoomIn as ZoomInIcon,
} from "@mui/icons-material";
import { Scene, Job, VideoGenerationSettings } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
@@ -22,6 +24,8 @@ interface SceneCardProps {
generatingImage: string | null;
isBusy: boolean;
avatarImageUrl?: string | null;
bible?: any;
analysis?: any;
onRender: (sceneId: string, mode: "preview" | "full") => void;
onImageGenerate: (sceneId: string) => void;
onVideoGenerate: (sceneId: string, settings: VideoGenerationSettings) => void;
@@ -75,6 +79,8 @@ export const SceneCard: React.FC<SceneCardProps> = ({
generatingImage,
isBusy,
avatarImageUrl,
bible,
analysis,
onRender,
onImageGenerate,
onVideoGenerate,
@@ -96,6 +102,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
const [videoBlobUrl, setVideoBlobUrl] = useState<string | null>(null);
const [showVideoModal, setShowVideoModal] = useState(false);
const [showImageModal, setShowImageModal] = useState(false);
const [initialVideoPrompt, setInitialVideoPrompt] = useState<string>("");
// Prepare a simple default prompt based on the scene title/description
@@ -261,96 +268,151 @@ export const SceneCard: React.FC<SceneCardProps> = ({
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
{/* Header */}
<Stack direction="row" spacing={2} alignItems="flex-start">
<Stack direction="row" spacing={2} alignItems="center">
{/* Visual Avatar */}
<Paper
sx={{
width: 56,
height: 56,
width: 48,
height: 48,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: alpha("#667eea", 0.2),
border: "1px solid rgba(102,126,234,0.3)",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "#ffffff",
fontWeight: 700,
fontSize: "1.2rem",
fontSize: "1.1rem",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
}}
>
{initials}
</Paper>
{/* Title and Metadata */}
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 0.5, color: "#0f172a", fontWeight: 600 }}>
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip label={`Scene ${scene.id.slice(-4)}`} size="small" variant="outlined" />
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700, fontSize: "1.05rem" }}>
{scene.title}
</Typography>
{/* Quick Downloads */}
<Stack direction="row" spacing={1.5} alignItems="center">
{job?.finalUrl && (
<Box
component="a"
href={job.finalUrl}
target="_blank"
rel="noopener noreferrer"
sx={{
color: "#64748b",
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: 0.5,
fontSize: "0.75rem",
fontWeight: 600,
"&:hover": { color: "#6366f1" }
}}
>
<OpenInNewIcon sx={{ fontSize: 14 }} />
Audio
</Box>
)}
{hasVideo && videoBlobUrl && (
<Box
component="a"
href={videoBlobUrl}
download={`${scene.title.replace(/[^a-z0-9]/gi, '_')}_video.mp4`}
sx={{
color: "#64748b",
textDecoration: "none",
display: "flex",
alignItems: "center",
gap: 0.5,
fontSize: "0.75rem",
fontWeight: 600,
"&:hover": { color: "#6366f1" }
}}
>
<VideocamIcon sx={{ fontSize: 14 }} />
Video
</Box>
)}
</Stack>
</Stack>
{/* Compact Metadata Row */}
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" sx={{ mt: 0.5 }} useFlexGap>
{/* Scene ID */}
<Chip
label={`Scene ${scene.id.slice(-4)}`}
size="small"
sx={{
height: 20,
fontSize: "0.7rem",
background: alpha("#64748b", 0.08),
color: "#64748b",
fontWeight: 600
}}
/>
{/* Audio Status */}
<Chip
label={hasAudio ? "Audio Ready" : "Needs Audio"}
size="small"
sx={{
height: 20,
fontSize: "0.7rem",
background: hasAudio ? alpha("#10b981", 0.1) : alpha("#f59e0b", 0.1),
color: hasAudio ? "#059669" : "#d97706",
fontWeight: 700,
border: "1px solid",
borderColor: hasAudio ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
}}
/>
{/* Cost */}
{job?.cost != null && (
<Chip
label={`$${job.cost.toFixed(2)}`}
size="small"
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
title="Generation cost"
sx={{
height: 20,
fontSize: "0.7rem",
background: alpha("#6366f1", 0.08),
color: "#6366f1",
fontWeight: 600
}}
/>
)}
{job?.fileSize && (
<Typography variant="caption" color="text.secondary">
{(job.fileSize / 1024).toFixed(1)} KB
</Typography>
)}
{!job && (
{/* Job Status (if active/failed) */}
{job && job.status !== "idle" && (
<Chip
label={hasAudio ? "Audio Ready" : "Needs Audio"}
icon={getStatusIcon(status)}
label={status.charAt(0).toUpperCase() + status.slice(1)}
size="small"
color={hasAudio ? "success" : "warning"}
sx={{
background: hasAudio ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
color: hasAudio ? "#059669" : "#d97706",
fontWeight: 600,
height: 20,
fontSize: "0.7rem",
background: status === "completed" ? alpha("#10b981", 0.1) : status === "failed" ? alpha("#ef4444", 0.1) : alpha("#3b82f6", 0.1),
color: status === "completed" ? "#059669" : status === "failed" ? "#dc2626" : "#2563eb",
fontWeight: 700,
"& .MuiChip-icon": { fontSize: 14, color: "inherit" }
}}
/>
)}
</Stack>
{job?.finalUrl && (
<Box sx={{ mt: 1 }}>
<Box
component="a"
href={job.finalUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<OpenInNewIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">Download Final Audio</Typography>
</Box>
</Box>
)}
{hasVideo && videoBlobUrl && (
<Box sx={{ mt: 1 }}>
<Box
component="a"
href={videoBlobUrl}
download={`${scene.title.replace(/[^a-z0-9]/gi, '_')}_video.mp4`}
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<VideocamIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">Download Video</Typography>
</Box>
</Box>
)}
</Box>
{job && (
<Chip
icon={getStatusIcon(status)}
label={status.charAt(0).toUpperCase() + status.slice(1)}
color={getStatusColor(status)}
size="small"
sx={{
textTransform: "capitalize",
minWidth: 100,
}}
/>
)}
</Stack>
{/* Audio Player - Now directly in header section (visual integration) */}
{hasAudio && audioUrl && (
<Box sx={{ width: "100%", mt: 1 }}>
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
</Box>
)}
{/* Progress Bar */}
{job && job.status !== "idle" && job.status !== "completed" && (
<Box>
@@ -379,20 +441,6 @@ export const SceneCard: React.FC<SceneCardProps> = ({
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)" }} />
{/* Success Alert for Pre-generated Audio */}
{hasAudio && !job && (
<Alert severity="success" sx={{ width: "100%", background: alpha("#10b981", 0.1), border: "1px solid rgba(16,185,129,0.3)" }}>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
Audio already generated in Script Editor. Ready to use!
</Typography>
</Alert>
)}
{/* Audio Player */}
{hasAudio && audioUrl && (
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
)}
{/* Video Preview - Show video if available, otherwise show image */}
{hasVideo && videoBlobUrl ? (
<Box
@@ -415,7 +463,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
height: "auto",
display: "block",
maxHeight: 420,
objectFit: "cover",
objectFit: "contain",
backgroundColor: "black",
}}
onError={(e) => {
@@ -443,34 +491,62 @@ export const SceneCard: React.FC<SceneCardProps> = ({
VIDEO
</Box>
</Box>
) : hasImage && (imageBlobUrl || imageUrl) ? (
<Box
sx={{
width: "100%",
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05),
}}
>
) : hasImage && (imageBlobUrl || (imageUrl && !imageUrl.includes('/api/'))) ? (
<Box sx={{ position: "relative", width: "100%" }}>
<Box
component="img"
src={imageBlobUrl || imageUrl}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "cover",
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05),
cursor: "pointer",
"&:hover .zoom-icon": {
opacity: 1,
}
}}
onError={(e) => {
console.error("[SceneCard] Image failed to load:", {
src: e.currentTarget.src,
imageUrl,
});
}}
/>
onClick={() => setShowImageModal(true)}
>
<Box
component="img"
src={imageBlobUrl || imageUrl}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "contain",
background: "#000",
}}
onError={(e) => {
console.error("[SceneCard] Image failed to load:", {
src: e.currentTarget.src,
imageUrl,
});
}}
/>
<Box
className="zoom-icon"
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "rgba(0,0,0,0.6)",
color: "white",
borderRadius: "50%",
p: 1.5,
opacity: 0,
transition: "opacity 0.2s",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ZoomInIcon sx={{ fontSize: 32 }} />
</Box>
</Box>
</Box>
) : null}
@@ -505,6 +581,9 @@ export const SceneCard: React.FC<SceneCardProps> = ({
initialPrompt={initialVideoPrompt}
initialResolution="480p"
initialSeed={-1}
sceneTitle={scene.title}
bible={bible}
analysis={analysis}
/>
</Stack>
</GlassyCard>

View File

@@ -26,6 +26,10 @@ interface VideoRegenerateModalProps {
initialPrompt: string;
initialResolution?: "480p" | "720p";
initialSeed?: number | null;
// Add context props
sceneTitle?: string;
bible?: any;
analysis?: any;
}
export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
@@ -35,17 +39,45 @@ export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
initialPrompt,
initialResolution = "480p",
initialSeed = -1,
sceneTitle,
bible,
analysis,
}) => {
// Use a more intelligent default prompt based on context if available
const [prompt, setPrompt] = useState(initialPrompt);
// Update prompt when context changes or modal opens
useEffect(() => {
if (open) {
let smartPrompt = initialPrompt;
// If the initial prompt is generic/empty, try to build a better one
if (!smartPrompt || smartPrompt === "Professional podcast scene with subtle movement") {
const parts = [];
// Add scene context
if (sceneTitle) parts.push(`Scene: ${sceneTitle}`);
// Add bible/persona context
if (bible?.host_persona) parts.push(`Host Persona: ${bible.host_persona}`);
if (bible?.tone) parts.push(`Tone: ${bible.tone}`);
// Add analysis context
if (analysis?.content_type) parts.push(`Style: ${analysis.content_type}`);
// Combine into a descriptive prompt
if (parts.length > 0) {
smartPrompt = `Professional talking head video for podcast. ${parts.join(". ")}. Cinematic lighting, 4k, high detail.`;
}
}
setPrompt(smartPrompt);
}
}, [open, initialPrompt, sceneTitle, bible, analysis]);
const [resolution, setResolution] = useState<"480p" | "720p">(initialResolution);
const [seed, setSeed] = useState<string>(initialSeed != null && initialSeed !== -1 ? String(initialSeed) : "");
const [maskImageUrl, setMaskImageUrl] = useState<string>("");
useEffect(() => {
setPrompt(initialPrompt);
setResolution(initialResolution);
}, [initialResolution, initialPrompt]);
const handleGenerate = () => {
const parsedSeed = seed.trim() === "" ? undefined : Number.isNaN(Number(seed)) ? undefined : Number(seed);
const settings: VideoGenerationSettings = {

View File

@@ -7,6 +7,7 @@ interface UseRenderQueueProps {
jobs: Job[];
knobs: Knobs;
projectId: string;
bible?: any | null;
budgetCap?: number;
avatarImageUrl?: string | null;
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
@@ -21,6 +22,7 @@ export const useRenderQueue = ({
jobs,
knobs,
projectId,
bible,
budgetCap,
avatarImageUrl,
onUpdateJob,
@@ -441,6 +443,7 @@ export const useRenderQueue = ({
sceneTitle: scene.title,
sceneContent: sceneContent,
baseAvatarUrl: avatarImageUrl || undefined, // Use base avatar if available
bible: bible,
width: 1024,
height: 1024,
});
@@ -544,6 +547,7 @@ export const useRenderQueue = ({
sceneTitle: scene.title,
audioUrl,
avatarImageUrl: sceneImageUrl,
bible: bible,
resolution: targetResolution,
prompt: settings?.prompt || undefined,
seed: settings?.seed ?? -1,