Added video studio router and endpoints. Added research router and endpoints. Added youtube router and endpoints. Added onboarding utils router and endpoints. Added onboarding utils service. Added onboarding utils models. Added onboarding utils routes. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils.

This commit is contained in:
ajaysi
2026-01-01 17:56:25 +05:30
parent 7512933c65
commit b134e9dc7e
252 changed files with 40333 additions and 2712 deletions

View File

@@ -1,72 +1,26 @@
import React, { useState, useEffect } from "react";
/**
* Podcast Image Regenerate Modal
*
* A Podcast-specific wrapper around the shared ImageGenerationModal.
* Provides Podcast-optimized presets, recommendations, and branding.
*
* This maintains backward compatibility with existing usage while
* leveraging the shared component infrastructure.
*/
import React from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Box,
Typography,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Divider,
alpha,
Tooltip,
IconButton,
Paper,
} from "@mui/material";
ImageGenerationModal,
ImageGenerationSettings as SharedImageGenerationSettings,
} from '../../shared/ImageGenerationModal';
import {
Info as InfoIcon,
HelpOutline as HelpOutlineIcon,
Close as CloseIcon,
} from "@mui/icons-material";
import { PrimaryButton, SecondaryButton } from "../ui";
type PresetKey = "studioNeutral" | "warmBroadcast" | "techModern";
const PRESETS: Record<
PresetKey,
{
title: string;
subtitle: string;
prompt: string;
style: "Auto" | "Fiction" | "Realistic";
renderingSpeed: "Default" | "Turbo" | "Quality";
aspectRatio: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
}
> = {
studioNeutral: {
title: "Studio Neutral",
subtitle: "Clean, well-lit studio, neutral background",
prompt:
"Professional podcast studio, neutral light grey backdrop, soft key + fill lighting, subtle depth of field, clear microphone framing",
style: "Realistic",
renderingSpeed: "Quality",
aspectRatio: "16:9",
},
warmBroadcast: {
title: "Warm Broadcast",
subtitle: "Warm tones, friendly and inviting broadcast desk",
prompt:
"Warm broadcast desk, soft amber lighting, cozy ambience, gentle vignette, inviting expression, polished but approachable look",
style: "Realistic",
renderingSpeed: "Quality",
aspectRatio: "16:9",
},
techModern: {
title: "Tech Modern",
subtitle: "Crisp, modern look with cool accent lighting",
prompt:
"Modern tech podcast set, cool accent lights (teal/purple), minimal backdrop, crisp highlights, premium camera look, subtle bokeh",
style: "Auto",
renderingSpeed: "Quality",
aspectRatio: "16:9",
},
};
PODCAST_PRESETS,
PODCAST_THEME,
PODCAST_RECOMMENDATIONS,
} from '../../shared/ImageGenerationPresets';
// Re-export settings type for backward compatibility
// Podcast doesn't use model selection, so model is optional
export interface ImageGenerationSettings {
prompt: string;
style: "Auto" | "Fiction" | "Realistic";
@@ -95,469 +49,50 @@ export const ImageRegenerateModal: React.FC<ImageRegenerateModalProps> = ({
initialAspectRatio = "16:9",
isGenerating = false,
}) => {
const [prompt, setPrompt] = useState(initialPrompt);
const [style, setStyle] = useState<"Auto" | "Fiction" | "Realistic">(initialStyle);
const [renderingSpeed, setRenderingSpeed] = useState<"Default" | "Turbo" | "Quality">(initialRenderingSpeed);
const [aspectRatio, setAspectRatio] = useState<"1:1" | "16:9" | "9:16" | "4:3" | "3:4">(initialAspectRatio);
// Update state when initial values change
useEffect(() => {
setPrompt(initialPrompt);
setStyle(initialStyle);
setRenderingSpeed(initialRenderingSpeed);
setAspectRatio(initialAspectRatio);
}, [initialPrompt, initialStyle, initialRenderingSpeed, initialAspectRatio]);
const handleRegenerate = () => {
onRegenerate({
prompt,
style,
renderingSpeed,
aspectRatio,
});
};
const applyPreset = (presetKey: PresetKey) => {
const p = PRESETS[presetKey];
// Combine the preset prompt with current scene prompt context
setPrompt((current) => {
// If user already customized, append; otherwise replace with preset
if (!current || current.trim() === "" || current.trim() === initialPrompt.trim()) {
return `${initialPrompt}\n${p.prompt}`.trim();
}
return `${current}\n${p.prompt}`.trim();
});
setStyle(p.style);
setRenderingSpeed(p.renderingSpeed);
setAspectRatio(p.aspectRatio);
// Adapter to convert shared settings to Podcast-specific settings
const handleGenerate = (settings: SharedImageGenerationSettings) => {
const podcastSettings: ImageGenerationSettings = {
prompt: settings.prompt,
style: settings.style,
renderingSpeed: settings.renderingSpeed,
aspectRatio: settings.aspectRatio,
};
onRegenerate(podcastSettings);
};
return (
<Dialog
<ImageGenerationModal
// Core props
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: alpha("#0f172a", 0.95),
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 4,
},
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6" sx={{ color: "white", fontWeight: 600 }}>
Regenerate Image with Custom Settings
</Typography>
<IconButton
onClick={onClose}
size="small"
sx={{ color: "rgba(255,255,255,0.7)" }}
>
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.6)", mt: 1 }}>
Customize the image generation parameters to get the perfect result for your scene
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
{/* Presets */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Podcast-ready presets
</Typography>
<Tooltip
title="Quickly apply a podcast-friendly look. Each preset adjusts lighting, background, and ratio while keeping your base avatar consistent."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
{(
Object.entries(PRESETS) as Array<[PresetKey, (typeof PRESETS)[PresetKey]]>
).map(([key, p]) => (
<Paper
key={key}
onClick={() => applyPreset(key)}
sx={{
p: 1.5,
flex: 1,
cursor: "pointer",
backgroundColor: alpha("#ffffff", 0.04),
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 2,
transition: "all 0.2s ease",
"&:hover": {
borderColor: "rgba(102,126,234,0.7)",
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
backgroundColor: alpha("#667eea", 0.08),
},
}}
>
<Typography variant="subtitle2" sx={{ color: "white", fontWeight: 700 }}>
{p.title}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.5, mb: 0.75 }}>
{p.subtitle}
</Typography>
<Stack direction="row" spacing={1} sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.8rem" }}>
<Typography variant="caption">Style: {p.style}</Typography>
<Typography variant="caption">Speed: {p.renderingSpeed}</Typography>
<Typography variant="caption">AR: {p.aspectRatio}</Typography>
</Stack>
</Paper>
))}
</Stack>
</Box>
{/* Prompt Section */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Generation Prompt
</Typography>
<Tooltip
title="The prompt describes what you want to see in the generated image. It should include scene context, visual elements, and style preferences. The AI will use this along with your base avatar to create a consistent character in the scene."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<TextField
fullWidth
multiline
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe the scene, visual elements, and style..."
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover fieldset": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused fieldset": {
borderColor: "#667eea",
},
},
"& .MuiInputBase-input": {
color: "white",
},
}}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.5)", mt: 0.5, display: "block" }}>
This prompt will be combined with scene context to generate your image. Be specific about visual elements, mood, and composition.
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
{/* Style Selection */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Character Style
</Typography>
<Tooltip
title="Determines the artistic style of the character generation. Auto lets the AI choose, Fiction creates more stylized/artistic characters, and Realistic produces photorealistic results."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={style}
onChange={(e) => setStyle(e.target.value as "Auto" | "Fiction" | "Realistic")}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#667eea",
},
"& .MuiSvgIcon-root": {
color: "rgba(255,255,255,0.7)",
},
}}
>
<MenuItem value="Auto">
<Stack>
<Typography sx={{ color: "white" }}>Auto</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
AI automatically selects the best style
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Fiction">
<Stack>
<Typography sx={{ color: "white" }}>Fiction</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Stylized, artistic character appearance
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Realistic">
<Stack>
<Typography sx={{ color: "white" }}>Realistic</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Photorealistic, professional appearance
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Paper
sx={{
mt: 1.5,
p: 1.5,
backgroundColor: alpha("#667eea", 0.1),
border: "1px solid rgba(102,126,234,0.3)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1}>
<InfoIcon sx={{ color: "#667eea", fontSize: "1.2rem", mt: 0.1 }} />
<Box>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)", fontWeight: 500, mb: 0.5 }}>
Style Impact:
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.6 }}>
<strong>Auto:</strong> Best for most cases, balances realism and style<br />
<strong>Fiction:</strong> Great for creative, artistic podcasts with stylized visuals<br />
<strong>Realistic:</strong> Ideal for professional, corporate, or news-style podcasts
</Typography>
</Box>
</Stack>
</Paper>
</Box>
{/* Rendering Speed */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Rendering Speed
</Typography>
<Tooltip
title="Controls the balance between generation speed, cost, and quality. Turbo is fastest and cheapest but lower quality. Quality is slowest and most expensive but produces the best results. Default provides a balanced approach."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={renderingSpeed}
onChange={(e) => setRenderingSpeed(e.target.value as "Default" | "Turbo" | "Quality")}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#667eea",
},
"& .MuiSvgIcon-root": {
color: "rgba(255,255,255,0.7)",
},
}}
>
<MenuItem value="Turbo">
<Stack>
<Typography sx={{ color: "white" }}>Turbo </Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Fastest (~10-20s) Cheapest Lower quality
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Default">
<Stack>
<Typography sx={{ color: "white" }}>Default </Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Balanced (~30-60s) Moderate cost Good quality
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Quality">
<Stack>
<Typography sx={{ color: "white" }}>Quality </Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Slowest (~60-120s) Most expensive Highest quality
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Paper
sx={{
mt: 1.5,
p: 1.5,
backgroundColor: alpha("#10b981", 0.1),
border: "1px solid rgba(16,185,129,0.3)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1}>
<InfoIcon sx={{ color: "#10b981", fontSize: "1.2rem", mt: 0.1 }} />
<Box>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)", fontWeight: 500, mb: 0.5 }}>
Speed vs Quality Trade-off:
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.6 }}>
<strong>Turbo:</strong> Use for quick iterations and testing (~$0.02/image)<br />
<strong>Default:</strong> Best balance for most production use (~$0.04/image)<br />
<strong>Quality:</strong> Use for final, high-quality outputs (~$0.08/image)
</Typography>
</Box>
</Stack>
</Paper>
</Box>
{/* Aspect Ratio */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Aspect Ratio
</Typography>
<Tooltip
title="The width-to-height ratio of the generated image. Choose based on your video format: 16:9 for standard widescreen, 9:16 for vertical/social media, 1:1 for square formats, or 4:3 for traditional formats."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value as "1:1" | "16:9" | "9:16" | "4:3" | "3:4")}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#667eea",
},
"& .MuiSvgIcon-root": {
color: "rgba(255,255,255,0.7)",
},
}}
>
<MenuItem value="16:9">
<Stack>
<Typography sx={{ color: "white" }}>16:9 (Widescreen)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Standard video format, best for YouTube, web
</Typography>
</Stack>
</MenuItem>
<MenuItem value="9:16">
<Stack>
<Typography sx={{ color: "white" }}>9:16 (Vertical)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Mobile/social media format (TikTok, Instagram Stories)
</Typography>
</Stack>
</MenuItem>
<MenuItem value="1:1">
<Stack>
<Typography sx={{ color: "white" }}>1:1 (Square)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Instagram posts, profile images
</Typography>
</Stack>
</MenuItem>
<MenuItem value="4:3">
<Stack>
<Typography sx={{ color: "white" }}>4:3 (Traditional)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Classic TV format, presentations
</Typography>
</Stack>
</MenuItem>
<MenuItem value="3:4">
<Stack>
<Typography sx={{ color: "white" }}>3:4 (Portrait)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Portrait orientation, mobile apps
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Paper
sx={{
mt: 1.5,
p: 1.5,
backgroundColor: alpha("#f59e0b", 0.1),
border: "1px solid rgba(245,158,11,0.3)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1}>
<InfoIcon sx={{ color: "#f59e0b", fontSize: "1.2rem", mt: 0.1 }} />
<Box>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)", fontWeight: 500, mb: 0.5 }}>
Format Recommendation:
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.6 }}>
<strong>16:9</strong> is recommended for most podcast videos as it matches standard video player dimensions and provides optimal viewing experience.
</Typography>
</Box>
</Stack>
</Paper>
</Box>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 2 }}>
<SecondaryButton onClick={onClose} disabled={isGenerating}>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={handleRegenerate}
loading={isGenerating}
disabled={!prompt.trim() || isGenerating}
>
{isGenerating ? "Generating..." : "Regenerate Image"}
</PrimaryButton>
</DialogActions>
</Dialog>
onGenerate={handleGenerate}
initialPrompt={initialPrompt}
isGenerating={isGenerating}
// Podcast-specific context
title="Regenerate Image with Custom Settings"
promptLabel="Generation Prompt"
promptHelp="The prompt describes what you want to see in the generated image. It should include scene context, visual elements, and style preferences. The AI will use this along with your base avatar to create a consistent character in the scene."
generateButtonLabel="Regenerate Image"
// Podcast presets
presets={PODCAST_PRESETS}
presetsLabel="Podcast-ready presets"
presetsHelp="Quickly apply a podcast-friendly look. Each preset adjusts lighting, background, and ratio while keeping your base avatar consistent."
// Model selection disabled for Podcast (uses default)
showModelSelection={false}
// Default values
defaultStyle={initialStyle}
defaultRenderingSpeed={initialRenderingSpeed}
defaultAspectRatio={initialAspectRatio}
// Podcast theming
theme={PODCAST_THEME}
// Podcast-specific recommendations
recommendations={PODCAST_RECOMMENDATIONS}
/>
);
};

View File

@@ -0,0 +1,738 @@
/**
* IntentResearchWizard Component
*
* A new research experience that:
* 1. Understands what the user wants to accomplish
* 2. Shows quick options for confirmation
* 3. Executes targeted research
* 4. Displays results organized by deliverable type
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Button,
Paper,
Chip,
CircularProgress,
Alert,
Collapse,
IconButton,
Tooltip,
Divider,
Card,
CardContent,
Grid,
Tabs,
Tab,
List,
ListItem,
ListItemIcon,
ListItemText,
Accordion,
AccordionSummary,
AccordionDetails,
Link,
} from '@mui/material';
import {
Search as SearchIcon,
Psychology as BrainIcon,
CheckCircle as CheckIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
TrendingUp as TrendIcon,
FormatQuote as QuoteIcon,
BarChart as StatsIcon,
School as CaseStudyIcon,
Compare as CompareIcon,
Lightbulb as IdeaIcon,
PlayArrow as PlayIcon,
Refresh as RefreshIcon,
OpenInNew as OpenIcon,
} from '@mui/icons-material';
import { useIntentResearch } from './hooks/useIntentResearch';
import {
ResearchIntent,
QuickOption,
IntentDrivenResearchResponse,
DELIVERABLE_DISPLAY,
PURPOSE_DISPLAY,
DEPTH_DISPLAY,
ExpectedDeliverable,
} from './types/intent.types';
interface IntentResearchWizardProps {
onComplete?: (result: IntentDrivenResearchResponse) => void;
onCancel?: () => void;
initialInput?: string;
showQuickMode?: boolean;
}
export const IntentResearchWizard: React.FC<IntentResearchWizardProps> = ({
onComplete,
onCancel,
initialInput = '',
showQuickMode = true,
}) => {
const [inputValue, setInputValue] = useState(initialInput);
const [resultTab, setResultTab] = useState(0);
const {
state,
isLoading,
hasIntent,
hasResults,
needsConfirmation,
confidence,
analyzeIntent,
updateQuickOption,
toggleQuerySelection,
confirmAndExecute,
quickResearch,
reset,
} = useIntentResearch({
usePersona: true,
useCompetitorData: true,
autoExecute: false,
});
// Handle result completion
useEffect(() => {
if (hasResults && state.result && onComplete) {
onComplete(state.result);
}
}, [hasResults, state.result, onComplete]);
// Handle input submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!inputValue.trim()) return;
await analyzeIntent(inputValue);
};
// Handle quick research
const handleQuickResearch = async () => {
if (!inputValue.trim()) return;
await quickResearch(inputValue);
};
// Handle confirmation and execution
const handleConfirmAndExecute = async () => {
const result = await confirmAndExecute();
if (result && onComplete) {
onComplete(result);
}
};
// Render input form
const renderInputForm = () => (
<Paper
elevation={0}
sx={{
p: 3,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: 3,
color: 'white',
}}
>
<Typography variant="h5" fontWeight={600} mb={1}>
🔍 What do you want to research?
</Typography>
<Typography variant="body2" mb={3} sx={{ opacity: 0.9 }}>
Enter your topic, question, or describe what you need. AI will understand your intent
and find exactly what you need.
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
multiline
rows={3}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder='Examples:&#10;• "AI trends in healthcare 2025"&#10;• "What are the best project management tools?"&#10;• "I need to write a blog about sustainable fashion for millennials"'
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
backgroundColor: 'rgba(255,255,255,0.95)',
borderRadius: 2,
},
}}
disabled={isLoading}
/>
<Box display="flex" gap={2} justifyContent="flex-end">
{showQuickMode && (
<Button
variant="outlined"
onClick={handleQuickResearch}
disabled={isLoading || !inputValue.trim()}
sx={{
color: 'white',
borderColor: 'rgba(255,255,255,0.5)',
'&:hover': { borderColor: 'white', backgroundColor: 'rgba(255,255,255,0.1)' },
}}
>
Quick Research
</Button>
)}
<Button
type="submit"
variant="contained"
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : <BrainIcon />}
disabled={isLoading || !inputValue.trim()}
sx={{
backgroundColor: 'white',
color: '#667eea',
'&:hover': { backgroundColor: 'rgba(255,255,255,0.9)' },
}}
>
{state.isAnalyzing ? 'Analyzing...' : 'Analyze Intent'}
</Button>
</Box>
</form>
</Paper>
);
// Render intent confirmation
const renderIntentConfirmation = () => {
if (!state.intent) return null;
return (
<Paper elevation={0} sx={{ p: 3, mt: 3, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<BrainIcon color="primary" />
<Typography variant="h6" fontWeight={600}>
AI Understood Your Research
</Typography>
<Chip
size="small"
label={`${Math.round(confidence * 100)}% confident`}
color={confidence > 0.8 ? 'success' : confidence > 0.6 ? 'warning' : 'error'}
/>
</Box>
{/* Analysis Summary */}
<Typography variant="body1" color="text.secondary" mb={3}>
{state.analysisSummary}
</Typography>
{/* Primary Question */}
<Alert severity="info" sx={{ mb: 3 }}>
<Typography fontWeight={500}>
Main Question: {state.intent.primary_question}
</Typography>
</Alert>
{/* Quick Options */}
<Grid container spacing={2} mb={3}>
{state.quickOptions.map((option) => (
<Grid item xs={12} sm={6} key={option.id}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
{option.label}
</Typography>
<Typography variant="body1" fontWeight={500}>
{Array.isArray(option.display)
? option.display.slice(0, 3).join(', ')
: option.display}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* Expected Deliverables */}
<Typography variant="subtitle2" gutterBottom>
What I'll find for you:
</Typography>
<Box display="flex" flexWrap="wrap" gap={1} mb={3}>
{state.intent.expected_deliverables.map((d) => (
<Chip
key={d}
label={DELIVERABLE_DISPLAY[d as ExpectedDeliverable] || d}
color="primary"
variant="outlined"
size="small"
icon={getDeliverableIcon(d)}
/>
))}
</Box>
{/* Suggested Queries (collapsible) */}
<Accordion elevation={0} sx={{ border: '1px solid', borderColor: 'divider' }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">
Research Queries ({state.suggestedQueries.length})
</Typography>
</AccordionSummary>
<AccordionDetails>
<List dense>
{state.suggestedQueries.map((query, idx) => (
<ListItem
key={idx}
button
onClick={() => toggleQuerySelection(query)}
selected={state.selectedQueries.some(q => q.query === query.query)}
>
<ListItemIcon>
<Chip
size="small"
label={query.provider.toUpperCase()}
color={query.provider === 'exa' ? 'primary' : 'secondary'}
/>
</ListItemIcon>
<ListItemText
primary={query.query}
secondary={`Finding: ${query.expected_results}`}
/>
</ListItem>
))}
</List>
</AccordionDetails>
</Accordion>
{/* Action Buttons */}
<Box display="flex" gap={2} justifyContent="flex-end" mt={3}>
<Button variant="outlined" onClick={reset}>
Start Over
</Button>
<Button
variant="contained"
startIcon={state.isResearching ? <CircularProgress size={20} color="inherit" /> : <PlayIcon />}
onClick={handleConfirmAndExecute}
disabled={state.isResearching}
>
{state.isResearching ? 'Researching...' : 'Start Research'}
</Button>
</Box>
</Paper>
);
};
// Render results
const renderResults = () => {
if (!state.result) return null;
const result = state.result;
// Available tabs based on what we have
const tabs = [
{ id: 'summary', label: 'Summary', count: 0 },
{ id: 'statistics', label: 'Statistics', count: result.statistics.length },
{ id: 'quotes', label: 'Expert Quotes', count: result.expert_quotes.length },
{ id: 'case_studies', label: 'Case Studies', count: result.case_studies.length },
{ id: 'trends', label: 'Trends', count: result.trends.length },
{ id: 'sources', label: 'Sources', count: result.sources.length },
].filter(t => t.id === 'summary' || t.id === 'sources' || t.count > 0);
return (
<Paper elevation={0} sx={{ mt: 3, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
{/* Header */}
<Box sx={{ p: 3, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<CheckIcon color="success" />
<Typography variant="h6" fontWeight={600}>
Research Complete
</Typography>
<Chip
size="small"
label={`${result.sources.length} sources`}
color="primary"
variant="outlined"
/>
</Box>
{/* Executive Summary */}
<Typography variant="body1" color="text.secondary">
{result.executive_summary}
</Typography>
</Box>
{/* Tabs */}
<Tabs
value={resultTab}
onChange={(_, v) => setResultTab(v)}
sx={{ px: 2, borderBottom: '1px solid', borderColor: 'divider' }}
>
{tabs.map((tab, idx) => (
<Tab
key={tab.id}
label={
<Box display="flex" alignItems="center" gap={0.5}>
{tab.label}
{tab.count > 0 && (
<Chip size="small" label={tab.count} color="primary" sx={{ height: 20 }} />
)}
</Box>
}
/>
))}
</Tabs>
{/* Tab Content */}
<Box sx={{ p: 3 }}>
{/* Summary Tab */}
{tabs[resultTab]?.id === 'summary' && (
<Box>
{/* Primary Answer */}
<Alert severity="success" sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Answer to your question:
</Typography>
<Typography>{result.primary_answer}</Typography>
</Alert>
{/* Key Takeaways */}
{result.key_takeaways.length > 0 && (
<Box mb={3}>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Key Takeaways
</Typography>
<List dense>
{result.key_takeaways.map((takeaway, idx) => (
<ListItem key={idx}>
<ListItemIcon>
<IdeaIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText primary={takeaway} />
</ListItem>
))}
</List>
</Box>
)}
{/* Best Practices */}
{result.best_practices.length > 0 && (
<Box mb={3}>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Best Practices
</Typography>
<List dense>
{result.best_practices.map((bp, idx) => (
<ListItem key={idx}>
<ListItemIcon>
<CheckIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary={bp} />
</ListItem>
))}
</List>
</Box>
)}
{/* Suggested Outline */}
{result.suggested_outline.length > 0 && (
<Box>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Suggested Content Outline
</Typography>
<List dense>
{result.suggested_outline.map((item, idx) => (
<ListItem key={idx}>
<ListItemText primary={item} />
</ListItem>
))}
</List>
</Box>
)}
</Box>
)}
{/* Statistics Tab */}
{tabs[resultTab]?.id === 'statistics' && (
<Grid container spacing={2}>
{result.statistics.map((stat, idx) => (
<Grid item xs={12} md={6} key={idx}>
<Card variant="outlined">
<CardContent>
<Box display="flex" alignItems="flex-start" gap={1}>
<StatsIcon color="primary" />
<Box flex={1}>
<Typography variant="body1" fontWeight={500}>
{stat.statistic}
</Typography>
<Typography variant="caption" color="text.secondary">
{stat.context}
</Typography>
<Box display="flex" alignItems="center" gap={1} mt={1}>
<Link href={stat.url} target="_blank" rel="noopener" variant="caption">
{stat.source} <OpenIcon sx={{ fontSize: 12 }} />
</Link>
<Chip
size="small"
label={`${Math.round(stat.credibility * 100)}% credible`}
color={stat.credibility > 0.8 ? 'success' : 'warning'}
/>
</Box>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Expert Quotes Tab */}
{tabs[resultTab]?.id === 'quotes' && (
<Grid container spacing={2}>
{result.expert_quotes.map((quote, idx) => (
<Grid item xs={12} key={idx}>
<Card variant="outlined">
<CardContent>
<Box display="flex" gap={2}>
<QuoteIcon color="primary" sx={{ fontSize: 40 }} />
<Box>
<Typography variant="body1" fontStyle="italic" mb={1}>
"{quote.quote}"
</Typography>
<Typography variant="subtitle2">
— {quote.speaker}
{quote.title && `, ${quote.title}`}
{quote.organization && ` at ${quote.organization}`}
</Typography>
<Link href={quote.url} target="_blank" rel="noopener" variant="caption">
Source: {quote.source} <OpenIcon sx={{ fontSize: 12 }} />
</Link>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Case Studies Tab */}
{tabs[resultTab]?.id === 'case_studies' && (
<Grid container spacing={2}>
{result.case_studies.map((cs, idx) => (
<Grid item xs={12} key={idx}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>
{cs.title}
</Typography>
<Typography variant="subtitle2" color="primary" gutterBottom>
{cs.organization}
</Typography>
<Divider sx={{ my: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
Challenge
</Typography>
<Typography variant="body2">{cs.challenge}</Typography>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
Solution
</Typography>
<Typography variant="body2">{cs.solution}</Typography>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
Outcome
</Typography>
<Typography variant="body2">{cs.outcome}</Typography>
</Grid>
</Grid>
{cs.key_metrics.length > 0 && (
<Box mt={2} display="flex" gap={1} flexWrap="wrap">
{cs.key_metrics.map((metric, i) => (
<Chip key={i} label={metric} size="small" color="success" variant="outlined" />
))}
</Box>
)}
<Box mt={2}>
<Link href={cs.url} target="_blank" rel="noopener" variant="caption">
Read full case study <OpenIcon sx={{ fontSize: 12 }} />
</Link>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Trends Tab */}
{tabs[resultTab]?.id === 'trends' && (
<Grid container spacing={2}>
{result.trends.map((trend, idx) => (
<Grid item xs={12} md={6} key={idx}>
<Card variant="outlined">
<CardContent>
<Box display="flex" alignItems="center" gap={1} mb={1}>
<TrendIcon
color={trend.direction === 'growing' ? 'success' : trend.direction === 'declining' ? 'error' : 'info'}
/>
<Typography variant="subtitle1" fontWeight={500}>
{trend.trend}
</Typography>
<Chip
size="small"
label={trend.direction}
color={trend.direction === 'growing' ? 'success' : trend.direction === 'declining' ? 'error' : 'info'}
/>
</Box>
<Typography variant="body2" color="text.secondary" mb={1}>
{trend.impact}
</Typography>
{trend.timeline && (
<Typography variant="caption" color="text.secondary">
Timeline: {trend.timeline}
</Typography>
)}
<Box mt={1}>
<Typography variant="caption" color="text.secondary">
Evidence:
</Typography>
<List dense>
{trend.evidence.slice(0, 3).map((e, i) => (
<ListItem key={i} sx={{ py: 0 }}>
<ListItemText primary={e} primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
))}
</List>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Sources Tab */}
{tabs[resultTab]?.id === 'sources' && (
<List>
{result.sources.map((source, idx) => (
<ListItem
key={idx}
component="a"
href={source.url}
target="_blank"
rel="noopener"
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
>
<ListItemText
primary={source.title}
secondary={
<>
{source.excerpt && <Typography variant="caption">{source.excerpt}</Typography>}
<Box display="flex" gap={1} mt={0.5}>
{source.content_type && (
<Chip size="small" label={source.content_type} variant="outlined" />
)}
<Chip
size="small"
label={`${Math.round(source.relevance_score * 100)}% relevant`}
color="primary"
variant="outlined"
/>
<Chip
size="small"
label={`${Math.round(source.credibility_score * 100)}% credible`}
color={source.credibility_score > 0.8 ? 'success' : 'warning'}
variant="outlined"
/>
</Box>
</>
}
/>
<OpenIcon color="action" />
</ListItem>
))}
</List>
)}
</Box>
{/* Footer */}
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider', display: 'flex', justifyContent: 'space-between' }}>
<Button startIcon={<RefreshIcon />} onClick={reset}>
New Research
</Button>
{result.gaps_identified.length > 0 && (
<Tooltip
title={
<Box>
<Typography variant="caption" fontWeight={600}>Gaps Identified:</Typography>
<List dense>
{result.gaps_identified.map((gap, i) => (
<ListItem key={i} sx={{ py: 0 }}>
<ListItemText primary={gap} primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
))}
</List>
</Box>
}
>
<Chip
icon={<InfoIcon />}
label={`${result.gaps_identified.length} gaps identified`}
color="warning"
variant="outlined"
size="small"
/>
</Tooltip>
)}
</Box>
</Paper>
);
};
return (
<Box>
{/* Error display */}
{state.error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => reset()}>
{state.error}
</Alert>
)}
{/* Input Form (always visible unless we have results) */}
{!hasResults && renderInputForm()}
{/* Intent Confirmation */}
{hasIntent && !hasResults && !state.isResearching && renderIntentConfirmation()}
{/* Loading state during research */}
{state.isResearching && (
<Box display="flex" flexDirection="column" alignItems="center" py={4}>
<CircularProgress size={60} sx={{ mb: 2 }} />
<Typography variant="h6">Executing Research...</Typography>
<Typography color="text.secondary">
Finding exactly what you need...
</Typography>
</Box>
)}
{/* Results */}
{hasResults && renderResults()}
</Box>
);
};
// Helper function to get icon for deliverable
const getDeliverableIcon = (deliverable: string): React.ReactElement | undefined => {
const iconMap: Record<string, React.ReactElement> = {
key_statistics: <StatsIcon fontSize="small" />,
expert_quotes: <QuoteIcon fontSize="small" />,
case_studies: <CaseStudyIcon fontSize="small" />,
trends: <TrendIcon fontSize="small" />,
comparisons: <CompareIcon fontSize="small" />,
best_practices: <CheckIcon fontSize="small" />,
step_by_step: <PlayIcon fontSize="small" />,
examples: <IdeaIcon fontSize="small" />,
predictions: <TrendIcon fontSize="small" />,
};
return iconMap[deliverable];
};
export default IntentResearchWizard;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
@@ -21,9 +21,10 @@ import {
Business as BusinessIcon,
Assessment as AssessmentIcon,
OpenInNew as OpenInNewIcon,
Link as LinkIcon
Link as LinkIcon,
Refresh as RefreshIcon
} from '@mui/icons-material';
import { CompetitorAnalysisResponse } from '../../api/researchConfig';
import { CompetitorAnalysisResponse, refreshCompetitorAnalysis } from '../../api/researchConfig';
interface OnboardingCompetitorModalProps {
open: boolean;
@@ -31,6 +32,7 @@ interface OnboardingCompetitorModalProps {
data: CompetitorAnalysisResponse | null;
loading?: boolean;
error?: string | null;
onRefresh?: (newData: CompetitorAnalysisResponse) => void;
}
export const OnboardingCompetitorModal: React.FC<OnboardingCompetitorModalProps> = ({
@@ -38,8 +40,12 @@ export const OnboardingCompetitorModal: React.FC<OnboardingCompetitorModalProps>
onClose,
data,
loading = false,
error = null
error = null,
onRefresh
}) => {
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
if (!data && !loading && !error) {
return null;
}
@@ -48,6 +54,24 @@ export const OnboardingCompetitorModal: React.FC<OnboardingCompetitorModalProps>
const socialMediaAccounts = data?.social_media_accounts || {};
const researchSummary = data?.research_summary || {};
const handleRefresh = async () => {
setRefreshing(true);
setRefreshError(null);
try {
const newData = await refreshCompetitorAnalysis();
if (newData.success && onRefresh) {
onRefresh(newData);
} else {
setRefreshError(newData.error || 'Failed to refresh competitor analysis');
}
} catch (err: any) {
setRefreshError(err.message || 'Failed to refresh competitor analysis');
} finally {
setRefreshing(false);
}
};
const avgScore = competitors.length > 0
? competitors.reduce((sum, c) => sum + (c.similarity_score || 0), 0) / competitors.length
: 0;
@@ -85,9 +109,33 @@ export const OnboardingCompetitorModal: React.FC<OnboardingCompetitorModalProps>
</Typography>
</Box>
</Box>
<Button onClick={onClose} size="small" sx={{ minWidth: 'auto', p: 1 }}>
<CloseIcon />
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
onClick={handleRefresh}
disabled={refreshing}
startIcon={refreshing ? <CircularProgress size={16} /> : <RefreshIcon />}
variant="outlined"
size="small"
sx={{
minWidth: 'auto',
borderColor: '#0ea5e9',
color: '#0ea5e9',
'&:hover': {
borderColor: '#0284c7',
backgroundColor: 'rgba(14, 165, 233, 0.08)'
},
'&:disabled': {
borderColor: '#cbd5e1',
color: '#94a3b8'
}
}}
>
{refreshing ? 'Refreshing...' : 'Refresh'}
</Button>
<Button onClick={onClose} size="small" sx={{ minWidth: 'auto', p: 1 }}>
<CloseIcon />
</Button>
</Box>
</DialogTitle>
<DialogContent sx={{ py: 3, overflowY: 'auto' }}>
@@ -100,9 +148,9 @@ export const OnboardingCompetitorModal: React.FC<OnboardingCompetitorModalProps>
</Box>
)}
{error && (
{(error || refreshError) && (
<Alert severity="error" sx={{ mb: 3 }}>
<Typography variant="body2">{error}</Typography>
<Typography variant="body2">{error || refreshError}</Typography>
</Alert>
)}

View File

@@ -100,13 +100,13 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
switch (wizard.state.currentStep) {
case 1:
return <ResearchInput {...stepProps} advanced={advanced} onAdvancedChange={setAdvanced} />;
return <ResearchInput {...stepProps} advanced={advanced} onAdvancedChange={setAdvanced} execution={execution} />;
case 2:
return <StepProgress {...stepProps} execution={execution} />;
case 3:
return <StepResults {...stepProps} />;
return <StepResults {...stepProps} execution={execution} />;
default:
return <ResearchInput {...stepProps} advanced={advanced} onAdvancedChange={setAdvanced} />;
return <ResearchInput {...stepProps} advanced={advanced} onAdvancedChange={setAdvanced} execution={execution} />;
}
};
@@ -336,6 +336,51 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
Back
</button>
{/* Intent-Driven Research Button (Primary) - Only show on Step 1 */}
{wizard.state.currentStep === 1 && (
<button
onClick={async () => {
// Analyze intent and execute if successful
const analysis = await execution.analyzeIntent(wizard.state);
if (analysis?.success) {
// If high confidence, auto-execute
if (analysis.intent.confidence >= 0.8 && !analysis.intent.needs_clarification) {
const result = await execution.executeIntentResearch(wizard.state);
if (result?.success) {
wizard.updateState({ currentStep: 3 }); // Skip to results
}
}
}
}}
disabled={!wizard.canGoNext() || execution.isAnalyzingIntent || execution.isExecuting}
style={{
padding: '10px 24px',
background: wizard.canGoNext() && !execution.isAnalyzingIntent
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'rgba(100, 116, 139, 0.2)',
color: wizard.canGoNext() ? 'white' : '#94a3b8',
border: 'none',
borderRadius: '10px',
cursor: wizard.canGoNext() && !execution.isAnalyzingIntent ? 'pointer' : 'not-allowed',
fontSize: '13px',
fontWeight: '600',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
marginRight: '10px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
{execution.isAnalyzingIntent ? (
<>🧠 Analyzing...</>
) : execution.isExecuting ? (
<>🔍 Researching...</>
) : (
<>🧠 Smart Research</>
)}
</button>
)}
<button
onClick={wizard.nextStep}
disabled={!wizard.canGoNext()}

View File

@@ -0,0 +1,323 @@
/**
* useIntentResearch Hook
*
* React hook for managing intent-driven research flow:
* 1. Analyze user input to understand intent
* 2. Show quick options for user confirmation
* 3. Execute research with confirmed intent
* 4. Display results organized by deliverable type
*/
import { useState, useCallback } from 'react';
import { intentResearchApi } from '../../../api/intentResearchApi';
import {
ResearchIntent,
ResearchQuery,
QuickOption,
IntentDrivenResearchResponse,
IntentWizardState,
AnalyzeIntentResponse,
} from '../types/intent.types';
const initialState: IntentWizardState = {
userInput: '',
keywords: [],
intent: null,
suggestedQueries: [],
selectedQueries: [],
quickOptions: [],
analysisSummary: '',
suggestedKeywords: [],
suggestedAngles: [],
isAnalyzing: false,
isResearching: false,
hasConfirmedIntent: false,
result: null,
error: null,
};
export interface UseIntentResearchOptions {
usePersona?: boolean;
useCompetitorData?: boolean;
maxSources?: number;
includeDomains?: string[];
excludeDomains?: string[];
autoExecute?: boolean; // Auto-execute research after intent analysis (if high confidence)
autoExecuteThreshold?: number; // Confidence threshold for auto-execute (default: 0.85)
}
export const useIntentResearch = (options: UseIntentResearchOptions = {}) => {
const [state, setState] = useState<IntentWizardState>(initialState);
const {
usePersona = true,
useCompetitorData = true,
maxSources = 10,
includeDomains = [],
excludeDomains = [],
autoExecute = false,
autoExecuteThreshold = 0.85,
} = options;
/**
* Analyze user input to understand research intent.
*/
const analyzeIntent = useCallback(async (userInput: string) => {
setState(prev => ({
...prev,
userInput,
keywords: userInput.split(' ').filter(k => k.length > 2),
isAnalyzing: true,
error: null,
}));
try {
const response: AnalyzeIntentResponse = await intentResearchApi.analyzeIntent({
user_input: userInput,
keywords: userInput.split(' ').filter(k => k.length > 2),
use_persona: usePersona,
use_competitor_data: useCompetitorData,
});
if (!response.success) {
setState(prev => ({
...prev,
isAnalyzing: false,
error: response.error_message || 'Failed to analyze intent',
}));
return null;
}
const newState: Partial<IntentWizardState> = {
intent: response.intent,
suggestedQueries: response.suggested_queries,
selectedQueries: response.suggested_queries.slice(0, 5), // Select top 5 by default
quickOptions: response.quick_options,
analysisSummary: response.analysis_summary,
suggestedKeywords: response.suggested_keywords,
suggestedAngles: response.suggested_angles,
isAnalyzing: false,
};
setState(prev => ({ ...prev, ...newState }));
// Auto-execute if confidence is high enough
if (
autoExecute &&
response.intent.confidence >= autoExecuteThreshold &&
!response.intent.needs_clarification
) {
// Trigger research automatically
await executeResearchInternal(response.intent, response.suggested_queries.slice(0, 5));
}
return response;
} catch (error: any) {
setState(prev => ({
...prev,
isAnalyzing: false,
error: error.message || 'Failed to analyze intent',
}));
return null;
}
}, [usePersona, useCompetitorData, autoExecute, autoExecuteThreshold]);
/**
* Update a quick option value.
*/
const updateQuickOption = useCallback((optionId: string, newValue: any) => {
setState(prev => {
if (!prev.intent) return prev;
// Update intent based on option
const updatedIntent = { ...prev.intent };
switch (optionId) {
case 'purpose':
updatedIntent.purpose = newValue;
break;
case 'content_output':
updatedIntent.content_output = newValue;
break;
case 'deliverables':
updatedIntent.expected_deliverables = newValue;
break;
case 'depth':
updatedIntent.depth = newValue;
break;
}
return {
...prev,
intent: updatedIntent,
quickOptions: prev.quickOptions.map(opt =>
opt.id === optionId ? { ...opt, value: newValue } : opt
),
};
});
}, []);
/**
* Toggle a query selection.
*/
const toggleQuerySelection = useCallback((query: ResearchQuery) => {
setState(prev => {
const isSelected = prev.selectedQueries.some(q => q.query === query.query);
return {
...prev,
selectedQueries: isSelected
? prev.selectedQueries.filter(q => q.query !== query.query)
: [...prev.selectedQueries, query],
};
});
}, []);
/**
* Confirm intent and execute research.
*/
const confirmAndExecute = useCallback(async () => {
if (!state.intent) {
setState(prev => ({ ...prev, error: 'No intent to confirm' }));
return null;
}
return executeResearchInternal(state.intent, state.selectedQueries);
}, [state.intent, state.selectedQueries]);
/**
* Internal research execution.
*/
const executeResearchInternal = async (
intent: ResearchIntent,
queries: ResearchQuery[]
): Promise<IntentDrivenResearchResponse | null> => {
setState(prev => ({
...prev,
isResearching: true,
hasConfirmedIntent: true,
error: null,
}));
try {
const response = await intentResearchApi.executeIntentResearch({
user_input: state.userInput || intent.original_input,
confirmed_intent: intent,
selected_queries: queries,
max_sources: maxSources,
include_domains: includeDomains,
exclude_domains: excludeDomains,
skip_inference: true,
});
if (!response.success) {
setState(prev => ({
...prev,
isResearching: false,
error: response.error_message || 'Research failed',
}));
return null;
}
setState(prev => ({
...prev,
isResearching: false,
result: response,
}));
return response;
} catch (error: any) {
setState(prev => ({
...prev,
isResearching: false,
error: error.message || 'Research failed',
}));
return null;
}
};
/**
* Quick research - analyze and execute in one step.
* Skips user confirmation.
*/
const quickResearch = useCallback(async (userInput: string) => {
setState(prev => ({
...prev,
userInput,
isAnalyzing: true,
isResearching: true,
error: null,
}));
try {
const response = await intentResearchApi.quickIntentResearch(userInput, {
usePersona,
useCompetitorData,
maxSources,
includeDomains,
excludeDomains,
});
setState(prev => ({
...prev,
isAnalyzing: false,
isResearching: false,
result: response,
intent: response.intent,
hasConfirmedIntent: true,
error: response.success ? null : response.error_message,
}));
return response;
} catch (error: any) {
setState(prev => ({
...prev,
isAnalyzing: false,
isResearching: false,
error: error.message || 'Research failed',
}));
return null;
}
}, [usePersona, useCompetitorData, maxSources, includeDomains, excludeDomains]);
/**
* Reset to initial state.
*/
const reset = useCallback(() => {
setState(initialState);
}, []);
/**
* Clear just the results.
*/
const clearResults = useCallback(() => {
setState(prev => ({
...prev,
result: null,
hasConfirmedIntent: false,
}));
}, []);
return {
// State
state,
// Derived state
isLoading: state.isAnalyzing || state.isResearching,
hasIntent: state.intent !== null,
hasResults: state.result !== null,
needsConfirmation: state.intent?.needs_clarification || false,
confidence: state.intent?.confidence || 0,
// Actions
analyzeIntent,
updateQuickOption,
toggleQuerySelection,
confirmAndExecute,
quickResearch,
reset,
clearResults,
};
};
export default useIntentResearch;

View File

@@ -1,12 +1,26 @@
import { useState, useCallback } from 'react';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../../services/blogWriterApi';
import { useResearchPolling } from '../../../hooks/usePolling';
import { researchCache } from '../../../services/researchCache';
import { WizardState } from '../types/research.types';
import { researchEngineApi, ResearchEngineRequest } from '../../../services/researchEngineApi';
import { useResearchPolling } from '../../../hooks/usePolling';
import { intentResearchApi } from '../../../api/intentResearchApi';
import {
ResearchIntent,
IntentDrivenResearchResponse,
AnalyzeIntentResponse
} from '../types/intent.types';
export const useResearchExecution = () => {
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<any>(null);
// Intent-driven research state
const [isAnalyzingIntent, setIsAnalyzingIntent] = useState(false);
const [intentAnalysis, setIntentAnalysis] = useState<AnalyzeIntentResponse | null>(null);
const [confirmedIntent, setConfirmedIntent] = useState<ResearchIntent | null>(null);
const [intentResult, setIntentResult] = useState<IntentDrivenResearchResponse | null>(null);
const [useIntentMode, setUseIntentMode] = useState(true); // Enable by default
const polling = useResearchPolling({
onComplete: (result) => {
@@ -19,6 +33,7 @@ export const useResearchExecution = () => {
);
}
setIsExecuting(false);
setResult(result);
},
onError: (error) => {
console.error('Research polling error:', error);
@@ -41,18 +56,55 @@ export const useResearchExecution = () => {
if (cachedResult) {
setIsExecuting(false);
setResult(cachedResult);
return 'cached';
}
const payload: BlogResearchRequest = {
// Build Research Engine request (tool-agnostic)
const payload: ResearchEngineRequest = {
query: state.keywords.join(' ') || 'research',
keywords: state.keywords,
goal: 'factual',
depth: state.researchMode === 'basic' ? 'standard' : state.researchMode === 'comprehensive' ? 'comprehensive' : 'standard',
provider: state.config.provider || 'auto',
content_type: 'blog',
industry: state.industry,
target_audience: state.targetAudience,
research_mode: state.researchMode,
config: state.config,
max_sources: state.config.max_sources,
recency: state.config.tavily_time_range,
include_domains: state.config.exa_include_domains || state.config.tavily_include_domains,
exclude_domains: state.config.exa_exclude_domains || state.config.tavily_exclude_domains,
advanced_mode: true, // expose raw params if provided
// Exa params
exa_category: state.config.exa_category,
exa_search_type: state.config.exa_search_type,
// Tavily params
tavily_topic: state.config.tavily_topic,
tavily_search_depth: state.config.tavily_search_depth,
tavily_include_answer: state.config.tavily_include_answer,
tavily_include_raw_content: state.config.tavily_include_raw_content,
tavily_time_range: state.config.tavily_time_range,
tavily_country: state.config.tavily_country,
config: state.config, // keep compatibility
};
const { task_id } = await blogWriterApi.startResearch(payload);
// For fast smoke tests: use synchronous path when basic mode
if (state.researchMode === 'basic') {
const syncResult = await researchEngineApi.execute(payload);
// Cache and surface immediately
researchCache.cacheResult(
state.keywords,
state.industry,
state.targetAudience,
syncResult
);
setResult(syncResult);
setIsExecuting(false);
return 'sync';
}
// Start async research to reuse existing progress step
const { task_id } = await researchEngineApi.start(payload);
polling.startPolling(task_id);
return task_id;
} catch (err) {
@@ -69,14 +121,173 @@ export const useResearchExecution = () => {
setError(null);
}, [polling]);
/**
* Analyze user input to understand research intent.
* Call this before executeResearch to show intent confirmation.
*/
const analyzeIntent = useCallback(async (state: WizardState): Promise<AnalyzeIntentResponse | null> => {
setIsAnalyzingIntent(true);
setError(null);
setIntentAnalysis(null);
setConfirmedIntent(null);
try {
const userInput = state.keywords.join(' ');
const response = await intentResearchApi.analyzeIntent({
user_input: userInput,
keywords: state.keywords,
use_persona: true,
use_competitor_data: true,
});
setIntentAnalysis(response);
// Auto-confirm if confidence is high and no clarification needed
if (response.success && response.intent.confidence >= 0.85 && !response.intent.needs_clarification) {
setConfirmedIntent(response.intent);
}
setIsAnalyzingIntent(false);
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to analyze intent';
setError(errorMessage);
setIsAnalyzingIntent(false);
return null;
}
}, []);
/**
* Confirm the analyzed intent (possibly with user modifications).
*/
const confirmIntent = useCallback((intent: ResearchIntent) => {
setConfirmedIntent(intent);
}, []);
/**
* Update a specific field in the analyzed intent.
*/
const updateIntentField = useCallback(<K extends keyof ResearchIntent>(
field: K,
value: ResearchIntent[K]
) => {
if (intentAnalysis?.intent) {
const updatedIntent = { ...intentAnalysis.intent, [field]: value };
setIntentAnalysis({
...intentAnalysis,
intent: updatedIntent,
});
}
}, [intentAnalysis]);
/**
* Execute research using intent-driven approach.
*/
const executeIntentResearch = useCallback(async (state: WizardState): Promise<IntentDrivenResearchResponse | null> => {
// First analyze intent if not already done
let intent = confirmedIntent;
if (!intent) {
const analysis = await analyzeIntent(state);
if (!analysis?.success) {
return null;
}
intent = analysis.intent;
}
setIsExecuting(true);
setError(null);
try {
const response = await intentResearchApi.executeIntentResearch({
user_input: state.keywords.join(' '),
confirmed_intent: intent,
selected_queries: intentAnalysis?.suggested_queries?.slice(0, 5),
max_sources: state.config.max_sources || 10,
include_domains: state.config.exa_include_domains || state.config.tavily_include_domains || [],
exclude_domains: state.config.exa_exclude_domains || state.config.tavily_exclude_domains || [],
skip_inference: true,
});
if (!response.success) {
setError(response.error_message || 'Research failed');
setIsExecuting(false);
return null;
}
setIntentResult(response);
// Also set the legacy result for backward compatibility with StepResults
// Transform intent result to match the expected format
const legacyResult = {
success: true,
sources: response.sources.map(s => ({
title: s.title,
url: s.url,
excerpt: s.excerpt ?? undefined, // Convert null to undefined
credibility_score: s.credibility_score,
})),
keyword_analysis: {
primary_keywords: state.keywords,
secondary: response.suggested_outline,
},
competitor_analysis: {},
suggested_angles: response.key_takeaways,
search_queries: [],
// Add intent-specific data for enhanced display
intent_result: response,
};
setResult(legacyResult);
setIsExecuting(false);
// Cache the result
researchCache.cacheResult(
state.keywords,
state.industry,
state.targetAudience,
legacyResult
);
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Research failed';
setError(errorMessage);
setIsExecuting(false);
return null;
}
}, [confirmedIntent, intentAnalysis, analyzeIntent]);
/**
* Clear intent analysis state.
*/
const clearIntent = useCallback(() => {
setIntentAnalysis(null);
setConfirmedIntent(null);
setIntentResult(null);
}, []);
return {
// Legacy API
executeResearch,
stopExecution,
isExecuting,
error,
progressMessages: polling.progressMessages,
currentStatus: polling.currentStatus,
result: polling.result,
result: result ?? polling.result,
// Intent-driven API
useIntentMode,
setUseIntentMode,
isAnalyzingIntent,
intentAnalysis,
confirmedIntent,
intentResult,
analyzeIntent,
confirmIntent,
updateIntentField,
executeIntentResearch,
clearIntent,
};
};

View File

@@ -5,15 +5,18 @@ import { ResearchMode, ResearchConfig, BlogResearchResponse } from '../../../ser
const WIZARD_STATE_KEY = 'alwrity_research_wizard_state';
const MAX_STEPS = 3; // Input (combined) -> Progress -> Results
// Default state: "General" is a placeholder that gets replaced by persona defaults on mount
// Phase 2: Backend never returns "General" - persona defaults are always hyper-personalized
// ResearchInput.tsx loads persona defaults and updates these values immediately
const defaultState: WizardState = {
currentStep: 1,
keywords: [],
industry: 'General',
targetAudience: 'General',
researchMode: 'basic' as ResearchMode,
industry: 'General', // Placeholder - replaced by persona defaults on mount
targetAudience: 'General', // Placeholder - replaced by persona defaults on mount
researchMode: 'comprehensive' as ResearchMode,
config: {
mode: 'basic',
provider: 'google',
mode: 'comprehensive',
provider: 'exa', // Phase 2: Default to Exa (primary provider)
max_sources: 10,
include_statistics: true,
include_expert_quotes: true,

View File

@@ -3,3 +3,7 @@ export { useResearchWizard } from './hooks/useResearchWizard';
export { useResearchExecution } from './hooks/useResearchExecution';
export * from './types/research.types';
// Intent-driven research exports
export { IntentResearchWizard } from './IntentResearchWizard';
export { useIntentResearch } from './hooks/useIntentResearch';
export * from './types/intent.types';

View File

@@ -7,7 +7,8 @@ import {
ResearchHistoryEntry
} from '../../../utils/researchHistory';
import {
expandKeywords
expandKeywords,
expandKeywordsWithPersona
} from '../../../utils/keywordExpansion';
import {
generateResearchAngles
@@ -28,13 +29,17 @@ import { CurrentKeywords } from './components/CurrentKeywords';
import { ResearchAngles } from './components/ResearchAngles';
import { TavilyOptions } from './components/TavilyOptions';
import { ExaOptions } from './components/ExaOptions';
import { PersonalizationIndicator, PersonalizationBadge } from './components/PersonalizationIndicator';
import { IntentConfirmationPanel } from './components/IntentConfirmationPanel';
import { ResearchExecution } from '../types/research.types';
interface ResearchInputProps extends WizardStepProps {
advanced?: boolean;
onAdvancedChange?: (advanced: boolean) => void;
execution?: ResearchExecution;
}
export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, advanced: advancedProp, onAdvancedChange }) => {
export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, onNext, advanced: advancedProp, onAdvancedChange, execution }) => {
const [currentPlaceholder, setCurrentPlaceholder] = useState(0);
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability | null>(null);
const [loadingConfig, setLoadingConfig] = useState(true);
@@ -46,6 +51,18 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, a
suggestions: string[];
} | null>(null);
const [researchAngles, setResearchAngles] = useState<string[]>([]);
const [researchPersona, setResearchPersona] = useState<{
research_angles?: string[];
recommended_presets?: Array<{
name: string;
keywords: string | string[];
description?: string;
}>;
suggested_keywords?: string[];
keyword_expansion_patterns?: Record<string, string[]>;
industry?: string;
target_audience?: string;
} | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
// Use prop if provided, otherwise use local state
@@ -81,36 +98,165 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, a
exa_key_status: 'missing'
});
// Apply persona defaults if not already set (with null checks)
// Phase 2: Apply persona defaults from API
// Backend now returns hyper-personalized values (never "General")
// Always apply if we have values and user hasn't customized
if (config?.persona_defaults) {
if (config.persona_defaults.industry && state.industry === 'General') {
onUpdate({ industry: config.persona_defaults.industry });
const defaults = config.persona_defaults;
// Log whether research persona exists
console.log('[ResearchInput] Persona defaults loaded:', {
hasResearchPersona: defaults.has_research_persona,
industry: defaults.industry,
targetAudience: defaults.target_audience,
hasDomains: defaults.suggested_domains?.length > 0
});
// Apply industry if provided and user hasn't customized
// Phase 2: Backend never returns "General", so we apply unless user has real value
if (defaults.industry && (!state.industry || state.industry === 'General')) {
onUpdate({ industry: defaults.industry });
}
if (config.persona_defaults.target_audience && state.targetAudience === 'General') {
onUpdate({ targetAudience: config.persona_defaults.target_audience });
// Apply target audience if provided
if (defaults.target_audience && (!state.targetAudience || state.targetAudience === 'General')) {
onUpdate({ targetAudience: defaults.target_audience });
}
// Apply suggested Exa domains if Exa is available and not already set
if (config.provider_availability?.exa_available && config.persona_defaults.suggested_domains?.length > 0) {
if (config.provider_availability?.exa_available && defaults.suggested_domains?.length > 0) {
if (!state.config.exa_include_domains || state.config.exa_include_domains.length === 0) {
onUpdate({
config: {
...state.config,
exa_include_domains: config.persona_defaults.suggested_domains
exa_include_domains: defaults.suggested_domains
}
});
}
}
// Apply suggested Exa category if available
if (config.persona_defaults.suggested_exa_category && !state.config.exa_category) {
if (defaults.suggested_exa_category && !state.config.exa_category) {
onUpdate({
config: {
...state.config,
exa_category: config.persona_defaults.suggested_exa_category
exa_category: defaults.suggested_exa_category
}
});
}
// Phase 2+: Apply enhanced Exa defaults from research persona
if (defaults.suggested_exa_search_type && !state.config.exa_search_type) {
onUpdate({
config: {
...state.config,
exa_search_type: defaults.suggested_exa_search_type as 'auto' | 'keyword' | 'neural'
}
});
}
// Phase 2+: Apply Tavily defaults from research persona
if (defaults.suggested_tavily_topic && !state.config.tavily_topic) {
onUpdate({
config: {
...state.config,
tavily_topic: defaults.suggested_tavily_topic as 'general' | 'news' | 'finance'
}
});
}
if (defaults.suggested_tavily_search_depth && !state.config.tavily_search_depth) {
onUpdate({
config: {
...state.config,
tavily_search_depth: defaults.suggested_tavily_search_depth as 'basic' | 'advanced'
}
});
}
if (defaults.suggested_tavily_include_answer && !state.config.tavily_include_answer) {
const answerValue = defaults.suggested_tavily_include_answer === 'true' ? true :
defaults.suggested_tavily_include_answer === 'false' ? false :
defaults.suggested_tavily_include_answer as 'basic' | 'advanced';
onUpdate({
config: {
...state.config,
tavily_include_answer: answerValue
}
});
}
if (defaults.suggested_tavily_time_range && !state.config.tavily_time_range) {
onUpdate({
config: {
...state.config,
tavily_time_range: defaults.suggested_tavily_time_range as 'day' | 'week' | 'month' | 'year'
}
});
}
if (defaults.suggested_tavily_raw_content_format && !state.config.tavily_include_raw_content) {
const rawContentValue = defaults.suggested_tavily_raw_content_format === 'true' ? true :
defaults.suggested_tavily_raw_content_format === 'false' ? false :
defaults.suggested_tavily_raw_content_format as 'markdown' | 'text';
onUpdate({
config: {
...state.config,
tavily_include_raw_content: rawContentValue
}
});
}
// Phase 2: Apply additional hyper-personalization defaults from research persona
if (defaults.has_research_persona && config.research_persona) {
console.log('[ResearchInput] Applying research persona hyper-personalization:', {
researchMode: defaults.default_research_mode,
provider: defaults.default_provider,
suggestedKeywords: defaults.suggested_keywords?.length || 0,
researchAngles: defaults.research_angles?.length || 0,
recommendedPresets: config.research_persona.recommended_presets?.length || 0
});
// Store research persona data for personalized placeholders, keyword expansion, and research angles
setResearchPersona({
research_angles: config.research_persona.research_angles || defaults.research_angles,
recommended_presets: config.research_persona.recommended_presets || [],
suggested_keywords: config.research_persona.suggested_keywords || defaults.suggested_keywords,
keyword_expansion_patterns: config.research_persona.keyword_expansion_patterns,
industry: config.research_persona.default_industry || defaults.industry,
target_audience: config.research_persona.default_target_audience || defaults.target_audience
});
// Apply default research mode if not already customized
if (defaults.default_research_mode && state.researchMode === 'comprehensive') {
const validModes = ['basic', 'comprehensive', 'targeted'] as const;
if (validModes.includes(defaults.default_research_mode as typeof validModes[number])) {
onUpdate({ researchMode: defaults.default_research_mode as typeof validModes[number] });
}
}
// Apply default provider (only if it's available)
if (defaults.default_provider) {
const validProviders = ['exa', 'tavily', 'google'] as const;
type ValidProvider = typeof validProviders[number];
if (validProviders.includes(defaults.default_provider as ValidProvider)) {
const providerAvailable =
(defaults.default_provider === 'exa' && config.provider_availability?.exa_available) ||
(defaults.default_provider === 'tavily' && config.provider_availability?.tavily_available) ||
(defaults.default_provider === 'google' && config.provider_availability?.google_available);
if (providerAvailable && !state.config.provider) {
onUpdate({
config: {
...state.config,
provider: defaults.default_provider as ValidProvider
}
});
}
}
}
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -135,8 +281,8 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, a
loadConfig();
}, []); // Only run once on mount
// Get industry-specific placeholders
const placeholderExamples = getIndustryPlaceholders(state.industry);
// Get industry-specific placeholders, enhanced with research persona data
const placeholderExamples = getIndustryPlaceholders(state.industry, researchPersona || undefined);
// Rotate placeholder examples every 4 seconds
useEffect(() => {
@@ -151,41 +297,26 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, a
setCurrentPlaceholder(0);
}, [state.industry]);
// Auto-set provider based on research mode
// Auto-set provider based on availability
// Priority: Exa → Tavily → Google for ALL modes (including basic)
// This provides better semantic search results for content creators
useEffect(() => {
if (!providerAvailability) return;
// Priority: Exa → Tavily → Google for all modes
let newProvider: ResearchProvider = 'google';
switch (state.researchMode) {
case 'basic':
// Basic: Google only (fast, simple)
newProvider = 'google';
break;
case 'comprehensive':
// Comprehensive: Prefer Exa if available, then Tavily, fallback to Google
if (providerAvailability.exa_available) {
newProvider = 'exa';
} else if (providerAvailability.tavily_available) {
newProvider = 'tavily';
} else {
newProvider = 'google';
}
break;
case 'targeted':
// Targeted: Prefer Exa if available, then Tavily, fallback to Google
if (providerAvailability.exa_available) {
newProvider = 'exa';
} else if (providerAvailability.tavily_available) {
newProvider = 'tavily';
} else {
newProvider = 'google';
}
break;
if (providerAvailability.exa_available) {
newProvider = 'exa';
} else if (providerAvailability.tavily_available) {
newProvider = 'tavily';
} else {
newProvider = 'google';
}
// Only update if provider changed
if (state.config.provider !== newProvider) {
console.log('[ResearchInput] Auto-selecting provider:', newProvider, 'for mode:', state.researchMode);
onUpdate({ config: { ...state.config, provider: newProvider } });
}
}, [state.researchMode, providerAvailability]);
@@ -225,26 +356,70 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, a
}, [state.industry, providerAvailability]);
// Expand keywords when keywords or industry changes
// Enhanced to use research persona data if available
useEffect(() => {
if (state.keywords.length > 0 && state.industry !== 'General') {
const expansion = expandKeywords(state.keywords, state.industry);
if (state.keywords.length > 0) {
let expansion;
// If we have research persona with keyword expansion patterns, use them
if (researchPersona?.keyword_expansion_patterns && Object.keys(researchPersona.keyword_expansion_patterns).length > 0) {
expansion = expandKeywordsWithPersona(state.keywords, researchPersona.keyword_expansion_patterns, researchPersona.suggested_keywords);
} else if (state.industry !== 'General') {
// Fallback to industry-based expansion
expansion = expandKeywords(state.keywords, state.industry);
} else {
expansion = { original: state.keywords, expanded: state.keywords, suggestions: [] };
}
setKeywordExpansion(expansion);
} else {
setKeywordExpansion(null);
}
}, [state.keywords, state.industry]);
}, [state.keywords, state.industry, researchPersona]);
// Generate research angles when keywords change
// Enhanced to prioritize research persona angles if available
useEffect(() => {
if (state.keywords.length > 0) {
// Use the first keyword (or joined keywords) as the query
const query = state.keywords.join(' ');
const angles = generateResearchAngles(query, state.industry);
setResearchAngles(angles);
let angles: string[] = [];
// Priority 1: Use research persona angles if available and relevant
if (researchPersona?.research_angles && researchPersona.research_angles.length > 0) {
// Filter persona angles that are relevant to the current query
const relevantPersonaAngles = researchPersona.research_angles
.filter(angle => {
const angleLower = angle.toLowerCase();
const queryLower = query.toLowerCase();
// Check if angle contains any keyword from query or vice versa
return state.keywords.some(kw => angleLower.includes(kw.toLowerCase()) || queryLower.includes(kw.toLowerCase())) ||
angleLower.includes(queryLower) || queryLower.includes(angleLower);
})
.slice(0, 3); // Use top 3 relevant persona angles
angles.push(...relevantPersonaAngles);
}
// Priority 2: Generate additional angles using pattern matching
const generatedAngles = generateResearchAngles(query, state.industry);
// Merge and deduplicate, prioritizing persona angles
const allAngles = [...angles, ...generatedAngles];
const uniqueAngles = Array.from(new Set(allAngles.map(a => a.toLowerCase())))
.slice(0, 5) // Limit to 5 total
.map(a => {
// Find original casing from persona angles first, then generated
const personaMatch = angles.find(pa => pa.toLowerCase() === a);
if (personaMatch) return personaMatch;
const generatedMatch = generatedAngles.find(ga => ga.toLowerCase() === a);
return generatedMatch || a.charAt(0).toUpperCase() + a.slice(1);
});
setResearchAngles(uniqueAngles);
} else {
setResearchAngles([]);
}
}, [state.keywords, state.industry]);
}, [state.keywords, state.industry, researchPersona]);
// Event handlers
const handleKeywordsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -356,6 +531,11 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, a
fontSize: '20px',
}}>🔍</span>
Research Topic & Keywords
<PersonalizationIndicator
type="placeholder"
hasPersona={!!researchPersona}
source={researchPersona ? "from your research persona" : undefined}
/>
</label>
{/* Advanced Toggle and Upload Button */}
@@ -482,6 +662,26 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, a
{/* Smart Input Detection Indicator */}
<SmartInputIndicator keywords={state.keywords} />
{/* Intent Analysis Panel - Show when intent analysis is available */}
{execution && (execution.isAnalyzingIntent || execution.intentAnalysis) && (
<IntentConfirmationPanel
isAnalyzing={execution.isAnalyzingIntent}
intentAnalysis={execution.intentAnalysis}
confirmedIntent={execution.confirmedIntent}
onConfirm={execution.confirmIntent}
onUpdateField={execution.updateIntentField}
onExecute={async () => {
const result = await execution.executeIntentResearch(state);
if (result?.success) {
// Skip to results step
onUpdate({ currentStep: 3 });
}
}}
onDismiss={execution.clearIntent}
isExecuting={execution.isExecuting}
/>
)}
{/* Keyword Expansion Suggestions */}
{keywordExpansion && keywordExpansion.suggestions.length > 0 && (
<KeywordExpansion
@@ -502,6 +702,7 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, a
<ResearchAngles
angles={researchAngles}
onUseAngle={handleUseAngle}
hasPersona={!!researchPersona}
/>
</div>

View File

@@ -58,7 +58,12 @@ export const StepProgress: React.FC<WizardStepProps> = ({ state, onNext, onUpdat
return '#1976d2';
};
const providerName = state.config.provider === 'exa' ? 'Exa Neural' : 'Google Search';
const providerName =
state.config.provider === 'exa'
? 'Exa Neural'
: state.config.provider === 'tavily'
? 'Tavily AI Search'
: 'Google Search';
const modeName = state.researchMode === 'basic' ? 'Basic' : state.researchMode === 'comprehensive' ? 'Comprehensive' : 'Targeted';
return (

View File

@@ -1,9 +1,20 @@
import React from 'react';
import { WizardStepProps } from '../types/research.types';
import { WizardStepProps, ResearchExecution } from '../types/research.types';
import { ResearchResults } from '../../BlogWriter/ResearchResults';
import { BlogResearchResponse } from '../../../services/blogWriterApi';
import { IntentResultsDisplay } from './components/IntentResultsDisplay';
import { IntentDrivenResearchResponse } from '../types/intent.types';
export const StepResults: React.FC<WizardStepProps> = ({ state, onUpdate, onBack }) => {
interface StepResultsProps extends WizardStepProps {
execution?: ResearchExecution;
}
export const StepResults: React.FC<StepResultsProps> = ({ state, onUpdate, onBack, execution }) => {
// Check if we have intent-driven results
const intentResult: IntentDrivenResearchResponse | null =
execution?.intentResult ||
(state.results as any)?.intent_result ||
null;
if (!state.results) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
@@ -100,8 +111,13 @@ export const StepResults: React.FC<WizardStepProps> = ({ state, onUpdate, onBack
borderRadius: '8px',
border: '1px solid #e0e0e0',
overflow: 'hidden',
padding: intentResult ? '16px' : '0',
}}>
<ResearchResults research={state.results} />
{intentResult ? (
<IntentResultsDisplay result={intentResult} />
) : (
<ResearchResults research={state.results} />
)}
</div>
{/* Action Section */}

View File

@@ -0,0 +1,381 @@
/**
* IntentConfirmationPanel Component
*
* Shows the AI-inferred research intent and allows user to confirm or modify.
* Embedded in the existing ResearchInput component.
*/
import React from 'react';
import {
Box,
Typography,
Chip,
Paper,
Button,
Alert,
CircularProgress,
Collapse,
IconButton,
Tooltip,
Grid,
Card,
CardContent,
FormControl,
Select,
MenuItem,
InputLabel,
} from '@mui/material';
import {
Psychology as BrainIcon,
CheckCircle as CheckIcon,
Close as CloseIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
PlayArrow as PlayIcon,
Edit as EditIcon,
} from '@mui/icons-material';
import {
ResearchIntent,
AnalyzeIntentResponse,
ExpectedDeliverable,
ResearchPurpose,
ContentOutput,
ResearchDepthLevel,
DELIVERABLE_DISPLAY,
PURPOSE_DISPLAY,
DEPTH_DISPLAY,
CONTENT_OUTPUT_DISPLAY,
} from '../../types/intent.types';
interface IntentConfirmationPanelProps {
isAnalyzing: boolean;
intentAnalysis: AnalyzeIntentResponse | null;
confirmedIntent: ResearchIntent | null;
onConfirm: (intent: ResearchIntent) => void;
onUpdateField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
onExecute: () => void;
onDismiss: () => void;
isExecuting: boolean;
}
export const IntentConfirmationPanel: React.FC<IntentConfirmationPanelProps> = ({
isAnalyzing,
intentAnalysis,
confirmedIntent,
onConfirm,
onUpdateField,
onExecute,
onDismiss,
isExecuting,
}) => {
const [showDetails, setShowDetails] = React.useState(false);
const [isEditing, setIsEditing] = React.useState(false);
// Loading state
if (isAnalyzing) {
return (
<Paper
elevation={0}
sx={{
p: 3,
mt: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'primary.light',
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)',
}}
>
<Box display="flex" alignItems="center" gap={2}>
<CircularProgress size={24} />
<Box>
<Typography variant="subtitle1" fontWeight={600}>
🧠 Analyzing your research intent...
</Typography>
<Typography variant="body2" color="text.secondary">
AI is understanding what you want to accomplish
</Typography>
</Box>
</Box>
</Paper>
);
}
// No analysis yet
if (!intentAnalysis || !intentAnalysis.success) {
return null;
}
const intent = intentAnalysis.intent;
const confidence = intent.confidence;
const isHighConfidence = confidence >= 0.8;
return (
<Paper
elevation={0}
sx={{
mt: 2,
borderRadius: 2,
border: '1px solid',
borderColor: isHighConfidence ? 'success.light' : 'warning.light',
overflow: 'hidden',
}}
>
{/* Header */}
<Box
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: isHighConfidence
? 'linear-gradient(135deg, rgba(76, 175, 80, 0.1) 0%, rgba(67, 160, 71, 0.1) 100%)'
: 'linear-gradient(135deg, rgba(255, 152, 0, 0.1) 0%, rgba(251, 140, 0, 0.1) 100%)',
}}
>
<Box display="flex" alignItems="center" gap={1.5}>
<BrainIcon color={isHighConfidence ? 'success' : 'warning'} />
<Box>
<Typography variant="subtitle1" fontWeight={600}>
AI Understood Your Research
</Typography>
<Typography variant="caption" color="text.secondary">
{intentAnalysis.analysis_summary}
</Typography>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Chip
size="small"
label={`${Math.round(confidence * 100)}% confident`}
color={isHighConfidence ? 'success' : 'warning'}
variant="outlined"
/>
<IconButton size="small" onClick={onDismiss}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* Main Content */}
<Box sx={{ p: 2 }}>
{/* Primary Question */}
<Alert
severity="info"
sx={{ mb: 2 }}
icon={<CheckIcon />}
>
<Typography variant="body2" fontWeight={500}>
<strong>Main Question:</strong> {intent.primary_question}
</Typography>
</Alert>
{/* Quick Summary Grid */}
<Grid container spacing={2} mb={2}>
{/* Purpose */}
<Grid item xs={6} sm={3}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent sx={{ py: 1, px: 1.5, '&:last-child': { pb: 1 } }}>
<Typography variant="caption" color="text.secondary">
Purpose
</Typography>
{isEditing ? (
<FormControl size="small" fullWidth sx={{ mt: 0.5 }}>
<Select
value={intent.purpose}
onChange={(e) => onUpdateField('purpose', e.target.value as ResearchPurpose)}
>
{Object.entries(PURPOSE_DISPLAY).map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</Select>
</FormControl>
) : (
<Typography variant="body2" fontWeight={500}>
{PURPOSE_DISPLAY[intent.purpose as ResearchPurpose] || intent.purpose}
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* Content Type */}
<Grid item xs={6} sm={3}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent sx={{ py: 1, px: 1.5, '&:last-child': { pb: 1 } }}>
<Typography variant="caption" color="text.secondary">
Creating
</Typography>
{isEditing ? (
<FormControl size="small" fullWidth sx={{ mt: 0.5 }}>
<Select
value={intent.content_output}
onChange={(e) => onUpdateField('content_output', e.target.value as ContentOutput)}
>
{Object.entries(CONTENT_OUTPUT_DISPLAY).map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</Select>
</FormControl>
) : (
<Typography variant="body2" fontWeight={500}>
{CONTENT_OUTPUT_DISPLAY[intent.content_output as ContentOutput] || intent.content_output}
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* Depth */}
<Grid item xs={6} sm={3}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent sx={{ py: 1, px: 1.5, '&:last-child': { pb: 1 } }}>
<Typography variant="caption" color="text.secondary">
Depth
</Typography>
{isEditing ? (
<FormControl size="small" fullWidth sx={{ mt: 0.5 }}>
<Select
value={intent.depth}
onChange={(e) => onUpdateField('depth', e.target.value as ResearchDepthLevel)}
>
{Object.entries(DEPTH_DISPLAY).map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</Select>
</FormControl>
) : (
<Typography variant="body2" fontWeight={500}>
{DEPTH_DISPLAY[intent.depth as ResearchDepthLevel] || intent.depth}
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* Queries */}
<Grid item xs={6} sm={3}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent sx={{ py: 1, px: 1.5, '&:last-child': { pb: 1 } }}>
<Typography variant="caption" color="text.secondary">
Queries
</Typography>
<Typography variant="body2" fontWeight={500}>
{intentAnalysis.suggested_queries?.length || 0} targeted
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* What we'll find */}
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom display="block">
What I'll find for you:
</Typography>
<Box display="flex" flexWrap="wrap" gap={0.5}>
{intent.expected_deliverables.slice(0, 5).map((d) => (
<Chip
key={d}
label={DELIVERABLE_DISPLAY[d as ExpectedDeliverable] || d}
size="small"
color="primary"
variant="outlined"
/>
))}
{intent.expected_deliverables.length > 5 && (
<Chip
label={`+${intent.expected_deliverables.length - 5} more`}
size="small"
variant="outlined"
/>
)}
</Box>
</Box>
{/* Expandable Details */}
<Collapse in={showDetails}>
<Box sx={{ pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
{/* Secondary Questions */}
{intent.secondary_questions.length > 0 && (
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom display="block">
Also answering:
</Typography>
{intent.secondary_questions.slice(0, 3).map((q, idx) => (
<Typography key={idx} variant="body2" sx={{ ml: 1 }}>
• {q}
</Typography>
))}
</Box>
)}
{/* Focus Areas */}
{intent.focus_areas.length > 0 && (
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom display="block">
Focus areas:
</Typography>
<Box display="flex" flexWrap="wrap" gap={0.5}>
{intent.focus_areas.map((area, idx) => (
<Chip key={idx} label={area} size="small" variant="outlined" />
))}
</Box>
</Box>
)}
{/* Research Angles */}
{intentAnalysis.suggested_angles?.length > 0 && (
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom display="block">
Research angles:
</Typography>
{intentAnalysis.suggested_angles.slice(0, 3).map((angle, idx) => (
<Typography key={idx} variant="body2" sx={{ ml: 1 }}>
• {angle}
</Typography>
))}
</Box>
)}
</Box>
</Collapse>
{/* Action Buttons */}
<Box display="flex" justifyContent="space-between" alignItems="center" mt={2}>
<Box>
<Button
size="small"
onClick={() => setShowDetails(!showDetails)}
endIcon={showDetails ? <ExpandLessIcon /> : <ExpandMoreIcon />}
>
{showDetails ? 'Less details' : 'More details'}
</Button>
<Button
size="small"
startIcon={<EditIcon />}
onClick={() => setIsEditing(!isEditing)}
sx={{ ml: 1 }}
>
{isEditing ? 'Done editing' : 'Edit'}
</Button>
</Box>
<Box display="flex" gap={1}>
<Button
variant="contained"
color="primary"
startIcon={isExecuting ? <CircularProgress size={16} color="inherit" /> : <PlayIcon />}
onClick={() => {
onConfirm(intent);
onExecute();
}}
disabled={isExecuting}
>
{isExecuting ? 'Researching...' : 'Start Research'}
</Button>
</Box>
</Box>
</Box>
</Paper>
);
};
export default IntentConfirmationPanel;

View File

@@ -0,0 +1,451 @@
/**
* IntentResultsDisplay Component
*
* Displays intent-driven research results organized by deliverable type.
* Shows statistics, quotes, case studies, trends, etc. in a structured format.
*/
import React, { useState } from 'react';
import {
Box,
Typography,
Tabs,
Tab,
Card,
CardContent,
Chip,
Alert,
List,
ListItem,
ListItemIcon,
ListItemText,
Grid,
Link,
Divider,
Accordion,
AccordionSummary,
AccordionDetails,
Paper,
} from '@mui/material';
import {
CheckCircle as CheckIcon,
TrendingUp as TrendIcon,
FormatQuote as QuoteIcon,
BarChart as StatsIcon,
School as CaseStudyIcon,
Lightbulb as IdeaIcon,
OpenInNew as OpenIcon,
ExpandMore as ExpandMoreIcon,
Warning as WarningIcon,
} from '@mui/icons-material';
import {
IntentDrivenResearchResponse,
DELIVERABLE_DISPLAY,
} from '../../types/intent.types';
interface IntentResultsDisplayProps {
result: IntentDrivenResearchResponse;
}
export const IntentResultsDisplay: React.FC<IntentResultsDisplayProps> = ({ result }) => {
const [tabIndex, setTabIndex] = useState(0);
// Build available tabs based on what we have
const tabs = [
{ id: 'summary', label: 'Summary', icon: <IdeaIcon />, count: 0 },
...(result.statistics.length > 0 ? [{ id: 'statistics', label: 'Statistics', icon: <StatsIcon />, count: result.statistics.length }] : []),
...(result.expert_quotes.length > 0 ? [{ id: 'quotes', label: 'Expert Quotes', icon: <QuoteIcon />, count: result.expert_quotes.length }] : []),
...(result.case_studies.length > 0 ? [{ id: 'case_studies', label: 'Case Studies', icon: <CaseStudyIcon />, count: result.case_studies.length }] : []),
...(result.trends.length > 0 ? [{ id: 'trends', label: 'Trends', icon: <TrendIcon />, count: result.trends.length }] : []),
{ id: 'sources', label: 'Sources', icon: <OpenIcon />, count: result.sources.length },
];
const currentTab = tabs[tabIndex]?.id || 'summary';
return (
<Box>
{/* Executive Summary Banner */}
{result.executive_summary && (
<Alert
severity="success"
icon={<CheckIcon />}
sx={{ mb: 3, borderRadius: 2 }}
>
<Typography variant="body1">{result.executive_summary}</Typography>
</Alert>
)}
{/* Primary Answer */}
{result.primary_answer && (
<Paper elevation={0} sx={{ p: 3, mb: 3, borderRadius: 2, bgcolor: 'primary.light', color: 'primary.contrastText' }}>
<Typography variant="subtitle2" gutterBottom>
Answer to Your Question:
</Typography>
<Typography variant="body1" fontWeight={500}>
{result.primary_answer}
</Typography>
</Paper>
)}
{/* Tabs */}
<Tabs
value={tabIndex}
onChange={(_, v) => setTabIndex(v)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
>
{tabs.map((tab, idx) => (
<Tab
key={tab.id}
icon={tab.icon}
iconPosition="start"
label={
<Box display="flex" alignItems="center" gap={0.5}>
{tab.label}
{tab.count > 0 && (
<Chip size="small" label={tab.count} color="primary" sx={{ height: 20, fontSize: '0.7rem' }} />
)}
</Box>
}
sx={{ minHeight: 48, textTransform: 'none' }}
/>
))}
</Tabs>
{/* Tab Content */}
<Box sx={{ minHeight: 300 }}>
{/* Summary Tab */}
{currentTab === 'summary' && (
<Box>
{/* Key Takeaways */}
{result.key_takeaways.length > 0 && (
<Box mb={3}>
<Typography variant="h6" gutterBottom color="primary">
Key Takeaways
</Typography>
<List>
{result.key_takeaways.map((takeaway, idx) => (
<ListItem key={idx} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<CheckIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary={takeaway} />
</ListItem>
))}
</List>
</Box>
)}
{/* Best Practices */}
{result.best_practices.length > 0 && (
<Box mb={3}>
<Typography variant="h6" gutterBottom color="primary">
📋 Best Practices
</Typography>
<List>
{result.best_practices.map((practice, idx) => (
<ListItem key={idx} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<IdeaIcon color="info" fontSize="small" />
</ListItemIcon>
<ListItemText primary={practice} />
</ListItem>
))}
</List>
</Box>
)}
{/* Suggested Content Outline */}
{result.suggested_outline.length > 0 && (
<Box mb={3}>
<Typography variant="h6" gutterBottom color="primary">
📝 Suggested Content Outline
</Typography>
<Paper variant="outlined" sx={{ p: 2 }}>
<List dense>
{result.suggested_outline.map((item, idx) => (
<ListItem key={idx}>
<ListItemText primary={item} />
</ListItem>
))}
</List>
</Paper>
</Box>
)}
{/* Definitions */}
{Object.keys(result.definitions).length > 0 && (
<Box mb={3}>
<Typography variant="h6" gutterBottom color="primary">
📖 Key Definitions
</Typography>
<Grid container spacing={2}>
{Object.entries(result.definitions).map(([term, definition], idx) => (
<Grid item xs={12} md={6} key={idx}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" color="primary" gutterBottom>
{term}
</Typography>
<Typography variant="body2">{definition}</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
)}
</Box>
)}
{/* Statistics Tab */}
{currentTab === 'statistics' && (
<Grid container spacing={2}>
{result.statistics.map((stat, idx) => (
<Grid item xs={12} md={6} key={idx}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent>
<Box display="flex" alignItems="flex-start" gap={1}>
<StatsIcon color="primary" />
<Box flex={1}>
<Typography variant="body1" fontWeight={500}>
{stat.statistic}
</Typography>
{stat.value && (
<Chip label={stat.value} color="primary" size="small" sx={{ mt: 0.5 }} />
)}
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
{stat.context}
</Typography>
<Box display="flex" alignItems="center" gap={1} mt={1}>
<Link href={stat.url} target="_blank" rel="noopener" variant="caption">
{stat.source} <OpenIcon sx={{ fontSize: 12 }} />
</Link>
<Chip
size="small"
label={`${Math.round(stat.credibility * 100)}% credible`}
color={stat.credibility > 0.8 ? 'success' : 'warning'}
variant="outlined"
/>
</Box>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Expert Quotes Tab */}
{currentTab === 'quotes' && (
<Box>
{result.expert_quotes.map((quote, idx) => (
<Card key={idx} variant="outlined" sx={{ mb: 2 }}>
<CardContent>
<Box display="flex" gap={2}>
<QuoteIcon color="primary" sx={{ fontSize: 40, opacity: 0.5 }} />
<Box>
<Typography variant="body1" fontStyle="italic" mb={1}>
"{quote.quote}"
</Typography>
<Typography variant="subtitle2" color="primary">
{quote.speaker}
{quote.title && `, ${quote.title}`}
{quote.organization && ` at ${quote.organization}`}
</Typography>
<Link href={quote.url} target="_blank" rel="noopener" variant="caption">
Source: {quote.source} <OpenIcon sx={{ fontSize: 12 }} />
</Link>
</Box>
</Box>
</CardContent>
</Card>
))}
</Box>
)}
{/* Case Studies Tab */}
{currentTab === 'case_studies' && (
<Box>
{result.case_studies.map((cs, idx) => (
<Accordion key={idx} defaultExpanded={idx === 0}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box>
<Typography variant="subtitle1" fontWeight={600}>
{cs.title}
</Typography>
<Typography variant="caption" color="primary">
{cs.organization}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">Challenge</Typography>
<Typography variant="body2">{cs.challenge}</Typography>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">Solution</Typography>
<Typography variant="body2">{cs.solution}</Typography>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">Outcome</Typography>
<Typography variant="body2">{cs.outcome}</Typography>
</Grid>
</Grid>
{cs.key_metrics.length > 0 && (
<Box mt={2} display="flex" gap={1} flexWrap="wrap">
{cs.key_metrics.map((metric, i) => (
<Chip key={i} label={metric} size="small" color="success" variant="outlined" />
))}
</Box>
)}
<Box mt={2}>
<Link href={cs.url} target="_blank" rel="noopener" variant="caption">
Read full case study <OpenIcon sx={{ fontSize: 12 }} />
</Link>
</Box>
</AccordionDetails>
</Accordion>
))}
</Box>
)}
{/* Trends Tab */}
{currentTab === 'trends' && (
<Grid container spacing={2}>
{result.trends.map((trend, idx) => (
<Grid item xs={12} md={6} key={idx}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent>
<Box display="flex" alignItems="center" gap={1} mb={1}>
<TrendIcon
color={trend.direction === 'growing' ? 'success' : trend.direction === 'declining' ? 'error' : 'info'}
/>
<Typography variant="subtitle1" fontWeight={500}>
{trend.trend}
</Typography>
<Chip
size="small"
label={trend.direction}
color={trend.direction === 'growing' ? 'success' : trend.direction === 'declining' ? 'error' : 'info'}
/>
</Box>
{trend.impact && (
<Typography variant="body2" color="text.secondary" mb={1}>
Impact: {trend.impact}
</Typography>
)}
{trend.timeline && (
<Typography variant="caption" color="text.secondary">
Timeline: {trend.timeline}
</Typography>
)}
<Box mt={1}>
<Typography variant="caption" color="text.secondary">Evidence:</Typography>
<List dense>
{trend.evidence.slice(0, 3).map((e, i) => (
<ListItem key={i} sx={{ py: 0, pl: 1 }}>
<ListItemText primary={`${e}`} primaryTypographyProps={{ variant: 'caption' }} />
</ListItem>
))}
</List>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Sources Tab */}
{currentTab === 'sources' && (
<List>
{result.sources.map((source, idx) => (
<ListItem
key={idx}
component="a"
href={source.url}
target="_blank"
rel="noopener"
sx={{
borderBottom: '1px solid',
borderColor: 'divider',
'&:hover': { bgcolor: 'action.hover' }
}}
>
<ListItemText
primary={source.title}
secondary={
<Box>
{source.excerpt && (
<Typography variant="caption" display="block" color="text.secondary">
{source.excerpt}
</Typography>
)}
<Box display="flex" gap={1} mt={0.5}>
{source.content_type && (
<Chip size="small" label={source.content_type} variant="outlined" />
)}
<Chip
size="small"
label={`${Math.round(source.relevance_score * 100)}% relevant`}
color="primary"
variant="outlined"
/>
<Chip
size="small"
label={`${Math.round(source.credibility_score * 100)}% credible`}
color={source.credibility_score > 0.8 ? 'success' : 'warning'}
variant="outlined"
/>
</Box>
</Box>
}
/>
<OpenIcon color="action" />
</ListItem>
))}
</List>
)}
</Box>
{/* Gaps Identified */}
{result.gaps_identified.length > 0 && (
<Alert severity="warning" icon={<WarningIcon />} sx={{ mt: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Gaps Identified:
</Typography>
<List dense>
{result.gaps_identified.map((gap, idx) => (
<ListItem key={idx} sx={{ py: 0 }}>
<ListItemText primary={`${gap}`} />
</ListItem>
))}
</List>
{result.follow_up_queries.length > 0 && (
<Box mt={1}>
<Typography variant="caption" color="text.secondary">
Suggested follow-up: {result.follow_up_queries.slice(0, 2).join(', ')}
</Typography>
</Box>
)}
</Alert>
)}
{/* Confidence */}
<Box mt={2} display="flex" justifyContent="flex-end">
<Chip
label={`Research confidence: ${Math.round(result.confidence * 100)}%`}
color={result.confidence > 0.8 ? 'success' : result.confidence > 0.6 ? 'warning' : 'error'}
variant="outlined"
/>
</Box>
</Box>
);
};
export default IntentResultsDisplay;

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { Tooltip } from '@mui/material';
import { InfoOutlined, AutoAwesome } from '@mui/icons-material';
interface PersonalizationIndicatorProps {
type: 'placeholder' | 'keywords' | 'presets' | 'angles' | 'provider' | 'mode';
hasPersona: boolean;
source?: string; // e.g., "from your website content", "from your writing style"
}
const PERSONALIZATION_TOOLTIPS = {
placeholder: {
title: 'Personalized Placeholders',
description: 'These placeholders are customized based on your research persona, including research angles and recommended presets from your website analysis.',
source: 'from your research persona'
},
keywords: {
title: 'Personalized Keywords',
description: 'Keywords are extracted from your actual website content and matched to your industry and audience preferences.',
source: 'from your website content'
},
presets: {
title: 'Personalized Presets',
description: 'Research presets are generated based on your content types, writing patterns, and website topics for maximum relevance.',
source: 'from your content strategy'
},
angles: {
title: 'Personalized Research Angles',
description: 'Research angles are derived from your writing patterns and style guidelines to match your content approach.',
source: 'from your writing patterns'
},
provider: {
title: 'Smart Provider Selection',
description: 'Research provider is automatically selected based on your writing style complexity and content type preferences.',
source: 'from your writing style'
},
mode: {
title: 'Optimized Research Depth',
description: 'Research depth is matched to your writing complexity level - high complexity gets comprehensive research, simple gets basic.',
source: 'from your writing complexity'
}
};
export const PersonalizationIndicator: React.FC<PersonalizationIndicatorProps> = ({
type,
hasPersona,
source
}) => {
if (!hasPersona) {
return null; // Don't show indicator if no persona
}
const tooltip = PERSONALIZATION_TOOLTIPS[type];
const displaySource = source || tooltip.source;
return (
<Tooltip
title={
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 600, marginBottom: '4px', fontSize: '13px' }}>
{tooltip.title}
</div>
<div style={{ fontSize: '12px', lineHeight: '1.5', marginBottom: '4px' }}>
{tooltip.description}
</div>
<div style={{ fontSize: '11px', color: 'rgba(255, 255, 255, 0.7)', fontStyle: 'italic' }}>
Personalized {displaySource}
</div>
</div>
}
arrow
placement="top"
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
cursor: 'help',
marginLeft: '6px',
color: '#0ea5e9',
}}
>
<AutoAwesome sx={{ fontSize: 14, color: '#0ea5e9' }} />
</span>
</Tooltip>
);
};
interface PersonalizationBadgeProps {
label: string;
source: string;
compact?: boolean;
}
export const PersonalizationBadge: React.FC<PersonalizationBadgeProps> = ({
label,
source,
compact = false
}) => {
return (
<Tooltip
title={`Personalized ${source} - This is customized based on your research persona and website analysis`}
arrow
placement="top"
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: compact ? '2px 6px' : '4px 8px',
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '6px',
fontSize: compact ? '10px' : '11px',
color: '#0369a1',
fontWeight: 500,
cursor: 'help',
}}
>
<AutoAwesome sx={{ fontSize: compact ? 12 : 14, color: '#0ea5e9' }} />
<span>{label}</span>
</div>
</Tooltip>
);
};

View File

@@ -11,39 +11,23 @@ export const ProviderChips: React.FC<ProviderChipsProps> = ({ providerAvailabili
if (!providerAvailability) return null;
// Provider priority: Exa → Tavily → Google for all modes
// Status indicators show availability (green=configured, red=not configured)
const providers = [
{
id: 'google',
name: 'Google',
available: providerAvailability.google_available,
status: providerAvailability.gemini_key_status,
icon: '🔍',
tooltip: 'Google Search powered by Gemini AI. Provides comprehensive web search results with semantic understanding and real-time information from across the web.',
color: providerAvailability.google_available
? 'linear-gradient(135deg, rgba(66, 133, 244, 0.15) 0%, rgba(52, 168, 83, 0.15) 100%)'
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
borderColor: providerAvailability.google_available
? 'rgba(66, 133, 244, 0.3)'
: 'rgba(239, 68, 68, 0.2)',
textColor: providerAvailability.google_available ? '#4285f4' : '#ef4444',
},
{
id: 'exa',
name: 'Exa',
available: providerAvailability.exa_available,
status: providerAvailability.exa_key_status,
icon: '🧠',
tooltip: 'Exa Neural Search. Advanced semantic search engine that understands context and meaning, providing highly relevant results through neural network-powered query understanding.',
// Show green when advanced is ON and available, red when advanced is OFF or not available
isAdvanced: true,
color: (advanced && providerAvailability.exa_available)
tooltip: 'Exa Neural Search (Primary). Advanced semantic search engine that understands context and meaning. Used by default when available.',
color: providerAvailability.exa_available
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)'
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
borderColor: (advanced && providerAvailability.exa_available)
borderColor: providerAvailability.exa_available
? 'rgba(16, 185, 129, 0.3)'
: 'rgba(239, 68, 68, 0.2)',
textColor: (advanced && providerAvailability.exa_available) ? '#10b981' : '#ef4444',
chipStatus: (advanced && providerAvailability.exa_available) ? '#10b981' : '#ef4444',
textColor: providerAvailability.exa_available ? '#10b981' : '#ef4444',
},
{
id: 'tavily',
@@ -51,17 +35,29 @@ export const ProviderChips: React.FC<ProviderChipsProps> = ({ providerAvailabili
available: providerAvailability.tavily_available,
status: providerAvailability.tavily_key_status,
icon: '🤖',
tooltip: 'Tavily AI Research Engine. Specialized AI-powered research tool designed for comprehensive content discovery, providing deep insights and structured research data from multiple sources.',
// Show green when advanced is ON and available, red when advanced is OFF or not available
isAdvanced: true,
color: (advanced && providerAvailability.tavily_available)
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)'
tooltip: 'Tavily AI Research (Secondary). Specialized AI-powered research tool with real-time data and news. Used when Exa is unavailable.',
color: providerAvailability.tavily_available
? 'linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(37, 99, 235, 0.15) 100%)'
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
borderColor: (advanced && providerAvailability.tavily_available)
? 'rgba(16, 185, 129, 0.3)'
borderColor: providerAvailability.tavily_available
? 'rgba(59, 130, 246, 0.3)'
: 'rgba(239, 68, 68, 0.2)',
textColor: (advanced && providerAvailability.tavily_available) ? '#10b981' : '#ef4444',
chipStatus: (advanced && providerAvailability.tavily_available) ? '#10b981' : '#ef4444',
textColor: providerAvailability.tavily_available ? '#3b82f6' : '#ef4444',
},
{
id: 'google',
name: 'Google',
available: providerAvailability.google_available,
status: providerAvailability.gemini_key_status,
icon: '🔍',
tooltip: 'Google Search (Fallback). Gemini-powered web search. Used when Exa and Tavily are unavailable.',
color: providerAvailability.google_available
? 'linear-gradient(135deg, rgba(66, 133, 244, 0.15) 0%, rgba(52, 168, 83, 0.15) 100%)'
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
borderColor: providerAvailability.google_available
? 'rgba(66, 133, 244, 0.3)'
: 'rgba(239, 68, 68, 0.2)',
textColor: providerAvailability.google_available ? '#4285f4' : '#ef4444',
},
];
@@ -111,8 +107,8 @@ export const ProviderChips: React.FC<ProviderChipsProps> = ({ providerAvailabili
width: '6px',
height: '6px',
borderRadius: '50%',
background: (provider as any).chipStatus || (provider.available ? '#10b981' : '#ef4444'),
boxShadow: ((provider as any).chipStatus === '#10b981') || (provider.available && !(provider as any).isAdvanced)
background: provider.available ? '#10b981' : '#ef4444',
boxShadow: provider.available
? '0 0 4px rgba(16, 185, 129, 0.4)'
: '0 0 4px rgba(239, 68, 68, 0.4)',
}} />

View File

@@ -1,12 +1,14 @@
import React from 'react';
import { formatAngle } from '../../../../utils/researchAngles';
import { PersonalizationIndicator } from './PersonalizationIndicator';
interface ResearchAnglesProps {
angles: string[];
onUseAngle: (angle: string) => void;
hasPersona?: boolean;
}
export const ResearchAngles: React.FC<ResearchAnglesProps> = ({ angles, onUseAngle }) => {
export const ResearchAngles: React.FC<ResearchAnglesProps> = ({ angles, onUseAngle, hasPersona = false }) => {
if (angles.length === 0) return null;
return (
@@ -33,6 +35,13 @@ export const ResearchAngles: React.FC<ResearchAnglesProps> = ({ angles, onUseAng
}}>
Explore Alternative Research Angles
</span>
{hasPersona && (
<PersonalizationIndicator
type="angles"
hasPersona={hasPersona}
source="from your writing patterns"
/>
)}
</div>
<div style={{
display: 'grid',

View File

@@ -1,17 +1,20 @@
import React from 'react';
import { ProviderAvailability } from '../../../../api/researchConfig';
import { industries } from '../utils/constants';
import { PersonalizationIndicator } from './PersonalizationIndicator';
interface ResearchControlsBarProps {
industry: string;
providerAvailability: ProviderAvailability | null;
onIndustryChange: (industry: string) => void;
hasPersona?: boolean;
}
export const ResearchControlsBar: React.FC<ResearchControlsBarProps> = ({
industry,
providerAvailability,
onIndustryChange,
hasPersona = false,
}) => {
const dropdownStyle = {
minWidth: '130px',
@@ -83,21 +86,29 @@ export const ResearchControlsBar: React.FC<ResearchControlsBarProps> = ({
flexWrap: 'wrap',
}}>
{/* Industry Dropdown */}
<select
value={industry}
onChange={(e) => onIndustryChange(e.target.value)}
title="Select industry for targeted research"
style={dropdownStyle}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{industries.map(ind => (
<option key={ind} value={ind}>{ind}</option>
))}
</select>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<select
value={industry}
onChange={(e) => onIndustryChange(e.target.value)}
title="Select industry for targeted research"
style={dropdownStyle}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{industries.map(ind => (
<option key={ind} value={ind}>{ind}</option>
))}
</select>
{hasPersona && industry !== 'General' && (
<PersonalizationIndicator
type="keywords"
hasPersona={hasPersona}
source="from your research persona"
/>
)}
</div>
</div>
</div>
);

View File

@@ -1,21 +1,31 @@
import React from 'react';
import { PersonalizationIndicator } from './PersonalizationIndicator';
interface TargetAudienceProps {
value: string;
onChange: (value: string) => void;
hasPersona?: boolean;
}
export const TargetAudience: React.FC<TargetAudienceProps> = ({ value, onChange }) => {
export const TargetAudience: React.FC<TargetAudienceProps> = ({ value, onChange, hasPersona = false }) => {
return (
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
marginBottom: '8px',
fontSize: '13px',
fontWeight: '600',
color: '#0c4a6e',
}}>
Target Audience (Optional)
{hasPersona && (
<PersonalizationIndicator
type="keywords"
hasPersona={hasPersona}
source="from your research persona"
/>
)}
</label>
<input
type="text"

View File

@@ -1,58 +1,139 @@
/**
* Industry-specific placeholder examples for personalized experience
* Enhanced to use research persona data (research_angles and recommended_presets)
*/
export const getIndustryPlaceholders = (industry: string): string[] => {
export interface PersonaPlaceholderData {
research_angles?: string[];
recommended_presets?: Array<{
name: string;
keywords: string | string[];
description?: string;
}>;
industry?: string;
target_audience?: string;
}
export const getIndustryPlaceholders = (
industry: string,
personaData?: PersonaPlaceholderData
): string[] => {
// If we have research persona data, use it to generate personalized placeholders
if (personaData) {
const personalizedPlaceholders: string[] = [];
// Priority 1: Use recommended presets (most actionable)
if (personaData.recommended_presets && personaData.recommended_presets.length > 0) {
const presets = personaData.recommended_presets.slice(0, 4); // Use first 4 presets
presets.forEach((preset) => {
const keywords = typeof preset.keywords === 'string'
? preset.keywords
: Array.isArray(preset.keywords)
? preset.keywords.join(', ')
: '';
if (keywords && keywords.trim().length > 0) {
// Make placeholders concise and actionable
personalizedPlaceholders.push(keywords.trim());
}
});
}
// Priority 2: Use research angles (formatted as actionable queries)
if (personaData.research_angles && personaData.research_angles.length > 0 && personalizedPlaceholders.length < 4) {
const angles = personaData.research_angles.slice(0, 4 - personalizedPlaceholders.length);
angles.forEach((angle) => {
// Format angle as a concise research query
let placeholder = angle;
// Replace topic placeholders with industry if available
if (placeholder.includes('{topic}') || placeholder.includes('{{topic}}')) {
placeholder = placeholder.replace(/\{topic\}/g, industry || 'your topic')
.replace(/\{\{topic\}\}/g, industry || 'your topic');
}
// Make it concise - remove "Research:" prefix if present, keep it natural
placeholder = placeholder.replace(/^Research:\s*/i, '').trim();
if (placeholder && placeholder.length > 10) { // Only add meaningful angles
personalizedPlaceholders.push(placeholder);
}
});
}
// If we have personalized placeholders, return them (with fallback to industry defaults)
if (personalizedPlaceholders.length > 0) {
// Add 1-2 industry-specific ones as backup for variety
const industryDefaults = getIndustryDefaults(industry);
const needed = Math.max(0, 5 - personalizedPlaceholders.length);
return [...personalizedPlaceholders, ...industryDefaults.slice(0, needed)];
}
}
// Fallback to industry-specific defaults
return getIndustryDefaults(industry);
};
/**
* Get industry-specific default placeholders (original logic)
*/
const getIndustryDefaults = (industry: string): string[] => {
const industryExamples: Record<string, string[]> = {
Healthcare: [
"Research: AI-powered diagnostic tools in clinical practice\n\n💡 What you'll get:\n• FDA-approved AI medical devices\n• Clinical accuracy and patient outcomes\n• Implementation costs and ROI",
"Analyze: Telemedicine adoption trends and patient satisfaction\n\n💡 Research includes:\n• Post-pandemic telehealth growth\n• Remote patient monitoring technologies\n• Insurance coverage and reimbursement",
"Investigate: Personalized medicine and genomic testing advances\n\n💡 You'll discover:\n• Latest genomic sequencing technologies\n• Precision therapy success rates\n• Ethical considerations and regulations"
"AI diagnostic tools and clinical applications",
"Telemedicine adoption and patient outcomes",
"Personalized medicine and genomic testing",
"Healthcare automation and workflow optimization"
],
Technology: [
"Investigate: Latest developments in edge computing and IoT\n\n💡 What you'll get:\n• Edge AI deployment strategies\n• 5G integration and performance\n• Industry use cases and benchmarks",
"Compare: Cloud providers for enterprise SaaS applications\n\n💡 Research includes:\n• AWS vs Azure vs GCP feature comparison\n• Cost optimization strategies\n• Security and compliance certifications",
"Analyze: Quantum computing breakthroughs and commercial applications\n\n💡 You'll discover:\n• Latest quantum hardware developments\n• Real-world problem solving examples\n• Investment landscape and timeline"
"Edge computing and IoT deployment strategies",
"Cloud provider comparison and cost optimization",
"Quantum computing breakthroughs and applications",
"AI and machine learning industry trends"
],
Finance: [
"Research: DeFi regulatory landscape and compliance challenges\n\n💡 What you'll get:\n• Global regulatory frameworks\n• Compliance best practices\n• Risk management strategies",
"Analyze: Digital banking customer retention strategies\n\n💡 Research includes:\n• Neobank growth and market share\n• Customer acquisition costs and LTV\n• Personalization and UX innovations",
"Investigate: ESG investing trends and impact measurement\n\n💡 You'll discover:\n• ESG rating methodologies\n• Fund performance and returns\n• Regulatory requirements and reporting"
"DeFi regulations and compliance strategies",
"Digital banking and customer retention",
"ESG investing trends and performance",
"Fintech innovations and market analysis"
],
Marketing: [
"Research: AI-powered marketing automation and personalization\n\n💡 What you'll get:\n• Top marketing AI platforms and features\n• ROI and conversion rate improvements\n• Implementation case studies",
"Analyze: Influencer marketing ROI and authenticity trends\n\n💡 Research includes:\n• Micro vs macro influencer effectiveness\n• Platform-specific engagement rates\n• Brand partnership best practices",
"Investigate: Privacy-first marketing in a cookieless world\n\n💡 You'll discover:\n• First-party data strategies\n• Contextual targeting innovations\n• Compliance with privacy regulations"
"AI marketing automation and personalization",
"Influencer marketing ROI and best practices",
"Privacy-first marketing in cookieless world",
"Content marketing strategies and trends"
],
Business: [
"Research: Remote work policies and hybrid workplace models\n\n💡 What you'll get:\n• Productivity metrics and employee satisfaction\n• Technology infrastructure requirements\n• Cultural impact and change management",
"Analyze: Supply chain resilience and diversification strategies\n\n💡 Research includes:\n• Nearshoring and reshoring trends\n• Technology solutions for visibility\n• Risk mitigation frameworks",
"Investigate: Sustainability initiatives and corporate ESG programs\n\n💡 You'll discover:\n• Industry-specific sustainability benchmarks\n• Cost-benefit analysis of green initiatives\n• Stakeholder communication strategies"
"Remote work policies and hybrid models",
"Supply chain resilience and diversification",
"Sustainability initiatives and ESG programs",
"Business automation and efficiency"
],
Education: [
"Research: EdTech tools for personalized learning experiences\n\n💡 What you'll get:\n• Adaptive learning platform comparisons\n• Student engagement and outcomes data\n• Implementation costs and training needs",
"Analyze: Microlearning and skill-based education trends\n\n💡 Research includes:\n• Corporate training effectiveness\n• Platform and content recommendations\n• ROI and completion rates",
"Investigate: AI tutoring systems and student support tools\n\n💡 You'll discover:\n• Natural language processing advances\n• Student performance improvements\n• Accessibility and inclusion features"
"EdTech tools and personalized learning",
"Microlearning and skill-based education",
"AI tutoring systems and student support",
"Online learning platforms and outcomes"
],
'Real Estate': [
"Research: PropTech innovations transforming property management\n\n💡 What you'll get:\n• Smart building technologies and IoT\n• Tenant experience platforms\n• Operational efficiency gains",
"Analyze: Virtual staging and 3D property tours adoption\n\n💡 Research includes:\n• Technology provider comparisons\n• Impact on sales velocity and pricing\n• Cost vs traditional staging",
"Investigate: Real estate tokenization and fractional ownership\n\n💡 You'll discover:\n• Blockchain platforms and regulations\n• Investor demographics and demand\n• Liquidity and exit strategies"
"PropTech innovations and property management",
"Virtual staging and 3D property tours",
"Real estate tokenization and fractional ownership",
"Smart building technologies and IoT"
],
Travel: [
"Research: Sustainable tourism trends and eco-travel preferences\n\n💡 What you'll get:\n• Green certification programs\n• Traveler willingness to pay premium\n• Destination best practices",
"Analyze: AI-powered travel personalization and recommendations\n\n💡 Research includes:\n• Recommendation engine technologies\n• Booking conversion rate improvements\n• Customer lifetime value impact",
"Investigate: Bleisure travel and workation destination trends\n\n💡 You'll discover:\n• Remote work-friendly destinations\n• Co-working and accommodation options\n• Digital nomad demographics"
"Sustainable tourism and eco-travel trends",
"AI travel personalization and recommendations",
"Bleisure travel and workation destinations",
"Travel technology and booking platforms"
]
};
// Default placeholders - concise and actionable
return industryExamples[industry] || [
"Research: Latest AI advancements in your industry\n\n💡 What you'll get:\n• Recent breakthroughs and innovations\n• Key companies and technologies\n• Expert insights and market trends",
"Write a blog on: Emerging trends shaping your industry in 2025\n\n💡 This will research:\n• Technology disruptions and innovations\n• Regulatory changes and compliance\n• Consumer behavior shifts",
"Analyze: Best practices and success stories in your field\n\n💡 Research includes:\n• Industry leader strategies\n• Implementation case studies\n• ROI and performance metrics",
"https://example.com/article\n\n💡 URL detected! Research will:\n• Extract key insights from the article\n• Find related sources and updates\n• Provide comprehensive context"
"Latest AI trends and innovations",
"Best practices and case studies",
"Market analysis and competitor insights",
"Emerging technologies and future predictions"
];
};

View File

@@ -0,0 +1,328 @@
/**
* Intent-Driven Research Types
*
* Types for the new intent-driven research system that:
* - Infers user intent from minimal input
* - Generates targeted queries
* - Analyzes results based on what user needs
*/
// ============================================================================
// Enums
// ============================================================================
export type ResearchPurpose =
| 'learn'
| 'create_content'
| 'make_decision'
| 'compare'
| 'solve_problem'
| 'find_data'
| 'explore_trends'
| 'validate'
| 'generate_ideas';
export type ContentOutput =
| 'blog'
| 'podcast'
| 'video'
| 'social_post'
| 'newsletter'
| 'presentation'
| 'report'
| 'whitepaper'
| 'email'
| 'general';
export type ExpectedDeliverable =
| 'key_statistics'
| 'expert_quotes'
| 'case_studies'
| 'comparisons'
| 'trends'
| 'best_practices'
| 'step_by_step'
| 'pros_cons'
| 'definitions'
| 'citations'
| 'examples'
| 'predictions';
export type ResearchDepthLevel = 'overview' | 'detailed' | 'expert';
export type InputType = 'keywords' | 'question' | 'goal' | 'mixed';
// ============================================================================
// Core Intent Types
// ============================================================================
export interface ResearchIntent {
primary_question: string;
secondary_questions: string[];
purpose: ResearchPurpose;
content_output: ContentOutput;
expected_deliverables: ExpectedDeliverable[];
depth: ResearchDepthLevel;
focus_areas: string[];
perspective: string | null;
time_sensitivity: string | null;
input_type: InputType;
original_input: string;
confidence: number;
needs_clarification: boolean;
clarifying_questions: string[];
}
export interface ResearchQuery {
query: string;
purpose: ExpectedDeliverable;
provider: 'exa' | 'tavily' | 'google';
priority: number;
expected_results: string;
}
// ============================================================================
// Deliverable Types
// ============================================================================
export interface StatisticWithCitation {
statistic: string;
value: string | null;
context: string;
source: string;
url: string;
credibility: number;
recency: string | null;
}
export interface ExpertQuote {
quote: string;
speaker: string;
title: string | null;
organization: string | null;
context: string | null;
source: string;
url: string;
}
export interface CaseStudySummary {
title: string;
organization: string;
challenge: string;
solution: string;
outcome: string;
key_metrics: string[];
source: string;
url: string;
}
export interface TrendAnalysis {
trend: string;
direction: 'growing' | 'declining' | 'emerging' | 'stable';
evidence: string[];
impact: string | null;
timeline: string | null;
sources: string[];
}
export interface ComparisonItem {
name: string;
description: string | null;
pros: string[];
cons: string[];
features: Record<string, string>;
rating: number | null;
source: string | null;
}
export interface ComparisonTable {
title: string;
criteria: string[];
items: ComparisonItem[];
winner: string | null;
verdict: string | null;
}
export interface ProsCons {
subject: string;
pros: string[];
cons: string[];
balanced_verdict: string;
}
export interface SourceWithRelevance {
title: string;
url: string;
excerpt: string | null;
relevance_score: number;
relevance_reason: string | null;
content_type: string | null;
published_date: string | null;
credibility_score: number;
}
// ============================================================================
// API Request/Response Types
// ============================================================================
export interface AnalyzeIntentRequest {
user_input: string;
keywords: string[];
use_persona: boolean;
use_competitor_data: boolean;
}
export interface AnalyzeIntentResponse {
success: boolean;
intent: ResearchIntent;
analysis_summary: string;
suggested_queries: ResearchQuery[];
suggested_keywords: string[];
suggested_angles: string[];
quick_options: QuickOption[];
error_message: string | null;
}
export interface QuickOption {
id: string;
label: string;
value: string | string[];
display: string | string[];
alternatives: string[];
confidence: number;
multi_select?: boolean;
}
export interface IntentDrivenResearchRequest {
user_input: string;
confirmed_intent?: ResearchIntent;
selected_queries?: ResearchQuery[];
max_sources: number;
include_domains: string[];
exclude_domains: string[];
skip_inference: boolean;
}
export interface IntentDrivenResearchResponse {
success: boolean;
// Direct answers
primary_answer: string;
secondary_answers: Record<string, string>;
// Deliverables
statistics: StatisticWithCitation[];
expert_quotes: ExpertQuote[];
case_studies: CaseStudySummary[];
trends: TrendAnalysis[];
comparisons: ComparisonTable[];
best_practices: string[];
step_by_step: string[];
pros_cons: ProsCons | null;
definitions: Record<string, string>;
examples: string[];
predictions: string[];
// Content-ready outputs
executive_summary: string;
key_takeaways: string[];
suggested_outline: string[];
// Sources and metadata
sources: SourceWithRelevance[];
confidence: number;
gaps_identified: string[];
follow_up_queries: string[];
// The intent used
intent: ResearchIntent | null;
// Error
error_message: string | null;
}
// ============================================================================
// UI State Types
// ============================================================================
export interface IntentWizardState {
// User input
userInput: string;
keywords: string[];
// Inferred/confirmed intent
intent: ResearchIntent | null;
// Suggested queries
suggestedQueries: ResearchQuery[];
selectedQueries: ResearchQuery[];
// Quick options for confirmation
quickOptions: QuickOption[];
// Analysis
analysisSummary: string;
suggestedKeywords: string[];
suggestedAngles: string[];
// State
isAnalyzing: boolean;
isResearching: boolean;
hasConfirmedIntent: boolean;
// Results
result: IntentDrivenResearchResponse | null;
// Errors
error: string | null;
}
// ============================================================================
// Display Helpers
// ============================================================================
export const PURPOSE_DISPLAY: Record<ResearchPurpose, string> = {
learn: 'Understand this topic',
create_content: 'Create content about this',
make_decision: 'Make a decision',
compare: 'Compare options',
solve_problem: 'Solve a problem',
find_data: 'Find specific data',
explore_trends: 'Explore trends',
validate: 'Validate information',
generate_ideas: 'Generate ideas',
};
export const CONTENT_OUTPUT_DISPLAY: Record<ContentOutput, string> = {
blog: 'Blog Post',
podcast: 'Podcast',
video: 'Video',
social_post: 'Social Post',
newsletter: 'Newsletter',
presentation: 'Presentation',
report: 'Report',
whitepaper: 'Whitepaper',
email: 'Email',
general: 'General Research',
};
export const DELIVERABLE_DISPLAY: Record<ExpectedDeliverable, string> = {
key_statistics: 'Key Statistics',
expert_quotes: 'Expert Quotes',
case_studies: 'Case Studies',
comparisons: 'Comparisons',
trends: 'Trends',
best_practices: 'Best Practices',
step_by_step: 'Step-by-Step Guide',
pros_cons: 'Pros & Cons',
definitions: 'Definitions',
citations: 'Citations',
examples: 'Examples',
predictions: 'Predictions',
};
export const DEPTH_DISPLAY: Record<ResearchDepthLevel, string> = {
overview: 'Quick Overview',
detailed: 'Detailed Analysis',
expert: 'Expert-Level Deep Dive',
};

View File

@@ -1,4 +1,9 @@
import { BlogResearchResponse, ResearchMode, ResearchConfig } from '../../../services/blogWriterApi';
import {
ResearchIntent,
AnalyzeIntentResponse,
IntentDrivenResearchResponse
} from './intent.types';
export interface WizardState {
currentStep: number;
@@ -11,6 +16,7 @@ export interface WizardState {
}
export interface ResearchExecution {
// Legacy API
executeResearch: (state: WizardState) => Promise<string | null>;
stopExecution: () => void;
isExecuting: boolean;
@@ -18,6 +24,19 @@ export interface ResearchExecution {
progressMessages: Array<{ timestamp: string; message: string }>;
currentStatus: string;
result: any;
// Intent-driven API
useIntentMode: boolean;
setUseIntentMode: (enabled: boolean) => void;
isAnalyzingIntent: boolean;
intentAnalysis: AnalyzeIntentResponse | null;
confirmedIntent: ResearchIntent | null;
intentResult: IntentDrivenResearchResponse | null;
analyzeIntent: (state: WizardState) => Promise<AnalyzeIntentResponse | null>;
confirmIntent: (intent: ResearchIntent) => void;
updateIntentField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
executeIntentResearch: (state: WizardState) => Promise<IntentDrivenResearchResponse | null>;
clearIntent: () => void;
}
export interface WizardStepProps {

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { Box, Paper, Stack, Typography, Chip } from '@mui/material';
import { VideoStudioLayout } from './VideoStudioLayout';
interface ModulePlaceholderProps {
title: string;
subtitle: string;
status?: 'live' | 'beta' | 'coming soon';
description?: string;
bullets?: string[];
}
const statusColor: Record<string, { bg: string; color: string }> = {
live: { bg: 'rgba(16,185,129,0.18)', color: '#10b981' },
beta: { bg: 'rgba(59,130,246,0.18)', color: '#3b82f6' },
'coming soon': { bg: 'rgba(249,115,22,0.18)', color: '#f97316' },
};
export const ModulePlaceholder: React.FC<ModulePlaceholderProps> = ({
title,
subtitle,
status = 'coming soon',
description,
bullets = [],
}) => {
const style = statusColor[status] || statusColor['coming soon'];
return (
<VideoStudioLayout headerProps={{ title, subtitle }}>
<Paper
elevation={0}
sx={{
maxWidth: 1100,
mx: 'auto',
borderRadius: 4,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.78)',
p: { xs: 3, md: 4 },
backdropFilter: 'blur(18px)',
}}
>
<Stack spacing={2}>
<Chip
label={status.toUpperCase()}
sx={{
alignSelf: 'flex-start',
backgroundColor: style.bg,
color: style.color,
fontWeight: 700,
}}
/>
{description && (
<Typography variant="body1" color="text.secondary">
{description}
</Typography>
)}
{bullets.length > 0 && (
<Stack spacing={1}>
{bullets.map(item => (
<Box
key={item}
sx={{
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 2,
px: 2,
py: 1.25,
background: 'rgba(255,255,255,0.02)',
}}
>
<Typography variant="body2" color="text.secondary">
{item}
</Typography>
</Box>
))}
</Stack>
)}
<Typography variant="body2" color="text.secondary">
Well surface cost estimates, provider choices, and templates here as the module goes live.
</Typography>
</Stack>
</Paper>
</VideoStudioLayout>
);
};
export default ModulePlaceholder;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Grid, Paper, Stack, Typography, Divider } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { VideoStudioLayout } from './VideoStudioLayout';
import { videoStudioModules } from './dashboard/modules';
import { ModuleCard } from './dashboard/ModuleCard';
export const VideoStudioDashboard: React.FC = () => {
const navigate = useNavigate();
const [hovered, setHovered] = React.useState<string>('');
return (
<VideoStudioLayout>
<Paper
elevation={0}
sx={{
maxWidth: 1400,
mx: 'auto',
borderRadius: 4,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.78)',
p: { xs: 3, md: 5 },
backdropFilter: 'blur(25px)',
}}
>
<Grid container spacing={3}>
{videoStudioModules.map(module => (
<Grid item xs={12} md={6} key={module.key}>
<ModuleCard
module={module}
isHovered={hovered === module.key}
onMouseEnter={() => setHovered(module.key)}
onMouseLeave={() => setHovered('')}
onNavigate={navigate}
/>
</Grid>
))}
</Grid>
</Paper>
</VideoStudioLayout>
);
};
export default VideoStudioDashboard;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { Box } from '@mui/material';
import { motion } from 'framer-motion';
import type { Variants } from 'framer-motion';
import DashboardHeader from '../shared/DashboardHeader';
import type { DashboardHeaderProps } from '../shared/types';
const MotionBox = motion(Box);
const sparkleVariants: Variants = {
initial: { scale: 0, rotate: 0 },
animate: {
scale: [0, 1, 0],
rotate: [0, 180, 360],
transition: { duration: 2, repeat: Infinity, ease: 'easeInOut' },
},
};
interface VideoStudioLayoutProps {
children: React.ReactNode;
showHeader?: boolean;
headerProps?: DashboardHeaderProps;
}
const defaultHeaderProps: DashboardHeaderProps = {
title: 'AI Video Studio',
subtitle:
'Provider-agnostic, cost-transparent video creation. Generate, enhance, and optimize videos with guided presets.',
};
export const VideoStudioLayout: React.FC<VideoStudioLayoutProps> = ({
children,
showHeader = true,
headerProps,
}) => {
const mergedHeaderProps = { ...defaultHeaderProps, ...headerProps };
return (
<Box
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f172a 0%, #1e1b4b 40%, #312e81 100%)',
py: 4,
px: 2,
position: 'relative',
overflow: 'hidden',
}}
>
<Box
sx={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 0,
}}
>
{[...Array(20)].map((_, i) => (
<MotionBox
key={i}
variants={sparkleVariants}
initial="initial"
animate="animate"
transition={{ delay: i * 0.08 }}
sx={{
position: 'absolute',
width: 4,
height: 4,
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.6)',
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
}}
/>
))}
</Box>
<Box
sx={{
maxWidth: 1400,
mx: 'auto',
position: 'relative',
zIndex: 1,
}}
>
{showHeader && (
<Box sx={{ mb: 3 }}>
<DashboardHeader {...mergedHeaderProps} />
</Box>
)}
{children}
</Box>
</Box>
);
};
export default VideoStudioLayout;

View File

@@ -0,0 +1,202 @@
import React from 'react';
import {
Box,
Paper,
Stack,
Typography,
Chip,
Button,
Tooltip,
Divider,
} from '@mui/material';
import LaunchIcon from '@mui/icons-material/Launch';
import LockIcon from '@mui/icons-material/Lock';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import SavingsIcon from '@mui/icons-material/Savings';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import { alpha } from '@mui/material/styles';
import type { ModuleConfig } from './types';
import { statusStyles } from './modules';
import { CreateVideoPreview, AvatarVideoPreview, EnhanceVideoPreview } from './previews';
interface ModuleCardProps {
module: ModuleConfig;
isHovered: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
onNavigate: (route: string) => void;
}
export const ModuleCard: React.FC<ModuleCardProps> = ({
module,
isHovered,
onMouseEnter,
onMouseLeave,
onNavigate,
}) => {
const status = statusStyles[module.status];
const disabled = module.status !== 'live';
return (
<Paper
sx={{
height: '100%',
borderRadius: 4,
p: 3,
border: '1px solid rgba(255,255,255,0.12)',
background: 'linear-gradient(160deg, rgba(15,23,42,0.95), rgba(30,41,59,0.92))',
display: 'flex',
flexDirection: 'column',
gap: 1.75,
position: 'relative',
transition: 'transform 0.28s ease, box-shadow 0.28s ease, border-color 0.28s ease',
boxShadow: isHovered
? '0 24px 50px rgba(79,70,229,0.32)'
: '0 12px 28px rgba(15,23,42,0.35)',
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
overflow: 'hidden',
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Stack direction="row" spacing={1.5} alignItems="center">
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: alpha('#6366f1', 0.2),
color: '#c7d2fe',
fontSize: 22,
}}
>
{module.icon}
</Box>
<Stack spacing={0.25}>
<Typography variant="h6" fontWeight={700}>
{module.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{module.subtitle}
</Typography>
</Stack>
</Stack>
<Chip
label={status.label}
size="small"
sx={{
backgroundColor: alpha(status.color, 0.2),
color: status.color,
fontWeight: 700,
}}
/>
</Stack>
<Typography variant="body2" sx={{ color: 'rgba(241,245,249,0.95)' }}>
{module.description}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{module.highlights.map(item => (
<Chip
key={item}
size="small"
label={item}
sx={{
background: 'linear-gradient(120deg, rgba(99,102,241,0.45), rgba(14,165,233,0.38))',
color: '#f8fafc',
border: '1px solid rgba(255,255,255,0.35)',
fontWeight: 600,
letterSpacing: 0.2,
}}
/>
))}
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<Tooltip title={module.help || 'Guidance and intended use cases'}>
<HelpOutlineIcon sx={{ fontSize: 18, color: 'rgba(148,163,184,0.95)' }} />
</Tooltip>
<Typography variant="body2" color="text.secondary">
{module.help || 'Built for creators: pick a template and we guide duration/aspect and cost.'}
</Typography>
</Stack>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
<Stack direction="row" spacing={1} alignItems="center">
<InfoOutlinedIcon sx={{ fontSize: 18, color: 'rgba(148,163,184,0.9)' }} />
<Typography variant="body2" color="text.secondary">
{module.pricingNote || 'Cost shown before run (duration, resolution, provider).'}
</Typography>
</Stack>
{module.costDrivers && (
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{module.costDrivers.map(driver => (
<Chip
key={driver}
size="small"
icon={<SavingsIcon sx={{ fontSize: 16 }} />}
label={driver}
sx={{
backgroundColor: 'rgba(15,118,110,0.25)',
color: '#99f6e4',
border: '1px solid rgba(34,197,94,0.35)',
fontWeight: 600,
}}
/>
))}
</Stack>
)}
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="caption" color="text.secondary">
ETA: {module.eta || 'TBD'}
</Typography>
</Stack>
{/* Visual Preview Component */}
{module.status === 'live' && (
<Box sx={{ mt: 1 }}>
{module.key === 'create' && <CreateVideoPreview />}
{module.key === 'avatar' && <AvatarVideoPreview />}
{module.key === 'enhance' && <EnhanceVideoPreview />}
</Box>
)}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 'auto' }}>
<Button
variant="contained"
size="small"
startIcon={disabled ? <LockIcon /> : <LaunchIcon />}
disabled={disabled}
onClick={() => onNavigate(module.route)}
sx={{
textTransform: 'none',
fontWeight: 700,
boxShadow: 'none',
background: disabled ? 'rgba(148,163,184,0.25)' : 'linear-gradient(120deg,#6366f1,#8b5cf6)',
}}
>
{disabled ? 'Preview' : 'Open'}
</Button>
<Tooltip title="Feature details & roadmap">
<Button
size="small"
variant="text"
color="inherit"
onClick={() => onNavigate(module.route)}
sx={{ textTransform: 'none', color: '#c7d2fe' }}
>
Learn more
</Button>
</Tooltip>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,50 @@
export const createVideoExamples = [
{
id: 'instagram-reel',
label: 'Instagram Reel',
prompt: 'A modern coffee shop interior with baristas crafting latte art, warm golden hour lighting streaming through large windows, customers chatting at wooden tables, cozy atmosphere, 9:16 vertical format',
description: 'Perfect for Instagram Reels and TikTok. Shows how text descriptions become engaging short-form video content.',
price: '$0.50',
eta: '~15s',
provider: 'Auto-select',
video: '/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4',
platform: 'Instagram',
useCase: 'Social media content',
},
{
id: 'linkedin-post',
label: 'LinkedIn Post',
prompt: 'Professional workspace with laptop, notebook, and coffee cup on a minimalist desk, soft natural lighting, clean modern office environment, 16:9 format',
description: 'Ideal for LinkedIn posts and professional content. Demonstrates how simple descriptions create polished business videos.',
price: '$0.75',
eta: '~18s',
provider: 'Auto-select',
video: '/videos/text-video-voiceover.mp4',
platform: 'LinkedIn',
useCase: 'Professional content',
},
{
id: 'youtube-short',
label: 'YouTube Short',
prompt: 'Dynamic product showcase with rotating view, vibrant colors, smooth camera movement, energetic music vibe, 9:16 vertical format',
description: 'Great for YouTube Shorts and product demos. Shows how product descriptions transform into engaging video content.',
price: '$0.60',
eta: '~16s',
provider: 'Auto-select',
video: '/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4',
platform: 'YouTube',
useCase: 'Product marketing',
},
];
export const enhanceVideoExamples = {
before: '/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4',
after: '/videos/text-video-voiceover.mp4',
description: 'Upscale 480p to 1080p, boost frame rate from 24fps to 60fps, and enhance clarity for professional use.',
};
export const avatarExamples = {
image: '/images/scene_1_Welcome_to_the_Cloud_Kitchen___ae6436d9.png',
video: '/videos/text-video-voiceover.mp4',
description: 'Upload a photo and audio to create a talking avatar perfect for explainer videos, tutorials, and personalized messages.',
};

View File

@@ -0,0 +1,203 @@
import React from 'react';
import MovieCreationIcon from '@mui/icons-material/MovieCreation';
import FaceRetouchingNaturalIcon from '@mui/icons-material/FaceRetouchingNatural';
import EditIcon from '@mui/icons-material/Edit';
import HighQualityIcon from '@mui/icons-material/HighQuality';
import TimelineIcon from '@mui/icons-material/Timeline';
import TransformIcon from '@mui/icons-material/Transform';
import ShareIcon from '@mui/icons-material/Share';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
import TranslateIcon from '@mui/icons-material/Translate';
import WallpaperIcon from '@mui/icons-material/Wallpaper';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
import type { ModuleConfig } from './types';
export const statusStyles = {
live: { label: 'Live', color: '#10b981' },
beta: { label: 'Beta', color: '#3b82f6' },
'coming soon': { label: 'Coming Soon', color: '#f97316' },
};
export const videoStudioModules: ModuleConfig[] = [
{
key: 'create',
title: 'Create Studio',
subtitle: 'Turn your ideas into videos',
description:
'Describe your video idea and we create it for you. Perfect for Instagram Reels, TikTok, YouTube Shorts, LinkedIn posts, and more. We automatically choose the best settings for your platform.',
highlights: ['Text to Video', 'Image to Video', 'Platform Ready'],
status: 'live',
route: '/video-studio/create',
pricingNote: 'Cost depends on video length and quality. We show you the price before generating.',
eta: 'Now',
icon: <MovieCreationIcon />,
help: 'Perfect for creating engaging social media content. Just describe what you want and we handle the rest. Add background music or voiceover later.',
costDrivers: ['Video length (510 seconds)', 'Quality (480p/720p/1080p)', 'Platform format'],
},
{
key: 'avatar',
title: 'Avatar Studio',
subtitle: 'Create talking videos from photos',
description:
'Upload a photo and audio to create a talking avatar. Perfect for explainer videos, tutorials, personalized messages, and social media content. Your photo comes to life with perfect lip-sync.',
highlights: ['Talking Avatars', 'Lip-sync', 'Translation'],
status: 'beta',
route: '/video-studio/avatar',
pricingNote: 'Cost depends on video length and quality',
eta: 'Beta',
icon: <FaceRetouchingNaturalIcon />,
help: 'Great for creating personalized video messages, explainer videos, and tutorials. Upload your photo and audio, and we create a talking video.',
costDrivers: ['Video length', 'Quality'],
},
{
key: 'enhance',
title: 'Enhance Studio',
subtitle: 'Upgrade your video quality',
description:
'Transform low-resolution videos into professional-quality content. Upscale from 480p to 1080p or 4K, boost frame rate, and improve clarity. Perfect for upgrading social media content or preparing videos for YouTube.',
highlights: ['Upscale Quality', 'Smooth Motion', 'Frame Rate Boost'],
status: 'live',
route: '/video-studio/enhance',
pricingNote: 'Cost depends on original quality and target quality',
eta: 'Now',
icon: <HighQualityIcon />,
help: 'Perfect for improving videos shot on phones or upgrading old content. Make your videos look professional and ready for any platform.',
costDrivers: ['Original quality', 'Target quality', 'Video length'],
},
{
key: 'extend',
title: 'Extend Studio',
subtitle: 'Extend short clips seamlessly',
description:
'Turn short video clips into longer videos with seamless motion and audio continuity. Perfect for extending social media content, creating longer scenes from existing footage, and adding smooth transitions.',
highlights: ['Motion Continuity', 'Audio Sync', 'Seamless Extension'],
status: 'live',
route: '/video-studio/extend',
pricingNote: 'Cost depends on extension duration and resolution',
eta: 'Now',
icon: <TimelineIcon />,
help: 'Great for extending short clips into longer videos. Describe how you want the video to continue, and we create a seamless extension with preserved motion and style.',
costDrivers: ['Extension duration', 'Resolution', 'Video length'],
},
{
key: 'edit',
title: 'Edit Studio',
subtitle: 'Trim, enhance, and customize',
description:
'Trim and cut videos, adjust speed, stabilize shaky footage, replace backgrounds, swap faces, add captions and subtitles, and color grade. All the editing tools you need in one place.',
highlights: ['Trim & Cut', 'Background Swap', 'Add Captions'],
status: 'coming soon',
route: '/video-studio/edit',
pricingNote: 'Cost depends on video length and number of edits',
eta: 'Coming soon',
icon: <EditIcon />,
help: 'Complete video editing suite for content creators. Make your videos perfect before sharing on social media.',
costDrivers: ['Video length', 'Number of edits'],
},
{
key: 'transform',
title: 'Transform Studio',
subtitle: 'Change format and style',
description:
'Convert videos between different formats (MP4, MOV, WebM, GIF), change aspect ratios (16:9, 9:16, 1:1), adjust speed, scale resolution, and compress files. All transformations use fast FFmpeg processing.',
highlights: ['Format Conversion', 'Aspect Ratio', 'Speed Control', 'Resolution Scaling', 'Compression'],
status: 'live',
route: '/video-studio/transform',
pricingNote: 'Free (FFmpeg processing)',
eta: 'Now',
icon: <TransformIcon />,
help: 'Perfect for adapting one video for multiple platforms. Convert formats, change aspect ratios, adjust speed, scale resolution, and compress files - all for free using FFmpeg.',
costDrivers: ['Free processing'],
},
{
key: 'social',
title: 'Social Optimizer',
subtitle: 'One-click platform optimization',
description:
'Create optimized versions of your video for Instagram, TikTok, YouTube, LinkedIn, and Twitter with one click. Includes safe zones, compression, and thumbnails. Make your content platform-ready instantly.',
highlights: ['Multi-Platform', 'Safe Zones', 'Auto Thumbnails'],
status: 'live',
route: '/video-studio/social',
pricingNote: 'Free (FFmpeg processing)',
eta: 'Now',
icon: <ShareIcon />,
help: 'Save time by creating platform-optimized versions automatically. One video, multiple platforms, perfect formatting for each.',
costDrivers: ['Free processing'],
},
{
key: 'faceswap',
title: 'Face Swap Studio',
subtitle: 'Replace characters in videos',
description:
'Swap faces or characters in videos using MoCha AI. Upload a reference image and source video to seamlessly replace characters while preserving motion, emotion, and camera perspective.',
highlights: ['Character Replacement', 'Motion Preservation', 'Identity Consistency'],
status: 'live',
route: '/video-studio/face-swap',
pricingNote: '$0.04/s (480p) or $0.08/s (720p), min 5s charge',
eta: 'Now',
icon: <SwapHorizIcon />,
help: 'Perfect for film, advertising, digital avatars, and creative character transformation. No pose or depth maps needed.',
costDrivers: ['Video duration', 'Resolution (480p/720p)'],
},
{
key: 'video-translate',
title: 'Video Translate Studio',
subtitle: 'Translate videos to 70+ languages',
description:
'Translate videos to 70+ languages and 175+ dialects with AI. Preserves lip-sync and natural voice. Perfect for global content, localization, and reaching international audiences.',
highlights: ['70+ Languages', 'Lip-sync Preservation', 'Natural Voice'],
status: 'live',
route: '/video-studio/video-translate',
pricingNote: '$0.0375/second',
eta: 'Now',
icon: <TranslateIcon />,
help: 'Perfect for global content creators, localization, and reaching international audiences. No voice actors or dubbing needed.',
costDrivers: ['Video duration'],
},
{
key: 'video-background-remover',
title: 'Background Remover Studio',
subtitle: 'Remove or replace video backgrounds',
description:
'Remove or replace video backgrounds with clean matting and edge-aware blending. Upload a background image to replace, or leave empty for transparent background. Perfect for product videos, presentations, and creative content.',
highlights: ['Clean Matting', 'Edge-Aware Blending', 'Background Replacement'],
status: 'live',
route: '/video-studio/video-background-remover',
pricingNote: '$0.01/second (min $0.05, max $6.00)',
eta: 'Now',
icon: <WallpaperIcon />,
help: 'Perfect for product videos, presentations, and creative content. Remove backgrounds or replace them with custom images.',
costDrivers: ['Video duration'],
},
{
key: 'add-audio-to-video',
title: 'Add Audio to Video Studio',
subtitle: 'Generate realistic Foley and ambient audio',
description:
'Generate realistic Foley and ambient audio directly from video using AI. Choose between Hunyuan Video Foley (48 kHz hi-fi, multi-scene sync) or Think Sound (context-aware, flat rate pricing). Perfect for post-production, social content, and prototyping.',
highlights: ['2 AI Models', '48 kHz Hi-Fi', 'Context-Aware'],
status: 'live',
route: '/video-studio/add-audio-to-video',
pricingNote: '$0.02/s (Hunyuan) or $0.05/video (Think Sound)',
eta: 'Now',
icon: <MusicNoteIcon />,
help: 'Perfect for post-production, social content, and prototyping. Use optional text prompts to guide specific sounds or let AI automatically generate appropriate audio based on visual cues.',
costDrivers: ['Video duration'],
},
{
key: 'library',
title: 'Asset Library',
subtitle: 'Organize and manage your videos',
description:
'Keep all your videos organized with AI-powered tagging, version tracking, usage analytics, and secure sharing. Manage your video content library like a pro.',
highlights: ['AI Tagging', 'Version Control', 'Usage Analytics'],
status: 'beta',
route: '/video-studio/library',
pricingNote: 'Storage and download costs',
eta: 'Beta',
icon: <LibraryBooksIcon />,
help: 'Perfect for content creators managing multiple videos. Keep everything organized, track usage, and share securely.',
costDrivers: ['Storage space', 'Downloads'],
},
];

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { Box, Stack, Typography, Chip } from '@mui/material';
import { avatarExamples } from '../constants';
import { OptimizedImage } from '../../../ImageStudio/dashboard/utils/OptimizedImage';
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
export const AvatarVideoPreview: React.FC = () => {
return (
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.5)',
p: { xs: 2, md: 3 },
}}
>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Box
sx={{
flex: 1,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.12)',
background: 'linear-gradient(135deg,#0ea5e9,#6366f1)',
color: '#e0f2fe',
p: 2,
minHeight: 260,
display: 'flex',
flexDirection: 'column',
gap: 1.2,
}}
>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#cffafe' }}>
Step 1: Upload Photo + Audio
</Typography>
<Typography variant="body2">{avatarExamples.description}</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{['Photo upload', 'Audio upload', 'Lip-sync'].map(label => (
<Chip
key={label}
size="small"
label={label}
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
/>
))}
</Stack>
<Box
sx={{
mt: 'auto',
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.25)',
boxShadow: '0 20px 45px rgba(2,6,23,0.45)',
}}
>
<OptimizedImage
src={avatarExamples.image}
alt="Avatar photo example"
loading="lazy"
sizes="(max-width: 600px) 100vw, 50vw"
sx={{ width: '100%', display: 'block' }}
/>
</Box>
</Box>
<Box
sx={{
flex: 1.5,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.12)',
background: '#020617',
p: { xs: 1, md: 2 },
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#38bdf8' }}>
Result: Talking Avatar
</Typography>
<Chip
label="720p"
size="small"
sx={{ background: 'rgba(56,189,248,0.15)', color: '#38bdf8', borderRadius: 999 }}
/>
</Stack>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.08)',
position: 'relative',
}}
>
<OptimizedVideo
src={avatarExamples.video}
poster={avatarExamples.image}
alt="Avatar video preview"
controls
preload="metadata"
muted
loop
playsInline
sx={{ width: '100%', display: 'block' }}
/>
</Box>
</Box>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
Perfect for explainer videos, tutorials, personalized messages, and social media content. Your photo comes to life with perfect lip-sync.
</Typography>
</Box>
);
};

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { Box, Stack, Typography, Chip } from '@mui/material';
import { createVideoExamples } from '../constants';
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
export const CreateVideoPreview: React.FC = () => {
const [textHovered, setTextHovered] = React.useState(false);
const [exampleIndex, setExampleIndex] = React.useState(0);
const example = createVideoExamples[exampleIndex];
const videoWidth = textHovered ? '20%' : '70%';
const textWidth = textHovered ? '80%' : '30%';
return (
<Box
sx={{
borderRadius: 3,
border: '3px solid',
borderImage:
'linear-gradient(135deg, rgba(124,58,237,0.8), rgba(14,165,233,0.8), rgba(16,185,129,0.8)) 1',
overflow: 'hidden',
height: { xs: 260, md: 300 },
display: 'flex',
background: '#0f172a',
mt: 1,
}}
>
<Box
sx={{
flex: '0 0 auto',
width: videoWidth,
transition: 'width 0.4s ease, filter 0.4s ease',
filter: textHovered ? 'saturate(1.1)' : 'saturate(1)',
position: 'relative',
overflow: 'hidden',
}}
>
<OptimizedVideo
src={example.video}
alt={example.label}
controls
muted
loop
playsInline
preload="metadata"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<Stack
direction="row"
spacing={1}
sx={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(15,23,42,0.85)',
borderRadius: 999,
px: 1.5,
py: 0.5,
boxShadow: '0 10px 20px rgba(2,6,23,0.45)',
}}
>
{createVideoExamples.map((_, idx) => (
<Box
key={_.id}
onClick={() => setExampleIndex(idx)}
sx={{
width: 32,
height: 10,
borderRadius: 999,
background: idx === exampleIndex ? '#c4b5fd' : 'rgba(255,255,255,0.3)',
cursor: 'pointer',
transition: 'background 0.2s ease',
}}
/>
))}
</Stack>
</Box>
<Box
sx={{
flex: '0 0 auto',
width: textWidth,
background: 'rgba(248,250,252,0.95)',
color: '#0f172a',
p: 3,
display: 'flex',
flexDirection: 'column',
gap: 1,
boxShadow: '-12px 0 24px rgba(15,23,42,0.25)',
transition: 'width 0.4s ease',
}}
onMouseEnter={() => setTextHovered(true)}
onMouseLeave={() => setTextHovered(false)}
>
<Stack spacing={0.5} sx={{ overflowY: textHovered ? 'auto' : 'hidden', pr: 1 }}>
<Typography variant="overline" sx={{ letterSpacing: 1.5, color: '#818cf8' }}>
Step 1: Enter Your Video Requirements
</Typography>
<Typography variant="subtitle2" fontWeight={700}>
Example Prompt
</Typography>
<Typography variant="body2">{example.prompt}</Typography>
<Typography variant="body2" sx={{ fontStyle: 'italic', color: '#475569' }}>
{example.description}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
size="small"
label={`Price ${example.price}`}
sx={{ background: '#ede9fe', color: '#4c1d95', borderRadius: 999, fontWeight: 600 }}
/>
<Chip
size="small"
label={`Ready in ${example.eta}`}
sx={{ background: '#cffafe', color: '#0f766e', borderRadius: 999, fontWeight: 600 }}
/>
<Chip
size="small"
label={example.platform}
sx={{ background: '#dcfce7', color: '#166534', borderRadius: 999, fontWeight: 600 }}
/>
</Stack>
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.5 }}>
Best for: {example.useCase}
</Typography>
</Stack>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { Box, Stack, Typography, Chip } from '@mui/material';
import { enhanceVideoExamples } from '../constants';
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
export const EnhanceVideoPreview: React.FC = () => {
return (
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.5)',
p: { xs: 2, md: 3 },
}}
>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Box
sx={{
flex: 1,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.12)',
background: 'linear-gradient(135deg,#ef4444,#f97316)',
color: '#fee2e2',
p: 2,
minHeight: 260,
display: 'flex',
flexDirection: 'column',
gap: 1.2,
}}
>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#fecaca' }}>
Before: 480p @ 24fps
</Typography>
<Typography variant="body2">{enhanceVideoExamples.description}</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{['480p', '24fps', 'Standard'].map(label => (
<Chip
key={label}
size="small"
label={label}
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
/>
))}
</Stack>
<Box
sx={{
mt: 'auto',
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.25)',
boxShadow: '0 20px 45px rgba(2,6,23,0.45)',
}}
>
<OptimizedVideo
src={enhanceVideoExamples.before}
alt="Before enhancement"
controls
preload="metadata"
muted
loop
playsInline
sx={{ width: '100%', display: 'block' }}
/>
</Box>
</Box>
<Box
sx={{
flex: 1,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.12)',
background: 'linear-gradient(135deg,#10b981,#059669)',
color: '#d1fae5',
p: 2,
minHeight: 260,
display: 'flex',
flexDirection: 'column',
gap: 1.2,
}}
>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#a7f3d0' }}>
After: 1080p @ 60fps
</Typography>
<Typography variant="body2">Enhanced quality ready for professional use</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{['1080p', '60fps', 'Enhanced'].map(label => (
<Chip
key={label}
size="small"
label={label}
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
/>
))}
</Stack>
<Box
sx={{
mt: 'auto',
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.25)',
boxShadow: '0 20px 45px rgba(2,6,23,0.45)',
}}
>
<OptimizedVideo
src={enhanceVideoExamples.after}
alt="After enhancement"
controls
preload="metadata"
muted
loop
playsInline
sx={{ width: '100%', display: 'block' }}
/>
</Box>
</Box>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
Transform low-resolution videos into professional-quality content. Perfect for upgrading social media content or preparing videos for YouTube and other platforms.
</Typography>
</Box>
);
};

View File

@@ -0,0 +1,3 @@
export { CreateVideoPreview } from './CreateVideoPreview';
export { AvatarVideoPreview } from './AvatarVideoPreview';
export { EnhanceVideoPreview } from './EnhanceVideoPreview';

View File

@@ -0,0 +1,16 @@
export type ModuleStatus = 'live' | 'beta' | 'coming soon';
export interface ModuleConfig {
key: string;
title: string;
subtitle: string;
description: string;
highlights: string[];
status: ModuleStatus;
route: string;
pricingNote?: string;
eta?: string;
icon?: React.ReactNode;
help?: string;
costDrivers?: string[];
}

View File

@@ -0,0 +1,14 @@
export { VideoStudioLayout } from './VideoStudioLayout';
export { VideoStudioDashboard } from './VideoStudioDashboard';
export { CreateVideo } from './modules/CreateVideo';
export { AvatarVideo } from './modules/AvatarVideo';
export { EnhanceVideo } from './modules/EnhanceVideo';
export { ExtendVideo } from './modules/ExtendVideo';
export { EditVideo } from './modules/EditVideo';
export { TransformVideo } from './modules/TransformVideo/TransformVideo';
export { SocialVideo } from './modules/SocialVideo/SocialVideo';
export { FaceSwap } from './modules/FaceSwap';
export { VideoTranslate } from './modules/VideoTranslate';
export { VideoBackgroundRemover } from './modules/VideoBackgroundRemover';
export { AddAudioToVideo } from './modules/AddAudioToVideo';
export { LibraryVideo } from './modules/LibraryVideo';

View File

@@ -0,0 +1,315 @@
import React from 'react';
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper } from '@mui/material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useAddAudioToVideo } from './hooks/useAddAudioToVideo';
import { VideoUpload, AudioSettings } from './components';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
const AddAudioToVideo: React.FC = () => {
const {
videoFile,
videoPreview,
model,
prompt,
seed,
processing,
progress,
error,
result,
setVideoFile,
setModel,
setPrompt,
setSeed,
canAddAudio,
costHint,
addAudio,
reset,
} = useAddAudioToVideo();
return (
<VideoStudioLayout
headerProps={{
title: 'Add Audio to Video Studio',
subtitle: 'Generate realistic Foley and ambient audio directly from video using Tencent Hunyuan\'s video-to-audio model. Aligns on-screen actions and scene context to produce timing-accurate, high-quality audio tracks with 48 kHz hi-fi output.',
}}
>
<Grid container spacing={4}>
{/* Left Panel - Upload & Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
<AudioSettings
model={model}
prompt={prompt}
seed={seed}
costHint={costHint}
onModelChange={setModel}
onPromptChange={setPrompt}
onSeedChange={setSeed}
/>
<Box>
<Button
fullWidth
variant="contained"
size="large"
startIcon={processing ? <CircularProgress size={20} color="inherit" /> : <MusicNoteIcon />}
onClick={addAudio}
disabled={!canAddAudio || processing}
sx={{
py: 1.5,
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{processing ? 'Processing...' : 'Add Audio to Video'}
</Button>
</Box>
{processing && (
<Box>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
Generating audio... This may take a few minutes...
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 1,
backgroundColor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#3b82f6',
},
}}
/>
</Stack>
</Box>
)}
{error && (
<Alert severity="error" onClose={() => {}} icon={<ErrorIcon />}>
{error}
</Alert>
)}
{result && (
<Alert
severity="success"
icon={<CheckCircleIcon />}
action={
<Button size="small" onClick={reset}>
Process Another
</Button>
}
>
Audio added successfully! Cost: ${result.cost.toFixed(4)}
</Alert>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Results */}
<Grid item xs={12} lg={7}>
<Stack spacing={3}>
{result ? (
// Result view
<Box>
<Typography
variant="h6"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Video with Audio
</Typography>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #10b981',
backgroundColor: '#000',
mb: 2,
}}
>
<video
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
controls
style={{
width: '100%',
maxHeight: 500,
display: 'block',
}}
/>
<Box sx={{ p: 2, backgroundColor: '#f0fdf4' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#059669' }}>
Audio Added ({result.model_used})
</Typography>
</Box>
</Box>
<Stack direction="row" spacing={2}>
<Button
variant="contained"
fullWidth
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
download
sx={{
backgroundColor: '#10b981',
'&:hover': {
backgroundColor: '#059669',
},
}}
>
Download Video
</Button>
<Button variant="outlined" fullWidth onClick={reset}>
Process Another
</Button>
</Stack>
</Box>
) : videoPreview ? (
// Original video preview
<Box>
<Typography
variant="h6"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Original Video Preview
</Typography>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 500,
display: 'block',
}}
/>
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
<Typography variant="body2" color="text.secondary">
Upload a video and configure audio settings to get started
</Typography>
</Box>
</Box>
</Box>
) : (
<Box
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 6,
textAlign: 'center',
backgroundColor: '#f8fafc',
}}
>
<Typography variant="body2" color="text.secondary">
Upload a video to see preview
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Your video with audio will appear here
</Typography>
</Box>
)}
{/* Info Box */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
About Audio Generation Models
</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 0.5 }}>
Hunyuan Video Foley:
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Multi-scene synchronization Audio aligned to complex, fast-cut visuals
</Typography>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
48 kHz hi-fi output Professional clarity with low noise
</Typography>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Pricing: $0.02/second
</Typography>
</Stack>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 0.5 }}>
Think Sound:
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Context-aware sound Analyzes visual elements to generate matching audio
</Typography>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Prompt-guided output with built-in Prompt Enhancer for AI-assisted optimization
</Typography>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
High-quality output with clear, realistic audio
</Typography>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Pricing: $0.05 per video (flat rate)
</Typography>
</Stack>
</Box>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>
Pro Tips for Best Quality:
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Use videos with clear visuals and distinct actions for best audio matching
</Typography>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Add prompts to specify the type of sound (e.g., "engine roaring", "footsteps on gravel")
</Typography>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Ensure videos have visible sound-producing elements like movement or impacts
</Typography>
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
Fix the seed when iterating to compare different prompt variations
</Typography>
</Stack>
</Box>
</Stack>
</Grid>
</Grid>
</VideoStudioLayout>
);
};
export { AddAudioToVideo };
export default AddAudioToVideo;

View File

@@ -0,0 +1,190 @@
import React from 'react';
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, TextField, Paper, Chip } from '@mui/material';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
import type { AudioModel } from '../hooks/useAddAudioToVideo';
interface AudioSettingsProps {
model: AudioModel;
prompt: string;
seed: number | null;
costHint: string;
onModelChange: (model: AudioModel) => void;
onPromptChange: (prompt: string) => void;
onSeedChange: (seed: number | null) => void;
}
export const AudioSettings: React.FC<AudioSettingsProps> = ({
model,
prompt,
seed,
costHint,
onModelChange,
onPromptChange,
onSeedChange,
}) => {
return (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
}}
>
<Stack spacing={3}>
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<MusicNoteIcon sx={{ color: '#3b82f6' }} />
<Typography
variant="subtitle2"
sx={{
color: '#0f172a',
fontWeight: 700,
}}
>
Audio Settings
</Typography>
</Stack>
</Box>
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 600,
}}
>
Audio Model
</Typography>
<FormControl fullWidth>
<Select
value={model}
onChange={(e) => onModelChange(e.target.value as AudioModel)}
sx={{
backgroundColor: '#fff',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e2e8f0',
},
}}
>
<MenuItem value="hunyuan-video-foley">Hunyuan Video Foley ($0.02/s)</MenuItem>
<MenuItem value="think-sound">Think Sound ($0.05/video)</MenuItem>
</Select>
</FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{model === 'hunyuan-video-foley'
? 'Tencent Hunyuan\'s video-to-audio model: Multi-scene synchronization, 48 kHz hi-fi output, SOTA performance'
: model === 'think-sound'
? 'Context-aware video-to-audio generation: Analyzes visual elements to generate matching audio. Features built-in Prompt Enhancer for AI-assisted optimization.'
: 'Generate audio from video'}
</Typography>
</Box>
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 600,
}}
>
Audio Prompt (Optional)
</Typography>
<TextField
fullWidth
multiline
rows={3}
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
placeholder={
model === 'hunyuan-video-foley'
? "Briefly describe the mood or key sounds (e.g., 'Rainy street ambience, soft footsteps, distant cars' or 'Kitchen ASMR: chopping vegetables, sizzling pan')"
: "Describe the type of sound you want (e.g., 'engine roaring', 'footsteps on gravel', 'ocean waves crashing'). The built-in Prompt Enhancer will optimize your prompt for better results."
}
sx={{
backgroundColor: '#fff',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e2e8f0',
},
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{model === 'hunyuan-video-foley'
? 'Optional: Leave empty to let AI automatically generate appropriate sounds based on visual cues'
: 'Optional: Add text descriptions to guide the style and type of audio generated. The built-in Prompt Enhancer will optimize your prompt for better results. Use clear, descriptive prompts for best quality.'}
</Typography>
</Box>
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 600,
}}
>
Seed (Optional)
</Typography>
<TextField
fullWidth
type="number"
value={seed === null ? '' : seed}
onChange={(e) => {
const value = e.target.value;
onSeedChange(value === '' ? null : parseInt(value, 10));
}}
placeholder="-1 for random"
sx={{
backgroundColor: '#fff',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e2e8f0',
},
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Use -1 for random seed, or specify a number for reproducible results. Fix the seed when iterating to compare different prompt variations.
</Typography>
</Box>
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Estimated Cost:
</Typography>
<Chip
label={costHint}
size="small"
sx={{
backgroundColor: '#3b82f6',
color: '#fff',
fontWeight: 600,
}}
/>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{model === 'think-sound'
? 'Pricing: $0.05 per video (flat rate)'
: 'Pricing: $0.02/second (estimated)'}
</Typography>
{model === 'hunyuan-video-foley' && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Minimum charge: 5 seconds | Maximum: 10 minutes (600 seconds)
</Typography>
)}
</Box>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,125 @@
import React, { useRef } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material';
import VideocamIcon from '@mui/icons-material/Videocam';
interface VideoUploadProps {
videoPreview: string | null;
onVideoSelect: (file: File | null) => void;
}
export const VideoUpload: React.FC<VideoUploadProps> = ({
videoPreview,
onVideoSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate video file
if (!file.type.startsWith('video/')) {
alert('Please select a video file');
return;
}
if (file.size > 500 * 1024 * 1024) {
alert('Video file must be less than 500MB');
return;
}
onVideoSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
onVideoSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Source Video
</Typography>
<input
ref={fileInputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{videoPreview ? (
<Box
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 400,
display: 'block',
}}
/>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemove}
sx={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
}}
>
Remove
</Button>
</Box>
) : (
<Box
onClick={handleClick}
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#f8fafc',
},
}}
>
<Stack spacing={2} alignItems="center">
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary">
Click to upload video
</Typography>
<Typography variant="caption" color="text.secondary">
MP4, WebM up to 500MB (max 10 minutes)
</Typography>
</Stack>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,2 @@
export { VideoUpload } from './VideoUpload';
export { AudioSettings } from './AudioSettings';

View File

@@ -0,0 +1,193 @@
import { useState, useMemo, useEffect } from 'react';
import { aiApiClient } from '../../../../../api/client';
export type AudioModel = 'hunyuan-video-foley' | 'think-sound';
export const useAddAudioToVideo = () => {
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreview, setVideoPreview] = useState<string | null>(null);
const [model, setModel] = useState<AudioModel>('hunyuan-video-foley');
const [prompt, setPrompt] = useState<string>('');
const [seed, setSeed] = useState<number | null>(null);
const [processing, setProcessing] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ video_url: string; cost: number; model_used: string } | null>(null);
const [estimatedDuration, setEstimatedDuration] = useState<number>(10.0);
const [costEstimate, setCostEstimate] = useState<number | null>(null);
// Update preview when file changes
useEffect(() => {
if (videoFile) {
const url = URL.createObjectURL(videoFile);
setVideoPreview(url);
// Rough estimate: 1MB ≈ 1 second at 1080p
const estimated = Math.max(5, videoFile.size / (1024 * 1024));
setEstimatedDuration(estimated);
return () => URL.revokeObjectURL(url);
} else {
setVideoPreview(null);
setEstimatedDuration(10.0);
}
}, [videoFile]);
// Fetch cost estimate when model or duration changes
useEffect(() => {
const fetchCostEstimate = async () => {
if (!videoFile || estimatedDuration < 5) {
setCostEstimate(null);
return;
}
try {
const formData = new FormData();
formData.append('model', model);
formData.append('estimated_duration', estimatedDuration.toString());
const response = await aiApiClient.post('/api/video-studio/add-audio-to-video/estimate-cost', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (response.data.estimated_cost) {
setCostEstimate(response.data.estimated_cost);
}
} catch (err) {
console.error('Failed to fetch cost estimate:', err);
// Fallback to client-side calculation
if (model === 'think-sound') {
setCostEstimate(0.05); // Flat rate per video
} else {
const costPerSecond = 0.02;
setCostEstimate(Math.max(5.0, estimatedDuration) * costPerSecond);
}
}
};
fetchCostEstimate();
}, [videoFile, model, estimatedDuration]);
const canAddAudio = useMemo(() => {
return videoFile !== null;
}, [videoFile]);
const costHint = useMemo(() => {
if (!videoFile) return 'Upload a video to see cost estimate';
if (costEstimate !== null) {
return `Est. ~$${costEstimate.toFixed(2)} (${estimatedDuration.toFixed(0)}s)`;
}
// Fallback calculation
if (model === 'think-sound') {
return `Est. ~$0.05 (flat rate per video)`;
} else {
const costPerSecond = 0.02;
const estimatedCost = Math.max(5.0, estimatedDuration) * costPerSecond;
return `Est. ~$${estimatedCost.toFixed(2)} (${estimatedDuration.toFixed(0)}s)`;
}
}, [videoFile, estimatedDuration, costEstimate]);
const addAudio = async () => {
if (!videoFile) return;
setProcessing(true);
setError(null);
setResult(null);
setProgress(0);
try {
const formData = new FormData();
formData.append('video_file', videoFile);
formData.append('model', model);
if (prompt) {
formData.append('prompt', prompt);
}
if (seed !== null) {
formData.append('seed', seed.toString());
}
// Submit audio addition request
setProgress(10);
const response = await aiApiClient.post('/api/video-studio/add-audio-to-video', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const uploadProgress = Math.round((progressEvent.loaded * 30) / progressEvent.total);
setProgress(uploadProgress);
}
},
timeout: 600000, // 10 minutes timeout
});
setProgress(40);
// Simulate progress updates
let simulatedProgress = 40;
const progressInterval = setInterval(() => {
simulatedProgress = Math.min(90, simulatedProgress + 5);
setProgress(simulatedProgress);
}, 2000);
try {
if (response.data.success) {
clearInterval(progressInterval);
setProcessing(false);
setResult(response.data);
setProgress(100);
} else {
clearInterval(progressInterval);
throw new Error(response.data.error || 'Adding audio failed');
}
} catch (err) {
clearInterval(progressInterval);
throw err;
}
} catch (err: any) {
setProcessing(false);
setProgress(0);
setError(err.response?.data?.detail || err.message || 'Failed to add audio');
}
};
const reset = () => {
setProcessing(false);
setProgress(0);
setError(null);
setResult(null);
setVideoFile(null);
setPrompt('');
setSeed(null);
};
return {
// State
videoFile,
videoPreview,
model,
prompt,
seed,
processing,
progress,
error,
result,
estimatedDuration,
costEstimate,
// Setters
setVideoFile,
setModel,
setPrompt,
setSeed,
// Computed
canAddAudio,
costHint,
// Actions
addAudio,
reset,
};
};

View File

@@ -0,0 +1,2 @@
export { AddAudioToVideo } from './AddAudioToVideo';
export { default } from './AddAudioToVideo';

View File

@@ -0,0 +1,3 @@
// Re-export from the AvatarVideo component
export { AvatarVideo } from './AvatarVideo/AvatarVideo';
export { default } from './AvatarVideo/AvatarVideo';

View File

@@ -0,0 +1,249 @@
import React, { useState } from 'react';
import { Grid, Box, Button, Typography, Stack, CircularProgress } from '@mui/material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useAvatarVideo } from './hooks/useAvatarVideo';
import { ImageUpload, AudioUpload, AvatarSettings } from './components';
import { aiApiClient } from '../../../../api/client';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
export const AvatarVideo: React.FC = () => {
const {
imageFile,
imagePreview,
audioFile,
audioPreview,
resolution,
model,
prompt,
seed,
setImageFile,
setAudioFile,
setResolution,
setModel,
setPrompt,
setSeed,
canGenerate,
costHint,
} = useAvatarVideo();
const [generating, setGenerating] = useState(false);
const [taskId, setTaskId] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ video_url: string; cost: number } | null>(null);
const handleGenerate = async () => {
if (!imageFile || !audioFile) return;
setGenerating(true);
setError(null);
setResult(null);
setProgress(0);
setStatusMessage('Starting avatar generation...');
try {
// Create FormData
const formData = new FormData();
formData.append('image', imageFile);
formData.append('audio', audioFile);
formData.append('resolution', resolution);
formData.append('model', model);
if (prompt) {
formData.append('prompt', prompt);
}
if (seed !== null) {
formData.append('seed', seed.toString());
}
// Submit generation request
const response = await aiApiClient.post('/api/video-studio/avatar/create-async', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
const { task_id } = response.data;
setTaskId(task_id);
setStatusMessage('Avatar generation started. Polling for updates...');
// Poll for status
const pollInterval = setInterval(async () => {
try {
const statusResponse = await aiApiClient.get(`/api/video-studio/task/${task_id}/status`);
const status = statusResponse.data;
setProgress(status.progress || 0);
setStatusMessage(status.message || 'Processing...');
if (status.status === 'completed') {
clearInterval(pollInterval);
setGenerating(false);
setResult(status.result);
setStatusMessage('Avatar generation complete!');
} else if (status.status === 'failed') {
clearInterval(pollInterval);
setGenerating(false);
setError(status.error || 'Avatar generation failed');
setStatusMessage('Generation failed');
}
} catch (err: any) {
console.error('Polling error:', err);
// Continue polling on transient errors
}
}, 2000); // Poll every 2 seconds
// Cleanup on unmount
return () => clearInterval(pollInterval);
} catch (err: any) {
setGenerating(false);
setError(err.response?.data?.detail || err.message || 'Failed to start avatar generation');
setStatusMessage('Failed to start generation');
}
};
return (
<VideoStudioLayout
headerProps={{
title: "Avatar Studio",
subtitle: "Create talking videos from photos",
}}
>
<Grid container spacing={4}>
{/* Left Panel: Uploads and Settings */}
<Grid item xs={12} md={6}>
<Stack spacing={3}>
<ImageUpload
imagePreview={imagePreview}
onImageSelect={setImageFile}
/>
<AudioUpload
audioPreview={audioPreview}
onAudioSelect={setAudioFile}
/>
<AvatarSettings
resolution={resolution}
model={model}
prompt={prompt}
seed={seed}
onResolutionChange={setResolution}
onModelChange={setModel}
onPromptChange={setPrompt}
onSeedChange={setSeed}
/>
{/* Cost and Generate */}
<Box
sx={{
p: 3,
borderRadius: 2,
backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0',
}}
>
<Stack spacing={2}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Estimated Cost
</Typography>
<Typography variant="h6" fontWeight={700} color="#3b82f6">
{costHint}
</Typography>
</Box>
{error && (
<Typography variant="body2" color="error">
{error}
</Typography>
)}
{generating && (
<Box>
<Stack direction="row" spacing={2} alignItems="center">
<CircularProgress size={20} />
<Typography variant="body2" color="text.secondary">
{statusMessage}
</Typography>
</Stack>
{progress > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary">
Progress: {progress.toFixed(0)}%
</Typography>
</Box>
)}
</Box>
)}
<Button
variant="contained"
size="large"
fullWidth
startIcon={generating ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
onClick={handleGenerate}
disabled={!canGenerate || generating}
sx={{
py: 1.5,
borderRadius: 2,
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
}}
>
{generating ? 'Generating...' : 'Create Avatar'}
</Button>
</Stack>
</Box>
</Stack>
</Grid>
{/* Right Panel: Preview/Result */}
<Grid item xs={12} md={6}>
<Box
sx={{
p: 3,
borderRadius: 2,
backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0',
minHeight: 400,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{result ? (
<Stack spacing={2} alignItems="center">
<Typography variant="h6" fontWeight={700}>
Avatar Generated!
</Typography>
<video
src={result.video_url}
controls
style={{
maxWidth: '100%',
maxHeight: 500,
borderRadius: 8,
}}
/>
<Typography variant="body2" color="text.secondary">
Cost: ${result.cost.toFixed(2)}
</Typography>
</Stack>
) : (
<Typography variant="body2" color="text.secondary" textAlign="center">
{imagePreview && audioPreview
? 'Upload your photo and audio, then click "Create Avatar" to generate your talking avatar.'
: 'Upload a photo and audio to create your talking avatar.'}
</Typography>
)}
</Box>
</Grid>
</Grid>
</VideoStudioLayout>
);
};
export default AvatarVideo;

View File

@@ -0,0 +1,122 @@
import React, { useRef } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import AudioFileIcon from '@mui/icons-material/AudioFile';
interface AudioUploadProps {
audioPreview: string | null;
onAudioSelect: (file: File | null) => void;
}
export const AudioUpload: React.FC<AudioUploadProps> = ({
audioPreview,
onAudioSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate audio file
if (!file.type.startsWith('audio/')) {
alert('Please select an audio file');
return;
}
if (file.size > 50 * 1024 * 1024) {
alert('Audio file must be less than 50MB');
return;
}
onAudioSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
onAudioSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Upload Audio
</Typography>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{audioPreview ? (
<Box
sx={{
border: '2px solid #e2e8f0',
borderRadius: 2,
p: 2,
}}
>
<Stack direction="row" spacing={2} alignItems="center">
<AudioFileIcon sx={{ color: '#3b82f6' }} />
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
Audio file selected
</Typography>
<audio
src={audioPreview}
controls
style={{ width: '100%', marginTop: 8 }}
/>
</Box>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemove}
>
Remove
</Button>
</Stack>
</Box>
) : (
<Box
onClick={handleClick}
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#f8fafc',
},
}}
>
<Stack spacing={2} alignItems="center">
<AudioFileIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary">
Click to upload audio
</Typography>
<Typography variant="caption" color="text.secondary">
MP3, WAV up to 50MB (max 10 minutes)
</Typography>
</Stack>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,206 @@
import React, { useState } from 'react';
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, TextField, Button, CircularProgress, Tooltip } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import type { AvatarResolution, AvatarModel } from '../hooks/useAvatarVideo';
import { optimizePrompt } from '../../../../../api/videoStudioApi';
interface AvatarSettingsProps {
resolution: AvatarResolution;
model: AvatarModel;
prompt: string;
seed: number | null;
onResolutionChange: (value: AvatarResolution) => void;
onModelChange: (value: AvatarModel) => void;
onPromptChange: (value: string) => void;
onSeedChange: (value: number | null) => void;
}
export const AvatarSettings: React.FC<AvatarSettingsProps> = ({
resolution,
model,
prompt,
seed,
onResolutionChange,
onModelChange,
onPromptChange,
onSeedChange,
}) => {
const [enhancing, setEnhancing] = useState(false);
const handleEnhancePrompt = async () => {
if (!prompt.trim() || enhancing) return;
setEnhancing(true);
try {
const result = await optimizePrompt({
text: prompt,
mode: 'video', // Use 'video' mode for avatar generation
style: 'default',
});
if (result.success && result.optimized_prompt) {
onPromptChange(result.optimized_prompt);
}
} catch (error) {
console.error('Failed to enhance prompt:', error);
} finally {
setEnhancing(false);
}
};
return (
<Stack spacing={3}>
<FormControl fullWidth>
<InputLabel>AI Model</InputLabel>
<Select
value={model}
label="AI Model"
onChange={e => onModelChange(e.target.value as AvatarModel)}
sx={{
borderRadius: 2,
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
}}
>
<MenuItem value="infinitetalk">
<Stack>
<Typography variant="body2">InfiniteTalk - Long Form</Typography>
<Typography variant="caption" color="text.secondary">
Up to 10 minutes, $0.03-0.06/s
</Typography>
</Stack>
</MenuItem>
<MenuItem value="hunyuan-avatar">
<Stack>
<Typography variant="body2">Hunyuan Avatar - Fast & Affordable</Typography>
<Typography variant="caption" color="text.secondary">
Up to 2 minutes, $0.15-0.30 per 5s
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Video Quality</InputLabel>
<Select
value={resolution}
label="Video Quality"
onChange={e => onResolutionChange(e.target.value as AvatarResolution)}
sx={{
borderRadius: 2,
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
}}
>
<MenuItem value="480p">
<Stack>
<Typography variant="body2">480p - Fast & Affordable</Typography>
<Typography variant="caption" color="text.secondary">
{model === 'hunyuan-avatar' ? '$0.15 per 5 seconds' : '$0.03 per second'}
</Typography>
</Stack>
</MenuItem>
<MenuItem value="720p">
<Stack>
<Typography variant="body2">720p - High Quality</Typography>
<Typography variant="caption" color="text.secondary">
{model === 'hunyuan-avatar' ? '$0.30 per 5 seconds' : '$0.06 per second'}
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Expression Prompt (Optional)
</Typography>
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="caption" sx={{ display: 'block', fontWeight: 600, mb: 0.5 }}>
AI Prompt Optimizer
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
Enhances your expression prompt for better avatar results by improving:
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem', mt: 0.5 }}>
Visual clarity & composition
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
Expression details & style consistency
</Typography>
</Box>
}
arrow
placement="top"
>
<Button
size="small"
variant="outlined"
startIcon={enhancing ? <CircularProgress size={16} /> : <AutoAwesomeIcon />}
onClick={handleEnhancePrompt}
disabled={!prompt.trim() || enhancing}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
py: 0.5,
px: 1.5,
borderColor: '#3b82f6',
color: '#3b82f6',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
'&:disabled': {
borderColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{enhancing ? 'Enhancing...' : 'Enhance Instructions'}
</Button>
</Tooltip>
</Box>
<TextField
fullWidth
multiline
rows={3}
placeholder="e.g., 'Confident, friendly smile' or 'Professional, serious expression'"
value={prompt}
onChange={e => onPromptChange(e.target.value)}
helperText="Describe the expression or style you want for your avatar"
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
},
}}
/>
</Box>
<TextField
fullWidth
type="number"
label="Seed (Optional)"
placeholder="Leave empty for random"
value={seed || ''}
onChange={e => {
const value = e.target.value;
onSeedChange(value ? parseInt(value, 10) : null);
}}
helperText="Use the same seed to generate similar results"
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
},
}}
/>
</Stack>
);
};

View File

@@ -0,0 +1,126 @@
import React, { useRef } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import ImageIcon from '@mui/icons-material/Image';
interface ImageUploadProps {
imagePreview: string | null;
onImageSelect: (file: File | null) => void;
}
export const ImageUpload: React.FC<ImageUploadProps> = ({
imagePreview,
onImageSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate image file
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 10 * 1024 * 1024) {
alert('Image file must be less than 10MB');
return;
}
onImageSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
onImageSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Upload Photo
</Typography>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{imagePreview ? (
<Box
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
}}
>
<img
src={imagePreview}
alt="Preview"
style={{
width: '100%',
height: 'auto',
maxHeight: 400,
objectFit: 'contain',
display: 'block',
}}
/>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemove}
sx={{
position: 'absolute',
top: 8,
right: 8,
}}
>
Remove
</Button>
</Box>
) : (
<Box
onClick={handleClick}
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#f8fafc',
},
}}
>
<Stack spacing={2} alignItems="center">
<ImageIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary">
Click to upload a photo
</Typography>
<Typography variant="caption" color="text.secondary">
PNG, JPG up to 10MB
</Typography>
</Stack>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,3 @@
export { ImageUpload } from './ImageUpload';
export { AudioUpload } from './AudioUpload';
export { AvatarSettings } from './AvatarSettings';

View File

@@ -0,0 +1,92 @@
import { useState, useMemo, useCallback } from 'react';
export type AvatarResolution = '480p' | '720p';
export type AvatarModel = 'infinitetalk' | 'hunyuan-avatar';
export const useAvatarVideo = () => {
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [audioPreview, setAudioPreview] = useState<string | null>(null);
const [resolution, setResolution] = useState<AvatarResolution>('720p');
const [model, setModel] = useState<AvatarModel>('infinitetalk');
const [prompt, setPrompt] = useState('');
const [maskImageFile, setMaskImageFile] = useState<File | null>(null);
const [seed, setSeed] = useState<number | null>(null);
// Cost estimation
const costHint = useMemo(() => {
const estimatedDuration = 10; // TODO: Get actual audio duration
if (model === 'hunyuan-avatar') {
// Hunyuan Avatar: $0.15/5s (480p) or $0.30/5s (720p)
const costPer5Seconds = resolution === '480p' ? 0.15 : 0.30;
const billable5SecondBlocks = Math.ceil(estimatedDuration / 5);
const estimate = (costPer5Seconds * billable5SecondBlocks).toFixed(2);
return `Est. ~$${estimate}`;
} else {
// InfiniteTalk: $0.03/s (480p) or $0.06/s (720p)
const costPerSecond = resolution === '480p' ? 0.03 : 0.06;
const estimate = (costPerSecond * estimatedDuration).toFixed(2);
return `Est. ~$${estimate}`;
}
}, [resolution, model]);
const canGenerate = useMemo(() => {
return imageFile !== null && audioFile !== null;
}, [imageFile, audioFile]);
const handleImageSelect = useCallback((file: File | null) => {
setImageFile(file);
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
setImagePreview(e.target?.result as string);
};
reader.readAsDataURL(file);
} else {
setImagePreview(null);
}
}, []);
const handleAudioSelect = useCallback((file: File | null) => {
setAudioFile(file);
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
setAudioPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
} else {
setAudioPreview(null);
}
}, []);
const handleMaskImageSelect = useCallback((file: File | null) => {
setMaskImageFile(file);
}, []);
return {
// State
imageFile,
imagePreview,
audioFile,
audioPreview,
resolution,
model,
prompt,
maskImageFile,
seed,
// Setters
setImageFile: handleImageSelect,
setAudioFile: handleAudioSelect,
setResolution,
setModel,
setPrompt,
setMaskImageFile: handleMaskImageSelect,
setSeed,
// Computed
canGenerate,
costHint,
};
};

View File

@@ -0,0 +1,2 @@
export { AvatarVideo } from './AvatarVideo';
export { default } from './AvatarVideo';

View File

@@ -0,0 +1,108 @@
import React, { useEffect, useState, useRef } from 'react';
import { Box, Typography } from '@mui/material';
import { motion, AnimatePresence } from 'framer-motion';
interface CarouselPlaceholderProps {
examples: string[];
interval?: number;
onExampleChange?: (example: string, index: number) => void;
paused?: boolean;
}
export const CarouselPlaceholder: React.FC<CarouselPlaceholderProps> = ({
examples,
interval = 4000,
onExampleChange,
paused = false,
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (examples.length <= 1 || paused) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
intervalRef.current = setInterval(() => {
setCurrentIndex(prev => {
const next = (prev + 1) % examples.length;
if (onExampleChange) {
onExampleChange(examples[next], next);
}
return next;
});
}, interval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [examples.length, interval, onExampleChange, paused]);
if (examples.length === 0) return null;
return (
<Box
sx={{
position: 'relative',
minHeight: 24,
display: 'flex',
alignItems: 'center',
width: '100%',
}}
>
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
style={{ width: '100%' }}
>
<Typography
variant="body2"
sx={{
color: 'rgba(255,255,255,0.5)',
fontStyle: 'italic',
pointerEvents: 'none',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{examples[currentIndex]}
</Typography>
</motion.div>
</AnimatePresence>
{examples.length > 1 && (
<Box
sx={{
position: 'absolute',
bottom: -24,
right: 0,
display: 'flex',
gap: 0.5,
}}
>
{examples.map((_, idx) => (
<Box
key={idx}
sx={{
width: 6,
height: 6,
borderRadius: '50%',
background: idx === currentIndex ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.2)',
transition: 'background 0.3s ease',
}}
/>
))}
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { Grid } from '@mui/material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useCreateVideo } from './hooks/useCreateVideo';
import { GenerationSettingsPanel, VideoExamplesPanel } from './components';
import { handleExampleClick, handleAssetClick } from './utils/exampleHandlers';
import { createVideoExamples } from '../../dashboard/constants';
import type { ContentAsset } from '../../../../hooks/useContentAssets';
export const CreateVideo: React.FC = () => {
const {
mode,
setMode,
prompt,
setPrompt,
negativePrompt,
setNegativePrompt,
duration,
setDuration,
resolution,
setResolution,
aspect,
setAspect,
motion,
setMotion,
audioAttached,
setAudioAttached,
selectedModel,
setSelectedModel,
selectedExample,
setSelectedExample,
selectedAssetId,
setSelectedAssetId,
promptPlaceholderIndex,
setPromptPlaceholderIndex,
negativePlaceholderIndex,
setNegativePlaceholderIndex,
promptFocused,
setPromptFocused,
negativeFocused,
setNegativeFocused,
canGenerate,
costHint,
libraryVideos,
loadingLibraryVideos,
handleFileSelect,
} = useCreateVideo();
const handleExampleClickWrapper = (index: number) => {
const example = createVideoExamples[index];
handleExampleClick(
index,
example,
setPrompt,
setAspect,
setSelectedExample,
setSelectedAssetId
);
};
const handleAssetClickWrapper = (asset: ContentAsset) => {
handleAssetClick(
asset,
setPrompt,
setAspect,
setResolution,
setSelectedAssetId,
setSelectedExample
);
};
const handleGenerate = () => {
// Placeholder: hook preflight + job creation later
alert('This is a UI preview. Backend generation will be wired in the next step.');
};
return (
<VideoStudioLayout
headerProps={{
title: 'Create Studio',
subtitle: 'AI-Powered Video Generation for Content Creators. Turn your ideas into engaging videos for Instagram, TikTok, YouTube, LinkedIn, and more.',
}}
>
<Grid container spacing={3}>
{/* Left Panel - Generation Controls */}
<Grid item xs={12} lg={5}>
<GenerationSettingsPanel
mode={mode}
prompt={prompt}
negativePrompt={negativePrompt}
duration={duration}
resolution={resolution}
aspect={aspect}
motion={motion}
audioAttached={audioAttached}
costHint={costHint}
canGenerate={canGenerate}
promptFocused={promptFocused}
negativeFocused={negativeFocused}
promptPlaceholderIndex={promptPlaceholderIndex}
negativePlaceholderIndex={negativePlaceholderIndex}
selectedModel={selectedModel}
onModeChange={setMode}
onPromptChange={setPrompt}
onNegativePromptChange={setNegativePrompt}
onDurationChange={setDuration}
onResolutionChange={setResolution}
onAspectChange={setAspect}
onMotionChange={setMotion}
onModelChange={setSelectedModel}
onFileSelect={handleFileSelect}
onPromptFocus={() => setPromptFocused(true)}
onPromptBlur={() => setPromptFocused(false)}
onNegativeFocus={() => setNegativeFocused(true)}
onNegativeBlur={() => setNegativeFocused(false)}
onPromptPlaceholderChange={setPromptPlaceholderIndex}
onNegativePlaceholderChange={setNegativePlaceholderIndex}
onGenerate={handleGenerate}
/>
</Grid>
{/* Right Panel - Video Preview & Examples */}
<Grid item xs={12} lg={7}>
<VideoExamplesPanel
examples={createVideoExamples}
libraryVideos={libraryVideos}
loadingLibraryVideos={loadingLibraryVideos}
selectedExample={selectedExample}
selectedAssetId={selectedAssetId}
prompt={prompt}
onExampleClick={handleExampleClickWrapper}
onAssetClick={handleAssetClickWrapper}
/>
</Grid>
</Grid>
</VideoStudioLayout>
);
};
export default CreateVideo;

View File

@@ -0,0 +1,167 @@
import React from 'react';
import { Box, Card, CardContent, Stack, Typography, Chip } from '@mui/material';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import { motion as framerMotion } from 'framer-motion';
import { OptimizedVideo } from '../../../../ImageStudio/dashboard/utils/OptimizedVideo';
import type { ContentAsset } from '../../../../../hooks/useContentAssets';
interface AssetLibraryVideoCardProps {
asset: ContentAsset;
isSelected: boolean;
onClick: () => void;
}
export const AssetLibraryVideoCard: React.FC<AssetLibraryVideoCardProps> = ({
asset,
isSelected,
onClick,
}) => {
return (
<framerMotion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
sx={{
cursor: 'pointer',
border: isSelected ? '2px solid #667eea' : '1px solid #e2e8f0',
borderRadius: 2,
overflow: 'hidden',
transition: 'all 0.2s',
'&:hover': {
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.2)',
},
}}
onClick={onClick}
>
<Box
sx={{
position: 'relative',
width: '100%',
paddingTop: '56.25%', // 16:9 aspect ratio
backgroundColor: '#0f172a',
overflow: 'hidden',
}}
>
<OptimizedVideo
src={asset.file_url}
alt={asset.title || asset.filename}
controls
muted
loop
playsInline
preload="metadata"
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
{isSelected && (
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
background: '#667eea',
borderRadius: '50%',
p: 0.5,
}}
>
<PlayCircleOutlineIcon sx={{ color: '#fff', fontSize: 20 }} />
</Box>
)}
</Box>
<CardContent sx={{ p: 2 }}>
<Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography
variant="subtitle2"
sx={{
fontWeight: 700,
color: '#0f172a',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
title={asset.title || asset.filename}
>
{asset.title || asset.filename}
</Typography>
{asset.source_module && (
<Chip
label={asset.source_module}
size="small"
sx={{
background: 'rgba(102, 126, 234, 0.1)',
color: '#667eea',
fontWeight: 600,
fontSize: 10,
ml: 1,
}}
/>
)}
</Stack>
{asset.description && (
<Typography
variant="caption"
sx={{ color: '#475569', fontSize: 11 }}
title={asset.description}
>
{asset.description.length > 60
? `${asset.description.substring(0, 60)}...`
: asset.description}
</Typography>
)}
{asset.prompt && (
<Typography
variant="caption"
sx={{
color: '#6366f1',
fontSize: 10,
fontStyle: 'italic',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={asset.prompt}
>
"{asset.prompt.length > 50 ? `${asset.prompt.substring(0, 50)}...` : asset.prompt}"
</Typography>
)}
<Stack direction="row" spacing={1} flexWrap="wrap">
{asset.cost > 0 && (
<Chip
label={`$${asset.cost.toFixed(2)}`}
size="small"
sx={{
background: 'rgba(16, 185, 129, 0.1)',
color: '#047857',
fontWeight: 600,
fontSize: 10,
}}
/>
)}
{asset.asset_metadata?.resolution && (
<Chip
label={asset.asset_metadata.resolution}
size="small"
sx={{
background: 'rgba(59, 130, 246, 0.1)',
color: '#1e40af',
fontWeight: 600,
fontSize: 10,
}}
/>
)}
</Stack>
</Stack>
</CardContent>
</Card>
</framerMotion.div>
);
};

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { Box, Card, CardContent, Stack, Typography, Chip } from '@mui/material';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import { motion as framerMotion } from 'framer-motion';
import { OptimizedVideo } from '../../../../ImageStudio/dashboard/utils/OptimizedVideo';
import type { ExampleVideo } from '../types';
interface ExampleVideoCardProps {
example: ExampleVideo;
index: number;
isSelected: boolean;
onClick: () => void;
}
export const ExampleVideoCard: React.FC<ExampleVideoCardProps> = ({
example,
index,
isSelected,
onClick,
}) => {
return (
<framerMotion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
sx={{
cursor: 'pointer',
border: isSelected ? '2px solid #667eea' : '1px solid #e2e8f0',
borderRadius: 2,
overflow: 'hidden',
transition: 'all 0.2s',
'&:hover': {
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.2)',
},
}}
onClick={onClick}
>
<Box
sx={{
position: 'relative',
width: '100%',
paddingTop: '56.25%', // 16:9 aspect ratio
backgroundColor: '#0f172a',
overflow: 'hidden',
}}
>
<OptimizedVideo
src={example.video}
alt={example.label}
controls
muted
loop
playsInline
preload="metadata"
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
{isSelected && (
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
background: '#667eea',
borderRadius: '50%',
p: 0.5,
}}
>
<PlayCircleOutlineIcon sx={{ color: '#fff', fontSize: 20 }} />
</Box>
)}
</Box>
<CardContent sx={{ p: 2 }}>
<Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a' }}>
{example.label}
</Typography>
<Chip
label={example.platform}
size="small"
sx={{
background: 'rgba(102, 126, 234, 0.1)',
color: '#667eea',
fontWeight: 600,
fontSize: 10,
}}
/>
</Stack>
<Typography variant="caption" sx={{ color: '#475569', fontSize: 11 }}>
{example.description}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label={example.price}
size="small"
sx={{
background: 'rgba(16, 185, 129, 0.1)',
color: '#047857',
fontWeight: 600,
fontSize: 10,
}}
/>
<Chip
label={example.eta}
size="small"
sx={{
background: 'rgba(59, 130, 246, 0.1)',
color: '#1e40af',
fontWeight: 600,
fontSize: 10,
}}
/>
</Stack>
</Stack>
</CardContent>
</Card>
</framerMotion.div>
);
};

View File

@@ -0,0 +1,255 @@
import React from 'react';
import {
Box,
Paper,
Stack,
Typography,
ToggleButtonGroup,
ToggleButton,
Button,
Alert,
} from '@mui/material';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import type { Mode } from '../types';
import { PromptInput } from './PromptInput';
import { VideoSettings } from './VideoSettings';
import { ModelSelector } from './ModelSelector';
import type { Resolution, AspectPreset, MotionPreset, Duration } from '../types';
interface GenerationSettingsPanelProps {
mode: Mode;
prompt: string;
negativePrompt: string;
duration: Duration;
resolution: Resolution;
aspect: AspectPreset;
motion: MotionPreset;
audioAttached: boolean;
costHint: string;
canGenerate: boolean;
promptFocused: boolean;
negativeFocused: boolean;
promptPlaceholderIndex: number;
negativePlaceholderIndex: number;
selectedModel: string;
onModeChange: (mode: Mode) => void;
onPromptChange: (value: string) => void;
onNegativePromptChange: (value: string) => void;
onDurationChange: (value: Duration) => void;
onResolutionChange: (value: Resolution) => void;
onAspectChange: (value: AspectPreset) => void;
onMotionChange: (value: MotionPreset) => void;
onModelChange: (modelId: string) => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onPromptFocus: () => void;
onPromptBlur: () => void;
onNegativeFocus: () => void;
onNegativeBlur: () => void;
onPromptPlaceholderChange: (index: number) => void;
onNegativePlaceholderChange: (index: number) => void;
onGenerate: () => void;
}
export const GenerationSettingsPanel: React.FC<GenerationSettingsPanelProps> = ({
mode,
prompt,
negativePrompt,
duration,
resolution,
aspect,
motion,
costHint,
canGenerate,
promptFocused,
negativeFocused,
promptPlaceholderIndex,
negativePlaceholderIndex,
selectedModel,
onModeChange,
onPromptChange,
onNegativePromptChange,
onDurationChange,
onResolutionChange,
onAspectChange,
onMotionChange,
onModelChange,
onFileSelect,
onPromptFocus,
onPromptBlur,
onNegativeFocus,
onNegativeBlur,
onPromptPlaceholderChange,
onNegativePlaceholderChange,
onGenerate,
}) => {
return (
<Paper
elevation={0}
sx={{
background: 'rgba(248, 250, 252, 0.96)',
backdropFilter: 'blur(18px)',
border: '1px solid rgba(148, 163, 184, 0.35)',
borderRadius: 3,
p: 3,
height: '100%',
color: '#0f172a',
}}
>
<Typography
variant="h6"
sx={{
fontWeight: 700,
mb: 3,
display: 'flex',
alignItems: 'center',
gap: 1,
color: '#0f172a',
}}
>
<AutoAwesomeIcon sx={{ color: '#667eea' }} />
Generation Settings
</Typography>
<Stack spacing={3}>
{/* Mode Toggle */}
<ToggleButtonGroup
value={mode}
exclusive
onChange={(_, val) => val && onModeChange(val)}
size="small"
fullWidth
sx={{
background: 'rgba(255,255,255,0.8)',
borderRadius: 2,
'& .MuiToggleButton-root': {
color: '#475569',
'&.Mui-selected': {
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
color: '#fff',
fontWeight: 700,
},
},
}}
>
<ToggleButton value="t2v">Text to Video</ToggleButton>
<ToggleButton value="i2v">Image to Video</ToggleButton>
</ToggleButtonGroup>
{/* AI Model Selector (only for text-to-video) */}
{mode === 't2v' && (
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
duration={duration}
resolution={resolution}
/>
)}
{/* Prompt Input */}
<PromptInput
prompt={prompt}
negativePrompt={negativePrompt}
onPromptChange={onPromptChange}
onNegativePromptChange={onNegativePromptChange}
promptFocused={promptFocused}
negativeFocused={negativeFocused}
onPromptFocus={onPromptFocus}
onPromptBlur={onPromptBlur}
onNegativeFocus={onNegativeFocus}
onNegativeBlur={onNegativeBlur}
promptPlaceholderIndex={promptPlaceholderIndex}
negativePlaceholderIndex={negativePlaceholderIndex}
onPromptPlaceholderChange={onPromptPlaceholderChange}
onNegativePlaceholderChange={onNegativePlaceholderChange}
/>
{/* Image Upload for i2v */}
{mode === 'i2v' && (
<Button
variant="outlined"
component="label"
startIcon={<UploadFileIcon />}
fullWidth
sx={{
borderRadius: 2,
borderColor: '#d2d9ee',
color: '#0f172a',
backgroundColor: 'rgba(255, 255, 255, 0.85)',
'&:hover': {
borderColor: '#7c3aed',
background: 'rgba(124, 58, 237, 0.05)',
},
}}
>
Upload Image
<input hidden accept="image/*" type="file" onChange={onFileSelect} />
</Button>
)}
{/* Video Settings */}
<VideoSettings
resolution={resolution}
aspect={aspect}
motion={motion}
duration={duration}
onResolutionChange={onResolutionChange}
onAspectChange={onAspectChange}
onMotionChange={onMotionChange}
onDurationChange={onDurationChange}
/>
{/* Cost Estimate */}
<Alert
severity="info"
icon={<InfoOutlinedIcon />}
sx={{
borderRadius: 2,
background: 'rgba(99, 102, 241, 0.08)',
color: '#0f172a',
'& .MuiAlert-icon': { color: '#6366f1' },
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Estimated Cost: {costHint}
</Typography>
<Typography variant="caption">
Final cost is confirmed before generation. Lower cost = shorter duration + lower quality.
</Typography>
</Alert>
{/* Generate Button */}
<Button
variant="contained"
size="large"
startIcon={<PlayArrowIcon />}
disabled={!canGenerate}
fullWidth
onClick={onGenerate}
sx={{
py: 2,
borderRadius: 2,
background: canGenerate
? 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)'
: '#e2e8f0',
color: canGenerate ? '#fff' : '#94a3b8',
fontWeight: 700,
fontSize: 16,
textTransform: 'none',
boxShadow: canGenerate ? '0 8px 24px rgba(102, 126, 234, 0.4)' : 'none',
'&:hover': {
background: canGenerate
? 'linear-gradient(90deg, #5568d3 0%, #65408b 100%)'
: '#e2e8f0',
boxShadow: canGenerate ? '0 12px 32px rgba(102, 126, 234, 0.5)' : 'none',
},
}}
>
Create Video
</Button>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import {
Box,
Paper,
Stack,
Typography,
FormControl,
Select,
MenuItem,
Chip,
Tooltip,
IconButton,
Accordion,
AccordionSummary,
AccordionDetails,
List,
ListItem,
ListItemIcon,
ListItemText,
Divider,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import InfoIcon from '@mui/icons-material/Info';
import { VIDEO_MODELS, type VideoModelInfo } from '../models/videoModels';
interface ModelSelectorProps {
selectedModel: string;
onModelChange: (modelId: string) => void;
duration: number;
resolution: string;
}
export const ModelSelector: React.FC<ModelSelectorProps> = ({
selectedModel,
onModelChange,
duration,
resolution,
}) => {
const [expandedModel, setExpandedModel] = useState<string | false>(false);
const selectedModelInfo = VIDEO_MODELS.find(m => m.id === selectedModel);
const handleAccordionChange = (modelId: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpandedModel(isExpanded ? modelId : false);
};
const calculateCost = (model: VideoModelInfo): string => {
const costPerSecond = model.costPerSecond[resolution] || model.costPerSecond[Object.keys(model.costPerSecond)[0]];
const totalCost = costPerSecond * duration;
return `$${totalCost.toFixed(2)}`;
};
const isModelCompatible = (model: VideoModelInfo): { compatible: boolean; reason?: string } => {
if (!model.durations.includes(duration)) {
return { compatible: false, reason: `Duration ${duration}s not supported. Available: ${model.durations.join(', ')}s` };
}
if (!model.resolutions.includes(resolution)) {
return { compatible: false, reason: `Resolution ${resolution} not supported. Available: ${model.resolutions.join(', ')}` };
}
return { compatible: true };
};
return (
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
AI Model
</Typography>
<Tooltip
title="Choose the AI model that best fits your content needs. Each model has different strengths, pricing, and capabilities."
arrow
>
<IconButton size="small" sx={{ color: '#64748b' }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth sx={{ mb: 2 }}>
<Select
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
sx={{
borderRadius: 2,
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
'&:hover fieldset': { borderColor: '#cbd5f5' },
'&.Mui-focused fieldset': {
borderColor: '#7c3aed',
},
}}
>
{VIDEO_MODELS.map((model) => {
const compatibility = isModelCompatible(model);
return (
<MenuItem
key={model.id}
value={model.id}
disabled={!compatibility.compatible}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: '100%' }}>
<Box sx={{ flex: 1 }}>
<Typography sx={{ color: compatibility.compatible ? '#0f172a' : '#94a3b8', fontWeight: 600 }}>
{model.name}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block' }}>
{model.tagline}
</Typography>
{!compatibility.compatible && (
<Typography variant="caption" sx={{ color: '#ef4444', display: 'block', mt: 0.5 }}>
{compatibility.reason}
</Typography>
)}
</Box>
{compatibility.compatible && (
<Chip
label={calculateCost(model)}
size="small"
sx={{
backgroundColor: '#f0f9ff',
color: '#0369a1',
fontWeight: 600,
fontSize: '0.75rem',
}}
/>
)}
</Stack>
</MenuItem>
);
})}
</Select>
</FormControl>
{/* Selected Model Details */}
{selectedModelInfo && (
<Paper
elevation={0}
sx={{
border: '1px solid #e2e8f0',
borderRadius: 2,
p: 2,
backgroundColor: '#f8fafc',
}}
>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
{selectedModelInfo.name}
</Typography>
<Typography variant="body2" sx={{ color: '#475569' }}>
{selectedModelInfo.description}
</Typography>
</Box>
<Divider />
{/* Best For */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', textTransform: 'uppercase' }}>
Best For
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1 }}>
{selectedModelInfo.bestFor.slice(0, 3).map((useCase) => (
<Chip
key={useCase}
label={useCase}
size="small"
sx={{
backgroundColor: '#e0e7ff',
color: '#4338ca',
fontSize: '0.7rem',
}}
/>
))}
</Stack>
</Box>
{/* Cost & Duration Info */}
<Box>
<Stack direction="row" spacing={2}>
<Box>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block' }}>
Estimated Cost
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
{calculateCost(selectedModelInfo)}
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block' }}>
Audio Support
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: selectedModelInfo.audioSupport ? '#059669' : '#dc2626' }}>
{selectedModelInfo.audioSupport ? 'Yes' : 'No'}
</Typography>
</Box>
</Stack>
</Box>
{/* Expandable Details */}
<Accordion
expanded={expandedModel === selectedModel}
onChange={handleAccordionChange(selectedModel)}
sx={{
boxShadow: 'none',
border: '1px solid #e2e8f0',
borderRadius: 2,
'&:before': { display: 'none' },
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon sx={{ color: '#64748b' }} />}
sx={{ minHeight: 40 }}
>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>
View Full Details & Tips
</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
{/* Strengths */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', textTransform: 'uppercase', display: 'block', mb: 1 }}>
Strengths
</Typography>
<List dense sx={{ py: 0 }}>
{selectedModelInfo.strengths.map((strength, idx) => (
<ListItem key={idx} sx={{ py: 0.5, px: 0 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<CheckCircleIcon sx={{ fontSize: 16, color: '#059669' }} />
</ListItemIcon>
<ListItemText
primary={strength}
primaryTypographyProps={{
variant: 'body2',
sx: { color: '#475569' },
}}
/>
</ListItem>
))}
</List>
</Box>
{/* Tips */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', textTransform: 'uppercase', display: 'block', mb: 1 }}>
Pro Tips
</Typography>
<List dense sx={{ py: 0 }}>
{selectedModelInfo.tips.map((tip, idx) => (
<ListItem key={idx} sx={{ py: 0.5, px: 0 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<InfoIcon sx={{ fontSize: 16, color: '#0369a1' }} />
</ListItemIcon>
<ListItemText
primary={tip}
primaryTypographyProps={{
variant: 'body2',
sx: { color: '#475569' },
}}
/>
</ListItem>
))}
</List>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
</Stack>
</Paper>
)}
{/* Model Comparison Link */}
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Tooltip title="Compare all models side-by-side to find the best fit for your needs">
<Typography
variant="caption"
sx={{
color: '#667eea',
cursor: 'pointer',
textDecoration: 'underline',
'&:hover': { color: '#5568d3' },
}}
>
Compare all models
</Typography>
</Tooltip>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,241 @@
import React, { useState } from 'react';
import { Box, TextField, Typography, Stack, Button, CircularProgress, Tooltip } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import { CarouselPlaceholder } from '../../CarouselPlaceholder';
import { examplePrompts, exampleNegativePrompts, inputStyles, colors } from '../constants';
import { optimizePrompt } from '../../../../../api/videoStudioApi';
interface PromptInputProps {
prompt: string;
negativePrompt: string;
promptFocused: boolean;
negativeFocused: boolean;
promptPlaceholderIndex: number;
negativePlaceholderIndex: number;
onPromptChange: (value: string) => void;
onNegativePromptChange: (value: string) => void;
onPromptFocus: () => void;
onPromptBlur: () => void;
onNegativeFocus: () => void;
onNegativeBlur: () => void;
onPromptPlaceholderChange: (index: number) => void;
onNegativePlaceholderChange: (index: number) => void;
}
export const PromptInput: React.FC<PromptInputProps> = ({
prompt,
negativePrompt,
promptFocused,
negativeFocused,
promptPlaceholderIndex,
negativePlaceholderIndex,
onPromptChange,
onNegativePromptChange,
onPromptFocus,
onPromptBlur,
onNegativeFocus,
onNegativeBlur,
onPromptPlaceholderChange,
onNegativePlaceholderChange,
}) => {
const [enhancing, setEnhancing] = useState(false);
const handleEnhancePrompt = async () => {
if (!prompt.trim() || enhancing) return;
setEnhancing(true);
try {
const result = await optimizePrompt({
text: prompt,
mode: 'video', // Always use 'video' mode for Video Studio
style: 'default',
});
if (result.success && result.optimized_prompt) {
onPromptChange(result.optimized_prompt);
}
} catch (error) {
console.error('Failed to enhance prompt:', error);
// Optionally show error toast/notification
} finally {
setEnhancing(false);
}
};
return (
<Stack spacing={3}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography
variant="subtitle2"
sx={{
color: colors.primary,
fontWeight: 700,
}}
>
Describe Your Video
</Typography>
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="caption" sx={{ display: 'block', fontWeight: 600, mb: 0.5 }}>
AI Prompt Optimizer
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
Enhances your prompt for better video generation by improving:
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem', mt: 0.5 }}>
Visual clarity & composition
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
Cinematic framing & lighting
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
Camera movement & style consistency
</Typography>
</Box>
}
arrow
placement="top"
>
<Button
size="small"
variant="outlined"
startIcon={enhancing ? <CircularProgress size={16} /> : <AutoAwesomeIcon />}
onClick={handleEnhancePrompt}
disabled={!prompt.trim() || enhancing}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
py: 0.5,
px: 1.5,
borderColor: colors.primary,
color: colors.primary,
'&:hover': {
borderColor: colors.primary,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
'&:disabled': {
borderColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{enhancing ? 'Enhancing...' : 'Enhance Instructions'}
</Button>
</Tooltip>
</Box>
<Box sx={{ position: 'relative' }}>
<TextField
fullWidth
multiline
rows={4}
placeholder="Enter your video description..."
value={prompt}
onChange={e => onPromptChange(e.target.value)}
onFocus={onPromptFocus}
onBlur={onPromptBlur}
sx={{
'& .MuiOutlinedInput-root': {
...inputStyles.outlinedInputBase,
minHeight: 140,
},
'& .MuiInputBase-input': {
color: '#0f172a',
'&::placeholder': {
color: '#64748b',
opacity: 1,
},
},
}}
/>
{!prompt && (
<Box
sx={{
position: 'absolute',
top: 56,
left: 14,
right: 14,
pointerEvents: 'none',
zIndex: 1,
opacity: promptFocused ? 0 : 1,
transition: 'opacity 0.2s ease',
}}
>
<CarouselPlaceholder
examples={examplePrompts}
interval={4000}
paused={promptFocused}
onExampleChange={(_: string, idx: number) => onPromptPlaceholderChange(idx)}
/>
</Box>
)}
</Box>
</Box>
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: colors.primary,
fontWeight: 700,
}}
>
What to Avoid (Optional)
</Typography>
<Box sx={{ position: 'relative' }}>
<TextField
label="What to avoid (optional)"
value={negativePrompt}
onChange={e => onNegativePromptChange(e.target.value)}
onFocus={onNegativeFocus}
onBlur={onNegativeBlur}
fullWidth
sx={{
'& .MuiOutlinedInput-root': inputStyles.outlinedInputBase,
'& .MuiInputBase-input': {
color: '#0f172a',
'&::placeholder': {
color: '#64748b',
opacity: 1,
},
},
}}
/>
{!negativePrompt && (
<Box
sx={{
position: 'absolute',
top: 40,
left: 14,
right: 14,
pointerEvents: 'none',
zIndex: 1,
opacity: negativeFocused ? 0 : 1,
transition: 'opacity 0.2s ease',
}}
>
<CarouselPlaceholder
examples={exampleNegativePrompts}
interval={4000}
paused={negativeFocused}
onExampleChange={(_: string, idx: number) => onNegativePlaceholderChange(idx)}
/>
</Box>
)}
</Box>
<Typography
variant="caption"
sx={{
mt: 1,
display: 'block',
color: colors.muted,
}}
>
Use this to specify what you don't want in your video (e.g., "blurry, low quality, distorted faces")
</Typography>
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,197 @@
import React from 'react';
import {
Box,
Paper,
Stack,
Typography,
Divider,
Grid,
Chip,
} from '@mui/material';
import MovieCreationIcon from '@mui/icons-material/MovieCreation';
import type { ExampleVideo } from '../types';
import type { ContentAsset } from '../../../../../hooks/useContentAssets';
import { ExampleVideoCard } from './ExampleVideoCard';
import { AssetLibraryVideoCard } from './AssetLibraryVideoCard';
interface VideoExamplesPanelProps {
examples: ExampleVideo[];
libraryVideos: ContentAsset[];
loadingLibraryVideos: boolean;
selectedExample: number | null;
selectedAssetId: number | null;
prompt: string;
onExampleClick: (index: number) => void;
onAssetClick: (asset: ContentAsset) => void;
}
export const VideoExamplesPanel: React.FC<VideoExamplesPanelProps> = ({
examples,
libraryVideos,
loadingLibraryVideos,
selectedExample,
selectedAssetId,
prompt,
onExampleClick,
onAssetClick,
}) => {
return (
<Paper
elevation={0}
sx={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: 3,
p: 3,
minHeight: 600,
}}
>
<Typography
variant="h6"
sx={{
fontWeight: 700,
mb: 3,
display: 'flex',
alignItems: 'center',
gap: 1,
color: '#0f172a',
}}
>
<MovieCreationIcon sx={{ color: '#667eea' }} />
Video Examples & Preview
</Typography>
{/* Example Videos */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: '#0f172a' }}>
Example Videos
</Typography>
<Grid container spacing={2}>
{examples.map((example, index) => (
<Grid item xs={12} sm={6} key={example.id}>
<ExampleVideoCard
example={example}
index={index}
isSelected={selectedExample === index}
onClick={() => onExampleClick(index)}
/>
</Grid>
))}
</Grid>
</Box>
{/* Asset Library Videos */}
{libraryVideos.length > 0 && (
<>
<Divider sx={{ my: 3, borderColor: 'rgba(0,0,0,0.1)' }} />
<Box sx={{ mb: 3 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Your Videos from Asset Library
</Typography>
<Chip
label={`${libraryVideos.length} video${libraryVideos.length !== 1 ? 's' : ''}`}
size="small"
sx={{
background: 'rgba(102, 126, 234, 0.1)',
color: '#667eea',
fontWeight: 600,
}}
/>
</Stack>
{loadingLibraryVideos ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" sx={{ color: '#475569' }}>
Loading your videos...
</Typography>
</Box>
) : (
<Grid container spacing={2}>
{libraryVideos.map((asset) => (
<Grid item xs={12} sm={6} key={asset.id}>
<AssetLibraryVideoCard
asset={asset}
isSelected={selectedAssetId === asset.id}
onClick={() => onAssetClick(asset)}
/>
</Grid>
))}
</Grid>
)}
</Box>
</>
)}
<Divider sx={{ my: 3, borderColor: 'rgba(0,0,0,0.1)' }} />
{/* Empty State / Preview Area */}
{!prompt && (
<Box
sx={{
textAlign: 'center',
py: 8,
px: 3,
}}
>
<Box
sx={{
width: 120,
height: 120,
borderRadius: '50%',
background: 'linear-gradient(135deg, #667eea20, #764ba220)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 3,
}}
>
<MovieCreationIcon sx={{ fontSize: 60, color: '#667eea' }} />
</Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1, color: '#0f172a' }}>
No Video Yet
</Typography>
<Typography variant="body2" sx={{ color: '#475569', mb: 3 }}>
Enter a prompt and click "Create Video" to generate your video, or click an example above to see what's possible
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center', flexWrap: 'wrap' }}>
{['Instagram Reel', 'TikTok Video', 'YouTube Short', 'LinkedIn Post'].map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
sx={{
background: 'rgba(102, 126, 234, 0.1)',
color: '#667eea',
fontWeight: 600,
}}
/>
))}
</Box>
</Box>
)}
{/* Generated Video Preview (when available) */}
{prompt && (
<Box
sx={{
textAlign: 'center',
py: 4,
px: 3,
background: 'rgba(102, 126, 234, 0.05)',
borderRadius: 2,
border: '2px dashed rgba(102, 126, 234, 0.3)',
}}
>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2, color: '#0f172a' }}>
Your video will appear here
</Typography>
<Typography variant="body2" sx={{ color: '#475569' }}>
Click "Create Video" to generate your video based on your prompt and settings
</Typography>
</Box>
)}
</Paper>
);
};

View File

@@ -0,0 +1,166 @@
import React from 'react';
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, Slider } from '@mui/material';
import type { Resolution, AspectPreset, MotionPreset, Duration } from '../types';
import { motionPresets, aspectPresets, inputStyles } from '../constants';
interface VideoSettingsProps {
resolution: Resolution;
aspect: AspectPreset;
motion: MotionPreset;
duration: Duration;
onResolutionChange: (value: Resolution) => void;
onAspectChange: (value: AspectPreset) => void;
onMotionChange: (value: MotionPreset) => void;
onDurationChange: (value: Duration) => void;
}
export const VideoSettings: React.FC<VideoSettingsProps> = ({
resolution,
aspect,
motion,
duration,
onResolutionChange,
onAspectChange,
onMotionChange,
onDurationChange,
}) => {
return (
<>
{/* Resolution, Aspect, Motion */}
<Stack direction="row" spacing={2}>
<FormControl fullWidth>
<InputLabel sx={inputStyles.inputLabel}>Video Quality</InputLabel>
<Select
value={resolution}
label="Video Quality"
onChange={e => onResolutionChange(e.target.value as Resolution)}
sx={{
borderRadius: 2,
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
}}
>
<MenuItem value="480p">
<Stack>
<Typography variant="body2">480p - Fast & Affordable</Typography>
<Typography variant="caption" color="text.secondary">
Perfect for quick social media tests
</Typography>
</Stack>
</MenuItem>
<MenuItem value="720p">
<Stack>
<Typography variant="body2">720p - Balanced</Typography>
<Typography variant="caption" color="text.secondary">
Great for most platforms
</Typography>
</Stack>
</MenuItem>
<MenuItem value="1080p">
<Stack>
<Typography variant="body2">1080p - Premium</Typography>
<Typography variant="caption" color="text.secondary">
Ideal for YouTube and professional content
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel sx={inputStyles.inputLabel}>Video Format</InputLabel>
<Select
value={aspect}
label="Video Format"
onChange={e => onAspectChange(e.target.value as AspectPreset)}
sx={{
borderRadius: 2,
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
}}
>
<MenuItem value="9:16">
<Stack>
<Typography variant="body2">9:16 - Vertical</Typography>
<Typography variant="caption" color="text.secondary">
Instagram Reels, TikTok, YouTube Shorts
</Typography>
</Stack>
</MenuItem>
<MenuItem value="1:1">
<Stack>
<Typography variant="body2">1:1 - Square</Typography>
<Typography variant="caption" color="text.secondary">
Instagram posts, Facebook feed
</Typography>
</Stack>
</MenuItem>
<MenuItem value="16:9">
<Stack>
<Typography variant="body2">16:9 - Landscape</Typography>
<Typography variant="caption" color="text.secondary">
YouTube, LinkedIn, landscape content
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
</Stack>
<FormControl fullWidth>
<InputLabel sx={inputStyles.inputLabel}>Movement Style</InputLabel>
<Select
value={motion}
label="Movement Style"
onChange={e => onMotionChange(e.target.value as MotionPreset)}
sx={{
borderRadius: 2,
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
}}
>
{motionPresets.map(preset => (
<MenuItem key={preset} value={preset}>
<Stack>
<Typography variant="body2">{preset}</Typography>
<Typography variant="caption" color="text.secondary">
{preset === 'Subtle'
? 'Gentle movement, professional content'
: preset === 'Medium'
? 'Balanced motion, most social media'
: 'Energetic movement, attention-grabbing'}
</Typography>
</Stack>
</MenuItem>
))}
</Select>
</FormControl>
{/* Duration Slider */}
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: '#0f172a' }}>
Duration: {duration} seconds
</Typography>
<Slider
value={duration}
min={5}
max={10}
step={3}
marks={[
{ value: 5, label: '5s' },
{ value: 8, label: '8s' },
{ value: 10, label: '10s' },
]}
onChange={(_, val) => onDurationChange(val as Duration)}
sx={{
color: '#667eea',
'& .MuiSlider-markLabel': { color: '#475569' },
}}
/>
<Typography variant="caption" sx={{ color: '#475569', mt: 0.5 }}>
Shorter videos cost less. Perfect for testing ideas before investing in longer content.
</Typography>
</Box>
</>
);
};

View File

@@ -0,0 +1,7 @@
export { GenerationSettingsPanel } from './GenerationSettingsPanel';
export { VideoExamplesPanel } from './VideoExamplesPanel';
export { PromptInput } from './PromptInput';
export { VideoSettings } from './VideoSettings';
export { ExampleVideoCard } from './ExampleVideoCard';
export { AssetLibraryVideoCard } from './AssetLibraryVideoCard';
export { ModelSelector } from './ModelSelector';

View File

@@ -0,0 +1,43 @@
import type { MotionPreset, AspectPreset } from './types';
export const motionPresets: readonly MotionPreset[] = ['Subtle', 'Medium', 'Dynamic'] as const;
export const aspectPresets: readonly AspectPreset[] = ['9:16', '1:1', '16:9'] as const;
// Example prompts for content creators
export const examplePrompts = [
'A modern coffee shop interior with baristas crafting latte art, warm golden hour lighting streaming through large windows, customers chatting at wooden tables, cozy atmosphere, perfect for Instagram Reels',
'Professional workspace with laptop, notebook, and coffee cup on a minimalist desk, soft natural lighting, clean modern office environment, ideal for LinkedIn posts',
'Dynamic product showcase with rotating view, vibrant colors, smooth camera movement, energetic music vibe, perfect for YouTube Shorts and product demos',
];
export const exampleNegativePrompts = [
'blurry, low quality, distorted faces, text overlays',
'grainy footage, poor lighting, shaky camera, watermark',
'unprofessional, cluttered background, bad composition',
];
// Input styles
export const inputStyles = {
outlinedInputBase: {
borderRadius: 2,
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
'&:hover fieldset': { borderColor: '#cbd5f5' },
'&.Mui-focused fieldset': {
borderColor: '#7c3aed',
boxShadow: '0 0 0 3px rgba(124, 58, 237, 0.15)',
},
},
inputLabel: {
color: '#475569',
fontWeight: 600,
},
};
// Color constants
export const colors = {
primary: '#0f172a',
muted: '#475569',
accent: '#667eea',
accentSecondary: '#764ba2',
};

View File

@@ -0,0 +1,91 @@
import { useState, useMemo, useCallback } from 'react';
import { useContentAssets, type ContentAsset } from '../../../../../hooks/useContentAssets';
import { getModelInfo } from '../models/videoModels';
import type { Mode, Duration, Resolution, AspectPreset, MotionPreset } from '../types';
export const useCreateVideo = () => {
const [mode, setMode] = useState<Mode>('t2v');
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [duration, setDuration] = useState<Duration>(8);
const [resolution, setResolution] = useState<Resolution>('720p');
const [aspect, setAspect] = useState<AspectPreset>('9:16');
const [motion, setMotion] = useState<MotionPreset>('Medium');
const [audioAttached, setAudioAttached] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>('hunyuan-video-1.5'); // Default model
const [selectedExample, setSelectedExample] = useState<number | null>(null);
const [selectedAssetId, setSelectedAssetId] = useState<number | null>(null);
const [promptPlaceholderIndex, setPromptPlaceholderIndex] = useState(0);
const [negativePlaceholderIndex, setNegativePlaceholderIndex] = useState(0);
const [promptFocused, setPromptFocused] = useState(false);
const [negativeFocused, setNegativeFocused] = useState(false);
// Fetch videos from asset library
const { assets: libraryVideos, loading: loadingLibraryVideos } = useContentAssets({
asset_type: 'video',
limit: 6,
});
const canGenerate = useMemo(() => prompt.trim().length > 5, [prompt]);
const costHint = useMemo(() => {
// Get model-specific pricing
const modelInfo = getModelInfo(selectedModel);
if (modelInfo) {
const costPerSecond = modelInfo.costPerSecond[resolution] || modelInfo.costPerSecond[Object.keys(modelInfo.costPerSecond)[0]];
const estimate = (costPerSecond * duration).toFixed(2);
return `Est. ~$${estimate}`;
}
// Fallback to default pricing
const base = resolution === '480p' ? 0.02 : resolution === '720p' ? 0.04 : 0.06;
const estimate = (base * duration).toFixed(2);
return `Est. ~$${estimate}`;
}, [duration, resolution, selectedModel]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (mode === 'i2v' && e.target.files?.length) {
// Placeholder: in later phases, we'll upload/preview
}
}, [mode]);
return {
// State
mode,
setMode,
prompt,
setPrompt,
negativePrompt,
setNegativePrompt,
duration,
setDuration,
resolution,
setResolution,
aspect,
setAspect,
motion,
setMotion,
audioAttached,
setAudioAttached,
selectedModel,
setSelectedModel,
selectedExample,
setSelectedExample,
selectedAssetId,
setSelectedAssetId,
promptPlaceholderIndex,
setPromptPlaceholderIndex,
negativePlaceholderIndex,
setNegativePlaceholderIndex,
promptFocused,
setPromptFocused,
negativeFocused,
setNegativeFocused,
// Computed
canGenerate,
costHint,
libraryVideos,
loadingLibraryVideos,
// Handlers
handleFileSelect,
};
};

View File

@@ -0,0 +1,2 @@
export { CreateVideo } from './CreateVideo';
export { default } from './CreateVideo';

View File

@@ -0,0 +1,207 @@
/**
* Video Model Information for Content Creators
*
* Non-technical, creator-focused descriptions to help users choose the right AI model
* for their video generation needs.
*/
export interface VideoModelInfo {
id: string;
name: string;
tagline: string;
description: string;
bestFor: string[];
strengths: string[];
limitations: string[];
durations: number[];
resolutions: string[];
aspectRatios: string[];
audioSupport: boolean;
costPerSecond: {
[resolution: string]: number;
};
exampleUseCases: string[];
tips: string[];
icon?: string;
}
export const VIDEO_MODELS: VideoModelInfo[] = [
{
id: 'hunyuan-video-1.5',
name: 'HunyuanVideo 1.5',
tagline: 'Lightweight & Fast - Perfect for Quick Content',
description: 'A lightweight model that generates high-quality videos quickly. Great for social media content, quick iterations, and when you need fast results without breaking the bank.',
bestFor: [
'Instagram Reels & Stories',
'TikTok videos',
'Quick social media content',
'Testing ideas and concepts',
'Budget-conscious creators'
],
strengths: [
'Fast generation time',
'Affordable pricing',
'Good motion quality',
'Works well for short clips',
'Great for testing prompts'
],
limitations: [
'Limited to 5-10 second videos',
'Only 480p or 720p resolution',
'No audio generation',
'Best for shorter content'
],
durations: [5, 8, 10],
resolutions: ['480p', '720p'],
aspectRatios: ['16:9', '9:16'],
audioSupport: false,
costPerSecond: {
'480p': 0.02,
'720p': 0.04,
},
exampleUseCases: [
'Quick product showcases for social media',
'Story highlights and behind-the-scenes',
'Fast-paced social media content',
'Testing video concepts before production'
],
tips: [
'Use for 5-8 second clips for best results',
'Describe motion and camera movement clearly',
'Mention style and mood in your prompt',
'Perfect for Instagram and TikTok content'
],
},
{
id: 'lightricks/ltx-2-pro',
name: 'LTX-2 Pro',
tagline: 'Production Quality with Synchronized Audio',
description: 'Professional-grade video generation with perfectly synchronized audio. Designed for real production workflows where quality and audio-video sync matter. Creates cinematic scenes with matching sound.',
bestFor: [
'YouTube videos',
'Professional marketing content',
'Music videos',
'Film previsualization',
'Advertising campaigns',
'Production workflows'
],
strengths: [
'Synchronized audio generation',
'Cinematic quality',
'Perfect audio-video sync',
'Production-ready output',
'1080p native resolution',
'Great for longer content (6-10s)'
],
limitations: [
'Fixed at 1080p (no lower resolutions)',
'Higher cost per second',
'Longer generation time',
'Only 6-10 second durations'
],
durations: [6, 8, 10],
resolutions: ['1080p'],
aspectRatios: ['16:9', '9:16'],
audioSupport: true,
costPerSecond: {
'1080p': 0.06,
},
exampleUseCases: [
'YouTube video intros and outros',
'Product launch videos with music',
'Music video sequences',
'Professional marketing clips',
'Film storyboard visualization'
],
tips: [
'Describe camera movements and scene composition',
'Mention emotional tone and atmosphere',
'Audio is automatically generated to match motion',
'Best for 6-8 second clips for optimal quality',
'Perfect for professional content creation'
],
},
{
id: 'google/veo3.1',
name: 'Google Veo 3.1',
tagline: 'High-Quality with Flexible Options',
description: 'Google\'s advanced video generation model that creates high-quality videos with synchronized audio. Offers flexible resolution and aspect ratio options, perfect for various content platforms.',
bestFor: [
'YouTube content',
'Professional presentations',
'Multi-platform content',
'High-quality social media',
'Content requiring flexibility'
],
strengths: [
'720p and 1080p options',
'Synchronized audio generation',
'Negative prompt support',
'Seed control for consistency',
'Flexible aspect ratios',
'High visual quality'
],
limitations: [
'Shorter duration options (4-8s)',
'Higher cost for 1080p',
'No 480p option'
],
durations: [4, 6, 8],
resolutions: ['720p', '1080p'],
aspectRatios: ['16:9', '9:16'],
audioSupport: true,
costPerSecond: {
'720p': 0.08,
'1080p': 0.12,
},
exampleUseCases: [
'YouTube Shorts and regular videos',
'Professional social media content',
'Multi-platform content creation',
'High-quality product showcases',
'Content requiring specific aspect ratios'
],
tips: [
'Use negative prompts to exclude unwanted elements',
'Use seed values to create consistent variations',
'720p is great for social media, 1080p for YouTube',
'Describe scenes with clear visual details',
'Audio automatically matches video motion'
],
},
];
/**
* Get model information by ID
*/
export function getModelInfo(modelId: string): VideoModelInfo | undefined {
return VIDEO_MODELS.find(m => m.id === modelId);
}
/**
* Get recommended model based on use case
*/
export function getRecommendedModel(useCase: string): VideoModelInfo | undefined {
const useCaseLower = useCase.toLowerCase();
if (useCaseLower.includes('social') || useCaseLower.includes('instagram') || useCaseLower.includes('tiktok')) {
return VIDEO_MODELS.find(m => m.id === 'hunyuan-video-1.5');
}
if (useCaseLower.includes('youtube') || useCaseLower.includes('professional') || useCaseLower.includes('production')) {
return VIDEO_MODELS.find(m => m.id === 'lightricks/ltx-2-pro');
}
if (useCaseLower.includes('flexible') || useCaseLower.includes('multi-platform')) {
return VIDEO_MODELS.find(m => m.id === 'google/veo3.1');
}
return VIDEO_MODELS[0]; // Default to first model
}
/**
* Compare models side by side
*/
export function compareModels(modelIds: string[]): VideoModelInfo[] {
return VIDEO_MODELS.filter(m => modelIds.includes(m.id));
}

View File

@@ -0,0 +1,30 @@
export type Mode = 't2v' | 'i2v';
export type MotionPreset = 'Subtle' | 'Medium' | 'Dynamic';
export type AspectPreset = '9:16' | '1:1' | '16:9';
export type Resolution = '480p' | '720p' | '1080p';
export type Duration = 5 | 8 | 10;
export interface VideoGenerationSettings {
mode: Mode;
prompt: string;
negativePrompt: string;
duration: Duration;
resolution: Resolution;
aspect: AspectPreset;
motion: MotionPreset;
audioAttached: boolean;
}
export interface ExampleVideo {
id: string;
label: string;
prompt: string;
description: string;
price: string;
eta: string;
provider: string;
video: string;
platform: string;
useCase: string;
}

View File

@@ -0,0 +1,57 @@
import type { ExampleVideo, AspectPreset } from '../types';
import type { ContentAsset } from '../../../../../hooks/useContentAssets';
import { aspectPresets } from '../constants';
export const handleExampleClick = (
index: number,
example: ExampleVideo,
setPrompt: (value: string) => void,
setAspect: (value: AspectPreset) => void,
setSelectedExample: (index: number | null) => void,
setSelectedAssetId: (id: number | null) => void
) => {
setSelectedExample(index);
setSelectedAssetId(null);
setPrompt(example.prompt);
// Set appropriate settings based on example
if (example.platform === 'Instagram' || example.platform === 'YouTube') {
setAspect('9:16');
} else if (example.platform === 'LinkedIn') {
setAspect('16:9');
}
};
export const handleAssetClick = (
asset: ContentAsset,
setPrompt: (value: string) => void,
setAspect: (value: AspectPreset) => void,
setResolution: (value: '480p' | '720p' | '1080p') => void,
setSelectedAssetId: (id: number | null) => void,
setSelectedExample: (index: number | null) => void
) => {
setSelectedAssetId(asset.id);
setSelectedExample(null);
// Use prompt from asset if available, otherwise use title or description
if (asset.prompt) {
setPrompt(asset.prompt);
} else if (asset.title) {
setPrompt(asset.title);
} else if (asset.description) {
setPrompt(asset.description);
}
// Try to extract settings from metadata
if (asset.asset_metadata) {
if (asset.asset_metadata.aspect_ratio || asset.asset_metadata.aspect) {
const aspectValue = asset.asset_metadata.aspect_ratio || asset.asset_metadata.aspect;
if (aspectPresets.includes(aspectValue as any)) {
setAspect(aspectValue as AspectPreset);
}
}
if (asset.asset_metadata.resolution) {
const res = asset.asset_metadata.resolution.toLowerCase();
if (res.includes('480')) setResolution('480p');
else if (res.includes('720')) setResolution('720p');
else if (res.includes('1080')) setResolution('1080p');
}
}
};

View File

@@ -0,0 +1,20 @@
import React from 'react';
import ModulePlaceholder from '../ModulePlaceholder';
export const EditVideo: React.FC = () => {
return (
<ModulePlaceholder
title="Edit Studio"
subtitle="Trim, replace, captions"
status="coming soon"
description="Non-destructive trims, speed changes, stabilization, background replace, object/face swap, captions/subtitles."
bullets={[
'Use cases: polish social clips, remove sections, localize with captions',
'Planned: timeline editor, region/face selection, auto-captions',
'Guardrails: duration caps per tier, cost shown before edits run',
]}
/>
);
};
export default EditVideo;

View File

@@ -0,0 +1,3 @@
// Re-export from the EnhanceVideo component
export { EnhanceVideo } from './EnhanceVideo/EnhanceVideo';
export { default } from './EnhanceVideo/EnhanceVideo';

View File

@@ -0,0 +1,407 @@
import React, { useState, useEffect } from 'react';
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert } from '@mui/material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useEnhanceVideo } from './hooks/useEnhanceVideo';
import { VideoUpload, EnhancementSettings } from './components';
import { aiApiClient } from '../../../../api/client';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
const EnhanceVideo: React.FC = () => {
const {
videoFile,
videoPreview,
targetResolution,
enhancementType,
setVideoFile,
setTargetResolution,
setEnhancementType,
canEnhance,
costHint,
} = useEnhanceVideo();
const [enhancing, setEnhancing] = useState(false);
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ video_url: string; cost: number } | null>(null);
const [progressInterval, setProgressInterval] = useState<NodeJS.Timeout | null>(null);
// Cleanup progress interval on unmount
useEffect(() => {
return () => {
if (progressInterval) {
clearInterval(progressInterval);
}
};
}, [progressInterval]);
const handleEnhance = async () => {
if (!videoFile) return;
setEnhancing(true);
setError(null);
setResult(null);
setProgress(0);
setStatusMessage('Starting video enhancement...');
try {
// Create FormData
const formData = new FormData();
formData.append('file', videoFile);
formData.append('enhancement_type', enhancementType);
formData.append('target_resolution', targetResolution);
formData.append('provider', 'wavespeed');
formData.append('model', 'flashvsr');
// Submit enhancement request
setStatusMessage('Uploading video...');
const response = await aiApiClient.post('/api/video-studio/enhance', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const uploadProgress = Math.round((progressEvent.loaded * 20) / progressEvent.total);
setProgress(uploadProgress);
setStatusMessage(`Uploading video... ${uploadProgress}%`);
}
},
timeout: 600000, // 10 minutes timeout for long videos
});
setProgress(30);
setStatusMessage('Processing video with FlashVSR... This may take a few minutes...');
// FlashVSR processing can take 3-20 seconds per 1 second of video
// Simulate progress updates while waiting for response
let simulatedProgress = 30;
const interval = setInterval(() => {
simulatedProgress = Math.min(90, simulatedProgress + 5);
setProgress(simulatedProgress);
setStatusMessage(`Processing... ${simulatedProgress}% (This may take several minutes for long videos)`);
}, 2000);
setProgressInterval(interval);
try {
if (response.data.success) {
clearInterval(interval);
setProgressInterval(null);
setEnhancing(false);
setResult(response.data);
setProgress(100);
setStatusMessage('Video enhancement complete!');
} else {
clearInterval(interval);
setProgressInterval(null);
throw new Error(response.data.error || 'Enhancement failed');
}
} catch (err) {
clearInterval(interval);
setProgressInterval(null);
throw err;
}
} catch (err: any) {
if (progressInterval) {
clearInterval(progressInterval);
setProgressInterval(null);
}
setEnhancing(false);
setProgress(0);
setError(err.response?.data?.detail || err.message || 'Failed to enhance video');
setStatusMessage('Enhancement failed');
}
};
const handleReset = () => {
setEnhancing(false);
setProgress(0);
setStatusMessage('');
setError(null);
setResult(null);
if (progressInterval) {
clearInterval(progressInterval);
setProgressInterval(null);
}
};
return (
<VideoStudioLayout
headerProps={{
title: 'Enhance Studio',
subtitle: 'Upscale your videos to higher resolutions with FlashVSR. Improve video quality, restore clarity, and prepare content for professional delivery.',
}}
>
<Grid container spacing={4}>
{/* Left Panel - Upload & Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
<EnhancementSettings
targetResolution={targetResolution}
enhancementType={enhancementType}
costHint={costHint}
onTargetResolutionChange={setTargetResolution}
onEnhancementTypeChange={setEnhancementType}
/>
<Box>
<Button
fullWidth
variant="contained"
size="large"
startIcon={enhancing ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
onClick={handleEnhance}
disabled={!canEnhance || enhancing}
sx={{
py: 1.5,
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{enhancing ? 'Enhancing...' : 'Enhance Video'}
</Button>
</Box>
{enhancing && (
<Box>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
{statusMessage}
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 1,
backgroundColor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#3b82f6',
},
}}
/>
</Stack>
</Box>
)}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Results */}
<Grid item xs={12} lg={7}>
<Stack spacing={3}>
{result ? (
// Side-by-side comparison view
<Box>
<Typography
variant="h6"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Comparison
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview || ''}
controls
style={{
width: '100%',
maxHeight: 400,
display: 'block',
}}
/>
<Box sx={{ p: 1.5, backgroundColor: '#f8fafc' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>
Original Video
</Typography>
</Box>
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #10b981',
backgroundColor: '#000',
}}
>
<video
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
controls
style={{
width: '100%',
maxHeight: 400,
display: 'block',
}}
/>
<Box sx={{ p: 1.5, backgroundColor: '#f0fdf4' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#059669' }}>
Enhanced ({targetResolution.toUpperCase()})
</Typography>
</Box>
</Box>
</Grid>
</Grid>
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button
variant="contained"
fullWidth
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
download
sx={{
backgroundColor: '#10b981',
'&:hover': {
backgroundColor: '#059669',
},
}}
>
Download Enhanced Video
</Button>
<Button variant="outlined" fullWidth onClick={handleReset}>
Enhance Another
</Button>
</Stack>
<Box
sx={{
mt: 2,
p: 2,
borderRadius: 2,
backgroundColor: '#f0fdf4',
border: '1px solid #10b981',
}}
>
<Stack spacing={1}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#065f46' }}>
Enhancement Complete!
</Typography>
<Typography variant="caption" color="text.secondary">
Cost: ${result.cost.toFixed(4)} | Resolution: {targetResolution.toUpperCase()}
</Typography>
</Stack>
</Box>
</Box>
) : videoPreview ? (
// Original video preview
<Box>
<Typography
variant="h6"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Original Video Preview
</Typography>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 500,
display: 'block',
}}
/>
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
<Typography variant="body2" color="text.secondary">
Upload a video and select enhancement options to get started
</Typography>
</Box>
</Box>
</Box>
) : (
<Box
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 6,
textAlign: 'center',
backgroundColor: '#f8fafc',
}}
>
<Typography variant="body2" color="text.secondary">
Upload a video to see preview
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Your enhanced video will appear here
</Typography>
</Box>
)}
{/* Info Box */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
About FlashVSR
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
FlashVSR is the most advanced video upscaler, delivering:
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="body2" color="text.secondary">
Temporal consistency for stable motion
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Detail reconstruction for fine textures
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Artifact cleanup for compression blocks
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Natural look without overprocessing
</Typography>
</Stack>
</Box>
</Stack>
</Grid>
</Grid>
</VideoStudioLayout>
);
};
export default EnhanceVideo;
export { EnhanceVideo };

View File

@@ -0,0 +1,147 @@
import React from 'react';
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, Chip, Paper } from '@mui/material';
import HighQualityIcon from '@mui/icons-material/HighQuality';
import type { EnhancementResolution, EnhancementType } from '../hooks/useEnhanceVideo';
interface EnhancementSettingsProps {
targetResolution: EnhancementResolution;
enhancementType: EnhancementType;
costHint: string;
onTargetResolutionChange: (resolution: EnhancementResolution) => void;
onEnhancementTypeChange: (type: EnhancementType) => void;
}
export const EnhancementSettings: React.FC<EnhancementSettingsProps> = ({
targetResolution,
enhancementType,
costHint,
onTargetResolutionChange,
onEnhancementTypeChange,
}) => {
return (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
}}
>
<Stack spacing={3}>
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<HighQualityIcon sx={{ color: '#3b82f6' }} />
<Typography
variant="subtitle2"
sx={{
color: '#0f172a',
fontWeight: 700,
}}
>
Enhancement Settings
</Typography>
</Stack>
</Box>
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 600,
}}
>
Enhancement Type
</Typography>
<FormControl fullWidth>
<Select
value={enhancementType}
onChange={(e) => onEnhancementTypeChange(e.target.value as EnhancementType)}
sx={{
backgroundColor: '#fff',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e2e8f0',
},
}}
>
<MenuItem value="upscale">Upscale (FlashVSR)</MenuItem>
{/* Future enhancement types */}
{/* <MenuItem value="stabilize">Stabilize</MenuItem>
<MenuItem value="colorize">Colorize</MenuItem> */}
</Select>
</FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
FlashVSR upscales videos with temporal consistency and detail reconstruction
</Typography>
</Box>
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Target Resolution
</Typography>
<FormControl fullWidth>
<InputLabel>Resolution</InputLabel>
<Select
value={targetResolution}
onChange={(e) => onTargetResolutionChange(e.target.value as EnhancementResolution)}
label="Resolution"
sx={{
backgroundColor: '#fff',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e2e8f0',
},
}}
>
<MenuItem value="720p">720p HD ($0.06/5s)</MenuItem>
<MenuItem value="1080p">1080p Full HD ($0.09/5s)</MenuItem>
<MenuItem value="2k">2K ($0.12/5s)</MenuItem>
<MenuItem value="4k">4K Ultra HD ($0.16/5s)</MenuItem>
</Select>
</FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Higher resolution = better quality but higher cost
</Typography>
</Box>
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Estimated Cost:
</Typography>
<Chip
label={costHint}
size="small"
sx={{
backgroundColor: '#3b82f6',
color: '#fff',
fontWeight: 600,
}}
/>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
FlashVSR pricing: $0.012-$0.032/second (based on resolution)
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Minimum charge: 5 seconds | Maximum: 10 minutes (600 seconds)
</Typography>
</Box>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,126 @@
import React, { useRef } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import VideocamIcon from '@mui/icons-material/Videocam';
interface VideoUploadProps {
videoPreview: string | null;
onVideoSelect: (file: File | null) => void;
}
export const VideoUpload: React.FC<VideoUploadProps> = ({
videoPreview,
onVideoSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate video file
if (!file.type.startsWith('video/')) {
alert('Please select a video file');
return;
}
if (file.size > 500 * 1024 * 1024) {
alert('Video file must be less than 500MB');
return;
}
onVideoSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
onVideoSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Upload Video
</Typography>
<input
ref={fileInputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{videoPreview ? (
<Box
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 400,
display: 'block',
}}
/>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemove}
sx={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
}}
>
Remove
</Button>
</Box>
) : (
<Box
onClick={handleClick}
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#f8fafc',
},
}}
>
<Stack spacing={2} alignItems="center">
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary">
Click to upload a video
</Typography>
<Typography variant="caption" color="text.secondary">
MP4, WebM up to 500MB (max 10 minutes)
</Typography>
</Stack>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,2 @@
export { VideoUpload } from './VideoUpload';
export { EnhancementSettings } from './EnhancementSettings';

View File

@@ -0,0 +1,136 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { aiApiClient } from '../../../../../api/client';
export type EnhancementResolution = '720p' | '1080p' | '2k' | '4k';
export type EnhancementType = 'upscale';
export const useEnhanceVideo = () => {
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreview, setVideoPreview] = useState<string | null>(null);
const [targetResolution, setTargetResolution] = useState<EnhancementResolution>('1080p');
const [enhancementType, setEnhancementType] = useState<EnhancementType>('upscale');
const [estimatedDuration, setEstimatedDuration] = useState<number>(10.0);
const [costEstimate, setCostEstimate] = useState<number | null>(null);
// Update preview when file changes
useEffect(() => {
if (videoFile) {
const url = URL.createObjectURL(videoFile);
setVideoPreview(url);
// Rough estimate: 1MB ≈ 1 second at 1080p
// In production, you'd parse the video to get actual duration
const estimated = Math.max(5, videoFile.size / (1024 * 1024));
setEstimatedDuration(estimated);
return () => URL.revokeObjectURL(url);
} else {
setVideoPreview(null);
setEstimatedDuration(10.0);
}
}, [videoFile]);
// Fetch cost estimate when resolution or duration changes
useEffect(() => {
const fetchCostEstimate = async () => {
if (!videoFile || estimatedDuration < 5) {
setCostEstimate(null);
return;
}
try {
const formData = new FormData();
formData.append('target_resolution', targetResolution);
formData.append('estimated_duration', estimatedDuration.toString());
const response = await aiApiClient.post('/api/video-studio/enhance/estimate-cost', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (response.data.estimated_cost) {
setCostEstimate(response.data.estimated_cost);
}
} catch (err) {
console.error('Failed to fetch cost estimate:', err);
// Fallback to client-side calculation
const pricing = {
'720p': 0.06 / 5,
'1080p': 0.09 / 5,
'2k': 0.12 / 5,
'4k': 0.16 / 5,
};
const costPerSecond = pricing[targetResolution];
setCostEstimate(Math.max(5.0, estimatedDuration) * costPerSecond);
}
};
fetchCostEstimate();
}, [videoFile, targetResolution, estimatedDuration]);
// Cost hint for display
const costHint = useMemo(() => {
if (!videoFile) return 'Upload a video to see cost estimate';
if (costEstimate !== null) {
return `Est. ~$${costEstimate.toFixed(2)} (${estimatedDuration.toFixed(0)}s @ ${targetResolution})`;
}
// Fallback calculation
const pricing = {
'720p': 0.06 / 5,
'1080p': 0.09 / 5,
'2k': 0.12 / 5,
'4k': 0.16 / 5,
};
const costPerSecond = pricing[targetResolution];
const estimatedCost = Math.max(5.0, estimatedDuration) * costPerSecond;
return `Est. ~$${estimatedCost.toFixed(2)} (${estimatedDuration.toFixed(0)}s @ ${targetResolution})`;
}, [videoFile, targetResolution, estimatedDuration, costEstimate]);
const canEnhance = useMemo(() => {
return videoFile !== null;
}, [videoFile]);
const handleVideoSelect = useCallback((file: File | null) => {
setVideoFile(file);
if (file) {
// Validate video file
if (!file.type.startsWith('video/')) {
alert('Please select a video file');
return;
}
if (file.size > 500 * 1024 * 1024) {
alert('Video file must be less than 500MB');
return;
}
// Create preview URL
const reader = new FileReader();
reader.onload = (e) => {
setVideoPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
} else {
setVideoPreview(null);
}
}, []);
return {
// State
videoFile,
videoPreview,
targetResolution,
enhancementType,
estimatedDuration,
costEstimate,
// Setters
setVideoFile: handleVideoSelect,
setTargetResolution,
setEnhancementType,
// Computed
canEnhance,
costHint,
};
};

View File

@@ -0,0 +1,2 @@
export { EnhanceVideo } from './EnhanceVideo';
export { default } from './EnhanceVideo';

View File

@@ -0,0 +1,3 @@
// Re-export from the ExtendVideo component
export { ExtendVideo } from './ExtendVideo/ExtendVideo';
export { default } from './ExtendVideo/ExtendVideo';

View File

@@ -0,0 +1,373 @@
import React, { useState } from 'react';
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert } from '@mui/material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useExtendVideo } from './hooks/useExtendVideo';
import { VideoUpload, AudioUpload, ExtendSettings } from './components';
import { aiApiClient } from '../../../../api/client';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
const ExtendVideo: React.FC = () => {
const {
videoFile,
videoPreview,
audioFile,
prompt,
negativePrompt,
model,
resolution,
duration,
enablePromptExpansion,
generateAudio,
cameraFixed,
seed,
setVideoFile,
setAudioFile,
setPrompt,
setNegativePrompt,
setModel,
setResolution,
setDuration,
setEnablePromptExpansion,
setGenerateAudio,
setCameraFixed,
setSeed,
canExtend,
costHint,
} = useExtendVideo();
const [extending, setExtending] = useState(false);
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ video_url: string; cost: number; duration: number } | null>(null);
const handleExtend = async () => {
if (!videoFile || !prompt.trim()) return;
setExtending(true);
setError(null);
setResult(null);
setProgress(0);
setStatusMessage('Starting video extension...');
try {
// Create FormData
const formData = new FormData();
formData.append('file', videoFile);
formData.append('prompt', prompt);
formData.append('model', model);
if (negativePrompt && model === 'wan-2.5') {
formData.append('negative_prompt', negativePrompt);
}
if (audioFile && model === 'wan-2.5') {
formData.append('audio', audioFile);
}
formData.append('resolution', resolution);
formData.append('duration', duration.toString());
if (model === 'wan-2.5') {
formData.append('enable_prompt_expansion', enablePromptExpansion.toString());
}
if (model === 'seedance-1.5-pro') {
formData.append('generate_audio', generateAudio.toString());
formData.append('camera_fixed', cameraFixed.toString());
}
if (seed !== null) {
formData.append('seed', seed.toString());
}
// Submit extension request
setStatusMessage('Uploading video...');
const response = await aiApiClient.post('/api/video-studio/extend', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const uploadProgress = Math.round((progressEvent.loaded * 30) / progressEvent.total);
setProgress(uploadProgress);
setStatusMessage(`Uploading... ${uploadProgress}%`);
}
},
timeout: 600000, // 10 minutes timeout
});
setProgress(40);
setStatusMessage('Extending video with WAN 2.5... This may take a few minutes...');
if (response.data.success) {
setExtending(false);
setResult(response.data);
setProgress(100);
setStatusMessage('Video extension complete!');
} else {
throw new Error(response.data.error || 'Extension failed');
}
} catch (err: any) {
setExtending(false);
setError(err.response?.data?.detail || err.message || 'Failed to extend video');
setStatusMessage('Extension failed');
}
};
const handleReset = () => {
setExtending(false);
setProgress(0);
setStatusMessage('');
setError(null);
setResult(null);
};
return (
<VideoStudioLayout
headerProps={{
title: 'Extend Studio',
subtitle: 'Extend your short video clips into longer videos with motion and audio continuity. Perfect for creating seamless extended scenes from existing footage.',
}}
>
<Grid container spacing={4}>
{/* Left Panel - Upload & Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
{model === 'wan-2.5' && (
<AudioUpload audioPreview={null} onAudioSelect={setAudioFile} />
)}
<ExtendSettings
model={model}
prompt={prompt}
negativePrompt={negativePrompt}
resolution={resolution}
duration={duration}
enablePromptExpansion={enablePromptExpansion}
generateAudio={generateAudio}
cameraFixed={cameraFixed}
seed={seed}
costHint={costHint}
onModelChange={setModel}
onPromptChange={setPrompt}
onNegativePromptChange={setNegativePrompt}
onResolutionChange={setResolution}
onDurationChange={setDuration}
onEnablePromptExpansionChange={setEnablePromptExpansion}
onGenerateAudioChange={setGenerateAudio}
onCameraFixedChange={setCameraFixed}
onSeedChange={setSeed}
/>
<Box>
<Button
fullWidth
variant="contained"
size="large"
startIcon={extending ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
onClick={handleExtend}
disabled={!canExtend || extending}
sx={{
py: 1.5,
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{extending ? 'Extending...' : 'Extend Video'}
</Button>
</Box>
{extending && (
<Box>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
{statusMessage}
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 1,
backgroundColor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#3b82f6',
},
}}
/>
</Stack>
</Box>
)}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{result && (
<Alert
severity="success"
icon={<CheckCircleIcon />}
action={
<Button size="small" onClick={handleReset}>
Extend Another
</Button>
}
>
Video extended successfully! Cost: ${result.cost.toFixed(2)} ({result.duration}s)
</Alert>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Results */}
<Grid item xs={12} lg={7}>
<Stack spacing={3}>
<Box>
<Typography
variant="h6"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Preview
</Typography>
{videoPreview && !result && (
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 500,
display: 'block',
}}
/>
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
<Typography variant="caption" color="text.secondary">
Original Video
</Typography>
</Box>
</Box>
)}
{result && (
<Box>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #3b82f6',
backgroundColor: '#000',
mb: 2,
}}
>
<video
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
controls
style={{
width: '100%',
maxHeight: 500,
display: 'block',
}}
/>
<Box sx={{ p: 2, backgroundColor: '#f0f9ff' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Extended Video ({result.duration}s @ {resolution.toUpperCase()})
</Typography>
</Box>
</Box>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
fullWidth
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
download
>
Download Extended Video
</Button>
<Button variant="outlined" fullWidth onClick={handleReset}>
Extend Another Video
</Button>
</Stack>
</Box>
)}
{!videoPreview && !result && (
<Box
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 6,
textAlign: 'center',
backgroundColor: '#f8fafc',
}}
>
<Typography variant="body2" color="text.secondary">
Upload a video to see preview
</Typography>
</Box>
)}
</Box>
{/* Info Box */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
About Video Extension
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
WAN 2.5 Video-Extend creates seamless extensions of your videos with:
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="body2" color="text.secondary">
Motion continuity for smooth transitions
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Audio synchronization when audio is provided (3-30s, 15MB)
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Natural scene continuation with preserved style
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Multilingual support (Chinese and English prompts)
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Auto-generated audio if no audio is provided
</Typography>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block', fontStyle: 'italic' }}>
Note: If audio is longer than video duration, only the first segment is used. If audio is shorter, remaining video plays silently.
</Typography>
</Box>
</Stack>
</Grid>
</Grid>
</VideoStudioLayout>
);
};
export default ExtendVideo;
export { ExtendVideo };

View File

@@ -0,0 +1,122 @@
import React, { useRef } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material';
import AudioFileIcon from '@mui/icons-material/AudioFile';
interface AudioUploadProps {
audioPreview: string | null;
onAudioSelect: (file: File | null) => void;
}
export const AudioUpload: React.FC<AudioUploadProps> = ({
audioPreview,
onAudioSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate audio file
if (!file.type.startsWith('audio/')) {
alert('Please select an audio file');
return;
}
// Validate audio file size (max 15MB per WAN 2.5 documentation)
if (file.size > 15 * 1024 * 1024) {
alert('Audio file must be less than 15MB (per WAN 2.5 requirements)');
return;
}
onAudioSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
onAudioSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Optional Audio Guide
</Typography>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{audioPreview ? (
<Box
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#f8fafc',
p: 2,
}}
>
<audio
src={audioPreview}
controls
style={{
width: '100%',
}}
/>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemove}
sx={{
mt: 1,
}}
>
Remove
</Button>
</Box>
) : (
<Box
onClick={handleClick}
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 3,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#f8fafc',
},
}}
>
<Stack spacing={1} alignItems="center">
<AudioFileIcon sx={{ fontSize: 36, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary">
Click to upload audio (optional)
</Typography>
<Typography variant="caption" color="text.secondary">
MP3, WAV up to 15MB (3-30s recommended)
</Typography>
</Stack>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,429 @@
import React, { useState } from 'react';
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, TextField, FormControlLabel, Switch, Chip, Button, CircularProgress, Tooltip, Paper } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import type { ExtendResolution, ExtendModel } from '../hooks/useExtendVideo';
import { optimizePrompt } from '../../../../../api/videoStudioApi';
interface ExtendSettingsProps {
model: ExtendModel;
prompt: string;
negativePrompt: string;
resolution: ExtendResolution;
duration: number;
enablePromptExpansion: boolean;
generateAudio: boolean;
cameraFixed: boolean;
seed: number | null;
costHint: string;
onModelChange: (model: ExtendModel) => void;
onPromptChange: (value: string) => void;
onNegativePromptChange: (value: string) => void;
onResolutionChange: (resolution: ExtendResolution) => void;
onDurationChange: (duration: number) => void;
onEnablePromptExpansionChange: (enabled: boolean) => void;
onGenerateAudioChange: (enabled: boolean) => void;
onCameraFixedChange: (enabled: boolean) => void;
onSeedChange: (seed: number | null) => void;
}
export const ExtendSettings: React.FC<ExtendSettingsProps> = ({
model,
prompt,
negativePrompt,
resolution,
duration,
enablePromptExpansion,
generateAudio,
cameraFixed,
seed,
costHint,
onModelChange,
onPromptChange,
onNegativePromptChange,
onResolutionChange,
onDurationChange,
onEnablePromptExpansionChange,
onGenerateAudioChange,
onCameraFixedChange,
onSeedChange,
}) => {
const [enhancing, setEnhancing] = useState(false);
const handleEnhancePrompt = async () => {
if (!prompt.trim() || enhancing) return;
setEnhancing(true);
try {
const result = await optimizePrompt({
text: prompt,
mode: 'video',
style: 'default',
});
if (result.success && result.optimized_prompt) {
onPromptChange(result.optimized_prompt);
}
} catch (error) {
console.error('Failed to enhance prompt:', error);
} finally {
setEnhancing(false);
}
};
// Model-specific options
const isWan22Spicy = model === 'wan-2.2-spicy';
const isSeedance = model === 'seedance-1.5-pro';
const isWan25 = model === 'wan-2.5';
const availableResolutions: ExtendResolution[] = (isWan22Spicy || isSeedance)
? ['480p', '720p']
: ['480p', '720p', '1080p'];
const availableDurations = isWan22Spicy
? [5, 8]
: isSeedance
? [4, 5, 6, 7, 8, 9, 10, 11, 12]
: [3, 4, 5, 6, 7, 8, 9, 10];
return (
<Stack spacing={3}>
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
AI Model
</Typography>
<FormControl fullWidth>
<Select
value={model}
onChange={(e) => onModelChange(e.target.value as ExtendModel)}
sx={{
backgroundColor: '#fff',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e2e8f0',
},
}}
>
<MenuItem value="wan-2.5">WAN 2.5 (Full Featured)</MenuItem>
<MenuItem value="wan-2.2-spicy">WAN 2.2 Spicy (Fast & Affordable)</MenuItem>
<MenuItem value="seedance-1.5-pro">Seedance 1.5 Pro (Advanced)</MenuItem>
</Select>
</FormControl>
<Paper
elevation={0}
sx={{
mt: 1,
p: 1.5,
backgroundColor: isWan22Spicy ? '#fef3c7' : isSeedance ? '#f3e8ff' : '#eff6ff',
borderRadius: 1,
}}
>
<Typography variant="caption" sx={{ display: 'block', fontWeight: 600, color: '#0f172a', mb: 0.5 }}>
{isWan22Spicy ? 'WAN 2.2 Spicy' : isSeedance ? 'Seedance 1.5 Pro' : 'WAN 2.5'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{isWan22Spicy
? 'Fast and affordable: 480p/720p, 5 or 8 seconds. $0.03-0.06/s pricing. Perfect for quick extensions with expressive visuals.'
: isSeedance
? `Advanced features: 480p/720p, 4-12 seconds, auto audio generation, camera control. ${generateAudio ? '$0.024-0.052' : '$0.012-0.026'}/s pricing. Ideal for ad creatives and short dramas.`
: 'Full featured: 480p/720p/1080p, 3-10 seconds, audio upload, negative prompts, and prompt expansion. $0.05-0.15/s pricing.'}
</Typography>
</Paper>
</Box>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography
variant="subtitle2"
sx={{
color: '#0f172a',
fontWeight: 700,
}}
>
Extension Prompt *
</Typography>
<Tooltip title="AI will optimize your prompt for better video extension results, improving visual clarity, composition, and motion continuity.">
<Button
size="small"
variant="outlined"
startIcon={enhancing ? <CircularProgress size={16} /> : <AutoAwesomeIcon />}
onClick={handleEnhancePrompt}
disabled={!prompt.trim() || enhancing}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
py: 0.5,
px: 1.5,
borderColor: '#3b82f6',
color: '#3b82f6',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
'&:disabled': {
borderColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{enhancing ? 'Enhancing...' : 'Enhance Instructions'}
</Button>
</Tooltip>
</Box>
<TextField
fullWidth
multiline
rows={4}
placeholder="Describe how you want to extend the video. For example: 'Continue the motion smoothly', 'Add a zoom out effect', 'Extend the scene with the character walking forward'"
value={prompt}
onChange={e => onPromptChange(e.target.value)}
required
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
},
'& .MuiInputBase-input': {
color: '#0f172a',
'&::placeholder': {
color: '#64748b',
opacity: 1,
},
},
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Describe the motion, scene, or effect you want for the extended portion. Supports Chinese and English prompts.
</Typography>
</Box>
{isWan25 && (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Negative Prompt (Optional)
</Typography>
<TextField
fullWidth
multiline
rows={2}
placeholder="What to avoid in the extended video..."
value={negativePrompt}
onChange={e => onNegativePromptChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#fff',
'& fieldset': { borderColor: '#e2e8f0' },
},
}}
/>
</Box>
)}
{isSeedance && (
<>
<Box>
<FormControlLabel
control={
<Switch
checked={generateAudio}
onChange={(e) => onGenerateAudioChange(e.target.checked)}
color="primary"
/>
}
label="Generate Audio"
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Automatically generate audio for the extended video
{generateAudio
? ' (Adds ~$0.012-0.026/s to cost)'
: ' (Saves ~$0.012-0.026/s)'}
</Typography>
</Box>
<Box>
<FormControlLabel
control={
<Switch
checked={cameraFixed}
onChange={(e) => onCameraFixedChange(e.target.checked)}
color="primary"
/>
}
label="Fix Camera Position"
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Keep camera position fixed for stable shots
</Typography>
</Box>
</>
)}
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Resolution
</Typography>
<FormControl fullWidth>
<Select
value={resolution}
onChange={(e) => onResolutionChange(e.target.value as ExtendResolution)}
sx={{
backgroundColor: '#fff',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e2e8f0',
},
}}
>
{availableResolutions.map((res) => {
// Model-specific pricing
let price: string;
if (isWan22Spicy) {
price = res === '480p' ? '$0.03' : '$0.06';
} else if (isSeedance) {
// Seedance pricing varies by audio generation
if (generateAudio) {
price = res === '480p' ? '$0.024' : '$0.052';
} else {
price = res === '480p' ? '$0.012' : '$0.026';
}
} else {
price = res === '480p' ? '$0.05' : res === '720p' ? '$0.10' : '$0.15';
}
return (
<MenuItem key={res} value={res}>
{res} ({price}/s)
</MenuItem>
);
})}
</Select>
</FormControl>
</Box>
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Extension Duration
</Typography>
<FormControl fullWidth>
<Select
value={duration}
onChange={(e) => onDurationChange(Number(e.target.value))}
sx={{
backgroundColor: '#fff',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e2e8f0',
},
}}
>
{availableDurations.map((d) => (
<MenuItem key={d} value={d}>
{d} seconds
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
How long should the extended portion be?
</Typography>
</Box>
{isWan25 && (
<Box>
<FormControlLabel
control={
<Switch
checked={enablePromptExpansion}
onChange={(e) => onEnablePromptExpansionChange(e.target.checked)}
color="primary"
/>
}
label="Enable Prompt Expansion"
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Automatically enhance your prompt for better results
</Typography>
</Box>
)}
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Seed (Optional)
</Typography>
<TextField
fullWidth
type="number"
placeholder="Leave empty for random"
value={seed ?? ''}
onChange={(e) => {
const value = e.target.value;
onSeedChange(value === '' ? null : Number(value));
}}
sx={{
backgroundColor: '#fff',
'& .MuiOutlinedInput-root': {
'& fieldset': { borderColor: '#e2e8f0' },
},
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Use the same seed to reproduce similar results
</Typography>
</Box>
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Estimated Cost:
</Typography>
<Chip
label={costHint}
size="small"
sx={{
backgroundColor: '#3b82f6',
color: '#fff',
fontWeight: 600,
}}
/>
</Stack>
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,125 @@
import React, { useRef } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material';
import VideocamIcon from '@mui/icons-material/Videocam';
interface VideoUploadProps {
videoPreview: string | null;
onVideoSelect: (file: File | null) => void;
}
export const VideoUpload: React.FC<VideoUploadProps> = ({
videoPreview,
onVideoSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate video file
if (!file.type.startsWith('video/')) {
alert('Please select a video file');
return;
}
if (file.size > 500 * 1024 * 1024) {
alert('Video file must be less than 500MB');
return;
}
onVideoSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
onVideoSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Upload Video to Extend
</Typography>
<input
ref={fileInputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{videoPreview ? (
<Box
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 400,
display: 'block',
}}
/>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemove}
sx={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
}}
>
Remove
</Button>
</Box>
) : (
<Box
onClick={handleClick}
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#f8fafc',
},
}}
>
<Stack spacing={2} alignItems="center">
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary">
Click to upload a video
</Typography>
<Typography variant="caption" color="text.secondary">
MP4, WebM up to 500MB
</Typography>
</Stack>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,3 @@
export { VideoUpload } from './VideoUpload';
export { AudioUpload } from './AudioUpload';
export { ExtendSettings } from './ExtendSettings';

View File

@@ -0,0 +1,161 @@
import { useState, useMemo, useCallback } from 'react';
export type ExtendResolution = '480p' | '720p' | '1080p';
export type ExtendModel = 'wan-2.5' | 'wan-2.2-spicy' | 'seedance-1.5-pro';
export const useExtendVideo = () => {
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreview, setVideoPreview] = useState<string | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [audioPreview, setAudioPreview] = useState<string | null>(null);
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [model, setModel] = useState<ExtendModel>('wan-2.5');
const [resolution, setResolution] = useState<ExtendResolution>('720p');
const [duration, setDuration] = useState<number>(5);
const [enablePromptExpansion, setEnablePromptExpansion] = useState(false);
const [generateAudio, setGenerateAudio] = useState<boolean>(true); // Seedance 1.5 Pro only
const [cameraFixed, setCameraFixed] = useState<boolean>(false); // Seedance 1.5 Pro only
const [seed, setSeed] = useState<number | null>(null);
// Adjust resolution and duration when model changes
const handleModelChange = useCallback((newModel: ExtendModel) => {
setModel(newModel);
// Adjust resolution if needed
if ((newModel === 'wan-2.2-spicy' || newModel === 'seedance-1.5-pro') && resolution === '1080p') {
setResolution('720p');
}
// Adjust duration if needed
if (newModel === 'wan-2.2-spicy' && duration !== 5 && duration !== 8) {
setDuration(5);
} else if (newModel === 'seedance-1.5-pro' && (duration < 4 || duration > 12)) {
setDuration(5); // Default to 5s for Seedance
}
}, [resolution, duration]);
// Cost estimation (model-specific pricing)
const costHint = useMemo(() => {
if (!videoFile) return 'Upload a video to see cost estimate';
// Model-specific pricing
let pricing: { [key: string]: number };
if (model === 'wan-2.2-spicy') {
// WAN 2.2 Spicy: $0.03/s (480p), $0.06/s (720p)
pricing = {
'480p': 0.03,
'720p': 0.06,
};
} else if (model === 'seedance-1.5-pro') {
// Seedance 1.5 Pro pricing varies by audio generation
// With audio: $0.024/s (480p), $0.052/s (720p)
// Without audio: $0.012/s (480p), $0.026/s (720p)
if (generateAudio) {
pricing = {
'480p': 0.024,
'720p': 0.052,
};
} else {
pricing = {
'480p': 0.012,
'720p': 0.026,
};
}
} else {
// WAN 2.5: $0.05/s (480p), $0.10/s (720p), $0.15/s (1080p)
pricing = {
'480p': 0.05,
'720p': 0.10,
'1080p': 0.15,
};
}
const costPerSecond = pricing[resolution as keyof typeof pricing] || pricing['720p'];
const estimatedCost = (costPerSecond * duration).toFixed(2);
return `Est. ~$${estimatedCost} (${duration}s @ ${resolution})`;
}, [videoFile, model, resolution, duration, generateAudio]);
const canExtend = useMemo(() => {
return videoFile !== null && prompt.trim().length > 0;
}, [videoFile, prompt]);
const handleVideoSelect = useCallback((file: File | null) => {
setVideoFile(file);
if (file) {
// Validate video file
if (!file.type.startsWith('video/')) {
alert('Please select a video file');
return;
}
if (file.size > 500 * 1024 * 1024) {
alert('Video file must be less than 500MB');
return;
}
// Create preview URL
const reader = new FileReader();
reader.onload = (e) => {
setVideoPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
} else {
setVideoPreview(null);
}
}, []);
const handleAudioSelect = useCallback((file: File | null) => {
setAudioFile(file);
if (file) {
// Validate audio file
if (!file.type.startsWith('audio/')) {
alert('Please select an audio file');
return;
}
if (file.size > 50 * 1024 * 1024) {
alert('Audio file must be less than 50MB');
return;
}
// Create preview URL
const reader = new FileReader();
reader.onload = (e) => {
setAudioPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
} else {
setAudioPreview(null);
}
}, []);
return {
// State
videoFile,
videoPreview,
audioFile,
audioPreview,
prompt,
negativePrompt,
model,
resolution,
duration,
enablePromptExpansion,
generateAudio,
cameraFixed,
seed,
// Setters
setVideoFile: handleVideoSelect,
setAudioFile: handleAudioSelect,
setPrompt,
setNegativePrompt,
setModel: handleModelChange,
setResolution,
setDuration,
setEnablePromptExpansion,
setGenerateAudio,
setCameraFixed,
setSeed,
// Computed
canExtend,
costHint,
};
};

View File

@@ -0,0 +1,2 @@
export { ExtendVideo } from './ExtendVideo';
export { default } from './ExtendVideo';

View File

@@ -0,0 +1,332 @@
import React from 'react';
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper } from '@mui/material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useFaceSwap } from './hooks/useFaceSwap';
import { ImageUpload, VideoUpload, SettingsPanel, ModelSelector } from './components';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
const FaceSwap: React.FC = () => {
const {
imageFile,
imagePreview,
videoFile,
videoPreview,
model,
prompt,
resolution,
seed,
targetGender,
targetIndex,
swapping,
progress,
error,
result,
setImageFile,
setVideoFile,
setModel,
setPrompt,
setResolution,
setSeed,
setTargetGender,
setTargetIndex,
canSwap,
costHint,
swapFace,
reset,
} = useFaceSwap();
return (
<VideoStudioLayout
headerProps={{
title: 'Face Swap Studio',
subtitle: 'Swap faces in videos using AI. Choose between MoCha (premium character replacement) or Video Face Swap (affordable multi-face support).',
}}
>
<Grid container spacing={4}>
{/* Left Panel - Upload & Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<ModelSelector selectedModel={model} onModelChange={setModel} />
<ImageUpload imagePreview={imagePreview} onImageSelect={setImageFile} />
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
{imageFile && videoFile && (
<SettingsPanel
model={model}
prompt={prompt}
resolution={resolution}
seed={seed}
targetGender={targetGender}
targetIndex={targetIndex}
onPromptChange={setPrompt}
onResolutionChange={setResolution}
onSeedChange={setSeed}
onTargetGenderChange={setTargetGender}
onTargetIndexChange={setTargetIndex}
/>
)}
<Box>
<Button
fullWidth
variant="contained"
size="large"
startIcon={swapping ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
onClick={swapFace}
disabled={!canSwap || swapping}
sx={{
py: 1.5,
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{swapping ? 'Swapping Face...' : 'Swap Face'}
</Button>
</Box>
{imageFile && videoFile && (
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" color="text.secondary">
<strong>Cost:</strong> {costHint}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{model === 'mocha'
? 'Minimum charge: 5 seconds | Maximum billed: 120 seconds'
: 'Minimum charge: 5 seconds | Maximum billed: 600 seconds (10 minutes)'}
</Typography>
</Box>
)}
{swapping && (
<Box>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
Processing face swap... This may take a few minutes...
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 1,
backgroundColor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#3b82f6',
},
}}
/>
</Stack>
</Box>
)}
{error && (
<Alert severity="error" onClose={() => {}} icon={<ErrorIcon />}>
{error}
</Alert>
)}
{result && (
<Alert
severity="success"
icon={<CheckCircleIcon />}
action={
<Button size="small" onClick={reset}>
Swap Another
</Button>
}
>
Face swap successful! Cost: ${result.cost.toFixed(2)}
</Alert>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Results */}
<Grid item xs={12} lg={7}>
<Stack spacing={3}>
<Box>
<Typography
variant="h6"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Preview
</Typography>
{result ? (
<Box>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #3b82f6',
backgroundColor: '#000',
mb: 2,
}}
>
<video
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
controls
style={{
width: '100%',
maxHeight: 500,
display: 'block',
}}
/>
<Box sx={{ p: 2, backgroundColor: '#f0f9ff' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Face-Swapped Video
</Typography>
</Box>
</Box>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
fullWidth
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
download
>
Download Video
</Button>
<Button variant="outlined" fullWidth onClick={reset}>
Swap Another
</Button>
</Stack>
</Box>
) : (
<Stack spacing={2}>
{imagePreview && (
<Box>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
Reference Image:
</Typography>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#f8fafc',
}}
>
<Box
component="img"
src={imagePreview}
alt="Reference"
sx={{
width: '100%',
maxHeight: 200,
objectFit: 'contain',
display: 'block',
}}
/>
</Box>
</Box>
)}
{videoPreview && (
<Box>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
Source Video:
</Typography>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 400,
display: 'block',
}}
/>
</Box>
</Box>
)}
{!imagePreview && !videoPreview && (
<Box
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 6,
textAlign: 'center',
backgroundColor: '#f8fafc',
}}
>
<Typography variant="body2" color="text.secondary">
Upload image and video to see preview
</Typography>
</Box>
)}
</Stack>
)}
</Box>
{/* Info Box */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
About Face Swap Studio
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
MoCha performs seamless character replacement in videos:
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="body2" color="text.secondary">
Structure-free replacement - no pose or depth maps needed
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Preserves motion, emotion, and camera perspective
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Maintains identity consistency across frames
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Works with a single reference image and source video
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, fontStyle: 'italic' }}>
<strong>Tips:</strong> Match pose & composition, keep aspect ratios consistent, limit video length to 60s for best results.
</Typography>
</Box>
</Stack>
</Grid>
</Grid>
</VideoStudioLayout>
);
};
export default FaceSwap;
export { FaceSwap };

View File

@@ -0,0 +1,127 @@
import React, { useRef } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material';
import ImageIcon from '@mui/icons-material/Image';
interface ImageUploadProps {
imagePreview: string | null;
onImageSelect: (file: File | null) => void;
}
export const ImageUpload: React.FC<ImageUploadProps> = ({
imagePreview,
onImageSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate image file
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 10 * 1024 * 1024) {
alert('Image file must be less than 10MB');
return;
}
onImageSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
onImageSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Reference Image (Character to Swap In)
</Typography>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{imagePreview ? (
<Box
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#f8fafc',
}}
>
<Box
component="img"
src={imagePreview}
alt="Reference image"
sx={{
width: '100%',
maxHeight: 300,
objectFit: 'contain',
display: 'block',
}}
/>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemove}
sx={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
}}
>
Remove
</Button>
</Box>
) : (
<Box
onClick={handleClick}
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#f8fafc',
},
}}
>
<Stack spacing={2} alignItems="center">
<ImageIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary">
Click to upload reference image
</Typography>
<Typography variant="caption" color="text.secondary">
JPG, PNG up to 10MB (avoid WEBP)
</Typography>
</Stack>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { Box, Paper, Stack, Typography, FormControl, Select, MenuItem, Chip, Divider } from '@mui/material';
import { FaceSwapModel } from '../hooks/useFaceSwap';
interface ModelSelectorProps {
selectedModel: FaceSwapModel;
onModelChange: (model: FaceSwapModel) => void;
}
const MODEL_INFO = {
mocha: {
name: 'MoCha',
tagline: 'Character Replacement with Motion Preservation',
description: 'Advanced character replacement that preserves motion, emotion, and camera perspective. Perfect for film, advertising, and creative character transformation.',
pricing: '$0.04/s (480p) or $0.08/s (720p)',
maxLength: '120 seconds',
features: ['Motion preservation', 'Expression transfer', 'Prompt guidance', 'Seed control', 'High quality output'],
},
'video-face-swap': {
name: 'Video Face Swap',
tagline: 'Simple Face Swap with Multi-Face Support',
description: 'Affordable face swap with gender filtering and face index selection. Ideal for content creation, memes, and social media.',
pricing: '$0.01/s',
maxLength: '10 minutes (600 seconds)',
features: ['Multi-face support', 'Gender filter', 'Face index selection', 'Affordable pricing', 'Long video support'],
},
};
export const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelChange }) => {
const selectedInfo = MODEL_INFO[selectedModel];
return (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
}}
>
<Typography
variant="subtitle2"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
AI Model
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<Select
value={selectedModel}
onChange={(e) => onModelChange(e.target.value as FaceSwapModel)}
sx={{
'& .MuiSelect-select': {
py: 1.5,
},
}}
>
<MenuItem value="mocha">
<Stack direction="row" spacing={1} alignItems="center">
<Typography>MoCha</Typography>
<Chip label="Premium" size="small" color="primary" />
</Stack>
</MenuItem>
<MenuItem value="video-face-swap">
<Stack direction="row" spacing={1} alignItems="center">
<Typography>Video Face Swap</Typography>
<Chip label="Affordable" size="small" color="success" />
</Stack>
</MenuItem>
</Select>
</FormControl>
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#0f172a' }}>
{selectedInfo.tagline}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
{selectedInfo.description}
</Typography>
<Divider sx={{ my: 1.5 }} />
<Stack spacing={1}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="caption" color="text.secondary">
Pricing:
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{selectedInfo.pricing}
</Typography>
</Stack>
<Stack direction="row" justifyContent="space-between">
<Typography variant="caption" color="text.secondary">
Max Length:
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{selectedInfo.maxLength}
</Typography>
</Stack>
</Stack>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Features:
</Typography>
<Stack direction="row" flexWrap="wrap" gap={0.5}>
{selectedInfo.features.map((feature, idx) => (
<Chip
key={idx}
label={feature}
size="small"
variant="outlined"
sx={{
fontSize: '0.7rem',
height: '20px',
borderColor: '#cbd5e1',
color: '#475569',
}}
/>
))}
</Stack>
</Box>
</Paper>
);
};

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { Box, Typography, TextField, FormControl, InputLabel, Select, MenuItem, Paper, Stack } from '@mui/material';
import { Resolution, FaceSwapModel, TargetGender } from '../hooks/useFaceSwap';
interface SettingsPanelProps {
model: FaceSwapModel;
prompt: string;
resolution: Resolution;
seed: number | null;
targetGender: TargetGender;
targetIndex: number;
onPromptChange: (value: string) => void;
onResolutionChange: (value: Resolution) => void;
onSeedChange: (value: number | null) => void;
onTargetGenderChange: (value: TargetGender) => void;
onTargetIndexChange: (value: number) => void;
}
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
model,
prompt,
resolution,
seed,
targetGender,
targetIndex,
onPromptChange,
onResolutionChange,
onSeedChange,
onTargetGenderChange,
onTargetIndexChange,
}) => {
if (model === 'mocha') {
return (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
}}
>
<Typography
variant="subtitle2"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
MoCha Settings
</Typography>
<Stack spacing={2}>
<TextField
label="Prompt (Optional)"
placeholder="e.g., preserve outfit; natural expressions; no background changes"
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
multiline
rows={3}
fullWidth
helperText="Optional prompt to guide the character replacement"
/>
<FormControl fullWidth>
<InputLabel>Resolution</InputLabel>
<Select
value={resolution}
label="Resolution"
onChange={(e) => onResolutionChange(e.target.value as Resolution)}
>
<MenuItem value="480p">480p ($0.04/second)</MenuItem>
<MenuItem value="720p">720p ($0.08/second)</MenuItem>
</Select>
</FormControl>
<TextField
label="Seed (Optional)"
type="number"
value={seed || ''}
onChange={(e) => {
const value = e.target.value;
onSeedChange(value === '' ? null : parseInt(value, 10));
}}
fullWidth
helperText="Random seed for reproducibility (-1 for random, leave empty for random)"
inputProps={{ min: -1 }}
/>
</Stack>
</Paper>
);
}
// video-face-swap settings
return (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
}}
>
<Typography
variant="subtitle2"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Video Face Swap Settings
</Typography>
<Stack spacing={2}>
<FormControl fullWidth>
<InputLabel>Target Gender</InputLabel>
<Select
value={targetGender}
label="Target Gender"
onChange={(e) => onTargetGenderChange(e.target.value as TargetGender)}
>
<MenuItem value="all">All (no filter)</MenuItem>
<MenuItem value="female">Female only</MenuItem>
<MenuItem value="male">Male only</MenuItem>
</Select>
</FormControl>
<TextField
label="Target Face Index"
type="number"
value={targetIndex}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 0 && value <= 10) {
onTargetIndexChange(value);
}
}}
fullWidth
helperText="0 = largest face, 1 = second largest, etc. (0-10)"
inputProps={{ min: 0, max: 10 }}
/>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,125 @@
import React, { useRef } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material';
import VideocamIcon from '@mui/icons-material/Videocam';
interface VideoUploadProps {
videoPreview: string | null;
onVideoSelect: (file: File | null) => void;
}
export const VideoUpload: React.FC<VideoUploadProps> = ({
videoPreview,
onVideoSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate video file
if (!file.type.startsWith('video/')) {
alert('Please select a video file');
return;
}
if (file.size > 500 * 1024 * 1024) {
alert('Video file must be less than 500MB');
return;
}
onVideoSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
onVideoSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Source Video (Character to Replace)
</Typography>
<input
ref={fileInputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{videoPreview ? (
<Box
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 400,
display: 'block',
}}
/>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemove}
sx={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
}}
>
Remove
</Button>
</Box>
) : (
<Box
onClick={handleClick}
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#f8fafc',
},
}}
>
<Stack spacing={2} alignItems="center">
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary">
Click to upload source video
</Typography>
<Typography variant="caption" color="text.secondary">
MP4, WebM up to 500MB (max 120 seconds)
</Typography>
</Stack>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,4 @@
export { ImageUpload } from './ImageUpload';
export { VideoUpload } from './VideoUpload';
export { SettingsPanel } from './SettingsPanel';
export { ModelSelector } from './ModelSelector';

View File

@@ -0,0 +1,168 @@
import { useState, useMemo, useEffect } from 'react';
import { aiApiClient } from '../../../../../api/client';
export type Resolution = '480p' | '720p';
export type FaceSwapModel = 'mocha' | 'video-face-swap';
export type TargetGender = 'all' | 'female' | 'male';
export const useFaceSwap = () => {
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreview, setVideoPreview] = useState<string | null>(null);
const [model, setModel] = useState<FaceSwapModel>('mocha');
const [prompt, setPrompt] = useState<string>('');
const [resolution, setResolution] = useState<Resolution>('480p');
const [seed, setSeed] = useState<number | null>(null);
const [targetGender, setTargetGender] = useState<TargetGender>('all');
const [targetIndex, setTargetIndex] = useState<number>(0);
const [swapping, setSwapping] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ video_url: string; cost: number; model: string } | null>(null);
// Update previews when files change
useEffect(() => {
if (imageFile) {
const url = URL.createObjectURL(imageFile);
setImagePreview(url);
return () => URL.revokeObjectURL(url);
} else {
setImagePreview(null);
}
}, [imageFile]);
useEffect(() => {
if (videoFile) {
const url = URL.createObjectURL(videoFile);
setVideoPreview(url);
return () => URL.revokeObjectURL(url);
} else {
setVideoPreview(null);
}
}, [videoFile]);
const canSwap = useMemo(() => {
return imageFile !== null && videoFile !== null;
}, [imageFile, videoFile]);
const costHint = useMemo(() => {
if (!imageFile || !videoFile) return 'Upload image and video to see cost';
// MoCha pricing: $0.04/s (480p), $0.08/s (720p)
// Video Face Swap pricing: $0.01/s
// Minimum charge: 5 seconds for both
// We'll estimate based on a default duration (actual cost calculated on backend)
let costPerSecond: number;
if (model === 'mocha') {
costPerSecond = resolution === '480p' ? 0.04 : 0.08;
} else {
costPerSecond = 0.01;
}
const estimatedCost = costPerSecond * 10; // Estimate 10 seconds
return `~$${estimatedCost.toFixed(2)} (estimated, based on video duration)`;
}, [imageFile, videoFile, model, resolution]);
const swapFace = async (): Promise<void> => {
if (!imageFile || !videoFile) return;
setSwapping(true);
setProgress(0);
setError(null);
setResult(null);
try {
const formData = new FormData();
formData.append('image_file', imageFile);
formData.append('video_file', videoFile);
formData.append('model', model);
if (model === 'mocha') {
if (prompt) {
formData.append('prompt', prompt);
}
formData.append('resolution', resolution);
if (seed !== null) {
formData.append('seed', seed.toString());
}
} else {
formData.append('target_gender', targetGender);
formData.append('target_index', targetIndex.toString());
}
setProgress(10);
const response = await aiApiClient.post('/api/video-studio/face-swap', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const uploadProgress = Math.round((progressEvent.loaded * 20) / progressEvent.total);
setProgress(10 + uploadProgress);
}
},
timeout: 600000, // 10 minutes
});
setProgress(50);
if (response.data.success) {
setResult(response.data);
setProgress(100);
} else {
throw new Error(response.data.error || 'Face swap failed');
}
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to swap face');
setProgress(0);
} finally {
setSwapping(false);
}
};
const reset = () => {
setImageFile(null);
setImagePreview(null);
setVideoFile(null);
setVideoPreview(null);
setModel('mocha');
setPrompt('');
setResolution('480p');
setSeed(null);
setTargetGender('all');
setTargetIndex(0);
setResult(null);
setError(null);
setProgress(0);
};
return {
imageFile,
imagePreview,
videoFile,
videoPreview,
model,
prompt,
resolution,
seed,
targetGender,
targetIndex,
swapping,
progress,
error,
result,
setImageFile,
setVideoFile,
setModel,
setPrompt,
setResolution,
setSeed,
setTargetGender,
setTargetIndex,
canSwap,
costHint,
swapFace,
reset,
};
};

View File

@@ -0,0 +1,2 @@
export { FaceSwap } from './FaceSwap';
export { default } from './FaceSwap';

View File

@@ -0,0 +1,20 @@
import React from 'react';
import ModulePlaceholder from '../ModulePlaceholder';
export const LibraryVideo: React.FC = () => {
return (
<ModulePlaceholder
title="Asset Library"
subtitle="Governed delivery"
status="beta"
description="AI tagging, versions, usage analytics, and signed links to deliver videos safely across teams."
bullets={[
'Use cases: campaign organization, handoff to ads, compliance audits',
'Planned: search by tags/prompts, usage stats, shareable signed URLs',
'Guardrails: access control, audit logs, storage/egress visibility',
]}
/>
);
};
export default LibraryVideo;

View File

@@ -0,0 +1,285 @@
import React from 'react';
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper } from '@mui/material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useSocialVideo } from './hooks/useSocialVideo';
import { VideoUpload, PlatformSelector, OptimizationOptions, PreviewGrid } from './components';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import DownloadIcon from '@mui/icons-material/Download';
const SocialVideo: React.FC = () => {
const {
videoFile,
videoPreview,
selectedPlatforms,
autoCrop,
generateThumbnails,
compress,
trimMode,
optimizing,
progress,
results,
errors,
platformSpecs,
setVideoFile,
togglePlatform,
setAutoCrop,
setGenerateThumbnails,
setCompress,
setTrimMode,
canOptimize,
costHint,
optimize,
reset,
} = useSocialVideo();
const handleDownload = (result: any) => {
const videoUrl = result.video_url.startsWith('http')
? result.video_url
: `${window.location.origin}${result.video_url}`;
window.open(videoUrl, '_blank');
};
const handleDownloadAll = () => {
results.forEach((result) => {
const videoUrl = result.video_url.startsWith('http')
? result.video_url
: `${window.location.origin}${result.video_url}`;
window.open(videoUrl, '_blank');
});
};
return (
<VideoStudioLayout
headerProps={{
title: 'Social Optimizer',
subtitle: 'Optimize videos for Instagram, TikTok, YouTube, LinkedIn, Facebook, and Twitter. One video, multiple platform-ready versions.',
}}
>
<Grid container spacing={4}>
{/* Left Panel - Upload & Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
{videoFile && (
<>
<PlatformSelector
selectedPlatforms={selectedPlatforms}
platformSpecs={platformSpecs}
onTogglePlatform={togglePlatform}
/>
<OptimizationOptions
autoCrop={autoCrop}
generateThumbnails={generateThumbnails}
compress={compress}
trimMode={trimMode}
onAutoCropChange={setAutoCrop}
onGenerateThumbnailsChange={setGenerateThumbnails}
onCompressChange={setCompress}
onTrimModeChange={setTrimMode}
/>
</>
)}
<Box>
<Button
fullWidth
variant="contained"
size="large"
startIcon={optimizing ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
onClick={optimize}
disabled={!canOptimize || optimizing}
sx={{
py: 1.5,
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{optimizing ? 'Optimizing...' : 'Optimize for All Platforms'}
</Button>
</Box>
{videoFile && (
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" color="text.secondary">
<strong>Cost:</strong> {costHint}
</Typography>
</Box>
)}
{optimizing && (
<Box>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
Optimizing videos for {selectedPlatforms.length} platform{selectedPlatforms.length !== 1 ? 's' : ''}...
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 1,
backgroundColor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#3b82f6',
},
}}
/>
</Stack>
</Box>
)}
{errors.length > 0 && (
<Alert severity="error" icon={<ErrorIcon />}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
Optimization Errors:
</Typography>
{errors.map((error, index) => (
<Typography key={index} variant="body2">
{error.platform}: {error.error}
</Typography>
))}
</Alert>
)}
{results.length > 0 && (
<Alert
severity="success"
icon={<CheckCircleIcon />}
action={
<Button size="small" onClick={reset}>
Optimize Another
</Button>
}
>
Successfully optimized {results.length} video{results.length !== 1 ? 's' : ''}!
</Alert>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Results */}
<Grid item xs={12} lg={7}>
<Stack spacing={3}>
{results.length > 0 ? (
<PreviewGrid
results={results}
optimizing={optimizing}
onDownload={handleDownload}
onDownloadAll={handleDownloadAll}
/>
) : (
<Box>
<Typography
variant="h6"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Preview
</Typography>
{videoPreview && (
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 500,
display: 'block',
}}
/>
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
<Typography variant="caption" color="text.secondary">
Original Video
</Typography>
</Box>
</Box>
)}
{!videoPreview && (
<Box
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 6,
textAlign: 'center',
backgroundColor: '#f8fafc',
}}
>
<Typography variant="body2" color="text.secondary">
Upload a video to see preview
</Typography>
</Box>
)}
</Box>
)}
{/* Info Box */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
About Social Optimizer
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Social Optimizer automatically creates platform-ready versions of your video:
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="body2" color="text.secondary">
Aspect ratio conversion (9:16, 16:9, 1:1)
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Duration trimming to platform limits
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
File size compression for platform requirements
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Thumbnail generation for each platform
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, fontStyle: 'italic' }}>
All processing is free using FFmpeg.
</Typography>
</Box>
</Stack>
</Grid>
</Grid>
</VideoStudioLayout>
);
};
export default SocialVideo;
export { SocialVideo };

View File

@@ -0,0 +1,147 @@
import React from 'react';
import { Box, Typography, FormControlLabel, Switch, FormControl, RadioGroup, Radio, Stack, Paper } from '@mui/material';
import { TrimMode } from '../hooks/useSocialVideo';
interface OptimizationOptionsProps {
autoCrop: boolean;
generateThumbnails: boolean;
compress: boolean;
trimMode: TrimMode;
onAutoCropChange: (value: boolean) => void;
onGenerateThumbnailsChange: (value: boolean) => void;
onCompressChange: (value: boolean) => void;
onTrimModeChange: (value: TrimMode) => void;
}
export const OptimizationOptions: React.FC<OptimizationOptionsProps> = ({
autoCrop,
generateThumbnails,
compress,
trimMode,
onAutoCropChange,
onGenerateThumbnailsChange,
onCompressChange,
onTrimModeChange,
}) => {
return (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
}}
>
<Typography
variant="subtitle2"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Optimization Options
</Typography>
<Stack spacing={2}>
<FormControlLabel
control={
<Switch
checked={autoCrop}
onChange={(e) => onAutoCropChange(e.target.checked)}
color="primary"
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
Auto-crop to platform ratio
</Typography>
<Typography variant="caption" color="text.secondary">
Automatically crop video to match platform aspect ratio
</Typography>
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={generateThumbnails}
onChange={(e) => onGenerateThumbnailsChange(e.target.checked)}
color="primary"
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
Generate thumbnails
</Typography>
<Typography variant="caption" color="text.secondary">
Create thumbnail images for each platform
</Typography>
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={compress}
onChange={(e) => onCompressChange(e.target.checked)}
color="primary"
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
Compress for file size limits
</Typography>
<Typography variant="caption" color="text.secondary">
Automatically compress videos to meet platform file size requirements
</Typography>
</Box>
}
/>
<FormControl>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
Trim Mode (if video exceeds duration)
</Typography>
<RadioGroup
value={trimMode}
onChange={(e) => onTrimModeChange(e.target.value as TrimMode)}
>
<FormControlLabel
value="beginning"
control={<Radio size="small" />}
label={
<Typography variant="body2">
Keep Beginning - Trim from the end
</Typography>
}
/>
<FormControlLabel
value="middle"
control={<Radio size="small" />}
label={
<Typography variant="body2">
Keep Middle - Trim from both ends
</Typography>
}
/>
<FormControlLabel
value="end"
control={<Radio size="small" />}
label={
<Typography variant="body2">
Keep End - Trim from the beginning
</Typography>
}
/>
</RadioGroup>
</FormControl>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { Box, Typography, FormControlLabel, Checkbox, Stack, Chip, Paper } from '@mui/material';
import { Platform } from '../hooks/useSocialVideo';
interface PlatformSelectorProps {
selectedPlatforms: Platform[];
platformSpecs: Record<string, any>;
onTogglePlatform: (platform: Platform) => void;
}
const platformInfo: Record<Platform, { label: string; icon: string; color: string }> = {
instagram: { label: 'Instagram Reels', icon: '📷', color: '#E4405F' },
tiktok: { label: 'TikTok', icon: '🎵', color: '#000000' },
youtube: { label: 'YouTube Shorts', icon: '▶️', color: '#FF0000' },
linkedin: { label: 'LinkedIn', icon: '💼', color: '#0077B5' },
facebook: { label: 'Facebook', icon: '👥', color: '#1877F2' },
twitter: { label: 'Twitter/X', icon: '🐦', color: '#1DA1F2' },
};
export const PlatformSelector: React.FC<PlatformSelectorProps> = ({
selectedPlatforms,
platformSpecs,
onTogglePlatform,
}) => {
const getPlatformSpec = (platform: Platform) => {
const specs = platformSpecs[platform];
if (!specs || specs.length === 0) return null;
return specs[0]; // Get first format
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Select Platforms
</Typography>
<Stack spacing={1.5}>
{(Object.keys(platformInfo) as Platform[]).map((platform) => {
const info = platformInfo[platform];
const spec = getPlatformSpec(platform);
const isSelected = selectedPlatforms.includes(platform);
return (
<Paper
key={platform}
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: `2px solid ${isSelected ? info.color : '#e2e8f0'}`,
backgroundColor: isSelected ? `${info.color}08` : '#ffffff',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: info.color,
backgroundColor: `${info.color}08`,
},
}}
onClick={() => onTogglePlatform(platform)}
>
<Stack direction="row" spacing={2} alignItems="center">
<Checkbox
checked={isSelected}
onChange={() => onTogglePlatform(platform)}
sx={{
color: info.color,
'&.Mui-checked': {
color: info.color,
},
}}
/>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{info.icon} {info.label}
</Typography>
</Stack>
{spec && (
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 0.5 }}>
<Chip
label={spec.aspect_ratio}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
<Chip
label={`Max ${spec.max_duration}s`}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
<Chip
label={`${spec.width}x${spec.height}`}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Stack>
)}
</Box>
</Stack>
</Paper>
);
})}
</Stack>
</Box>
);
};

View File

@@ -0,0 +1,198 @@
import React from 'react';
import { Grid, Box, Typography, Button, Stack, Chip, Paper, CircularProgress } from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download';
import { PlatformResult } from '../hooks/useSocialVideo';
interface PreviewGridProps {
results: PlatformResult[];
optimizing: boolean;
onDownload: (result: PlatformResult) => void;
onDownloadAll: () => void;
}
const platformColors: Record<string, string> = {
instagram: '#E4405F',
tiktok: '#000000',
youtube: '#FF0000',
linkedin: '#0077B5',
facebook: '#1877F2',
twitter: '#1DA1F2',
};
export const PreviewGrid: React.FC<PreviewGridProps> = ({
results,
optimizing,
onDownload,
onDownloadAll,
}) => {
if (optimizing) {
return (
<Box sx={{ textAlign: 'center', py: 8 }}>
<CircularProgress size={48} sx={{ mb: 2 }} />
<Typography variant="body1" color="text.secondary">
Optimizing videos for selected platforms...
</Typography>
</Box>
);
}
if (results.length === 0) {
return (
<Box
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 6,
textAlign: 'center',
backgroundColor: '#f8fafc',
}}
>
<Typography variant="body2" color="text.secondary">
Optimized videos will appear here
</Typography>
</Box>
);
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a' }}>
Optimized Videos ({results.length})
</Typography>
{results.length > 1 && (
<Button
variant="contained"
startIcon={<DownloadIcon />}
onClick={onDownloadAll}
sx={{
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
}}
>
Download All
</Button>
)}
</Stack>
<Grid container spacing={3}>
{results.map((result, index) => {
const color = platformColors[result.platform] || '#3b82f6';
const videoUrl = result.video_url.startsWith('http')
? result.video_url
: `${window.location.origin}${result.video_url}`;
return (
<Grid item xs={12} sm={6} md={4} key={index}>
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: `2px solid ${color}`,
backgroundColor: '#ffffff',
}}
>
<Stack spacing={2}>
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color }}>
{result.name}
</Typography>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
label={result.aspect_ratio}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
<Chip
label={`${result.width}x${result.height}`}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
<Chip
label={formatFileSize(result.file_size)}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Stack>
</Box>
<Box
sx={{
borderRadius: 1,
overflow: 'hidden',
border: '1px solid #e2e8f0',
backgroundColor: '#000',
aspectRatio: result.aspect_ratio === '9:16' ? '9/16' : '16/9',
}}
>
<video
src={videoUrl}
controls
style={{
width: '100%',
height: '100%',
display: 'block',
}}
/>
</Box>
{result.thumbnail_url && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Thumbnail:
</Typography>
<Box
component="img"
src={
result.thumbnail_url.startsWith('http')
? result.thumbnail_url
: `${window.location.origin}${result.thumbnail_url}`
}
alt={`${result.name} thumbnail`}
sx={{
width: '100%',
borderRadius: 1,
border: '1px solid #e2e8f0',
}}
/>
</Box>
)}
<Button
variant="outlined"
fullWidth
startIcon={<DownloadIcon />}
onClick={() => onDownload(result)}
href={videoUrl}
download
sx={{
borderColor: color,
color: color,
'&:hover': {
borderColor: color,
backgroundColor: `${color}08`,
},
}}
>
Download
</Button>
</Stack>
</Paper>
</Grid>
);
})}
</Grid>
</Box>
);
};

View File

@@ -0,0 +1,126 @@
import React, { useRef } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import VideocamIcon from '@mui/icons-material/Videocam';
interface VideoUploadProps {
videoPreview: string | null;
onVideoSelect: (file: File | null) => void;
}
export const VideoUpload: React.FC<VideoUploadProps> = ({
videoPreview,
onVideoSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate video file
if (!file.type.startsWith('video/')) {
alert('Please select a video file');
return;
}
if (file.size > 500 * 1024 * 1024) {
alert('Video file must be less than 500MB');
return;
}
onVideoSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
onVideoSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box>
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: '#0f172a',
fontWeight: 700,
}}
>
Upload Video
</Typography>
<input
ref={fileInputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{videoPreview ? (
<Box
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 400,
display: 'block',
}}
/>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemove}
sx={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
}}
>
Remove
</Button>
</Box>
) : (
<Box
onClick={handleClick}
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#f8fafc',
},
}}
>
<Stack spacing={2} alignItems="center">
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary">
Click to upload a video
</Typography>
<Typography variant="caption" color="text.secondary">
MP4, WebM up to 500MB (max 10 minutes)
</Typography>
</Stack>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,4 @@
export { VideoUpload } from './VideoUpload';
export { PlatformSelector } from './PlatformSelector';
export { OptimizationOptions } from './OptimizationOptions';
export { PreviewGrid } from './PreviewGrid';

View File

@@ -0,0 +1,163 @@
import { useState, useMemo, useEffect } from 'react';
import { aiApiClient } from '../../../../../api/client';
export type Platform = 'instagram' | 'tiktok' | 'youtube' | 'linkedin' | 'facebook' | 'twitter';
export type TrimMode = 'beginning' | 'middle' | 'end';
export interface PlatformResult {
platform: string;
name: string;
aspect_ratio: string;
video_url: string;
thumbnail_url?: string;
duration: number;
file_size: number;
width: number;
height: number;
}
export const useSocialVideo = () => {
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreview, setVideoPreview] = useState<string | null>(null);
const [selectedPlatforms, setSelectedPlatforms] = useState<Platform[]>([]);
const [autoCrop, setAutoCrop] = useState<boolean>(true);
const [generateThumbnails, setGenerateThumbnails] = useState<boolean>(true);
const [compress, setCompress] = useState<boolean>(true);
const [trimMode, setTrimMode] = useState<TrimMode>('beginning');
const [optimizing, setOptimizing] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
const [results, setResults] = useState<PlatformResult[]>([]);
const [errors, setErrors] = useState<Array<{ platform: string; error: string }>>([]);
const [platformSpecs, setPlatformSpecs] = useState<Record<string, any>>({});
// Update preview when file changes
useEffect(() => {
if (videoFile) {
const url = URL.createObjectURL(videoFile);
setVideoPreview(url);
return () => URL.revokeObjectURL(url);
} else {
setVideoPreview(null);
}
}, [videoFile]);
// Load platform specifications
useEffect(() => {
const loadPlatformSpecs = async () => {
try {
const response = await aiApiClient.get('/api/video-studio/social/platforms');
if (response.data.success) {
setPlatformSpecs(response.data.platforms);
}
} catch (error) {
console.error('Failed to load platform specs:', error);
}
};
loadPlatformSpecs();
}, []);
const togglePlatform = (platform: Platform) => {
setSelectedPlatforms((prev) =>
prev.includes(platform)
? prev.filter((p) => p !== platform)
: [...prev, platform]
);
};
const canOptimize = useMemo(() => {
return videoFile !== null && selectedPlatforms.length > 0;
}, [videoFile, selectedPlatforms]);
const costHint = useMemo(() => {
if (!videoFile) return 'Upload a video to optimize';
if (selectedPlatforms.length === 0) return 'Select at least one platform';
return 'Free (FFmpeg processing)';
}, [videoFile, selectedPlatforms]);
const optimize = async (): Promise<void> => {
if (!videoFile || selectedPlatforms.length === 0) return;
setOptimizing(true);
setProgress(0);
setResults([]);
setErrors([]);
try {
const formData = new FormData();
formData.append('file', videoFile);
formData.append('platforms', selectedPlatforms.join(','));
formData.append('auto_crop', autoCrop.toString());
formData.append('generate_thumbnails', generateThumbnails.toString());
formData.append('compress', compress.toString());
formData.append('trim_mode', trimMode);
setProgress(20);
const response = await aiApiClient.post('/api/video-studio/social/optimize', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const uploadProgress = Math.round((progressEvent.loaded * 30) / progressEvent.total);
setProgress(20 + uploadProgress);
}
},
timeout: 600000, // 10 minutes
});
setProgress(80);
if (response.data.success) {
setResults(response.data.results || []);
setErrors(response.data.errors || []);
setProgress(100);
} else {
throw new Error(response.data.error || 'Optimization failed');
}
} catch (error: any) {
setErrors([
{
platform: 'all',
error: error.response?.data?.detail || error.message || 'Optimization failed',
},
]);
} finally {
setOptimizing(false);
}
};
const reset = () => {
setVideoFile(null);
setVideoPreview(null);
setSelectedPlatforms([]);
setResults([]);
setErrors([]);
setProgress(0);
};
return {
videoFile,
videoPreview,
selectedPlatforms,
autoCrop,
generateThumbnails,
compress,
trimMode,
optimizing,
progress,
results,
errors,
platformSpecs,
setVideoFile,
togglePlatform,
setAutoCrop,
setGenerateThumbnails,
setCompress,
setTrimMode,
canOptimize,
costHint,
optimize,
reset,
};
};

View File

@@ -0,0 +1,2 @@
export { SocialVideo } from './SocialVideo';
export { default } from './SocialVideo';

View File

@@ -0,0 +1,449 @@
import React, { useState, useEffect } from 'react';
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper } from '@mui/material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useTransformVideo } from './hooks/useTransformVideo';
import {
VideoUpload,
TransformTabs,
FormatConverter,
AspectConverter,
SpeedAdjuster,
ResolutionScaler,
Compressor,
} from './components';
import { aiApiClient } from '../../../../api/client';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
const TransformVideo: React.FC = () => {
const {
videoFile,
videoPreview,
transformType,
outputFormat,
codec,
quality,
audioCodec,
targetAspect,
cropMode,
speedFactor,
targetResolution,
maintainAspect,
targetSizeMb,
compressQuality,
setVideoFile,
setTransformType,
setOutputFormat,
setCodec,
setQuality,
setAudioCodec,
setTargetAspect,
setCropMode,
setSpeedFactor,
setTargetResolution,
setMaintainAspect,
setTargetSizeMb,
setCompressQuality,
canTransform,
costHint,
} = useTransformVideo();
const [transforming, setTransforming] = useState(false);
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ video_url: string; cost: number } | null>(null);
const handleTransform = async () => {
if (!videoFile) return;
setTransforming(true);
setError(null);
setResult(null);
setProgress(0);
setStatusMessage('Starting video transformation...');
try {
// Create FormData
const formData = new FormData();
formData.append('file', videoFile);
formData.append('transform_type', transformType);
// Add transform-specific parameters
if (transformType === 'format') {
formData.append('output_format', outputFormat);
if (codec) formData.append('codec', codec);
formData.append('quality', quality);
if (audioCodec) formData.append('audio_codec', audioCodec);
} else if (transformType === 'aspect') {
formData.append('target_aspect', targetAspect);
formData.append('crop_mode', cropMode);
} else if (transformType === 'speed') {
formData.append('speed_factor', speedFactor.toString());
} else if (transformType === 'resolution') {
formData.append('target_resolution', targetResolution);
formData.append('maintain_aspect', maintainAspect.toString());
} else if (transformType === 'compress') {
formData.append('compress_quality', compressQuality);
if (targetSizeMb) {
formData.append('target_size_mb', targetSizeMb.toString());
}
}
// Submit transformation request
setStatusMessage('Uploading video...');
const response = await aiApiClient.post('/api/video-studio/transform', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const uploadProgress = Math.round((progressEvent.loaded * 20) / progressEvent.total);
setProgress(uploadProgress);
setStatusMessage(`Uploading video... ${uploadProgress}%`);
}
},
timeout: 600000, // 10 minutes timeout for long videos
});
setProgress(30);
setStatusMessage('Processing video... This may take a few minutes...');
if (response.data.success) {
setTransforming(false);
setResult(response.data);
setProgress(100);
setStatusMessage('Video transformation complete!');
} else {
throw new Error(response.data.error || 'Transformation failed');
}
} catch (err: any) {
setTransforming(false);
setError(err.response?.data?.detail || err.message || 'Failed to transform video');
setStatusMessage('Transformation failed');
}
};
const handleReset = () => {
setTransforming(false);
setProgress(0);
setStatusMessage('');
setError(null);
setResult(null);
};
const renderTransformSettings = () => {
switch (transformType) {
case 'format':
return (
<FormatConverter
outputFormat={outputFormat}
codec={codec}
quality={quality}
audioCodec={audioCodec}
onOutputFormatChange={setOutputFormat}
onCodecChange={setCodec}
onQualityChange={setQuality}
onAudioCodecChange={setAudioCodec}
/>
);
case 'aspect':
return (
<AspectConverter
targetAspect={targetAspect}
cropMode={cropMode}
onTargetAspectChange={setTargetAspect}
onCropModeChange={setCropMode}
/>
);
case 'speed':
return (
<SpeedAdjuster
speedFactor={speedFactor}
onSpeedFactorChange={setSpeedFactor}
/>
);
case 'resolution':
return (
<ResolutionScaler
targetResolution={targetResolution}
maintainAspect={maintainAspect}
onTargetResolutionChange={setTargetResolution}
onMaintainAspectChange={setMaintainAspect}
/>
);
case 'compress':
return (
<Compressor
targetSizeMb={targetSizeMb}
compressQuality={compressQuality}
onTargetSizeMbChange={setTargetSizeMb}
onCompressQualityChange={setCompressQuality}
/>
);
default:
return null;
}
};
return (
<VideoStudioLayout
headerProps={{
title: 'Transform Studio',
subtitle: 'Convert formats, change aspect ratios, adjust speed, scale resolution, and compress videos. All transformations use FFmpeg processing (free).',
}}
>
<Grid container spacing={4}>
{/* Left Panel - Upload & Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={3}>
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
{videoFile && (
<>
<TransformTabs
transformType={transformType}
onTransformTypeChange={setTransformType}
/>
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
}}
>
{renderTransformSettings()}
</Paper>
</>
)}
<Box>
<Button
fullWidth
variant="contained"
size="large"
startIcon={transforming ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
onClick={handleTransform}
disabled={!canTransform || transforming}
sx={{
py: 1.5,
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{transforming ? 'Transforming...' : 'Transform Video'}
</Button>
</Box>
{videoFile && (
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" color="text.secondary">
<strong>Cost:</strong> {costHint}
</Typography>
</Box>
)}
{transforming && (
<Box>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
{statusMessage}
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 1,
backgroundColor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#3b82f6',
},
}}
/>
</Stack>
</Box>
)}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{result && (
<Alert
severity="success"
icon={<CheckCircleIcon />}
action={
<Button size="small" onClick={handleReset}>
Transform Another
</Button>
}
>
Video transformed successfully! Cost: ${result.cost.toFixed(2)}
</Alert>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Results */}
<Grid item xs={12} lg={7}>
<Stack spacing={3}>
<Box>
<Typography
variant="h6"
sx={{
mb: 2,
color: '#0f172a',
fontWeight: 700,
}}
>
Preview
</Typography>
{videoPreview && !result && (
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #e2e8f0',
backgroundColor: '#000',
}}
>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: 500,
display: 'block',
}}
/>
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
<Typography variant="caption" color="text.secondary">
Original Video
</Typography>
</Box>
</Box>
)}
{result && (
<Box>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '2px solid #3b82f6',
backgroundColor: '#000',
mb: 2,
}}
>
<video
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
controls
style={{
width: '100%',
maxHeight: 500,
display: 'block',
}}
/>
<Box sx={{ p: 2, backgroundColor: '#f0f9ff' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Transformed Video ({transformType})
</Typography>
</Box>
</Box>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
fullWidth
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
download
>
Download Transformed Video
</Button>
<Button variant="outlined" fullWidth onClick={handleReset}>
Transform Another Video
</Button>
</Stack>
</Box>
)}
{!videoPreview && !result && (
<Box
sx={{
border: '2px dashed #cbd5e1',
borderRadius: 2,
p: 6,
textAlign: 'center',
backgroundColor: '#f8fafc',
}}
>
<Typography variant="body2" color="text.secondary">
Upload a video to see preview
</Typography>
</Box>
)}
</Box>
{/* Info Box */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
About Transform Studio
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Transform Studio uses FFmpeg for fast, free video processing:
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="body2" color="text.secondary">
Format conversion: MP4, MOV, WebM, GIF
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Aspect ratio conversion with smart cropping
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Speed adjustment (0.25x to 4x)
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
Resolution scaling (480p to 4K)
</Typography>
<Typography component="li" variant="body2" color="text.secondary">
File size compression
</Typography>
</Stack>
</Box>
</Stack>
</Grid>
</Grid>
</VideoStudioLayout>
);
};
export default TransformVideo;
export { TransformVideo };

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Stack, RadioGroup, FormControlLabel, Radio } from '@mui/material';
import type { AspectRatio } from '../hooks/useTransformVideo';
interface AspectConverterProps {
targetAspect: AspectRatio;
cropMode: 'center' | 'letterbox';
onTargetAspectChange: (aspect: AspectRatio) => void;
onCropModeChange: (mode: 'center' | 'letterbox') => void;
}
export const AspectConverter: React.FC<AspectConverterProps> = ({
targetAspect,
cropMode,
onTargetAspectChange,
onCropModeChange,
}) => {
return (
<Stack spacing={3}>
<Typography
variant="subtitle1"
sx={{
color: '#0f172a',
fontWeight: 700,
}}
>
Aspect Ratio Conversion Settings
</Typography>
<FormControl fullWidth>
<InputLabel>Target Aspect Ratio</InputLabel>
<Select
value={targetAspect}
label="Target Aspect Ratio"
onChange={(e) => onTargetAspectChange(e.target.value as AspectRatio)}
>
<MenuItem value="16:9">16:9 (Landscape - YouTube, TV)</MenuItem>
<MenuItem value="9:16">9:16 (Portrait - Instagram Reels, TikTok)</MenuItem>
<MenuItem value="1:1">1:1 (Square - Instagram Posts)</MenuItem>
<MenuItem value="4:5">4:5 (Portrait - Instagram Stories)</MenuItem>
<MenuItem value="21:9">21:9 (Ultrawide - Cinematic)</MenuItem>
</Select>
</FormControl>
<FormControl component="fieldset">
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Crop Mode
</Typography>
<RadioGroup
value={cropMode}
onChange={(e) => onCropModeChange(e.target.value as 'center' | 'letterbox')}
>
<FormControlLabel
value="center"
control={<Radio />}
label="Center Crop (Crop to fit, may lose edges)"
/>
<FormControlLabel
value="letterbox"
control={<Radio />}
label="Letterbox (Add black bars, preserves full video)"
/>
</RadioGroup>
</FormControl>
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" color="text.secondary">
<strong>Center Crop:</strong> Crops the video to fit the target aspect ratio. May remove parts of the video.
<br />
<strong>Letterbox:</strong> Adds black bars to fit the aspect ratio. Preserves the entire video.
</Typography>
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Stack, TextField } from '@mui/material';
import type { Quality } from '../hooks/useTransformVideo';
interface CompressorProps {
targetSizeMb: number | null;
compressQuality: Quality;
onTargetSizeMbChange: (size: number | null) => void;
onCompressQualityChange: (quality: Quality) => void;
}
export const Compressor: React.FC<CompressorProps> = ({
targetSizeMb,
compressQuality,
onTargetSizeMbChange,
onCompressQualityChange,
}) => {
return (
<Stack spacing={3}>
<Typography
variant="subtitle1"
sx={{
color: '#0f172a',
fontWeight: 700,
}}
>
Compression Settings
</Typography>
<FormControl fullWidth>
<InputLabel>Quality Preset</InputLabel>
<Select
value={compressQuality}
label="Quality Preset"
onChange={(e) => onCompressQualityChange(e.target.value as Quality)}
>
<MenuItem value="high">High (Best Quality, Larger File)</MenuItem>
<MenuItem value="medium">Medium (Balanced)</MenuItem>
<MenuItem value="low">Low (Smaller File, Lower Quality)</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="Target File Size (MB)"
type="number"
value={targetSizeMb || ''}
onChange={(e) => {
const value = e.target.value;
onTargetSizeMbChange(value ? parseFloat(value) : null);
}}
helperText="Optional: Specify target file size. If not set, quality preset will be used."
inputProps={{ min: 1, step: 0.1 }}
/>
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" color="text.secondary">
<strong>Quality Preset:</strong> Uses optimized bitrate settings for the selected quality level.
<br />
<strong>Target Size:</strong> Calculates bitrate to achieve the specified file size. Overrides quality preset if set.
</Typography>
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Stack } from '@mui/material';
import type { OutputFormat, Quality } from '../hooks/useTransformVideo';
interface FormatConverterProps {
outputFormat: OutputFormat;
codec: string;
quality: Quality;
audioCodec: string;
onOutputFormatChange: (format: OutputFormat) => void;
onCodecChange: (codec: string) => void;
onQualityChange: (quality: Quality) => void;
onAudioCodecChange: (codec: string) => void;
}
export const FormatConverter: React.FC<FormatConverterProps> = ({
outputFormat,
codec,
quality,
audioCodec,
onOutputFormatChange,
onCodecChange,
onQualityChange,
onAudioCodecChange,
}) => {
return (
<Stack spacing={3}>
<Typography
variant="subtitle1"
sx={{
color: '#0f172a',
fontWeight: 700,
}}
>
Format Conversion Settings
</Typography>
<FormControl fullWidth>
<InputLabel>Output Format</InputLabel>
<Select
value={outputFormat}
label="Output Format"
onChange={(e) => onOutputFormatChange(e.target.value as OutputFormat)}
>
<MenuItem value="mp4">MP4 (H.264)</MenuItem>
<MenuItem value="mov">MOV (QuickTime)</MenuItem>
<MenuItem value="webm">WebM (VP9)</MenuItem>
<MenuItem value="gif">GIF (Animated)</MenuItem>
</Select>
</FormControl>
{outputFormat !== 'gif' && (
<>
<FormControl fullWidth>
<InputLabel>Video Codec</InputLabel>
<Select
value={codec}
label="Video Codec"
onChange={(e) => onCodecChange(e.target.value)}
disabled={outputFormat === 'webm'} // Auto-selected for WebM
>
<MenuItem value="libx264">H.264 (MP4, MOV)</MenuItem>
<MenuItem value="libvpx-vp9">VP9 (WebM)</MenuItem>
<MenuItem value="libx265">H.265/HEVC</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Audio Codec</InputLabel>
<Select
value={audioCodec}
label="Audio Codec"
onChange={(e) => onAudioCodecChange(e.target.value)}
disabled={outputFormat === 'webm'} // Auto-selected for WebM
>
<MenuItem value="aac">AAC (MP4, MOV)</MenuItem>
<MenuItem value="libopus">Opus (WebM)</MenuItem>
<MenuItem value="mp3">MP3</MenuItem>
</Select>
</FormControl>
</>
)}
{outputFormat !== 'gif' && (
<FormControl fullWidth>
<InputLabel>Quality</InputLabel>
<Select
value={quality}
label="Quality"
onChange={(e) => onQualityChange(e.target.value as Quality)}
>
<MenuItem value="high">High (Best Quality)</MenuItem>
<MenuItem value="medium">Medium (Balanced)</MenuItem>
<MenuItem value="low">Low (Smaller File)</MenuItem>
</Select>
</FormControl>
)}
{outputFormat === 'gif' && (
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" color="text.secondary">
GIF format will be optimized for web with reduced frame rate (15fps) and no audio.
</Typography>
</Box>
)}
</Stack>
);
};

Some files were not shown because too many files have changed in this diff Show More