AI Video Generation Implementation

This commit is contained in:
ajaysi
2025-11-17 17:38:23 +05:30
parent 4901b7eb72
commit bf7493c366
132 changed files with 6200 additions and 19475 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Box,
Paper,
@@ -9,11 +9,16 @@ import {
Divider,
CircularProgress,
LinearProgress,
Tooltip,
} from '@mui/material';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
import DownloadIcon from '@mui/icons-material/Download';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { fetchMediaBlobUrl } from '../../../utils/fetchMediaBlobUrl';
import { triggerSubscriptionError } from '../../../api/client';
import SmartDisplayIcon from '@mui/icons-material/SmartDisplay';
import SceneVideoApproval from '../components/SceneVideoApproval';
interface StoryExportProps {
state: ReturnType<typeof useStoryWriterState>;
@@ -22,8 +27,28 @@ interface StoryExportProps {
const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [videoProgress, setVideoProgress] = useState(0);
const [videoMessage, setVideoMessage] = useState<string>('');
const [videoBlobUrl, setVideoBlobUrl] = useState<string | null>(null);
const [isGeneratingHdVideo, setIsGeneratingHdVideo] = useState(false);
const [hdVideoProgress, setHdVideoProgress] = useState(0);
const [hdVideoMessage, setHdVideoMessage] = useState<string>('');
const [hdVideoPrompts, setHdVideoPrompts] = useState<Map<number, string>>(new Map()); // Store prompts by scene number
const videoRef = useRef<HTMLVideoElement | null>(null);
const [error, setError] = useState<string | null>(null);
// Scene-by-scene approval state
const [approvalModal, setApprovalModal] = useState<{
open: boolean;
sceneNumber: number;
sceneTitle: string;
videoUrl: string;
promptUsed: string;
} | null>(null);
const [regeneratingScene, setRegeneratingScene] = useState<number | null>(null);
// Keep track of the processing function for continuation
const processSceneRef = useRef<((sceneIndex: number) => Promise<void>) | null>(null);
const handleCopyToClipboard = () => {
if (state.storyContent) {
navigator.clipboard.writeText(state.storyContent);
@@ -91,8 +116,8 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
throw new Error('Number of images and audio files must match number of scenes');
}
// Generate video
const response = await storyWriterApi.generateStoryVideo({
// Start async video generation
const startRes = await storyWriterApi.generateStoryVideoAsync({
scenes: scenes,
image_urls: imageUrls,
audio_urls: audioUrls,
@@ -101,12 +126,42 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
transition_duration: state.videoTransitionDuration,
});
if (response.success && response.video) {
state.setStoryVideo(response.video.video_url);
// Poll task status
const taskId = startRes.task_id;
setVideoMessage(startRes.message || 'Starting video generation...');
let done = false;
while (!done) {
await new Promise((r) => setTimeout(r, 1200));
const status = await storyWriterApi.getTaskStatus(taskId);
setVideoProgress(Math.round(status.progress ?? 0));
if (status.message) setVideoMessage(status.message);
if (status.status === 'completed') {
done = true;
const result = await storyWriterApi.getTaskResult(taskId);
// result.video exists under result.video
// @ts-ignore result typing is StoryFullGenerationResponse; our async returns a dict
const video = result.video || (result as any).video;
const videoUrl = video?.video_url;
if (!videoUrl) throw new Error('Video URL missing in result');
state.setStoryVideo(videoUrl);
// fetch blob for authenticated preview
const blobUrl = await fetchMediaBlobUrl(videoUrl);
setVideoBlobUrl(blobUrl);
setVideoProgress(100);
setVideoMessage('Video generation complete');
state.setError(null);
setVideoProgress(100);
} else {
throw new Error('Failed to generate video');
// Autoplay and fullscreen
setTimeout(() => {
const v = videoRef.current;
if (v) {
try { v.play().catch(() => {}); } catch {}
try { if (v.requestFullscreen) v.requestFullscreen(); } catch {}
}
}, 300);
} else if (status.status === 'failed') {
throw new Error(status.error || 'Video generation failed');
}
}
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate video';
@@ -117,19 +172,260 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
}
};
const handleDownloadVideo = () => {
const handleDownloadVideo = async () => {
if (state.storyVideo) {
const videoUrl = storyWriterApi.getVideoUrl(state.storyVideo);
const blobUrl = await fetchMediaBlobUrl(state.storyVideo);
const a = document.createElement('a');
a.href = videoUrl;
a.href = blobUrl;
a.download = `story-video-${Date.now()}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}
};
const handleGenerateHdVideo = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
setError('Please generate a structured outline first');
return;
}
const scenes = state.outlineScenes;
const totalScenes = scenes.length;
// Initialize HD videos map if not exists
if (!state.sceneHdVideos) {
state.setSceneHdVideos(new Map());
}
// Clear previous prompts
setHdVideoPrompts(new Map());
state.setHdVideoGenerationStatus('generating');
setIsGeneratingHdVideo(true);
setError(null);
// Build story context for prompt enhancement
const storyContext = {
persona: state.persona,
story_setting: state.storySetting,
characters: state.characters,
plot_elements: state.plotElements,
writing_style: state.writingStyle,
story_tone: state.storyTone,
narrative_pov: state.narrativePOV,
audience_age_group: state.audienceAgeGroup,
content_rating: state.contentRating,
premise: state.premise || '',
outline: state.outline || '',
story_content: state.storyContent || '',
};
// Iterate through scenes one at a time
const processScene = async (sceneIndex: number): Promise<void> => {
if (sceneIndex >= totalScenes) {
// All scenes processed
state.setHdVideoGenerationStatus('completed');
setIsGeneratingHdVideo(false);
setHdVideoProgress(100);
setHdVideoMessage(`All ${totalScenes} scenes processed`);
// Show completion message
const approvedCount = state.sceneHdVideos?.size || 0;
setHdVideoMessage(`HD video generation complete! ${approvedCount} of ${totalScenes} scenes approved.`);
return;
}
const scene = scenes[sceneIndex];
const sceneNumber = scene.scene_number || sceneIndex + 1;
state.setCurrentHdSceneIndex(sceneIndex);
setHdVideoProgress(Math.round((sceneIndex / totalScenes) * 100));
setHdVideoMessage(`Generating HD video for Scene ${sceneNumber}...`);
try {
// Generate video for current scene
const result = await storyWriterApi.generateHdVideoScene({
scene_number: sceneNumber,
scene_data: scene,
story_context: storyContext,
all_scenes: scenes,
provider: 'huggingface',
model: 'tencent/HunyuanVideo',
num_frames: 50,
guidance_scale: 7.5,
});
// Store prompt for this scene
setHdVideoPrompts((prev) => {
const newPrompts = new Map(prev);
newPrompts.set(sceneNumber, result.prompt_used);
return newPrompts;
});
// Show approval modal
state.setHdVideoGenerationStatus('awaiting_approval');
setApprovalModal({
open: true,
sceneNumber: sceneNumber,
sceneTitle: scene.title || `Scene ${sceneNumber}`,
videoUrl: result.video_url,
promptUsed: result.prompt_used,
});
} catch (err: any) {
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
// Subscription modal is showing, stop processing scenes
setIsGeneratingHdVideo(false);
state.setHdVideoGenerationStatus('idle');
return;
}
}
const errorMessage = err.response?.data?.detail || err.message || `Failed to generate HD video for scene ${sceneNumber}`;
setError(errorMessage);
// On subscription error, stop processing. On other errors, continue to next scene.
if (status !== 429 && status !== 402) {
await processScene(sceneIndex + 1);
} else {
setIsGeneratingHdVideo(false);
state.setHdVideoGenerationStatus('idle');
}
}
};
// Store processScene function in ref for continuation
processSceneRef.current = processScene;
// Start processing first scene
await processScene(0);
};
// Handle approval modal actions
const handleApprove = () => {
if (!approvalModal) return;
const sceneNumber = approvalModal.sceneNumber;
const hdVideos = state.sceneHdVideos || new Map();
hdVideos.set(sceneNumber, approvalModal.videoUrl);
state.setSceneHdVideos(new Map(hdVideos));
setApprovalModal(null);
// Continue to next scene
const currentIndex = state.currentHdSceneIndex;
const scenes = state.outlineScenes || [];
if (currentIndex + 1 < scenes.length && processSceneRef.current) {
state.setHdVideoGenerationStatus('generating');
processSceneRef.current(currentIndex + 1);
} else {
state.setHdVideoGenerationStatus('completed');
setIsGeneratingHdVideo(false);
const approvedCount = state.sceneHdVideos?.size || 0;
setHdVideoMessage(`HD video generation complete! ${approvedCount} of ${scenes.length} scenes approved.`);
}
};
const handleReject = () => {
if (!approvalModal) return;
// Skip scene and continue to next
setApprovalModal(null);
const currentIndex = state.currentHdSceneIndex;
const scenes = state.outlineScenes || [];
if (currentIndex + 1 < scenes.length && processSceneRef.current) {
state.setHdVideoGenerationStatus('generating');
processSceneRef.current(currentIndex + 1);
} else {
state.setHdVideoGenerationStatus('completed');
setIsGeneratingHdVideo(false);
const approvedCount = state.sceneHdVideos?.size || 0;
setHdVideoMessage(`HD video generation complete! ${approvedCount} of ${scenes.length} scenes approved.`);
}
};
const handleRegenerate = async () => {
if (!approvalModal) return;
const sceneNumber = approvalModal.sceneNumber;
const scenes = state.outlineScenes || [];
const sceneIndex = scenes.findIndex((s: any) => (s.scene_number || 0) === sceneNumber);
const scene = scenes[sceneIndex];
if (!scene) return;
setRegeneratingScene(sceneNumber);
try {
const storyContext = {
persona: state.persona,
story_setting: state.storySetting,
characters: state.characters,
plot_elements: state.plotElements,
writing_style: state.writingStyle,
story_tone: state.storyTone,
narrative_pov: state.narrativePOV,
audience_age_group: state.audienceAgeGroup,
content_rating: state.contentRating,
premise: state.premise || '',
outline: state.outline || '',
story_content: state.storyContent || '',
};
const result = await storyWriterApi.generateHdVideoScene({
scene_number: sceneNumber,
scene_data: scene,
story_context: storyContext,
all_scenes: scenes,
provider: 'huggingface',
model: 'tencent/HunyuanVideo',
num_frames: 50,
guidance_scale: 7.5,
});
// Update prompt for this scene
setHdVideoPrompts((prev) => {
const newPrompts = new Map(prev);
newPrompts.set(sceneNumber, result.prompt_used);
return newPrompts;
});
// Update approval modal with new video
setApprovalModal({
open: true,
sceneNumber: sceneNumber,
sceneTitle: scene.title || `Scene ${sceneNumber}`,
videoUrl: result.video_url,
promptUsed: result.prompt_used,
});
} catch (err: any) {
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
// Subscription modal is showing, stop here
setRegeneratingScene(null);
return;
}
}
const errorMessage = err.response?.data?.detail || err.message || 'Failed to regenerate video';
setError(errorMessage);
} finally {
setRegeneratingScene(null);
}
};
return (
<>
<Paper
sx={{
p: 4,
@@ -289,7 +585,7 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
<Box sx={{ mb: 2 }}>
<LinearProgress variant="determinate" value={videoProgress} sx={{ mb: 1 }} />
<Typography variant="body2" sx={{ color: '#5D4037' }}>
Generating video... {videoProgress}%
{videoMessage || 'Generating video...'} {videoProgress}%
</Typography>
</Box>
)}
@@ -297,8 +593,9 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
{state.storyVideo && (
<Box sx={{ mb: 2 }}>
<video
ref={videoRef}
controls
src={storyWriterApi.getVideoUrl(state.storyVideo)}
src={videoBlobUrl ?? undefined}
style={{ width: '100%', maxHeight: '500px' }}
>
Your browser does not support the video element.
@@ -306,6 +603,107 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#5D4037' }}>
Generated story video
</Typography>
<Box sx={{ mt: 1, display: 'flex', gap: 1, flexDirection: 'column' }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
Generate HD Animation with AI
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
Upgrade this storyboard into a highdefinition AI animation using Hugging Face texttovideo models.
Your draft was generated affordably (images + narration). This premium option uses an AI model to render motion.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600 }}>
Recommended models:
</Typography>
<Typography variant="caption" component="div" sx={{ display: 'block', mb: 1 }}>
tencent/HunyuanVideo<br />
Lightricks/LTX-Video<br />
Lightricks/LTX-Video-0.9.8-13B-distilled
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontStyle: 'italic' }}>
This will generate HD videos for each scene one at a time. You'll review and approve each scene before the next one is generated.
</Typography>
</Box>
}
arrow
placement="top"
>
<Button
variant="contained"
startIcon={<SmartDisplayIcon />}
onClick={handleGenerateHdVideo}
disabled={isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'}
>
{isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'
? 'Generating HD Animation...'
: 'Generate HD Animation with AI'}
</Button>
</Tooltip>
{/* Show progress and prompts during generation */}
{(isGeneratingHdVideo || state.hdVideoGenerationStatus === 'generating' || state.hdVideoGenerationStatus === 'awaiting_approval') && (
<Box sx={{ mt: 2, p: 2, backgroundColor: '#FAF9F6', borderRadius: 1, border: '1px solid #E0DCD4' }}>
<LinearProgress variant="determinate" value={hdVideoProgress} sx={{ mb: 1 }} />
<Typography variant="body2" sx={{ color: '#5D4037', fontWeight: 500, mb: 1 }}>
{hdVideoMessage || 'Generating HD video...'} {hdVideoProgress}%
</Typography>
{state.hdVideoGenerationStatus === 'awaiting_approval' && (
<Typography variant="body2" sx={{ color: '#1976d2', display: 'block', mb: 1, fontWeight: 500 }}>
⏸ Awaiting your approval for Scene {state.currentHdSceneIndex + 1} of {state.outlineScenes?.length || 0}
</Typography>
)}
{state.hdVideoGenerationStatus === 'generating' && (
<Typography variant="body2" sx={{ color: '#5D4037', display: 'block', mb: 1 }}>
Processing Scene {state.currentHdSceneIndex + 1} of {state.outlineScenes?.length || 0}...
</Typography>
)}
{state.sceneHdVideos && state.sceneHdVideos.size > 0 && (
<Typography variant="caption" sx={{ color: '#4caf50', display: 'block', mb: 1, fontWeight: 500 }}>
✓ {state.sceneHdVideos.size} of {state.outlineScenes?.length || 0} scenes approved
</Typography>
)}
{/* Display prompts for completed scenes */}
{hdVideoPrompts.size > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" sx={{ color: '#1A1611', mb: 1, fontWeight: 600 }}>
Generated Prompts:
</Typography>
{Array.from(hdVideoPrompts.entries())
.sort(([a], [b]) => a - b)
.map(([sceneNum, prompt]) => (
<Box key={sceneNum} sx={{ mb: 2, p: 1.5, backgroundColor: '#fff', borderRadius: 1, border: '1px solid #E0DCD4' }}>
<Typography variant="caption" sx={{ color: '#5D4037', fontWeight: 600, display: 'block', mb: 0.5 }}>
Scene {sceneNum}:
</Typography>
<Typography
variant="caption"
sx={{
color: '#2C2416',
fontFamily: 'monospace',
fontSize: '0.75rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
display: 'block',
}}
>
{prompt.length > 200 ? `${prompt.substring(0, 200)}...` : prompt}
</Typography>
</Box>
))}
</Box>
)}
</Box>
)}
{state.hdVideoGenerationStatus === 'completed' && (
<Alert severity="success" sx={{ mt: 2 }}>
HD video generation complete! {state.sceneHdVideos?.size || 0} of {state.outlineScenes?.length || 0} scenes were approved.
</Alert>
)}
</Box>
</Box>
)}
@@ -364,6 +762,29 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
</>
)}
</Paper>
{/* Scene Video Approval Modal */}
{approvalModal && state.outlineScenes && (
<SceneVideoApproval
open={approvalModal.open}
sceneNumber={approvalModal.sceneNumber}
sceneTitle={approvalModal.sceneTitle}
totalScenes={state.outlineScenes.length}
videoUrl={approvalModal.videoUrl}
promptUsed={approvalModal.promptUsed}
onApprove={handleApprove}
onReject={handleReject}
onRegenerate={handleRegenerate}
isRegenerating={regeneratingScene === approvalModal.sceneNumber}
onClose={() => {
if (!isGeneratingHdVideo && !regeneratingScene) {
setApprovalModal(null);
state.setHdVideoGenerationStatus('paused');
}
}}
/>
)}
</>
);
};

View File

@@ -579,17 +579,17 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
onContinue={handleContinue}
/>
</Box>
) : (
<TextField
fullWidth
multiline
rows={12}
value={state.outline || ''}
onChange={(e) => state.setOutline(e.target.value)}
label="Story Outline"
sx={{ mb: 3 }}
/>
)}
) : (
<TextField
fullWidth
multiline
rows={12}
value={state.outline || ''}
onChange={(e) => state.setOutline(e.target.value)}
label="Story Outline"
sx={{ mb: 3 }}
/>
)}
<EditSectionModal
open={isEditModalOpen}
sceneNumber={currentSceneNumber}
@@ -671,7 +671,7 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
setIsTitleModalOpen(false);
}}
/>
</Box>
</Box>
);
};

View File

@@ -142,7 +142,8 @@ export const StoryWriter: React.FC = () => {
throw new Error('Number of images and audio files must match number of scenes');
}
const response = await storyWriterApi.generateStoryVideo({
// Switch to async flow so UI can poll progress messages
const start = await storyWriterApi.generateStoryVideoAsync({
scenes: scenes,
image_urls: imageUrls,
audio_urls: audioUrls,
@@ -151,9 +152,22 @@ export const StoryWriter: React.FC = () => {
transition_duration: state.videoTransitionDuration,
});
if (response.success && response.video) {
state.setStoryVideo(response.video.video_url);
state.setError(null);
// Optional: set a lightweight spinner; export page shows detailed progress
let done = false;
while (!done) {
await new Promise((r) => setTimeout(r, 1200));
const status = await storyWriterApi.getTaskStatus(start.task_id);
if (status.status === 'completed') {
const result = await storyWriterApi.getTaskResult(start.task_id);
// @ts-ignore: async result includes video dict
const video = (result as any).video || (result as any)?.result?.video;
const finalUrl: string | undefined = video?.video_url;
if (finalUrl) state.setStoryVideo(finalUrl);
state.setError(null);
done = true;
} else if (status.status === 'failed') {
throw new Error(status.error || 'Video generation failed');
}
}
} catch (err: any) {
const status = err?.response?.status;

View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect } from 'react';
import { Box, Typography } from '@mui/material';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { aiApiClient } from '../../../api/client';
interface AudioPlayerListProps {
scenes: any[];
sceneAudioMap: Map<number, string>;
}
export const AudioPlayerList: React.FC<AudioPlayerListProps> = ({ scenes, sceneAudioMap }) => {
const [audioBlobUrls, setAudioBlobUrls] = useState<Map<number, string>>(new Map());
useEffect(() => {
if (!sceneAudioMap || sceneAudioMap.size === 0) {
setAudioBlobUrls((prev) => {
prev.forEach((url) => URL.revokeObjectURL(url));
return new Map();
});
return;
}
let isMounted = true;
const loadAudioBlobs = async () => {
const entries = Array.from(sceneAudioMap.entries());
const blobEntries: Array<[number, string]> = [];
for (const [sceneNumber, audioPath] of entries) {
if (!audioPath) continue;
try {
const normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`;
const response = await aiApiClient.get(normalizedPath, {
responseType: 'blob',
});
const blobUrl = URL.createObjectURL(response.data);
blobEntries.push([sceneNumber, blobUrl]);
} catch (err) {
console.error('Failed to load audio blob:', err);
}
}
if (!isMounted) {
blobEntries.forEach(([, url]) => URL.revokeObjectURL(url));
return;
}
setAudioBlobUrls((prev) => {
prev.forEach((url) => URL.revokeObjectURL(url));
return new Map(blobEntries);
});
};
loadAudioBlobs();
return () => {
isMounted = false;
setAudioBlobUrls((prev) => {
prev.forEach((url) => URL.revokeObjectURL(url));
return new Map();
});
};
}, [sceneAudioMap]);
return (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: '#5D4037', fontSize: '0.875rem', mb: 2 }}>
Audio narration generated for {sceneAudioMap.size} scene(s). Listen to audio for each scene:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{scenes.map((scene: any, index: number) => {
const sceneNumber = scene.scene_number || index + 1;
const audioUrl = sceneAudioMap.get(sceneNumber);
if (!audioUrl) return null;
const blobUrl = audioBlobUrls.get(sceneNumber);
return (
<Box
key={sceneNumber}
sx={{
p: 2,
backgroundColor: '#FFFFFF',
borderRadius: '8px',
border: '1px solid rgba(120, 90, 60, 0.2)',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#1A1611' }}>
Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`}
</Typography>
<audio
controls
src={blobUrl ? blobUrl : storyWriterApi.getAudioUrl(audioUrl)}
style={{ width: '100%' }}
>
Your browser does not support the audio element.
</audio>
</Box>
);
})}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,177 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Button,
Alert,
LinearProgress,
CircularProgress,
Chip,
} from '@mui/material';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../api/client';
import { SceneSelection } from './SceneSelection';
import { AudioPlayerList } from './AudioPlayerList';
interface AudioSectionProps {
state: ReturnType<typeof useStoryWriterState>;
selectedScenes: Set<number>;
onSelectedScenesChange: (scenes: Set<number>) => void;
showSceneSelection: boolean;
onShowSceneSelectionChange: (show: boolean) => void;
error: string | null;
onError: (error: string | null) => void;
}
export const AudioSection: React.FC<AudioSectionProps> = ({
state,
selectedScenes,
onSelectedScenesChange,
showSceneSelection,
onShowSceneSelectionChange,
error,
onError,
}) => {
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
const [audioProgress, setAudioProgress] = useState(0);
const hasScenes = state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0;
const narrationEnabled = state.enableNarration;
const hasAudio = narrationEnabled && state.sceneAudio && state.sceneAudio.size > 0;
const canGenerateAudio = hasScenes && selectedScenes.size > 0 && !isGeneratingAudio;
const handleGenerateAudio = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
onError('Please generate a structured outline first');
return;
}
if (!narrationEnabled) {
onError('Narration feature is disabled in Story Setup.');
return;
}
if (selectedScenes.size === 0) {
onError('Please select at least one scene to generate audio for');
return;
}
setIsGeneratingAudio(true);
onError(null);
setAudioProgress(0);
try {
const scenesToGenerate = state.outlineScenes.filter((scene: any, index: number) => {
const sceneNumber = scene.scene_number || index + 1;
return selectedScenes.has(sceneNumber);
});
const response = await storyWriterApi.generateSceneAudio({
scenes: scenesToGenerate,
provider: state.audioProvider,
lang: state.audioLang,
slow: state.audioSlow,
rate: state.audioRate,
});
if (response.success && response.audio_files) {
const audioMap = new Map<number, string>();
response.audio_files.forEach((audio) => {
if (audio.audio_url && !audio.error) {
audioMap.set(audio.scene_number, audio.audio_url);
}
});
state.setSceneAudio(audioMap);
state.setError(null);
setAudioProgress(100);
} else {
throw new Error('Failed to generate audio');
}
} catch (err: any) {
console.error('Audio generation failed:', err);
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
setIsGeneratingAudio(false);
return;
}
}
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate audio';
onError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGeneratingAudio(false);
}
};
if (!narrationEnabled) {
return (
<Alert severity="info" sx={{ mb: 3 }}>
Narration is disabled in Story Setup. Enable it to generate or listen to audio narration.
</Alert>
);
}
return (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VolumeUpIcon sx={{ color: hasAudio ? '#4caf50' : '#5D4037' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Audio Narration
</Typography>
{hasAudio && (
<Chip
icon={<CheckCircleIcon />}
label="Generated"
size="small"
color="success"
sx={{ ml: 1 }}
/>
)}
</Box>
<Button
variant={hasAudio ? 'outlined' : 'contained'}
startIcon={isGeneratingAudio ? <CircularProgress size={16} /> : <VolumeUpIcon />}
onClick={handleGenerateAudio}
disabled={!canGenerateAudio || isGeneratingAudio}
>
{hasAudio
? 'Regenerate Selected'
: `Generate Audio (${selectedScenes.size} scene${selectedScenes.size !== 1 ? 's' : ''})`}
</Button>
</Box>
{hasScenes && state.outlineScenes && (
<SceneSelection
scenes={state.outlineScenes}
selectedScenes={selectedScenes}
onSelectedScenesChange={onSelectedScenesChange}
sceneAudioMap={state.sceneAudio}
showSceneSelection={showSceneSelection}
onShowSceneSelectionChange={onShowSceneSelectionChange}
/>
)}
{isGeneratingAudio && (
<Box sx={{ mt: 1 }}>
<LinearProgress variant="indeterminate" />
<Typography variant="caption" sx={{ mt: 0.5, color: '#5D4037', display: 'block' }}>
Generating audio for {selectedScenes.size} selected scene
{selectedScenes.size !== 1 ? 's' : ''}...
</Typography>
</Box>
)}
{hasAudio && state.sceneAudio && state.outlineScenes && (
<AudioPlayerList scenes={state.outlineScenes} sceneAudioMap={state.sceneAudio} />
)}
</Box>
);
};

View File

@@ -0,0 +1,430 @@
import React, { useState, useRef } from 'react';
import {
Box,
Typography,
Button,
Alert,
LinearProgress,
Tooltip,
} from '@mui/material';
import SmartDisplayIcon from '@mui/icons-material/SmartDisplay';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../api/client';
import SceneVideoApproval from './SceneVideoApproval';
// Simple logger for frontend
const logger = {
error: (message: string, ...args: any[]) => console.error(`[HdVideoSection] ${message}`, ...args),
warn: (message: string, ...args: any[]) => console.warn(`[HdVideoSection] ${message}`, ...args),
info: (message: string, ...args: any[]) => console.info(`[HdVideoSection] ${message}`, ...args),
};
interface HdVideoSectionProps {
state: ReturnType<typeof useStoryWriterState>;
error: string | null;
onError: (error: string | null) => void;
}
export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }) => {
const [isGeneratingHdVideo, setIsGeneratingHdVideo] = useState(false);
const [hdVideoProgress, setHdVideoProgress] = useState(0);
const [hdVideoMessage, setHdVideoMessage] = useState<string>('');
const [hdVideoPrompts, setHdVideoPrompts] = useState<Map<number, string>>(new Map());
const [approvalModal, setApprovalModal] = useState<{
open: boolean;
sceneNumber: number;
sceneTitle: string;
videoUrl: string;
promptUsed: string;
} | null>(null);
const [regeneratingScene, setRegeneratingScene] = useState<number | null>(null);
const processSceneRef = useRef<((sceneIndex: number) => Promise<void>) | null>(null);
const handleGenerateHdVideo = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
onError('Please generate a structured outline first');
return;
}
const scenes = state.outlineScenes;
const totalScenes = scenes.length;
if (!state.sceneHdVideos) {
state.setSceneHdVideos(new Map());
}
setHdVideoPrompts(new Map());
state.setHdVideoGenerationStatus('generating');
setIsGeneratingHdVideo(true);
onError(null);
const storyContext = {
persona: state.persona,
story_setting: state.storySetting,
characters: state.characters,
plot_elements: state.plotElements,
writing_style: state.writingStyle,
story_tone: state.storyTone,
narrative_pov: state.narrativePOV,
audience_age_group: state.audienceAgeGroup,
content_rating: state.contentRating,
premise: state.premise || '',
outline: state.outline || '',
story_content: state.storyContent || '',
};
const processScene = async (sceneIndex: number): Promise<void> => {
if (sceneIndex >= totalScenes) {
state.setHdVideoGenerationStatus('completed');
setIsGeneratingHdVideo(false);
setHdVideoProgress(100);
const approvedCount = state.sceneHdVideos?.size || 0;
setHdVideoMessage(`HD video generation complete! ${approvedCount} of ${totalScenes} scenes approved.`);
return;
}
const scene = scenes[sceneIndex];
const sceneNumber = scene.scene_number || sceneIndex + 1;
state.setCurrentHdSceneIndex(sceneIndex);
setHdVideoProgress(Math.round((sceneIndex / totalScenes) * 100));
setHdVideoMessage(`Generating HD video for Scene ${sceneNumber}...`);
try {
const sceneImageUrl = state.sceneImages?.get(sceneNumber);
const result = await storyWriterApi.generateHdVideoScene({
scene_number: sceneNumber,
scene_data: scene,
story_context: storyContext,
all_scenes: scenes,
scene_image_url: sceneImageUrl,
provider: 'huggingface',
model: 'tencent/HunyuanVideo',
num_frames: 50,
guidance_scale: 7.5,
});
setHdVideoPrompts((prev) => {
const newPrompts = new Map(prev);
newPrompts.set(sceneNumber, result.prompt_used);
return newPrompts;
});
state.setHdVideoGenerationStatus('awaiting_approval');
setApprovalModal({
open: true,
sceneNumber: sceneNumber,
sceneTitle: scene.title || `Scene ${sceneNumber}`,
videoUrl: result.video_url,
promptUsed: result.prompt_used,
});
} catch (err: any) {
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
// Subscription modal is showing, stop processing scenes
setIsGeneratingHdVideo(false);
state.setHdVideoGenerationStatus('idle');
return;
}
}
// Extract error message as string (handle both string and object responses)
let errorMessage: string;
if (err.response?.data?.detail) {
const detail = err.response.data.detail;
if (typeof detail === 'string') {
errorMessage = detail;
} else if (typeof detail === 'object' && detail !== null) {
// Handle object response like {error: "...", message: "..."}
errorMessage = detail.message || detail.error || JSON.stringify(detail);
} else {
errorMessage = String(detail);
}
} else {
errorMessage = err.message || `Failed to generate HD video for scene ${sceneNumber}`;
}
onError(errorMessage);
// CRITICAL: Stop processing on ANY error to prevent wasting money on expensive video API calls
// This is an expensive operation ($0.40/video) - don't continue if there's an error
// Only retry/continue if the user explicitly requests it
logger.error(`[HdVideoSection] Video generation failed for scene ${sceneNumber}: ${errorMessage}`);
logger.error(`[HdVideoSection] Stopping video generation to prevent wasted API calls`);
setIsGeneratingHdVideo(false);
state.setHdVideoGenerationStatus('idle');
// Don't continue to next scene - stop immediately to save money
return;
}
};
processSceneRef.current = processScene;
await processScene(0);
};
const handleApprove = () => {
if (!approvalModal) return;
const sceneNumber = approvalModal.sceneNumber;
const hdVideos = state.sceneHdVideos || new Map();
hdVideos.set(sceneNumber, approvalModal.videoUrl);
state.setSceneHdVideos(new Map(hdVideos));
setApprovalModal(null);
const currentIndex = state.currentHdSceneIndex;
const scenes = state.outlineScenes || [];
if (currentIndex + 1 < scenes.length && processSceneRef.current) {
state.setHdVideoGenerationStatus('generating');
processSceneRef.current(currentIndex + 1);
} else {
state.setHdVideoGenerationStatus('completed');
setIsGeneratingHdVideo(false);
const approvedCount = state.sceneHdVideos?.size || 0;
setHdVideoMessage(`HD video generation complete! ${approvedCount} of ${scenes.length} scenes approved.`);
}
};
const handleReject = () => {
if (!approvalModal) return;
setApprovalModal(null);
const currentIndex = state.currentHdSceneIndex;
const scenes = state.outlineScenes || [];
if (currentIndex + 1 < scenes.length && processSceneRef.current) {
state.setHdVideoGenerationStatus('generating');
processSceneRef.current(currentIndex + 1);
} else {
state.setHdVideoGenerationStatus('completed');
setIsGeneratingHdVideo(false);
const approvedCount = state.sceneHdVideos?.size || 0;
setHdVideoMessage(`HD video generation complete! ${approvedCount} of ${scenes.length} scenes approved.`);
}
};
const handleRegenerate = async () => {
if (!approvalModal) return;
const sceneNumber = approvalModal.sceneNumber;
const scenes = state.outlineScenes || [];
const sceneIndex = scenes.findIndex((s: any) => (s.scene_number || 0) === sceneNumber);
const scene = scenes[sceneIndex];
if (!scene) return;
setRegeneratingScene(sceneNumber);
try {
const storyContext = {
persona: state.persona,
story_setting: state.storySetting,
characters: state.characters,
plot_elements: state.plotElements,
writing_style: state.writingStyle,
story_tone: state.storyTone,
narrative_pov: state.narrativePOV,
audience_age_group: state.audienceAgeGroup,
content_rating: state.contentRating,
premise: state.premise || '',
outline: state.outline || '',
story_content: state.storyContent || '',
};
const sceneImageUrl = state.sceneImages?.get(sceneNumber);
const result = await storyWriterApi.generateHdVideoScene({
scene_number: sceneNumber,
scene_data: scene,
story_context: storyContext,
all_scenes: scenes,
scene_image_url: sceneImageUrl,
provider: 'huggingface',
model: 'tencent/HunyuanVideo',
num_frames: 50,
guidance_scale: 7.5,
});
setHdVideoPrompts((prev) => {
const newPrompts = new Map(prev);
newPrompts.set(sceneNumber, result.prompt_used);
return newPrompts;
});
setApprovalModal({
open: true,
sceneNumber: sceneNumber,
sceneTitle: scene.title || `Scene ${sceneNumber}`,
videoUrl: result.video_url,
promptUsed: result.prompt_used,
});
} catch (err: any) {
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
// Subscription modal is showing, stop here
setRegeneratingScene(null);
return;
}
}
// Extract error message as string (handle both string and object responses)
let errorMessage: string;
if (err.response?.data?.detail) {
const detail = err.response.data.detail;
if (typeof detail === 'string') {
errorMessage = detail;
} else if (typeof detail === 'object' && detail !== null) {
// Handle object response like {error: "...", message: "..."}
errorMessage = detail.message || detail.error || JSON.stringify(detail);
} else {
errorMessage = String(detail);
}
} else {
errorMessage = err.message || 'Failed to regenerate video';
}
onError(errorMessage);
} finally {
setRegeneratingScene(null);
}
};
return (
<>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
Generate HD Animation with AI
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
Upgrade this storyboard into a highdefinition AI animation using Hugging Face texttovideo models.
Your draft was generated affordably (images + narration). This premium option uses an AI model to render motion.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600 }}>
Recommended models:
</Typography>
<Typography variant="caption" component="div" sx={{ display: 'block', mb: 1 }}>
tencent/HunyuanVideo<br />
Lightricks/LTX-Video<br />
Lightricks/LTX-Video-0.9.8-13B-distilled
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontStyle: 'italic' }}>
This will generate HD videos for each scene one at a time. You'll review and approve each scene before the next one is generated.
</Typography>
</Box>
}
arrow
placement="top"
>
<span style={{ display: 'inline-flex' }}>
<Button
variant="contained"
startIcon={<SmartDisplayIcon />}
onClick={handleGenerateHdVideo}
disabled={isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'}
>
{isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'
? 'Generating HD Animation...'
: 'Generate HD Animation with AI'}
</Button>
</span>
</Tooltip>
{(isGeneratingHdVideo || state.hdVideoGenerationStatus === 'generating' || state.hdVideoGenerationStatus === 'awaiting_approval') && (
<Box sx={{ mt: 2, p: 2, backgroundColor: '#FAF9F6', borderRadius: 1, border: '1px solid #E0DCD4' }}>
<LinearProgress variant="determinate" value={hdVideoProgress} sx={{ mb: 1 }} />
<Typography variant="body2" sx={{ color: '#5D4037', fontWeight: 500, mb: 1 }}>
{hdVideoMessage || 'Generating HD video...'} {hdVideoProgress}%
</Typography>
{state.hdVideoGenerationStatus === 'awaiting_approval' && (
<Typography variant="body2" sx={{ color: '#1976d2', display: 'block', mb: 1, fontWeight: 500 }}>
⏸ Awaiting your approval for Scene {state.currentHdSceneIndex + 1} of {state.outlineScenes?.length || 0}
</Typography>
)}
{state.hdVideoGenerationStatus === 'generating' && (
<Typography variant="body2" sx={{ color: '#5D4037', display: 'block', mb: 1 }}>
Processing Scene {state.currentHdSceneIndex + 1} of {state.outlineScenes?.length || 0}...
</Typography>
)}
{state.sceneHdVideos && state.sceneHdVideos.size > 0 && (
<Typography variant="caption" sx={{ color: '#4caf50', display: 'block', mb: 1, fontWeight: 500 }}>
✓ {state.sceneHdVideos.size} of {state.outlineScenes?.length || 0} scenes approved
</Typography>
)}
{hdVideoPrompts.size > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" sx={{ color: '#1A1611', mb: 1, fontWeight: 600 }}>
Generated Prompts:
</Typography>
{Array.from(hdVideoPrompts.entries())
.sort(([a], [b]) => a - b)
.map(([sceneNum, prompt]) => (
<Box key={sceneNum} sx={{ mb: 2, p: 1.5, backgroundColor: '#fff', borderRadius: 1, border: '1px solid #E0DCD4' }}>
<Typography variant="caption" sx={{ color: '#5D4037', fontWeight: 600, display: 'block', mb: 0.5 }}>
Scene {sceneNum}:
</Typography>
<Typography
variant="caption"
sx={{
color: '#2C2416',
fontFamily: 'monospace',
fontSize: '0.75rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
display: 'block',
}}
>
{prompt.length > 200 ? `${prompt.substring(0, 200)}...` : prompt}
</Typography>
</Box>
))}
</Box>
)}
</Box>
)}
{state.hdVideoGenerationStatus === 'completed' && (
<Alert severity="success" sx={{ mt: 2 }}>
HD video generation complete! {state.sceneHdVideos?.size || 0} of {state.outlineScenes?.length || 0} scenes were approved.
</Alert>
)}
</Box>
{approvalModal && state.outlineScenes && (
<SceneVideoApproval
open={approvalModal.open}
sceneNumber={approvalModal.sceneNumber}
sceneTitle={approvalModal.sceneTitle}
totalScenes={state.outlineScenes.length}
videoUrl={approvalModal.videoUrl}
promptUsed={approvalModal.promptUsed}
onApprove={handleApprove}
onReject={handleReject}
onRegenerate={handleRegenerate}
isRegenerating={regeneratingScene === approvalModal.sceneNumber}
onClose={() => {
if (!isGeneratingHdVideo && !regeneratingScene) {
setApprovalModal(null);
state.setHdVideoGenerationStatus('paused');
}
}}
/>
)}
</>
);
};

View File

@@ -3,52 +3,27 @@ import {
Box,
Paper,
Typography,
Button,
Alert,
Divider,
LinearProgress,
CircularProgress,
Chip,
FormGroup,
FormControlLabel,
Checkbox,
Collapse,
IconButton,
} from '@mui/material';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
import DownloadIcon from '@mui/icons-material/Download';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { triggerSubscriptionError, aiApiClient } from '../../../api/client';
import { AudioSection } from './AudioSection';
import { VideoSection } from './VideoSection';
interface MultimediaSectionProps {
state: ReturnType<typeof useStoryWriterState>;
}
export const MultimediaSection: React.FC<MultimediaSectionProps> = ({ state }) => {
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [audioProgress, setAudioProgress] = useState(0);
const [videoProgress, setVideoProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [selectedScenes, setSelectedScenes] = useState<Set<number>>(new Set());
const [showSceneSelection, setShowSceneSelection] = useState(false);
const [audioBlobUrls, setAudioBlobUrls] = useState<Map<number, string>>(new Map());
const hasScenes = state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0;
const narrationEnabled = state.enableNarration;
const videoEnabled = state.enableVideoNarration;
const hasAudio = narrationEnabled && state.sceneAudio && state.sceneAudio.size > 0;
const hasVideo = videoEnabled && !!state.storyVideo;
const hasImages = state.sceneImages && state.sceneImages.size > 0;
// Initialize selected scenes to all scenes by default
useEffect(() => {
if (!narrationEnabled || !state.outlineScenes) {
if (!state.enableNarration || !state.outlineScenes) {
setSelectedScenes(new Set());
return;
}
@@ -60,264 +35,14 @@ export const MultimediaSection: React.FC<MultimediaSectionProps> = ({ state }) =
);
return allSceneNumbers;
});
}, [narrationEnabled, state.outlineScenes]);
const canGenerateAudio = hasScenes && selectedScenes.size > 0 && !isGeneratingAudio;
const canGenerateVideo = hasScenes && hasImages && hasAudio && !isGeneratingVideo;
const handleSceneSelectionToggle = (sceneNumber: number) => {
setSelectedScenes((prev) => {
const next = new Set(prev);
if (next.has(sceneNumber)) {
next.delete(sceneNumber);
} else {
next.add(sceneNumber);
}
return next;
});
};
const handleSelectAllScenes = () => {
if (hasScenes && state.outlineScenes) {
const allSceneNumbers = new Set(
state.outlineScenes.map((scene: any, index: number) =>
scene.scene_number || index + 1
)
);
setSelectedScenes(allSceneNumbers);
}
};
const handleDeselectAllScenes = () => {
setSelectedScenes(new Set());
};
// Fetch authenticated audio blobs for playback
useEffect(() => {
const sceneAudioMap = state.sceneAudio;
if (!narrationEnabled || !sceneAudioMap || sceneAudioMap.size === 0) {
setAudioBlobUrls((prev) => {
prev.forEach((url) => URL.revokeObjectURL(url));
return new Map();
});
return;
}
let isMounted = true;
const loadAudioBlobs = async () => {
const entries = Array.from(sceneAudioMap.entries());
const blobEntries: Array<[number, string]> = [];
for (const [sceneNumber, audioPath] of entries) {
if (!audioPath) continue;
try {
const normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`;
const response = await aiApiClient.get(normalizedPath, {
responseType: 'blob',
});
const blobUrl = URL.createObjectURL(response.data);
blobEntries.push([sceneNumber, blobUrl]);
} catch (err) {
console.error('Failed to load audio blob:', err);
}
}
if (!isMounted) {
blobEntries.forEach(([, url]) => URL.revokeObjectURL(url));
return;
}
setAudioBlobUrls((prev) => {
prev.forEach((url) => URL.revokeObjectURL(url));
return new Map(blobEntries);
});
};
loadAudioBlobs();
return () => {
isMounted = false;
setAudioBlobUrls((prev) => {
prev.forEach((url) => URL.revokeObjectURL(url));
return new Map();
});
};
}, [state.sceneAudio, narrationEnabled]);
const handleGenerateAudio = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
setError('Please generate a structured outline first');
return;
}
if (!narrationEnabled) {
setError('Narration feature is disabled in Story Setup.');
return;
}
if (selectedScenes.size === 0) {
setError('Please select at least one scene to generate audio for');
return;
}
setIsGeneratingAudio(true);
setError(null);
setAudioProgress(0);
try {
// Filter scenes to only selected ones
const scenesToGenerate = state.outlineScenes.filter((scene: any, index: number) => {
const sceneNumber = scene.scene_number || index + 1;
return selectedScenes.has(sceneNumber);
});
const response = await storyWriterApi.generateSceneAudio({
scenes: scenesToGenerate,
provider: state.audioProvider,
lang: state.audioLang,
slow: state.audioSlow,
rate: state.audioRate,
});
if (response.success && response.audio_files) {
// Store audio URLs by scene number
const audioMap = new Map<number, string>();
response.audio_files.forEach((audio) => {
if (audio.audio_url && !audio.error) {
audioMap.set(audio.scene_number, audio.audio_url);
}
});
state.setSceneAudio(audioMap);
state.setError(null);
setAudioProgress(100);
} else {
throw new Error('Failed to generate audio');
}
} catch (err: any) {
console.error('Audio generation failed:', err);
// Check if this is a subscription error (429/402)
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
setIsGeneratingAudio(false);
return;
}
}
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate audio';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGeneratingAudio(false);
}
};
const handleGenerateVideo = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
setError('Please generate a structured outline first');
return;
}
if (!videoEnabled) {
setError('Story video feature is disabled in Story Setup.');
return;
}
if (!hasImages) {
setError('Please generate images for scenes first');
return;
}
if (!hasAudio) {
setError('Please generate audio for scenes first');
return;
}
setIsGeneratingVideo(true);
setError(null);
setVideoProgress(0);
try {
// Prepare image and audio URLs in scene order
const imageUrls: string[] = [];
const audioUrls: string[] = [];
const scenes = state.outlineScenes;
for (const scene of scenes) {
const sceneNumber = scene.scene_number || scenes.indexOf(scene) + 1;
const imageUrl = state.sceneImages?.get(sceneNumber);
const audioUrl = state.sceneAudio?.get(sceneNumber);
if (imageUrl && audioUrl) {
imageUrls.push(imageUrl);
audioUrls.push(audioUrl);
} else {
throw new Error(`Missing image or audio for scene ${sceneNumber}`);
}
}
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
throw new Error('Number of images and audio files must match number of scenes');
}
setVideoProgress(30);
// Generate video
const response = await storyWriterApi.generateStoryVideo({
scenes: scenes,
image_urls: imageUrls,
audio_urls: audioUrls,
story_title: state.storySetting || 'Story',
fps: state.videoFps,
transition_duration: state.videoTransitionDuration,
});
if (response.success && response.video) {
state.setStoryVideo(response.video.video_url);
state.setError(null);
setVideoProgress(100);
} else {
throw new Error('Failed to generate video');
}
} catch (err: any) {
console.error('Video generation failed:', err);
// Check if this is a subscription error (429/402)
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
setIsGeneratingVideo(false);
return;
}
}
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate video';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGeneratingVideo(false);
}
};
const handleDownloadVideo = () => {
if (state.storyVideo) {
const videoUrl = storyWriterApi.getVideoUrl(state.storyVideo);
const a = document.createElement('a');
a.href = videoUrl;
a.download = `story-video-${Date.now()}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
};
}, [state.enableNarration, state.outlineScenes]);
if (!hasScenes) {
return null; // Don't show if no scenes available
}
return (
<>
<Paper
sx={{
p: 3,
@@ -340,239 +65,22 @@ export const MultimediaSection: React.FC<MultimediaSectionProps> = ({ state }) =
)}
{/* Audio Section */}
{narrationEnabled ? (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VolumeUpIcon sx={{ color: hasAudio ? '#4caf50' : '#5D4037' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Audio Narration
</Typography>
{hasAudio && (
<Chip
icon={<CheckCircleIcon />}
label="Generated"
size="small"
color="success"
sx={{ ml: 1 }}
/>
)}
</Box>
<Button
variant={hasAudio ? 'outlined' : 'contained'}
startIcon={isGeneratingAudio ? <CircularProgress size={16} /> : <VolumeUpIcon />}
onClick={handleGenerateAudio}
disabled={!canGenerateAudio || isGeneratingAudio}
>
{hasAudio
? 'Regenerate Selected'
: `Generate Audio (${selectedScenes.size} scene${selectedScenes.size !== 1 ? 's' : ''})`}
</Button>
</Box>
{hasScenes && state.outlineScenes && (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ color: '#5D4037', fontWeight: 500 }}>
Select scenes to generate audio for:
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="text"
onClick={handleSelectAllScenes}
sx={{ minWidth: 'auto', px: 1, fontSize: '0.75rem' }}
>
Select All
</Button>
<Button
size="small"
variant="text"
onClick={handleDeselectAllScenes}
sx={{ minWidth: 'auto', px: 1, fontSize: '0.75rem' }}
>
Deselect All
</Button>
<IconButton
size="small"
onClick={() => setShowSceneSelection(!showSceneSelection)}
sx={{ p: 0.5 }}
>
{showSceneSelection ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
</Box>
<Collapse in={showSceneSelection}>
<FormGroup sx={{ pl: 1 }}>
{state.outlineScenes.map((scene: any, index: number) => {
const sceneNumber = scene.scene_number || index + 1;
const hasAudioForScene = state.sceneAudio?.has(sceneNumber);
return (
<FormControlLabel
key={sceneNumber}
control={
<Checkbox
checked={selectedScenes.has(sceneNumber)}
onChange={() => handleSceneSelectionToggle(sceneNumber)}
size="small"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">
Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`}
</Typography>
{hasAudioForScene && (
<CheckCircleIcon sx={{ fontSize: 16, color: '#4caf50' }} />
)}
</Box>
}
/>
);
})}
</FormGroup>
</Collapse>
</Box>
)}
{isGeneratingAudio && (
<Box sx={{ mt: 1 }}>
<LinearProgress variant="indeterminate" />
<Typography variant="caption" sx={{ mt: 0.5, color: '#5D4037', display: 'block' }}>
Generating audio for {selectedScenes.size} selected scene
{selectedScenes.size !== 1 ? 's' : ''}...
</Typography>
</Box>
)}
{hasAudio && state.sceneAudio && state.outlineScenes && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: '#5D4037', fontSize: '0.875rem', mb: 2 }}>
Audio narration generated for {state.sceneAudio.size} scene(s). Listen to audio for each scene:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{state.outlineScenes.map((scene: any, index: number) => {
const sceneNumber = scene.scene_number || index + 1;
const audioUrl = state.sceneAudio?.get(sceneNumber);
if (!audioUrl) return null;
const blobUrl = audioBlobUrls.get(sceneNumber);
return (
<Box
key={sceneNumber}
sx={{
p: 2,
backgroundColor: '#FFFFFF',
borderRadius: '8px',
border: '1px solid rgba(120, 90, 60, 0.2)',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#1A1611' }}>
Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`}
</Typography>
<audio
controls
src={blobUrl ? blobUrl : storyWriterApi.getAudioUrl(audioUrl)}
style={{ width: '100%' }}
>
Your browser does not support the audio element.
</audio>
</Box>
);
})}
</Box>
</Box>
)}
</Box>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
Narration is disabled in Story Setup. Enable it to generate or listen to audio narration.
</Alert>
)}
<AudioSection
state={state}
selectedScenes={selectedScenes}
onSelectedScenesChange={setSelectedScenes}
showSceneSelection={showSceneSelection}
onShowSceneSelectionChange={setShowSceneSelection}
error={error}
onError={setError}
/>
<Divider sx={{ my: 3 }} />
{/* Video Section */}
{videoEnabled ? (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VideoLibraryIcon sx={{ color: hasVideo ? '#4caf50' : '#5D4037' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Story Video
</Typography>
{hasVideo && (
<Chip
icon={<CheckCircleIcon />}
label="Generated"
size="small"
color="success"
sx={{ ml: 1 }}
/>
)}
{!hasVideo && !hasImages && (
<Chip label="Images required" size="small" color="warning" sx={{ ml: 1 }} />
)}
{!hasVideo && hasImages && !hasAudio && (
<Chip label="Audio required" size="small" color="warning" sx={{ ml: 1 }} />
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{hasVideo && (
<Button variant="outlined" startIcon={<DownloadIcon />} onClick={handleDownloadVideo}>
Download
</Button>
)}
<Button
variant={hasVideo ? 'outlined' : 'contained'}
startIcon={isGeneratingVideo ? <CircularProgress size={16} /> : <VideoLibraryIcon />}
onClick={handleGenerateVideo}
disabled={!canGenerateVideo || isGeneratingVideo}
>
{hasVideo ? 'Regenerate Video' : 'Generate Video'}
</Button>
</Box>
</Box>
{isGeneratingVideo && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant={videoProgress > 0 ? 'determinate' : 'indeterminate'}
value={videoProgress}
/>
<Typography variant="caption" sx={{ mt: 0.5, color: '#5D4037', display: 'block' }}>
Generating video... This may take a few minutes.
</Typography>
</Box>
)}
{hasVideo && state.storyVideo && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: '#5D4037', mb: 1, fontSize: '0.875rem' }}>
Video ready! Preview and download below.
</Typography>
<Box
component="video"
controls
src={storyWriterApi.getVideoUrl(state.storyVideo)}
sx={{
width: '100%',
maxWidth: '600px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
Your browser does not support the video tag.
</Box>
</Box>
)}
</Box>
) : (
<Alert severity="info">
Story video generation is disabled in Story Setup. Enable it to create narrative videos.
</Alert>
)}
<VideoSection state={state} error={error} onError={setError} />
</Paper>
</>
);
};

View File

@@ -0,0 +1,119 @@
import React from 'react';
import {
Box,
Typography,
Button,
FormGroup,
FormControlLabel,
Checkbox,
Collapse,
IconButton,
} from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
interface SceneSelectionProps {
scenes: any[];
selectedScenes: Set<number>;
onSelectedScenesChange: (scenes: Set<number>) => void;
sceneAudioMap?: Map<number, string> | null;
showSceneSelection: boolean;
onShowSceneSelectionChange: (show: boolean) => void;
}
export const SceneSelection: React.FC<SceneSelectionProps> = ({
scenes,
selectedScenes,
onSelectedScenesChange,
sceneAudioMap,
showSceneSelection,
onShowSceneSelectionChange,
}) => {
const handleSceneSelectionToggle = (sceneNumber: number) => {
const next = new Set(selectedScenes);
if (next.has(sceneNumber)) {
next.delete(sceneNumber);
} else {
next.add(sceneNumber);
}
onSelectedScenesChange(next);
};
const handleSelectAllScenes = () => {
const allSceneNumbers = new Set(
scenes.map((scene: any, index: number) => scene.scene_number || index + 1)
);
onSelectedScenesChange(allSceneNumbers);
};
const handleDeselectAllScenes = () => {
onSelectedScenesChange(new Set());
};
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ color: '#5D4037', fontWeight: 500 }}>
Select scenes to generate audio for:
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="text"
onClick={handleSelectAllScenes}
sx={{ minWidth: 'auto', px: 1, fontSize: '0.75rem' }}
>
Select All
</Button>
<Button
size="small"
variant="text"
onClick={handleDeselectAllScenes}
sx={{ minWidth: 'auto', px: 1, fontSize: '0.75rem' }}
>
Deselect All
</Button>
<IconButton
size="small"
onClick={() => onShowSceneSelectionChange(!showSceneSelection)}
sx={{ p: 0.5 }}
>
{showSceneSelection ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
</Box>
<Collapse in={showSceneSelection}>
<FormGroup sx={{ pl: 1 }}>
{scenes.map((scene: any, index: number) => {
const sceneNumber = scene.scene_number || index + 1;
const hasAudioForScene = sceneAudioMap?.has(sceneNumber);
return (
<FormControlLabel
key={sceneNumber}
control={
<Checkbox
checked={selectedScenes.has(sceneNumber)}
onChange={() => handleSceneSelectionToggle(sceneNumber)}
size="small"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">
Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`}
</Typography>
{hasAudioForScene && (
<CheckCircleIcon sx={{ fontSize: 16, color: '#4caf50' }} />
)}
</Box>
}
/>
);
})}
</FormGroup>
</Collapse>
</Box>
);
};

View File

@@ -0,0 +1,216 @@
import React, { useState, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
CircularProgress,
Paper,
} from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import RefreshIcon from '@mui/icons-material/Refresh';
import { fetchMediaBlobUrl } from '../../../utils/fetchMediaBlobUrl';
interface SceneVideoApprovalProps {
open: boolean;
sceneNumber: number;
sceneTitle: string;
totalScenes: number;
videoUrl: string;
promptUsed: string;
onApprove: () => void;
onReject: () => void;
onRegenerate: () => void;
isRegenerating?: boolean;
onClose?: () => void;
}
const SceneVideoApproval: React.FC<SceneVideoApprovalProps> = ({
open,
sceneNumber,
sceneTitle,
totalScenes,
videoUrl,
promptUsed,
onApprove,
onReject,
onRegenerate,
isRegenerating = false,
onClose,
}) => {
const [videoBlobUrl, setVideoBlobUrl] = useState<string | null>(null);
const [loadingVideo, setLoadingVideo] = useState(true);
const videoRef = useRef<HTMLVideoElement | null>(null);
// Load video when modal opens
React.useEffect(() => {
if (open && videoUrl) {
setLoadingVideo(true);
fetchMediaBlobUrl(videoUrl)
.then((blobUrl) => {
setVideoBlobUrl(blobUrl);
setLoadingVideo(false);
})
.catch((err) => {
console.error('Failed to load video:', err);
setLoadingVideo(false);
});
}
// Cleanup blob URL when modal closes
return () => {
if (videoBlobUrl) {
URL.revokeObjectURL(videoBlobUrl);
setVideoBlobUrl(null);
}
};
}, [open, videoUrl]);
const handleClose = () => {
if (onClose && !isRegenerating) {
onClose();
}
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
backgroundColor: '#fff',
borderRadius: 2,
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
border: '1px solid rgba(0,0,0,0.06)',
},
}}
>
<DialogTitle sx={{ color: '#1A1611', pb: 1 }}>
<Typography variant="h6" component="div">
Scene {sceneNumber} of {totalScenes}: {sceneTitle}
</Typography>
<Typography variant="caption" sx={{ color: '#5D4037', mt: 0.5, display: 'block' }}>
Review the generated HD video and choose an action
</Typography>
</DialogTitle>
<DialogContent dividers sx={{ color: '#2C2416' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Video Player */}
<Box
sx={{
position: 'relative',
width: '100%',
backgroundColor: '#000',
borderRadius: 1,
overflow: 'hidden',
minHeight: '300px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{loadingVideo ? (
<CircularProgress sx={{ color: '#fff' }} />
) : videoBlobUrl ? (
<video
ref={videoRef}
controls
src={videoBlobUrl}
style={{
width: '100%',
height: 'auto',
maxHeight: '500px',
}}
>
Your browser does not support the video element.
</video>
) : (
<Typography sx={{ color: '#fff' }}>Failed to load video</Typography>
)}
</Box>
{/* Prompt Used */}
{promptUsed && (
<Paper
elevation={0}
sx={{
p: 2,
backgroundColor: '#FAF9F6',
borderRadius: 1,
border: '1px solid #E0DCD4',
}}
>
<Typography variant="subtitle2" sx={{ color: '#1A1611', mb: 1, fontWeight: 600 }}>
Generated Prompt (for transparency):
</Typography>
<Typography
variant="body2"
sx={{
color: '#2C2416',
fontFamily: 'monospace',
fontSize: '0.85rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{promptUsed}
</Typography>
</Paper>
)}
{isRegenerating && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2, backgroundColor: '#FFF3E0', borderRadius: 1 }}>
<CircularProgress size={20} />
<Typography variant="body2" sx={{ color: '#5D4037' }}>
Regenerating video for this scene...
</Typography>
</Box>
)}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, gap: 1 }}>
<Button
onClick={onReject}
disabled={isRegenerating}
startIcon={<CancelIcon />}
sx={{ color: '#5D4037' }}
>
Reject & Skip
</Button>
<Button
onClick={onRegenerate}
disabled={isRegenerating}
startIcon={isRegenerating ? <CircularProgress size={16} /> : <RefreshIcon />}
sx={{ color: '#5D4037' }}
>
{isRegenerating ? 'Regenerating...' : 'Regenerate This Scene'}
</Button>
<Button
variant="contained"
onClick={onApprove}
disabled={isRegenerating || loadingVideo || !videoBlobUrl}
startIcon={<CheckCircleIcon />}
sx={{
backgroundColor: '#5D4037',
'&:hover': {
backgroundColor: '#4E342E',
},
}}
>
Approve & Continue
</Button>
</DialogActions>
</Dialog>
);
};
export default SceneVideoApproval;

View File

@@ -0,0 +1,260 @@
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Typography,
Button,
LinearProgress,
CircularProgress,
Chip,
Alert,
} from '@mui/material';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
import DownloadIcon from '@mui/icons-material/Download';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../api/client';
import { fetchMediaBlobUrl } from '../../../utils/fetchMediaBlobUrl';
import { HdVideoSection } from './HdVideoSection';
interface VideoSectionProps {
state: ReturnType<typeof useStoryWriterState>;
error: string | null;
onError: (error: string | null) => void;
}
export const VideoSection: React.FC<VideoSectionProps> = ({ state, error, onError }) => {
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [videoProgress, setVideoProgress] = useState(0);
const [videoMessage, setVideoMessage] = useState<string>('');
const [videoBlobUrl, setVideoBlobUrl] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const hasScenes = state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0;
const videoEnabled = state.enableVideoNarration;
const hasVideo = videoEnabled && !!state.storyVideo;
const hasImages = state.sceneImages && state.sceneImages.size > 0;
const hasAudio = state.enableNarration && state.sceneAudio && state.sceneAudio.size > 0;
const canGenerateVideo = hasScenes && hasImages && hasAudio && !isGeneratingVideo;
// Load video blob URL when storyVideo changes
useEffect(() => {
if (state.storyVideo) {
fetchMediaBlobUrl(state.storyVideo).then(setVideoBlobUrl);
} else {
if (videoBlobUrl) {
URL.revokeObjectURL(videoBlobUrl);
setVideoBlobUrl(null);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.storyVideo]);
const handleGenerateVideo = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
onError('Please generate a structured outline first');
return;
}
if (!videoEnabled) {
onError('Story video feature is disabled in Story Setup.');
return;
}
if (!hasImages) {
onError('Please generate images for scenes first');
return;
}
if (!hasAudio) {
onError('Please generate audio for scenes first');
return;
}
setIsGeneratingVideo(true);
onError(null);
setVideoProgress(0);
setVideoMessage('');
try {
const imageUrls: string[] = [];
const audioUrls: string[] = [];
const scenes = state.outlineScenes;
for (const scene of scenes) {
const sceneNumber = scene.scene_number || scenes.indexOf(scene) + 1;
const imageUrl = state.sceneImages?.get(sceneNumber);
const audioUrl = state.sceneAudio?.get(sceneNumber);
if (imageUrl && audioUrl) {
imageUrls.push(imageUrl);
audioUrls.push(audioUrl);
} else {
throw new Error(`Missing image or audio for scene ${sceneNumber}`);
}
}
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
throw new Error('Number of images and audio files must match number of scenes');
}
const start = await storyWriterApi.generateStoryVideoAsync({
scenes: scenes,
image_urls: imageUrls,
audio_urls: audioUrls,
story_title: state.storySetting || 'Story',
fps: state.videoFps,
transition_duration: state.videoTransitionDuration,
});
setVideoMessage(start.message || 'Starting video generation...');
const taskId = start.task_id;
let done = false;
while (!done) {
await new Promise((r) => setTimeout(r, 1200));
const status = await storyWriterApi.getTaskStatus(taskId);
setVideoProgress(Math.round(status.progress ?? 0));
if (status.message) setVideoMessage(status.message);
if (status.status === 'completed') {
done = true;
const result = await storyWriterApi.getTaskResult(taskId);
const video = (result as any).video || (result as any)?.result?.video;
const finalUrl: string | undefined = video?.video_url;
if (!finalUrl) throw new Error('Video URL not found in result');
state.setStoryVideo(finalUrl);
const blobUrl = await fetchMediaBlobUrl(finalUrl);
setVideoBlobUrl(blobUrl);
setVideoProgress(100);
setVideoMessage('Video generation complete');
state.setError(null);
setTimeout(() => {
const v = videoRef.current;
if (v) {
try { v.play().catch(() => {}); } catch {}
try { if (v.requestFullscreen) v.requestFullscreen(); } catch {}
}
}, 300);
} else if (status.status === 'failed') {
throw new Error(status.error || 'Video generation failed');
}
}
} catch (err: any) {
console.error('Video generation failed:', err);
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
setIsGeneratingVideo(false);
return;
}
}
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate video';
onError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGeneratingVideo(false);
}
};
const handleDownloadVideo = async () => {
if (state.storyVideo) {
const blobUrl = await fetchMediaBlobUrl(state.storyVideo);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `story-video-${Date.now()}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}
};
if (!videoEnabled) {
return (
<Alert severity="info">
Story video generation is disabled in Story Setup. Enable it to create narrative videos.
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VideoLibraryIcon sx={{ color: hasVideo ? '#4caf50' : '#5D4037' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Story Video
</Typography>
{hasVideo && (
<Chip
icon={<CheckCircleIcon />}
label="Generated"
size="small"
color="success"
sx={{ ml: 1 }}
/>
)}
{!hasVideo && !hasImages && (
<Chip label="Images required" size="small" color="warning" sx={{ ml: 1 }} />
)}
{!hasVideo && hasImages && !hasAudio && (
<Chip label="Audio required" size="small" color="warning" sx={{ ml: 1 }} />
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{hasVideo && (
<Button variant="outlined" startIcon={<DownloadIcon />} onClick={handleDownloadVideo}>
Download
</Button>
)}
<Button
variant={hasVideo ? 'outlined' : 'contained'}
startIcon={isGeneratingVideo ? <CircularProgress size={16} /> : <VideoLibraryIcon />}
onClick={handleGenerateVideo}
disabled={!canGenerateVideo || isGeneratingVideo}
>
{hasVideo ? 'Regenerate Video' : 'Generate Video'}
</Button>
</Box>
</Box>
{isGeneratingVideo && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant={videoProgress > 0 ? 'determinate' : 'indeterminate'}
value={videoProgress}
/>
<Typography variant="caption" sx={{ mt: 0.5, color: '#5D4037', display: 'block' }}>
{videoMessage || 'Generating video... This may take a few minutes.'}
</Typography>
</Box>
)}
{hasVideo && state.storyVideo && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: '#5D4037', mb: 1, fontSize: '0.875rem' }}>
Video ready! Preview and download below.
</Typography>
<Box sx={{ mt: 0 }}>
<video
ref={videoRef}
controls
src={videoBlobUrl ?? undefined}
style={{
width: '100%',
maxWidth: '600px',
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
Your browser does not support the video tag.
</video>
<HdVideoSection state={state} error={error} onError={onError} />
</Box>
</Box>
)}
</Box>
);
};

View File

@@ -121,6 +121,13 @@ const API_CATEGORIES = {
models: ['stable-diffusion-xl', 'stable-diffusion-3'],
pricing: '$0.04 per image generated',
use_cases: ['Image generation', 'Art creation', 'Visual content']
},
{
name: 'Image Editing',
description: 'AI-powered image editing using natural language prompts',
models: ['Qwen/Qwen-Image-Edit', 'FLUX.1-Kontext-dev'],
pricing: '$0.04 per image edited',
use_cases: ['Image editing', 'Photo manipulation', 'Natural language editing']
}
]
},
@@ -160,7 +167,7 @@ const ComprehensiveAPIBreakdown: React.FC<ComprehensiveAPIBreakdownProps> = ({
if (['firecrawl'].includes(provider)) {
return 'content_processing';
}
if (['stability'].includes(provider)) {
if (['stability', 'image_edit'].includes(provider)) {
return 'image_generation';
}
return 'llm_models'; // default