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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
738
frontend/src/components/Research/IntentResearchWizard.tsx
Normal file
738
frontend/src/components/Research/IntentResearchWizard.tsx
Normal 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: • "AI trends in healthcare 2025" • "What are the best project management tools?" • "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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
323
frontend/src/components/Research/hooks/useIntentResearch.ts
Normal file
323
frontend/src/components/Research/hooks/useIntentResearch.ts
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)',
|
||||
}} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
328
frontend/src/components/Research/types/intent.types.ts
Normal file
328
frontend/src/components/Research/types/intent.types.ts
Normal 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',
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
86
frontend/src/components/VideoStudio/ModulePlaceholder.tsx
Normal file
86
frontend/src/components/VideoStudio/ModulePlaceholder.tsx
Normal 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">
|
||||
We’ll surface cost estimates, provider choices, and templates here as the module goes live.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModulePlaceholder;
|
||||
45
frontend/src/components/VideoStudio/VideoStudioDashboard.tsx
Normal file
45
frontend/src/components/VideoStudio/VideoStudioDashboard.tsx
Normal 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;
|
||||
96
frontend/src/components/VideoStudio/VideoStudioLayout.tsx
Normal file
96
frontend/src/components/VideoStudio/VideoStudioLayout.tsx
Normal 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;
|
||||
202
frontend/src/components/VideoStudio/dashboard/ModuleCard.tsx
Normal file
202
frontend/src/components/VideoStudio/dashboard/ModuleCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
frontend/src/components/VideoStudio/dashboard/constants.ts
Normal file
50
frontend/src/components/VideoStudio/dashboard/constants.ts
Normal 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.',
|
||||
};
|
||||
203
frontend/src/components/VideoStudio/dashboard/modules.tsx
Normal file
203
frontend/src/components/VideoStudio/dashboard/modules.tsx
Normal 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 (5–10 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'],
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CreateVideoPreview } from './CreateVideoPreview';
|
||||
export { AvatarVideoPreview } from './AvatarVideoPreview';
|
||||
export { EnhanceVideoPreview } from './EnhanceVideoPreview';
|
||||
16
frontend/src/components/VideoStudio/dashboard/types.ts
Normal file
16
frontend/src/components/VideoStudio/dashboard/types.ts
Normal 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[];
|
||||
}
|
||||
14
frontend/src/components/VideoStudio/index.ts
Normal file
14
frontend/src/components/VideoStudio/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { AudioSettings } from './AudioSettings';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AddAudioToVideo } from './AddAudioToVideo';
|
||||
export { default } from './AddAudioToVideo';
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export from the AvatarVideo component
|
||||
export { AvatarVideo } from './AvatarVideo/AvatarVideo';
|
||||
export { default } from './AvatarVideo/AvatarVideo';
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ImageUpload } from './ImageUpload';
|
||||
export { AudioUpload } from './AudioUpload';
|
||||
export { AvatarSettings } from './AvatarSettings';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AvatarVideo } from './AvatarVideo';
|
||||
export { default } from './AvatarVideo';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CreateVideo } from './CreateVideo';
|
||||
export { default } from './CreateVideo';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
20
frontend/src/components/VideoStudio/modules/EditVideo.tsx
Normal file
20
frontend/src/components/VideoStudio/modules/EditVideo.tsx
Normal 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export from the EnhanceVideo component
|
||||
export { EnhanceVideo } from './EnhanceVideo/EnhanceVideo';
|
||||
export { default } from './EnhanceVideo/EnhanceVideo';
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { EnhancementSettings } from './EnhancementSettings';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EnhanceVideo } from './EnhanceVideo';
|
||||
export { default } from './EnhanceVideo';
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export from the ExtendVideo component
|
||||
export { ExtendVideo } from './ExtendVideo/ExtendVideo';
|
||||
export { default } from './ExtendVideo/ExtendVideo';
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { AudioUpload } from './AudioUpload';
|
||||
export { ExtendSettings } from './ExtendSettings';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ExtendVideo } from './ExtendVideo';
|
||||
export { default } from './ExtendVideo';
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ImageUpload } from './ImageUpload';
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
export { ModelSelector } from './ModelSelector';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FaceSwap } from './FaceSwap';
|
||||
export { default } from './FaceSwap';
|
||||
20
frontend/src/components/VideoStudio/modules/LibraryVideo.tsx
Normal file
20
frontend/src/components/VideoStudio/modules/LibraryVideo.tsx
Normal 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;
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { PlatformSelector } from './PlatformSelector';
|
||||
export { OptimizationOptions } from './OptimizationOptions';
|
||||
export { PreviewGrid } from './PreviewGrid';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SocialVideo } from './SocialVideo';
|
||||
export { default } from './SocialVideo';
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user