- Add ALWRITY_ENABLED_FEATURES env var for feature gating - Podcast-only mode: skip LLM bootstrap, scheduler, persona services - Enhance video generation prompt with scene context, analysis, narration - Add voice cloning support via custom_voice_id in WaveSpeed - Add text-to-speech for research results (browser speechSynthesis) - Fix render queue to sync images from script phase - Add WaveSpeed LLM pricing (gpt-oss-120b) - Fix podcast bible generation error handling - Refactor RouterManager for feature-based router loading
267 lines
9.8 KiB
TypeScript
267 lines
9.8 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Stack,
|
|
Box,
|
|
Typography,
|
|
TextField,
|
|
FormControl,
|
|
FormLabel,
|
|
RadioGroup,
|
|
FormControlLabel,
|
|
Radio,
|
|
Tooltip,
|
|
} from "@mui/material";
|
|
import { Info as InfoIcon } from "@mui/icons-material";
|
|
import { PrimaryButton, SecondaryButton } from "../ui";
|
|
import type { VideoGenerationSettings } from "../types";
|
|
|
|
interface VideoRegenerateModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onGenerate: (settings: VideoGenerationSettings) => void;
|
|
initialPrompt: string;
|
|
initialResolution?: "480p" | "720p";
|
|
initialSeed?: number | null;
|
|
// Add context props
|
|
sceneTitle?: string;
|
|
bible?: any;
|
|
analysis?: any;
|
|
}
|
|
|
|
export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
|
|
open,
|
|
onClose,
|
|
onGenerate,
|
|
initialPrompt,
|
|
initialResolution = "480p",
|
|
initialSeed = -1,
|
|
sceneTitle,
|
|
bible,
|
|
analysis,
|
|
}) => {
|
|
// Use a more intelligent default prompt based on context if available
|
|
const [prompt, setPrompt] = useState(initialPrompt);
|
|
|
|
// Update prompt when modal opens - build enhanced prompt from context
|
|
useEffect(() => {
|
|
if (open) {
|
|
// Always build an enhanced prompt from available context
|
|
const parts = [];
|
|
|
|
// Add scene context
|
|
if (sceneTitle) parts.push(`Scene: ${sceneTitle}`);
|
|
|
|
// Add bible/persona context
|
|
if (bible?.host_persona) parts.push(`Host Persona: ${bible.host_persona}`);
|
|
if (bible?.tone) parts.push(`Tone: ${bible.tone}`);
|
|
if (bible?.visual_style) parts.push(`Visual Style: ${bible.visual_style}`);
|
|
if (bible?.background) parts.push(`Background: ${bible.background}`);
|
|
|
|
// Add analysis context
|
|
if (analysis?.content_type) parts.push(`Content Type: ${analysis.content_type}`);
|
|
if (analysis?.audience) parts.push(`Target: ${analysis.audience}`);
|
|
if (analysis?.guestName) parts.push(`Guest: ${analysis.guestName}`);
|
|
if (analysis?.keyTakeaways?.length) parts.push(`Key: ${analysis.keyTakeaways[0]}`);
|
|
|
|
// Build enhanced prompt
|
|
let smartPrompt = "";
|
|
if (parts.length > 0) {
|
|
smartPrompt = `Professional podcast video. ${parts.join(". ")}. Cinematic lighting, high detail, 4k quality, smooth subtle motion.`;
|
|
} else {
|
|
// Fallback to initial prompt
|
|
smartPrompt = initialPrompt || "Professional podcast scene with subtle movement";
|
|
}
|
|
|
|
setPrompt(smartPrompt);
|
|
}
|
|
}, [open, sceneTitle, bible, analysis]);
|
|
|
|
const [resolution, setResolution] = useState<"480p" | "720p">(initialResolution);
|
|
const [seed, setSeed] = useState<string>(initialSeed != null && initialSeed !== -1 ? String(initialSeed) : "");
|
|
const [maskImageUrl, setMaskImageUrl] = useState<string>("");
|
|
|
|
const handleGenerate = () => {
|
|
const parsedSeed = seed.trim() === "" ? undefined : Number.isNaN(Number(seed)) ? undefined : Number(seed);
|
|
const settings: VideoGenerationSettings = {
|
|
prompt: prompt.trim(),
|
|
resolution,
|
|
seed: parsedSeed,
|
|
maskImageUrl: maskImageUrl.trim() || undefined,
|
|
};
|
|
onGenerate(settings);
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={onClose}
|
|
maxWidth="md"
|
|
fullWidth
|
|
PaperProps={{
|
|
sx: {
|
|
background: "rgba(15, 23, 42, 0.96)",
|
|
backdropFilter: "blur(18px)",
|
|
borderRadius: 4,
|
|
border: "1px solid rgba(148, 163, 184, 0.4)",
|
|
},
|
|
}}
|
|
>
|
|
<DialogTitle>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
<Typography variant="h6" sx={{ color: "white", fontWeight: 600 }}>
|
|
Configure Video Generation
|
|
</Typography>
|
|
<Tooltip title="Adjust how your talking-head video is rendered. These settings control resolution, prompt, and animation seed.">
|
|
<InfoIcon sx={{ color: "rgba(148,163,184,0.9)" }} />
|
|
</Tooltip>
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ color: "rgba(148,163,184,0.9)", mt: 1 }}>
|
|
Fine-tune how this scene is animated. InfiniteTalk is audio-driven, so use the prompt to describe the visual
|
|
look and feel you want while keeping it concise.
|
|
</Typography>
|
|
</DialogTitle>
|
|
|
|
<DialogContent>
|
|
<Stack spacing={3} sx={{ mt: 1 }}>
|
|
{/* Prompt */}
|
|
<Box>
|
|
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 0.5 }}>Visual prompt</FormLabel>
|
|
<TextField
|
|
multiline
|
|
minRows={3}
|
|
maxRows={6}
|
|
fullWidth
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
placeholder="Short description of how the scene should look (lighting, mood, camera feel, etc.)"
|
|
variant="outlined"
|
|
InputProps={{
|
|
sx: {
|
|
bgcolor: "rgba(15,23,42,0.9)",
|
|
color: "white",
|
|
"& .MuiOutlinedInput-notchedOutline": {
|
|
borderColor: "rgba(148,163,184,0.4)",
|
|
},
|
|
"&:hover .MuiOutlinedInput-notchedOutline": {
|
|
borderColor: "rgba(125,211,252,0.8)",
|
|
},
|
|
},
|
|
}}
|
|
InputLabelProps={{
|
|
sx: { color: "rgba(148,163,184,0.9)" },
|
|
}}
|
|
/>
|
|
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)", mt: 0.5, display: "block" }}>
|
|
Example: "Modern podcast studio with soft lighting, the host framed center, gentle camera movement."
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Resolution */}
|
|
<Box>
|
|
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 1 }}>Resolution & quality</FormLabel>
|
|
<RadioGroup
|
|
row
|
|
value={resolution}
|
|
onChange={(e) => setResolution(e.target.value as "480p" | "720p")}
|
|
>
|
|
<FormControlLabel
|
|
value="480p"
|
|
control={<Radio color="primary" />}
|
|
label={
|
|
<Box>
|
|
<Typography variant="body2">480p (Recommended)</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Faster render, lower cost, great for previews & social
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
<FormControlLabel
|
|
value="720p"
|
|
control={<Radio color="primary" />}
|
|
label={
|
|
<Box>
|
|
<Typography variant="body2">720p (Higher quality)</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Sharper video, slightly higher cost and render time
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
</RadioGroup>
|
|
</Box>
|
|
|
|
{/* Seed & advanced options */}
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
<FormControl fullWidth>
|
|
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 0.5 }}>Seed (optional)</FormLabel>
|
|
<TextField
|
|
type="number"
|
|
value={seed}
|
|
onChange={(e) => setSeed(e.target.value)}
|
|
placeholder="Random each time if left empty"
|
|
InputProps={{
|
|
sx: {
|
|
bgcolor: "rgba(15,23,42,0.9)",
|
|
color: "white",
|
|
"& .MuiOutlinedInput-notchedOutline": {
|
|
borderColor: "rgba(148,163,184,0.4)",
|
|
},
|
|
"&:hover .MuiOutlinedInput-notchedOutline": {
|
|
borderColor: "rgba(125,211,252,0.8)",
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)", mt: 0.5 }}>
|
|
Use the same seed to get a similar animation style across multiple scenes.
|
|
</Typography>
|
|
</FormControl>
|
|
|
|
<FormControl full-width="true">
|
|
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 0.5 }}>Mask image URL (optional)</FormLabel>
|
|
<TextField
|
|
value={maskImageUrl}
|
|
onChange={(e) => setMaskImageUrl(e.target.value)}
|
|
placeholder="e.g. /api/podcast/images/your_avatar_mask.png"
|
|
InputProps={{
|
|
sx: {
|
|
bgcolor: "rgba(15,23,42,0.9)",
|
|
color: "white",
|
|
"& .MuiOutlinedInput-notchedOutline": {
|
|
borderColor: "rgba(148,163,184,0.4)",
|
|
},
|
|
"&:hover .MuiOutlinedInput-notchedOutline": {
|
|
borderColor: "rgba(125,211,252,0.8)",
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)", mt: 0.5 }}>
|
|
Optional: limit animation to a specific region (e.g. face) by providing a mask image URL. Leave empty to
|
|
animate the whole frame.
|
|
</Typography>
|
|
</FormControl>
|
|
</Stack>
|
|
</Stack>
|
|
</DialogContent>
|
|
|
|
<DialogActions sx={{ p: 2.5, pt: 0, justifyContent: "space-between" }}>
|
|
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)" }}>
|
|
Estimated cost at 480p is lower than 720p. You'll only be billed for successful renders.
|
|
</Typography>
|
|
<Stack direction="row" spacing={1}>
|
|
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
|
<PrimaryButton onClick={handleGenerate}>Generate Video</PrimaryButton>
|
|
</Stack>
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
|