AI Video Generation Implementation
This commit is contained in:
@@ -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 high‑definition AI animation using Hugging Face text‑to‑video 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');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
177
frontend/src/components/StoryWriter/components/AudioSection.tsx
Normal file
177
frontend/src/components/StoryWriter/components/AudioSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 high‑definition AI animation using Hugging Face text‑to‑video 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');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
260
frontend/src/components/StoryWriter/components/VideoSection.tsx
Normal file
260
frontend/src/components/StoryWriter/components/VideoSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user