455 lines
17 KiB
TypeScript
455 lines
17 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Alert,
|
|
LinearProgress,
|
|
TextField,
|
|
MenuItem,
|
|
} 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 { OperationButton } from '../../shared/OperationButton';
|
|
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),
|
|
};
|
|
|
|
const VIDEO_MODEL_OPTIONS = [
|
|
{
|
|
id: 'huggingface:tencent/HunyuanVideo',
|
|
provider: 'huggingface',
|
|
model: 'tencent/HunyuanVideo',
|
|
label: 'HuggingFace · HunyuanVideo',
|
|
},
|
|
{
|
|
id: 'wavespeed:hunyuan-video-1.5',
|
|
provider: 'wavespeed',
|
|
model: 'hunyuan-video-1.5',
|
|
label: 'WaveSpeed · HunyuanVideo-1.5',
|
|
},
|
|
];
|
|
|
|
const getDefaultVideoOptionId = (storyMode: 'marketing' | 'pure') =>
|
|
storyMode === 'marketing' ? 'huggingface:tencent/HunyuanVideo' : 'wavespeed:hunyuan-video-1.5';
|
|
|
|
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 [selectedVideoOptionId, setSelectedVideoOptionId] = useState<string>(
|
|
getDefaultVideoOptionId(state.storyMode),
|
|
);
|
|
|
|
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 selectedVideoOption =
|
|
VIDEO_MODEL_OPTIONS.find((option) => option.id === selectedVideoOptionId) ||
|
|
VIDEO_MODEL_OPTIONS[0];
|
|
|
|
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 result = await storyWriterApi.generateHdVideoScene({
|
|
scene_number: sceneNumber,
|
|
scene_data: scene,
|
|
story_context: storyContext,
|
|
all_scenes: scenes,
|
|
provider: selectedVideoOption.provider,
|
|
model: selectedVideoOption.model,
|
|
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 result = await storyWriterApi.generateHdVideoScene({
|
|
scene_number: sceneNumber,
|
|
scene_data: scene,
|
|
story_context: storyContext,
|
|
all_scenes: scenes,
|
|
provider: selectedVideoOption.provider,
|
|
model: selectedVideoOption.model,
|
|
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 }}>
|
|
<TextField
|
|
select
|
|
label="HD Video Model"
|
|
value={selectedVideoOptionId}
|
|
onChange={(e) => setSelectedVideoOptionId(e.target.value)}
|
|
size="small"
|
|
sx={{ maxWidth: 320 }}
|
|
disabled={
|
|
isGeneratingHdVideo ||
|
|
state.hdVideoGenerationStatus === 'awaiting_approval' ||
|
|
state.hdVideoGenerationStatus === 'generating'
|
|
}
|
|
>
|
|
{VIDEO_MODEL_OPTIONS.map((option) => (
|
|
<MenuItem key={option.id} value={option.id}>
|
|
{option.label}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
<OperationButton
|
|
operation={{
|
|
provider: 'video',
|
|
model: selectedVideoOption.model,
|
|
tokens_requested: 0,
|
|
operation_type: 'video_generation',
|
|
actual_provider_name: selectedVideoOption.provider,
|
|
}}
|
|
label="Generate HD Animation with AI"
|
|
variant="contained"
|
|
startIcon={<SmartDisplayIcon />}
|
|
showCost={true}
|
|
checkOnHover={true}
|
|
checkOnMount={false}
|
|
onClick={handleGenerateHdVideo}
|
|
disabled={isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'}
|
|
loading={isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'}
|
|
tooltipPlacement="top"
|
|
buttonProps={{
|
|
children: isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'
|
|
? 'Generating HD Animation...'
|
|
: undefined,
|
|
}}
|
|
/>
|
|
|
|
{(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');
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|