AI story writer enhancements, text to video and voice generation, subscription management, and more.

This commit is contained in:
ajaysi
2025-11-19 09:55:32 +05:30
parent bf7493c366
commit e96525347b
64 changed files with 10367 additions and 400 deletions

View File

@@ -95,25 +95,38 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
try {
// Prepare image and audio URLs in scene order
const imageUrls: string[] = [];
const imageUrls: (string | null)[] = [];
const audioUrls: string[] = [];
const scenes = state.outlineScenes;
const videoUrls: (string | null)[] = [];
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);
const animatedVideoUrl = state.sceneAnimatedVideos?.get(sceneNumber);
if (imageUrl && audioUrl) {
imageUrls.push(imageUrl);
audioUrls.push(audioUrl);
} else {
throw new Error(`Missing image or audio for scene ${sceneNumber}`);
if (!audioUrl) {
throw new Error(`Missing audio for scene ${sceneNumber}`);
}
// Prefer animated video if available, otherwise use image
if (animatedVideoUrl) {
videoUrls.push(animatedVideoUrl);
imageUrls.push(null);
} else if (imageUrl) {
videoUrls.push(null);
imageUrls.push(imageUrl);
} else {
throw new Error(`Missing image or animated video for scene ${sceneNumber}`);
}
audioUrls.push(audioUrl);
}
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
throw new Error('Number of images and audio files must match number of scenes');
throw new Error('Number of images/videos and audio files must match number of scenes');
}
// Start async video generation
@@ -121,6 +134,8 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
scenes: scenes,
image_urls: imageUrls,
audio_urls: audioUrls,
video_urls: videoUrls.length > 0 ? videoUrls : undefined,
ai_audio_urls: undefined, // TODO: Track AI audio separately in state
story_title: state.storySetting || 'Story',
fps: state.videoFps,
transition_duration: state.videoTransitionDuration,
@@ -147,7 +162,11 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
state.setStoryVideo(videoUrl);
// fetch blob for authenticated preview
const blobUrl = await fetchMediaBlobUrl(videoUrl);
setVideoBlobUrl(blobUrl);
if (blobUrl) {
setVideoBlobUrl(blobUrl);
} else {
setVideoBlobUrl(null);
}
setVideoProgress(100);
setVideoMessage('Video generation complete');
state.setError(null);
@@ -175,6 +194,9 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
const handleDownloadVideo = async () => {
if (state.storyVideo) {
const blobUrl = await fetchMediaBlobUrl(state.storyVideo);
if (!blobUrl) {
return;
}
const a = document.createElement('a');
a.href = blobUrl;
a.download = `story-video-${Date.now()}.mp4`;

View File

@@ -14,9 +14,9 @@ import GlobalStyles from '@mui/material/GlobalStyles';
import ImageIcon from '@mui/icons-material/Image';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import { motion, AnimatePresence } from 'framer-motion';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { useStoryWriterState, SceneAnimationResume } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { aiApiClient } from '../../../api/client';
import { aiApiClient, triggerSubscriptionError } from '../../../api/client';
import OutlineHoverActions from './StoryOutlineParts/OutlineHoverActions';
import EditSectionModal from './StoryOutlineParts/EditSectionModal';
import { leftPageVariants, rightPageVariants } from './StoryOutlineParts/pageVariants';
@@ -48,7 +48,9 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
const [imageLoadError, setImageLoadError] = useState<Set<number>>(new Set());
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
const [audioBlobUrls, setAudioBlobUrls] = useState<Map<number, string>>(new Map());
const [videoBlobUrls, setVideoBlobUrls] = useState<Map<number, string>>(new Map());
const [audioLoadError, setAudioLoadError] = useState<Set<number>>(new Set());
const [hasVideoLoadError, setVideoLoadError] = useState<Set<number>>(new Set());
const [outlineToastOpen, setOutlineToastOpen] = useState(false);
const lastToastSceneCount = useRef<number | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
@@ -66,15 +68,182 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
const [isKeyEventsModalOpen, setIsKeyEventsModalOpen] = useState(false);
const [isTitleModalOpen, setIsTitleModalOpen] = useState(false);
const [titleDraft, setTitleDraft] = useState('');
const [animatingSceneNumber, setAnimatingSceneNumber] = useState<number | null>(null);
// Use state from hook instead of local state
const sceneImages = state.sceneImages || new Map<number, string>();
const sceneAudio = state.sceneAudio || new Map<number, string>();
const sceneAnimatedVideos = state.sceneAnimatedVideos || new Map<number, string>();
const sceneAnimationResumables = state.sceneAnimationResumables || new Map<number, SceneAnimationResume>();
const updateSceneAnimatedVideo = (sceneNumber: number, videoUrl: string) => {
const nextMap = new Map(state.sceneAnimatedVideos || []);
nextMap.set(sceneNumber, videoUrl);
state.setSceneAnimatedVideos(nextMap);
// Clear the blob URL for this scene so it reloads with the new video
setVideoBlobUrls((prev) => {
const next = new Map(prev);
const oldBlobUrl = next.get(sceneNumber);
if (oldBlobUrl) {
URL.revokeObjectURL(oldBlobUrl);
}
next.delete(sceneNumber);
return next;
});
// Clear any error state for this scene
setVideoLoadError((prev) => {
const next = new Set(prev);
next.delete(sceneNumber);
return next;
});
};
const handleAnimateSceneWithVoiceover = async () => {
if (!hasScenes || !currentScene) {
setError('Please generate your outline before animating scenes.');
return;
}
const sceneNumber = currentScene.scene_number || currentSceneIndex + 1;
const sceneImageRelativeUrl = state.sceneImages?.get(sceneNumber);
const sceneAudioRelativeUrl = state.sceneAudio?.get(sceneNumber);
if (!sceneImageRelativeUrl) {
setError('Please generate an image for this scene before animating it.');
return;
}
if (!sceneAudioRelativeUrl) {
setError('Please generate narration audio for this scene before animating with voiceover.');
return;
}
setAnimatingSceneNumber(sceneNumber);
setError(null);
updateSceneAnimationResume(sceneNumber, undefined);
const storyContextPayload = createStoryContextPayload();
try {
console.info('[Outline] Animate scene with voiceover requested', {
sceneNumber,
image: sceneImageRelativeUrl,
audio: sceneAudioRelativeUrl,
});
// Start async task
const startResponse = await storyWriterApi.animateSceneVoiceover({
scene_number: sceneNumber,
scene_data: currentScene,
story_context: storyContextPayload,
image_url: sceneImageRelativeUrl,
audio_url: sceneAudioRelativeUrl,
resolution: '720p',
});
// Poll for completion (InfiniteTalk can take up to 10 minutes)
const taskId = startResponse.task_id;
let done = false;
while (!done) {
await new Promise((r) => setTimeout(r, 2000)); // Poll every 2 seconds
const status = await storyWriterApi.getTaskStatus(taskId);
if (status.status === 'completed') {
done = true;
const result = await storyWriterApi.getTaskResult(taskId);
// Extract AnimateSceneResponse from result
// The result can be either the AnimateSceneResponse directly or wrapped in a result field
const animationResult = (result as any).result || result;
const videoUrl = animationResult.video_url;
const cost = animationResult.cost || 0;
if (videoUrl) {
updateSceneAnimatedVideo(sceneNumber, videoUrl);
console.info('[Outline] Animate with voiceover completed', {
sceneNumber,
video: videoUrl,
cost: cost,
});
} else {
throw new Error('Video URL not found in result');
}
} else if (status.status === 'failed') {
throw new Error(status.error || 'InfiniteTalk animation failed');
}
// Continue polling if status is 'pending' or 'processing'
}
} catch (err: any) {
const detail = err?.response?.data?.detail;
const handled = await triggerSubscriptionError(err);
const message = extractDetailMessage(detail, err.message || 'Failed to animate scene with voiceover.');
setError(message);
if (!handled) {
console.error('[Outline] Animate scene with voiceover failed', err);
}
} finally {
setAnimatingSceneNumber(null);
}
};
const updateSceneAnimationResume = (sceneNumber: number, info?: SceneAnimationResume) => {
const prevMap = state.sceneAnimationResumables || new Map<number, SceneAnimationResume>();
const nextMap = new Map(prevMap);
if (info) {
nextMap.set(sceneNumber, info);
} else {
nextMap.delete(sceneNumber);
}
state.setSceneAnimationResumables(nextMap.size > 0 ? nextMap : null);
};
const extractDetailMessage = (detail: any, fallback: string): string => {
if (!detail) return fallback;
if (typeof detail === 'string') return detail;
if (typeof detail === 'object') {
if (typeof detail.message === 'string') return detail.message;
if (typeof detail.error === 'string') return detail.error;
if (typeof detail.detail === 'string') return detail.detail;
}
return fallback;
};
const captureResumeOpportunity = (
sceneNumber: number,
duration: 5 | 10,
detail: any
): string | null => {
if (!detail || typeof detail !== 'object') {
return null;
}
if (!detail.resume_available || !detail.prediction_id) {
return null;
}
const message =
typeof detail.message === 'string'
? detail.message
: typeof detail.error === 'string'
? detail.error
: 'WaveSpeed is still finalizing this animation. Click Resume to download without extra cost.';
updateSceneAnimationResume(sceneNumber, {
predictionId: detail.prediction_id,
duration,
message,
createdAt: new Date().toISOString(),
});
return message;
};
const scenes = state.outlineScenes || [];
const sceneCount = scenes.length;
const hasScenes = state.isOutlineStructured && scenes.length > 0;
const hasOutlineScenes = Boolean(state.outlineScenes && state.outlineScenes.length > 0);
const resumableScenesArray = Array.from(sceneAnimationResumables.entries());
const resumableSummaryMessage =
resumableScenesArray.length === 0
? null
: resumableScenesArray.length === 1
? resumableScenesArray[0][1]?.message ||
`Scene ${resumableScenesArray[0][0]} animation is ready to resume without extra cost.`
: `Scenes ${resumableScenesArray.map(([scene]) => scene).join(', ')} have WaveSpeed animations ready to resume without extra cost. Open each scene and click Resume Animation.`;
// removed old accordion renderer (unused)
@@ -98,10 +267,14 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
// Get the current scene's image URL
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
const currentSceneResumeInfo = sceneAnimationResumables.get(currentSceneNumber) || null;
const canAnimateCurrentScene = !animatingSceneNumber && !currentSceneResumeInfo;
const isCurrentSceneAnimating = animatingSceneNumber === currentSceneNumber;
const currentSceneImageUrl = sceneImages.get(currentSceneNumber);
const hasImageLoadError = imageLoadError.has(currentSceneNumber);
const currentSceneAudioUrl = sceneAudio.get(currentSceneNumber);
const hasAudioLoadError = audioLoadError.has(currentSceneNumber);
const hasAudioForScene = Boolean(currentSceneAudioUrl);
// Fetch image as blob with authentication
useEffect(() => {
@@ -128,8 +301,12 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
next.set(currentSceneNumber, blobUrl);
return next;
});
} catch (err) {
console.error('Failed to load image:', err);
} catch (err: any) {
// Only log non-404 errors (404 means file doesn't exist, which is acceptable)
if (err?.response?.status !== 404) {
console.error('Failed to load image:', err);
}
// Mark as error to prevent retries
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
}
};
@@ -137,6 +314,47 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
loadImage();
}, [currentSceneNumber, currentSceneImageUrl, hasImageLoadError]);
// Fetch video as blob with authentication
useEffect(() => {
const animatedVideoRelativeUrl = sceneAnimatedVideos.get(currentSceneNumber);
if (!animatedVideoRelativeUrl || hasVideoLoadError.has(currentSceneNumber) || videoBlobUrls.has(currentSceneNumber)) {
return;
}
const loadVideo = async () => {
try {
// Remove query parameters (token) from URL if present, we'll use authenticated request instead
const cleanUrl = animatedVideoRelativeUrl.split('?')[0];
// Use relative URL path directly (aiApiClient will add base URL and auth)
const videoUrl = cleanUrl.startsWith('/')
? cleanUrl
: `/${cleanUrl}`;
// Use aiApiClient to get authenticated response with blob
const response = await aiApiClient.get(videoUrl, {
responseType: 'blob',
});
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
setVideoBlobUrls((prev) => {
const next = new Map(prev);
next.set(currentSceneNumber, blobUrl);
return next;
});
} catch (err: any) {
// Only log non-404 errors (404 means file doesn't exist, which is acceptable)
if (err?.response?.status !== 404) {
console.error('Failed to load video:', err);
}
// Mark as error to prevent retries
setVideoLoadError((prev) => new Set(prev).add(currentSceneNumber));
}
};
loadVideo();
}, [currentSceneNumber, sceneAnimatedVideos, hasVideoLoadError, videoBlobUrls]);
// Cleanup blob URLs when component unmounts or scenes change
useEffect(() => {
return () => {
@@ -147,13 +365,36 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
audioBlobUrls.forEach((blobUrl) => {
URL.revokeObjectURL(blobUrl);
});
videoBlobUrls.forEach((blobUrl) => {
URL.revokeObjectURL(blobUrl);
});
};
}, []);
const currentSceneImageFullUrl = imageBlobUrls.get(currentSceneNumber) || null;
const currentSceneAudioFullUrl = audioBlobUrls.get(currentSceneNumber) || null;
const resolvedSceneAudioUrl =
currentSceneAudioFullUrl ||
(currentSceneAudioUrl ? storyWriterApi.getAudioUrl(currentSceneAudioUrl) : null);
const currentSceneAnimatedVideoUrl = videoBlobUrls.get(currentSceneNumber) || null;
// Reset image load error when scene changes
const createStoryContextPayload = () => ({
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,
story_length: state.storyLength,
premise: state.premise,
outline: state.outline,
story_content: state.storyContent,
});
// Reset image/audio/video load errors when scene changes (to allow retry for new scene)
useEffect(() => {
setImageLoadError((prev) => {
const next = new Set(prev);
@@ -165,6 +406,11 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
next.delete(currentSceneNumber);
return next;
});
setVideoLoadError((prev) => {
const next = new Set(prev);
next.delete(currentSceneNumber);
return next;
});
}, [currentSceneNumber]);
useEffect(() => {
@@ -192,9 +438,20 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
const loadAudio = async () => {
try {
const audioPath = currentSceneAudioUrl.startsWith('/')
? currentSceneAudioUrl
: `/${currentSceneAudioUrl}`;
// Remove query parameters (token) from URL if present, we'll use authenticated request instead
const cleanUrl = currentSceneAudioUrl.split('?')[0];
// Normalize path - ensure it starts with /api/story/audio/
let audioPath = cleanUrl.startsWith('/')
? cleanUrl
: `/${cleanUrl}`;
// If path doesn't include /api/story/audio/, add it
if (!audioPath.includes('/api/story/audio/')) {
// Extract filename from path
const filename = cleanUrl.split('/').pop() || cleanUrl;
audioPath = `/api/story/audio/${filename}`;
}
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
@@ -210,8 +467,19 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
next.set(currentSceneNumber, blobUrl);
return next;
});
} catch (err) {
console.error('Failed to load audio:', err);
} catch (err: any) {
// Only log non-404 errors (404 means file doesn't exist, which is acceptable)
if (err?.response?.status !== 404) {
console.error(`Failed to load audio for scene ${currentSceneNumber}:`, err);
console.error(`Audio URL was: ${currentSceneAudioUrl}`);
// If auth error, log more details
if (err?.response?.status === 401) {
console.error(`Authentication failed for audio file. Make sure auth token is set.`);
}
}
// Mark as error to prevent retries
setAudioLoadError((prev) => new Set(prev).add(currentSceneNumber));
}
};
@@ -444,6 +712,104 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
}
};
const handleAnimateScene = async () => {
if (!hasScenes || !currentScene) {
setError('Please generate your outline before animating scenes.');
return;
}
const sceneNumber = currentScene.scene_number || currentSceneIndex + 1;
const sceneImageRelativeUrl = state.sceneImages?.get(sceneNumber);
if (!sceneImageRelativeUrl) {
setError('Please generate an image for this scene before animating it.');
return;
}
setAnimatingSceneNumber(sceneNumber);
setError(null);
updateSceneAnimationResume(sceneNumber, undefined);
const storyContextPayload = createStoryContextPayload();
const animationDuration: 5 | 10 = 5;
try {
console.info(
`[Outline] Animate scene requested`,
{ sceneNumber, duration: 5, image: sceneImageRelativeUrl }
);
const response = await storyWriterApi.animateScene({
scene_number: sceneNumber,
scene_data: currentScene,
story_context: storyContextPayload,
image_url: sceneImageRelativeUrl,
duration: animationDuration,
});
updateSceneAnimatedVideo(sceneNumber, response.video_url);
updateSceneAnimationResume(sceneNumber, undefined);
console.info(
`[Outline] Animate scene completed`,
{
sceneNumber,
video: response.video_url,
cost: response.cost,
prediction: response.prediction_id || 'n/a',
}
);
} catch (err: any) {
const detail = err?.response?.data?.detail;
const resumeMessage = captureResumeOpportunity(sceneNumber, animationDuration, detail);
const handled = await triggerSubscriptionError(err);
const message = resumeMessage || extractDetailMessage(detail, err.message || 'Failed to animate scene.');
setError(message);
if (!resumeMessage || !handled) {
console.error('[Outline] Animate scene failed', err);
}
} finally {
setAnimatingSceneNumber(null);
}
};
const handleResumeSceneAnimation = async (
sceneNumber: number,
resumeInfo: SceneAnimationResume
) => {
setAnimatingSceneNumber(sceneNumber);
setError(null);
try {
console.info('[Outline] Resume scene requested', {
sceneNumber,
prediction: resumeInfo.predictionId,
});
const response = await storyWriterApi.resumeAnimateScene({
prediction_id: resumeInfo.predictionId,
scene_number: sceneNumber,
duration: resumeInfo.duration,
});
updateSceneAnimatedVideo(sceneNumber, response.video_url);
updateSceneAnimationResume(sceneNumber, undefined);
console.info('[Outline] Resume scene completed', {
sceneNumber,
video: response.video_url,
cost: response.cost,
prediction: response.prediction_id || resumeInfo.predictionId,
});
} catch (err: any) {
const detail = err?.response?.data?.detail;
const message = extractDetailMessage(detail, err.message || 'Failed to resume animation.');
setError(message);
await triggerSubscriptionError(err);
console.error('[Outline] Resume scene failed', err);
} finally {
setAnimatingSceneNumber(null);
}
};
const handleRegenerateCurrentSceneImage = async () => {
if (!hasScenes || !currentScene) return;
setIsRegeneratingSceneImage(true);
@@ -532,6 +898,12 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
</Alert>
)}
{resumableSummaryMessage && (
<Alert severity="info" sx={{ mb: 3 }}>
{resumableSummaryMessage}
</Alert>
)}
{!state.premise && (
<Alert severity="warning" sx={{ mb: 3 }}>
Please generate a premise first in the Setup phase.
@@ -552,17 +924,24 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
imageUrl={currentSceneImageFullUrl}
onImageError={() => setImageLoadError((prev) => new Set(prev).add(currentSceneNumber))}
narrationEnabled={!!state.enableNarration}
audioUrl={
currentSceneAudioFullUrl || (state.sceneAudio && state.sceneAudio.get(currentSceneNumber)
? storyWriterApi.getAudioUrl(state.sceneAudio.get(currentSceneNumber) || '')
: null)
}
audioUrl={resolvedSceneAudioUrl || null}
hasAudio={hasAudioForScene}
onOpenImageModal={openImageModal}
onOpenAudioModal={openAudioModal}
onOpenCharactersModal={openCharactersModal}
onOpenKeyEventsModal={openKeyEventsModal}
onOpenTitleModal={openTitleModal}
onOpenEditModal={openEditModal}
onAnimateScene={canAnimateCurrentScene ? handleAnimateScene : undefined}
onAnimateWithVoiceover={hasAudioForScene ? handleAnimateSceneWithVoiceover : undefined}
onResumeScene={
currentSceneResumeInfo && !animatingSceneNumber
? () => handleResumeSceneAnimation(currentSceneNumber, currentSceneResumeInfo)
: undefined
}
resumeInfo={currentSceneResumeInfo}
isAnimatingScene={isCurrentSceneAnimating}
animatedVideoUrl={currentSceneAnimatedVideoUrl}
/>
<OutlineActionsBar
isGenerating={isGenerating}
@@ -617,6 +996,50 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
(state.setOutlineScenes as any)(updated);
setIsImageModalOpen(false);
}}
onRegenerate={async (prompt: string) => {
if (!hasScenes || !currentScene) return;
setIsRegeneratingSceneImage(true);
try {
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
const sceneTitle = currentScene.title || `Scene ${sceneNum}`;
const resp = await storyWriterApi.regenerateSceneImage({
scene_number: sceneNum,
scene_title: sceneTitle,
prompt: prompt.trim(),
provider: state.imageProvider || undefined,
width: state.imageWidth,
height: state.imageHeight,
model: state.imageModel || undefined,
});
if (resp.success && resp.image_url) {
const nextMap = new Map(state.sceneImages || []);
nextMap.set(sceneNum, resp.image_url);
state.setSceneImages(nextMap);
// Update the scene with the new prompt if generation was successful
const updated = [...scenes];
updated[currentSceneIndex] = { ...updated[currentSceneIndex], image_prompt: prompt.trim() };
(state.setOutlineScenes as any)(updated);
setImagePromptDraft(prompt.trim());
// Close the modal after successful regeneration
setIsImageModalOpen(false);
} else {
throw new Error(resp.error || 'Failed to regenerate image');
}
} catch (err: any) {
console.error('Failed to regenerate scene image:', err);
throw err; // Re-throw to be handled by modal
} finally {
setIsRegeneratingSceneImage(false);
}
}}
imageProvider={state.imageProvider}
imageWidth={state.imageWidth}
imageHeight={state.imageHeight}
imageModel={state.imageModel}
/>
<AudioScriptModal
open={isAudioModalOpen}
@@ -644,6 +1067,94 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
? storyWriterApi.getAudioUrl(state.sceneAudio.get(currentSceneNumber) || '')
: currentSceneAudioFullUrl) || null
}
onGenerateAI={async (params: {
text: string;
voice_id?: string;
speed?: number;
volume?: number;
pitch?: number;
emotion?: string;
}) => {
if (!hasScenes || !currentScene) return;
setIsRegeneratingSceneAudio(true);
try {
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
const sceneTitle = currentScene.title || `Scene ${sceneNum}`;
const resp = await storyWriterApi.generateAIAudio({
scene_number: sceneNum,
scene_title: sceneTitle,
text: params.text.trim(),
voice_id: params.voice_id || 'Wise_Woman',
speed: params.speed !== undefined ? params.speed : 1.0,
volume: params.volume !== undefined ? params.volume : 1.0,
pitch: params.pitch !== undefined ? params.pitch : 0.0,
emotion: params.emotion || 'happy',
});
if (resp.success && resp.audio_url) {
const nextMap = new Map(state.sceneAudio || []);
nextMap.set(sceneNum, resp.audio_url);
state.setSceneAudio(nextMap);
// Update the scene with the new audio_narration if generation was successful
const updated = [...scenes];
updated[currentSceneIndex] = { ...updated[currentSceneIndex], audio_narration: params.text.trim() };
(state.setOutlineScenes as any)(updated);
setAudioScriptDraft(params.text.trim());
// Close the modal after successful generation
setIsAudioModalOpen(false);
} else {
throw new Error(resp.error || 'Failed to generate AI audio');
}
} catch (err: any) {
console.error('Failed to generate AI audio:', err);
throw err; // Re-throw to be handled by modal
} finally {
setIsRegeneratingSceneAudio(false);
}
}}
onGenerateFree={async (text: string) => {
if (!hasScenes || !currentScene) return;
setIsRegeneratingSceneAudio(true);
try {
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
const sceneTitle = currentScene.title || `Scene ${sceneNum}`;
const resp = await storyWriterApi.generateFreeAudio({
scene_number: sceneNum,
scene_title: sceneTitle,
text: text.trim(),
provider: state.audioProvider || 'gtts',
lang: state.audioLang || 'en',
slow: state.audioSlow || false,
rate: state.audioRate || 150,
});
if (resp.success && resp.audio_url) {
const nextMap = new Map(state.sceneAudio || []);
nextMap.set(sceneNum, resp.audio_url);
state.setSceneAudio(nextMap);
// Update the scene with the new audio_narration if generation was successful
const updated = [...scenes];
updated[currentSceneIndex] = { ...updated[currentSceneIndex], audio_narration: text.trim() };
(state.setOutlineScenes as any)(updated);
setAudioScriptDraft(text.trim());
// Close the modal after successful generation
setIsAudioModalOpen(false);
} else {
throw new Error(resp.error || 'Failed to generate free audio');
}
} catch (err: any) {
console.error('Failed to generate free audio:', err);
throw err; // Re-throw to be handled by modal
} finally {
setIsRegeneratingSceneAudio(false);
}
}}
/>
<CharactersModal
open={isCharactersModalOpen}

View File

@@ -1,5 +1,14 @@
import React from 'react';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
TextField, Divider, CircularProgress, Typography, Tooltip, IconButton,
Slider, FormControl, InputLabel, Select, MenuItem, FormHelperText,
ToggleButtonGroup, ToggleButton
} from '@mui/material';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { OperationButton } from '../../../shared/OperationButton';
interface AudioScriptModalProps {
open: boolean;
@@ -18,14 +27,114 @@ interface AudioScriptModalProps {
onChangeSlow: (v: boolean) => void;
onChangeRate: (v: number) => void;
audioUrl?: string | null;
// audio generation callbacks - now with full parameters
onGenerateAI?: (params: {
text: string;
voice_id?: string;
speed?: number;
volume?: number;
pitch?: number;
emotion?: string;
}) => Promise<void>;
onGenerateFree?: (text: string) => Promise<void>;
}
// Available voice IDs from WaveSpeed Minimax
const AVAILABLE_VOICES = [
{ value: 'Wise_Woman', label: 'Wise Woman', description: 'Warm, authoritative female voice' },
{ value: 'Friendly_Person', label: 'Friendly Person', description: 'Approachable and conversational' },
{ value: 'Inspirational_girl', label: 'Inspirational Girl', description: 'Energetic and motivating' },
{ value: 'Deep_Voice_Man', label: 'Deep Voice Man', description: 'Rich, deep male voice' },
{ value: 'Calm_Woman', label: 'Calm Woman', description: 'Peaceful and soothing' },
{ value: 'Casual_Guy', label: 'Casual Guy', description: 'Relaxed and informal' },
{ value: 'Lively_Girl', label: 'Lively Girl', description: 'Vibrant and enthusiastic' },
{ value: 'Patient_Man', label: 'Patient Man', description: 'Steady and reassuring' },
{ value: 'Young_Knight', label: 'Young Knight', description: 'Brave and confident' },
{ value: 'Determined_Man', label: 'Determined Man', description: 'Strong and resolute' },
{ value: 'Lovely_Girl', label: 'Lovely Girl', description: 'Sweet and charming' },
{ value: 'Decent_Boy', label: 'Decent Boy', description: 'Polite and well-mannered' },
{ value: 'Imposing_Manner', label: 'Imposing Manner', description: 'Commanding and powerful' },
{ value: 'Elegant_Man', label: 'Elegant Man', description: 'Sophisticated and refined' },
{ value: 'Abbess', label: 'Abbess', description: 'Dignified and wise' },
{ value: 'Sweet_Girl_2', label: 'Sweet Girl 2', description: 'Gentle and kind' },
{ value: 'Exuberant_Girl', label: 'Exuberant Girl', description: 'Joyful and energetic' },
];
const EMOTIONS = [
{ value: 'happy', label: 'Happy', description: 'Cheerful and upbeat tone' },
{ value: 'sad', label: 'Sad', description: 'Melancholic and somber tone' },
{ value: 'angry', label: 'Angry', description: 'Intense and forceful tone' },
{ value: 'fear', label: 'Fear', description: 'Anxious and nervous tone' },
{ value: 'surprised', label: 'Surprised', description: 'Astonished and amazed tone' },
{ value: 'neutral', label: 'Neutral', description: 'Calm and balanced tone (default)' },
];
const AudioScriptModal: React.FC<AudioScriptModalProps> = ({
open, sceneNumber, value, onChange, onClose, onSave,
audioProvider, audioLang, audioSlow, audioRate,
onChangeProvider, onChangeLang, onChangeSlow, onChangeRate,
audioUrl,
onGenerateAI,
onGenerateFree,
}) => {
const [isGeneratingAI, setIsGeneratingAI] = React.useState(false);
const [isGeneratingFree, setIsGeneratingFree] = React.useState(false);
const [generateError, setGenerateError] = React.useState<string | null>(null);
// Audio type toggle - default to 'free'
const [audioType, setAudioType] = React.useState<'free' | 'ai'>('free');
// AI Audio generation parameters with intelligent defaults
const [voiceId, setVoiceId] = React.useState<string>('Wise_Woman');
const [customVoiceId, setCustomVoiceId] = React.useState<string>('');
const [useCustomVoice, setUseCustomVoice] = React.useState<boolean>(false);
const [emotion, setEmotion] = React.useState<string>('happy');
const [speed, setSpeed] = React.useState<number>(1.0);
const [volume, setVolume] = React.useState<number>(1.0);
const [pitch, setPitch] = React.useState<number>(0.0);
const handleGenerateAI = async () => {
if (!onGenerateAI || !value.trim()) {
return;
}
setIsGeneratingAI(true);
setGenerateError(null);
try {
await onGenerateAI({
text: value.trim(),
voice_id: useCustomVoice ? customVoiceId : voiceId,
emotion: emotion,
speed: speed,
volume: volume,
pitch: pitch,
});
// Optionally close modal after successful generation
// onClose();
} catch (err: any) {
setGenerateError(err?.response?.data?.detail || err?.message || 'Failed to generate AI audio');
} finally {
setIsGeneratingAI(false);
}
};
const handleGenerateFree = async () => {
if (!onGenerateFree || !value.trim()) {
return;
}
setIsGeneratingFree(true);
setGenerateError(null);
try {
await onGenerateFree(value.trim());
// Optionally close modal after successful generation
// onClose();
} catch (err: any) {
setGenerateError(err?.response?.data?.detail || err?.message || 'Failed to generate free audio');
} finally {
setIsGeneratingFree(false);
}
};
return (
<Dialog
open={open}
@@ -42,14 +151,43 @@ const AudioScriptModal: React.FC<AudioScriptModalProps> = ({
}}
>
<DialogTitle>Edit Audio Narration Script (Scene {sceneNumber})</DialogTitle>
<DialogContent dividers sx={{ color: '#2C2416' }}>
<DialogContent dividers sx={{ color: '#2C2416', bgcolor: '#fff' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
'& .MuiFormLabel-root': { color: '#6b5846' },
'& .MuiInputBase-root': { color: '#2C2416' },
gap: 3,
pt: 1,
'& .MuiFormLabel-root': { color: '#5D4037', fontWeight: 500 },
'& .MuiInputBase-root': {
color: '#2C2416',
bgcolor: '#fff',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(0, 0, 0, 0.23)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(0, 0, 0, 0.87)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'primary.main',
borderWidth: '2px',
},
},
'& .MuiInputBase-input': {
color: '#2C2416',
},
'& textarea': {
color: '#2C2416',
},
'& .MuiSelect-select': {
color: '#2C2416',
},
'& .MuiFormHelperText-root': {
color: 'rgba(0, 0, 0, 0.6)',
},
'& .MuiMenuItem-root': {
color: '#2C2416',
},
}}
>
{audioUrl ? (
@@ -73,40 +211,387 @@ const AudioScriptModal: React.FC<AudioScriptModalProps> = ({
multiline
minRows={6}
fullWidth
placeholder="Enter the narration text for this scene..."
sx={{
'& .MuiInputBase-input': {
color: '#2C2416',
},
}}
/>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
<TextField
select
label="Audio Provider"
value={audioProvider}
onChange={(e) => onChangeProvider(e.target.value)}
SelectProps={{ native: true }}
>
<option value="gtts">gTTS</option>
<option value="pyttsx3">pyttsx3</option>
</TextField>
<TextField
label="Language (e.g., en, hi)"
value={audioLang}
onChange={(e) => onChangeLang(e.target.value)}
/>
<TextField
select
label="Slow (gTTS)"
value={audioSlow ? 'true' : 'false'}
onChange={(e) => onChangeSlow(e.target.value === 'true')}
SelectProps={{ native: true }}
>
<option value="false">Normal</option>
<option value="true">Slow</option>
</TextField>
<TextField
type="number"
label="Rate (pyttsx3)"
value={audioRate}
onChange={(e) => onChangeRate(Number(e.target.value))}
inputProps={{ min: 50, max: 300, step: 10 }}
/>
{generateError && (
<Box sx={{ color: 'error.main', fontSize: '0.875rem', mt: -1 }}>
{generateError}
</Box>
)}
<Divider sx={{ my: 1 }} />
{/* Audio Type Toggle */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600, color: '#5D4037' }}>
Audio Type
</Typography>
<ToggleButtonGroup
value={audioType}
exclusive
onChange={(_, newValue) => {
if (newValue !== null) {
setAudioType(newValue);
setGenerateError(null);
}
}}
aria-label="audio type"
fullWidth
sx={{
'& .MuiToggleButton-root': {
textTransform: 'none',
borderColor: 'rgba(0, 0, 0, 0.23)',
color: '#5D4037',
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: '#fff',
'&:hover': {
backgroundColor: 'primary.dark',
},
},
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
},
}}
>
<ToggleButton value="free" aria-label="free audio">
<VolumeUpIcon sx={{ mr: 1 }} />
Free Audio (gTTS)
</ToggleButton>
<ToggleButton value="ai" aria-label="ai audio">
<SmartToyIcon sx={{ mr: 1 }} />
AI Audio (Minimax)
</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Generate Button - Context aware based on audio type */}
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{audioType === 'ai' && onGenerateAI && (
<OperationButton
operation={{
provider: 'audio',
model: 'minimax/speech-02-hd',
tokens_requested: value.trim().length, // Every character is 1 token
operation_type: 'audio_generation',
actual_provider_name: 'wavespeed',
}}
label="Generate AI Audio"
variant="contained"
size="medium"
startIcon={<SmartToyIcon />}
showCost={true}
checkOnHover={true}
checkOnMount={false}
onClick={handleGenerateAI}
disabled={isGeneratingAI || isGeneratingFree || !value.trim()}
loading={isGeneratingAI}
sx={{ flex: 1, minWidth: '200px' }}
/>
)}
{audioType === 'free' && onGenerateFree && (
<Button
variant="contained"
size="medium"
startIcon={isGeneratingFree ? <CircularProgress size={16} /> : <VolumeUpIcon />}
onClick={handleGenerateFree}
disabled={isGeneratingAI || isGeneratingFree || !value.trim()}
sx={{ flex: 1, minWidth: '200px' }}
>
{isGeneratingFree ? 'Generating...' : 'Generate Free Audio (gTTS)'}
</Button>
)}
</Box>
<Divider sx={{ my: 1 }} />
{/* Settings - Conditionally shown based on audio type */}
{audioType === 'ai' && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600, color: '#5D4037' }}>
AI Audio Generation Settings
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
{/* Voice Selection */}
<FormControl fullWidth>
<InputLabel>Voice</InputLabel>
<Select
value={useCustomVoice ? 'custom' : voiceId}
onChange={(e) => {
if (e.target.value === 'custom') {
setUseCustomVoice(true);
} else {
setUseCustomVoice(false);
setVoiceId(e.target.value);
}
}}
label="Voice"
renderValue={(value) => {
if (value === 'custom') {
return customVoiceId || 'Custom Voice ID';
}
const voice = AVAILABLE_VOICES.find(v => v.value === value);
return voice ? voice.label : value;
}}
>
{AVAILABLE_VOICES.map((voice) => (
<MenuItem key={voice.value} value={voice.value}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{voice.label}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{voice.description}
</Typography>
</Box>
</MenuItem>
))}
<MenuItem value="custom">
<Box>
<Typography variant="body2" sx={{ fontWeight: 500, fontStyle: 'italic' }}>
Custom Voice ID...
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Use a voice ID from voice cloning
</Typography>
</Box>
</MenuItem>
</Select>
<FormHelperText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Choose a voice that matches your story's tone
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
Current Voice ID: {voiceId}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
You can use system voices above or enter a custom voice ID from voice cloning.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
Learn more:{' '}
<a
href="https://wavespeed.ai/models/minimax/voice-clone"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#90caf9' }}
>
Voice Cloning Guide
</a>
</Typography>
</Box>
}
arrow
placement="top"
>
<InfoOutlinedIcon sx={{ fontSize: '0.875rem', color: 'text.secondary', cursor: 'help' }} />
</Tooltip>
</Box>
</FormHelperText>
</FormControl>
{/* Custom Voice ID Input (shown when custom voice is selected) */}
{useCustomVoice && (
<TextField
fullWidth
label="Custom Voice ID"
value={customVoiceId}
onChange={(e) => setCustomVoiceId(e.target.value)}
helperText="Enter your custom voice ID from voice cloning"
placeholder="your-custom-voice-id"
/>
)}
{/* Emotion Selection */}
<FormControl fullWidth>
<InputLabel>Emotion</InputLabel>
<Select
value={emotion}
onChange={(e) => setEmotion(e.target.value)}
label="Emotion"
>
{EMOTIONS.map((em) => (
<MenuItem key={em.value} value={em.value}>
<Box>
<Typography variant="body2">{em.label}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{em.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
<FormHelperText>
Select the emotional tone for the narration
</FormHelperText>
</FormControl>
{/* Speed Slider */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="body2" sx={{ minWidth: '60px' }}>
Speed
</Typography>
<Slider
value={speed}
onChange={(_, newValue) => setSpeed(newValue as number)}
min={0.5}
max={2.0}
step={0.1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}x`}
sx={{ flex: 1 }}
/>
<Typography variant="body2" sx={{ minWidth: '40px', textAlign: 'right' }}>
{speed.toFixed(1)}x
</Typography>
</Box>
<FormHelperText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Speech speed (0.5x = slow, 1.0x = normal, 2.0x = fast)
<Tooltip
title="Adjust how fast the narration speaks. 1.0 is normal speed, suitable for most content."
arrow
placement="top"
>
<InfoOutlinedIcon sx={{ fontSize: '0.875rem', color: 'text.secondary', cursor: 'help' }} />
</Tooltip>
</Box>
</FormHelperText>
</Box>
{/* Volume Slider */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="body2" sx={{ minWidth: '60px' }}>
Volume
</Typography>
<Slider
value={volume}
onChange={(_, newValue) => setVolume(newValue as number)}
min={0.1}
max={10.0}
step={0.1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value.toFixed(1)}`}
sx={{ flex: 1 }}
/>
<Typography variant="body2" sx={{ minWidth: '40px', textAlign: 'right' }}>
{volume.toFixed(1)}
</Typography>
</Box>
<FormHelperText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Audio volume level (0.1 = quiet, 1.0 = normal, 10.0 = loud)
<Tooltip
title="Control the loudness of the audio. 1.0 is standard volume. Increase for emphasis, decrease for subtlety."
arrow
placement="top"
>
<InfoOutlinedIcon sx={{ fontSize: '0.875rem', color: 'text.secondary', cursor: 'help' }} />
</Tooltip>
</Box>
</FormHelperText>
</Box>
{/* Pitch Slider */}
<Box sx={{ gridColumn: { xs: '1', md: '1 / -1' } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="body2" sx={{ minWidth: '60px' }}>
Pitch
</Typography>
<Slider
value={pitch}
onChange={(_, newValue) => setPitch(newValue as number)}
min={-12}
max={12}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value > 0 ? '+' : ''}${value}`}
marks={[
{ value: -12, label: '-12' },
{ value: 0, label: '0' },
{ value: 12, label: '+12' },
]}
sx={{ flex: 1 }}
/>
<Typography variant="body2" sx={{ minWidth: '50px', textAlign: 'right' }}>
{pitch > 0 ? '+' : ''}{pitch}
</Typography>
</Box>
<FormHelperText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Voice pitch adjustment (-12 = lower, 0 = normal, +12 = higher)
<Tooltip
title="Adjust the pitch of the voice. Negative values make the voice deeper, positive values make it higher. 0 keeps the natural voice pitch."
arrow
placement="top"
>
<InfoOutlinedIcon sx={{ fontSize: '0.875rem', color: 'text.secondary', cursor: 'help' }} />
</Tooltip>
</Box>
</FormHelperText>
</Box>
</Box>
</Box>
)}
{audioType === 'free' && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600, color: '#5D4037' }}>
Free Audio (gTTS) Settings
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
<TextField
select
label="Audio Provider"
value={audioProvider}
onChange={(e) => onChangeProvider(e.target.value)}
SelectProps={{ native: true }}
helperText="Text-to-speech engine for free audio generation"
>
<option value="gtts">gTTS (Google Text-to-Speech)</option>
<option value="pyttsx3">pyttsx3 (Offline)</option>
</TextField>
<TextField
label="Language"
value={audioLang}
onChange={(e) => onChangeLang(e.target.value)}
helperText="Language code (e.g., en for English, hi for Hindi)"
placeholder="en"
/>
<TextField
select
label="Speech Speed (gTTS)"
value={audioSlow ? 'true' : 'false'}
onChange={(e) => onChangeSlow(e.target.value === 'true')}
SelectProps={{ native: true }}
helperText="Whether to speak slowly (useful for clarity)"
>
<option value="false">Normal Speed</option>
<option value="true">Slow Speed</option>
</TextField>
<TextField
type="number"
label="Speech Rate (pyttsx3)"
value={audioRate}
onChange={(e) => onChangeRate(Number(e.target.value))}
inputProps={{ min: 50, max: 300, step: 10 }}
helperText="Words per minute (50-300, default: 150)"
/>
</Box>
</Box>
)}
</Box>
</Box>
</DialogContent>

View File

@@ -1,12 +1,17 @@
import React from 'react';
import { Box, Typography, Tooltip, Chip } from '@mui/material';
import { Box, Typography, Tooltip, Chip, CircularProgress } from '@mui/material';
import { motion, AnimatePresence } from 'framer-motion';
import OutlineHoverActions from './OutlineHoverActions';
import EditNoteIcon from '@mui/icons-material/EditNote';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import ReplayIcon from '@mui/icons-material/Replay';
import { OperationButton } from '../../../shared/OperationButton';
import { leftPageVariants, rightPageVariants } from './pageVariants';
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
import { StoryScene } from '../../../../services/storyWriterApi';
import type { SceneAnimationResume } from '../../../../hooks/useStoryWriterState';
const MotionBox = motion(Box);
@@ -33,12 +38,19 @@ interface BookPagesProps {
narrationEnabled: boolean;
audioUrl: string | null;
hasAudio: boolean;
onOpenImageModal: () => void;
onOpenAudioModal: () => void;
onOpenCharactersModal: () => void;
onOpenKeyEventsModal: () => void;
onOpenTitleModal: () => void;
onOpenEditModal: () => void;
onAnimateScene?: () => void;
onResumeScene?: () => void;
onAnimateWithVoiceover?: () => void;
isAnimatingScene?: boolean;
animatedVideoUrl?: string | null;
resumeInfo?: SceneAnimationResume | null;
}
const BookPages: React.FC<BookPagesProps> = ({
@@ -56,12 +68,22 @@ const BookPages: React.FC<BookPagesProps> = ({
onOpenImageModal,
onOpenAudioModal,
audioUrl,
hasAudio,
onOpenCharactersModal,
onOpenKeyEventsModal,
onOpenTitleModal,
onOpenEditModal,
onAnimateScene,
onResumeScene,
onAnimateWithVoiceover,
isAnimatingScene,
animatedVideoUrl,
resumeInfo,
}) => {
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
const showAnimatedVideo = Boolean(animatedVideoUrl);
const hasImage = Boolean(imageUrl);
const hasMedia = showAnimatedVideo || hasImage;
return (
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
@@ -213,13 +235,43 @@ const BookPages: React.FC<BookPagesProps> = ({
overflowY: 'auto',
mt: 3,
display: 'grid',
gridTemplateRows: imageUrl ? 'auto 1fr auto auto' : 'auto auto auto 1fr',
gridTemplateRows: hasMedia ? 'auto 1fr auto auto' : 'auto auto auto 1fr',
alignContent: 'start',
gap: 3,
}}
>
<Box sx={{ position: 'relative', '&:hover .left-image-actions': { opacity: 1, pointerEvents: 'auto' } }}>
{imageUrl ? (
{showAnimatedVideo ? (
<Box
sx={{
width: '100%',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 12px 24px rgba(0, 0, 0, 0.2)',
border: '3px solid rgba(120, 90, 60, 0.25)',
backgroundColor: '#000',
}}
>
<Box
component="video"
src={animatedVideoUrl ?? undefined}
poster={imageUrl ?? undefined}
autoPlay
muted
loop
controls
playsInline
sx={{
width: '100%',
height: 'auto',
display: 'block',
minHeight: '300px',
maxHeight: '500px',
objectFit: 'cover',
}}
/>
</Box>
) : hasImage ? (
<>
{/* Removed 'Scene Illustration' heading for cleaner look */}
<Box
@@ -239,7 +291,7 @@ const BookPages: React.FC<BookPagesProps> = ({
>
<Box
component="img"
src={imageUrl}
src={imageUrl || undefined}
alt={currentScene?.title || `Scene ${currentSceneNumber} illustration`}
sx={{
width: '100%',
@@ -258,11 +310,13 @@ const BookPages: React.FC<BookPagesProps> = ({
top: 8,
right: 8,
display: 'flex',
flexDirection: 'column',
gap: 1,
opacity: 0,
pointerEvents: 'none',
transition: 'opacity 0.2s ease',
zIndex: 5,
zIndex: 5,
alignItems: 'flex-end',
}}
>
<Tooltip title="Edit scene image prompt">
@@ -286,6 +340,152 @@ const BookPages: React.FC<BookPagesProps> = ({
<EditNoteIcon />
</Box>
</Tooltip>
{hasImage && onAnimateScene && (
<Box
onClick={(e) => {
e.stopPropagation();
}}
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
>
<OperationButton
operation={{
provider: 'video',
model: 'kling-v2.5-turbo-std-5s',
operation_type: 'scene_animation',
actual_provider_name: 'wavespeed',
}}
label="Animate Scene"
variant="contained"
size="small"
startIcon={<PlayArrowIcon />}
showCost
checkOnHover
checkOnMount={false}
onClick={onAnimateScene}
disabled={isAnimatingScene}
sx={{
minWidth: 'auto',
padding: '8px',
width: 40,
height: 40,
borderRadius: '50%',
background: 'linear-gradient(135deg, #1f8a70 0%, #32d9c8 100%)',
boxShadow: '0 8px 16px rgba(31,138,112,0.35)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #1a7a60 0%, #2dc9b8 100%)',
},
'& .MuiButton-startIcon': {
margin: 0,
},
'& .MuiButton-label': {
display: 'none',
},
}}
tooltipPlacement="left"
/>
</Box>
)}
{hasImage && hasAudio && onAnimateWithVoiceover && (
<Box
onClick={(e) => {
e.stopPropagation();
}}
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
>
<OperationButton
operation={{
provider: 'video',
model: 'wavespeed-ai/infinitetalk',
operation_type: 'scene_animation_voiceover',
actual_provider_name: 'wavespeed',
}}
label="Animate with Voiceover"
variant="contained"
size="small"
startIcon={<GraphicEqIcon />}
showCost
checkOnHover
checkOnMount={false}
onClick={onAnimateWithVoiceover}
disabled={isAnimatingScene}
sx={{
minWidth: 'auto',
padding: '8px',
width: 40,
height: 40,
borderRadius: '50%',
background: 'linear-gradient(135deg, #733dd9 0%, #bb86fc 100%)',
boxShadow: '0 8px 16px rgba(115,61,217,0.35)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #6030ba 0%, #a974f1 100%)',
},
'& .MuiButton-startIcon': {
margin: 0,
},
'& .MuiButton-label': {
display: 'none',
},
}}
tooltipPlacement="left"
/>
</Box>
)}
{resumeInfo && onResumeScene && (
<Tooltip
title={resumeInfo.message || 'Resume animation download (no extra cost)'}
placement="left"
>
<Box
onClick={(e) => {
e.stopPropagation();
}}
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
>
<OperationButton
operation={{
provider: 'video',
model: 'kling-v2.5-turbo-std-resume',
operation_type: 'scene_animation_resume',
actual_provider_name: 'wavespeed',
}}
label="Resume Animation"
variant="contained"
size="small"
startIcon={<ReplayIcon />}
showCost={false}
checkOnHover={false}
checkOnMount={false}
onClick={onResumeScene}
disabled={isAnimatingScene}
sx={{
minWidth: 'auto',
padding: '8px',
width: 40,
height: 40,
borderRadius: '50%',
background: 'linear-gradient(135deg, #b35c1e 0%, #f5a623 100%)',
boxShadow: '0 8px 16px rgba(179,92,30,0.35)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #9c511a 0%, #e1911c 100%)',
},
'& .MuiButton-startIcon': {
margin: 0,
},
'& .MuiButton-label': {
display: 'none',
},
}}
tooltipPlacement="left"
/>
</Box>
</Tooltip>
)}
</Box>
</Box>
</>
@@ -325,6 +525,27 @@ const BookPages: React.FC<BookPagesProps> = ({
</Box>
</>
)}
{isAnimatingScene && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(2px)',
backgroundColor: 'rgba(0,0,0,0.35)',
borderRadius: '12px',
color: '#fff',
gap: 1,
zIndex: 6,
}}
>
<CircularProgress color="inherit" size={36} />
<Typography variant="body2">Animating scene...</Typography>
</Box>
)}
</Box>
{/* Audio chip moved to right page */}
@@ -375,7 +596,10 @@ const BookPages: React.FC<BookPagesProps> = ({
'&:hover .chip-actions': { opacity: 1, pointerEvents: 'auto' },
}}
>
<OutlineHoverActions onEdit={onOpenEditModal} onImprove={onOpenEditModal} />
<OutlineHoverActions
onEdit={onOpenEditModal}
onImprove={onOpenEditModal}
/>
<Box sx={{ flex: 1, overflowY: 'auto', pt: { xs: 1, md: 2 } }}>
<Box className="chip-actions" sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5, opacity: 0, pointerEvents: 'none', transition: 'opacity 0.2s ease' }}>
<Chip

View File

@@ -1,5 +1,9 @@
import React from 'react';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Divider, CircularProgress } from '@mui/material';
import { OperationButton } from '../../../shared/OperationButton';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import RefreshIcon from '@mui/icons-material/Refresh';
import { storyWriterApi } from '../../../../services/storyWriterApi';
interface ImageEditModalProps {
open: boolean;
@@ -8,9 +12,82 @@ interface ImageEditModalProps {
onChange: (v: string) => void;
onClose: () => void;
onSave: () => void;
onRegenerate?: (prompt: string) => Promise<void>;
imageProvider?: string | null;
imageWidth?: number;
imageHeight?: number;
imageModel?: string | null;
}
const ImageEditModal: React.FC<ImageEditModalProps> = ({ open, sceneNumber, value, onChange, onClose, onSave }) => {
const ImageEditModal: React.FC<ImageEditModalProps> = ({
open,
sceneNumber,
value,
onChange,
onClose,
onSave,
onRegenerate,
imageProvider,
imageWidth = 1024,
imageHeight = 1024,
imageModel,
}) => {
const [isRegenerating, setIsRegenerating] = React.useState(false);
const [regenerateError, setRegenerateError] = React.useState<string | null>(null);
const [isOptimizing, setIsOptimizing] = React.useState(false);
const [optimizeError, setOptimizeError] = React.useState<string | null>(null);
const handleRegenerate = async () => {
if (!onRegenerate || !value.trim()) {
return;
}
setIsRegenerating(true);
setRegenerateError(null);
try {
await onRegenerate(value.trim());
// Optionally close modal after successful regeneration
// onClose();
} catch (err: any) {
setRegenerateError(err?.response?.data?.detail || err?.message || 'Failed to regenerate image');
} finally {
setIsRegenerating(false);
}
};
const handleOptimize = async () => {
if (!value.trim()) {
return;
}
setIsOptimizing(true);
setOptimizeError(null);
try {
const response = await storyWriterApi.optimizePrompt({
text: value.trim(),
mode: 'image', // Default to image mode for scene image prompts
style: 'default', // Could be made configurable in the future
});
if (response.success && response.optimized_prompt) {
onChange(response.optimized_prompt);
} else {
throw new Error('Optimization returned no result');
}
} catch (err: any) {
const errorMessage = err?.response?.data?.detail || err?.message || 'Failed to optimize prompt';
setOptimizeError(errorMessage);
console.error('Failed to optimize prompt:', err);
} finally {
setIsOptimizing(false);
}
};
// Determine the model for cost estimation
// Default to FLUX.1-Krea-dev for HuggingFace, or stability model
const modelForEstimation = imageModel || (imageProvider === 'stability' ? 'stable-diffusion' : 'black-forest-labs/FLUX.1-Krea-dev');
const providerForEstimation = imageProvider || 'huggingface';
return (
<Dialog
open={open}
@@ -44,7 +121,54 @@ const ImageEditModal: React.FC<ImageEditModalProps> = ({ open, sceneNumber, valu
multiline
minRows={5}
fullWidth
placeholder="Enter a detailed description of the scene image..."
/>
{(regenerateError || optimizeError) && (
<Box sx={{ color: 'error.main', fontSize: '0.875rem', mt: -1 }}>
{regenerateError || optimizeError}
</Box>
)}
<Divider sx={{ my: 1 }} />
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{/* AI Prompt Optimizer */}
<Button
variant="outlined"
size="medium"
startIcon={isOptimizing ? <CircularProgress size={16} /> : <AutoFixHighIcon />}
onClick={handleOptimize}
disabled={isOptimizing || !value.trim() || isRegenerating}
sx={{ flex: 1, minWidth: '200px' }}
>
{isOptimizing ? 'Optimizing...' : 'AI Prompt Optimizer'}
</Button>
{/* Regenerate Scene - Active with cost estimation */}
{onRegenerate && (
<OperationButton
operation={{
provider: 'stability',
model: modelForEstimation,
tokens_requested: 0,
operation_type: 'image_generation',
actual_provider_name: providerForEstimation,
}}
label="Regenerate Scene"
variant="contained"
size="medium"
startIcon={<RefreshIcon />}
showCost={true}
checkOnHover={true}
checkOnMount={false}
onClick={handleRegenerate}
disabled={isRegenerating || !value.trim()}
loading={isRegenerating}
sx={{ flex: 1, minWidth: '200px' }}
/>
)}
</Box>
</Box>
</DialogContent>
<DialogActions>

View File

@@ -8,7 +8,10 @@ interface OutlineHoverActionsProps {
onImprove: () => void;
}
const OutlineHoverActions: React.FC<OutlineHoverActionsProps> = ({ onEdit, onImprove }) => {
const OutlineHoverActions: React.FC<OutlineHoverActionsProps> = ({
onEdit,
onImprove,
}) => {
return (
<Box
className="outline-actions"

View File

@@ -13,6 +13,7 @@ import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../api/client';
import { aiApiClient } from '../../../api/client';
import { fetchMediaBlobUrl } from '../../../utils/fetchMediaBlobUrl';
import { MultimediaSection } from '../components/MultimediaSection';
const MotionBox = motion(Box);
@@ -123,10 +124,13 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
const [pageDirection, setPageDirection] = useState(0);
const [imageLoadError, setImageLoadError] = useState<Set<number>>(new Set());
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
const [videoBlobUrls, setVideoBlobUrls] = useState<Map<number, string>>(new Map());
const [videoLoadError, setVideoLoadError] = useState<Set<number>>(new Set());
// Get scenes and images from state
const scenes = state.outlineScenes || [];
const sceneImages = state.sceneImages || new Map<number, string>();
const sceneAnimatedVideos = state.sceneAnimatedVideos || new Map<number, string>();
const hasScenes = state.isOutlineStructured && scenes.length > 0;
// Split story content into sections mapped to scenes
@@ -201,6 +205,10 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
}, []);
const currentSceneImageFullUrl = imageBlobUrls.get(currentSceneNumber) || null;
const currentSceneAnimatedVideoUrl = sceneAnimatedVideos.get(currentSceneNumber) || null;
const currentSceneAnimatedVideoBlobUrl = videoBlobUrls.get(currentSceneNumber) || null;
const hasVideoLoadError = videoLoadError.has(currentSceneNumber);
const showAnimatedVideo = Boolean(currentSceneAnimatedVideoBlobUrl);
// Reset image load error when page changes
useEffect(() => {
@@ -211,6 +219,60 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
});
}, [currentSceneNumber]);
useEffect(() => {
if (!currentSceneAnimatedVideoUrl || hasVideoLoadError || currentSceneAnimatedVideoBlobUrl) {
return;
}
let cancelled = false;
const loadVideo = async () => {
try {
const videoPath = currentSceneAnimatedVideoUrl.startsWith('/')
? currentSceneAnimatedVideoUrl
: `/${currentSceneAnimatedVideoUrl}`;
const blobUrl = await fetchMediaBlobUrl(videoPath);
if (!blobUrl || cancelled) {
if (!blobUrl) {
setVideoLoadError((prev) => new Set(prev).add(currentSceneNumber));
}
return;
}
setVideoBlobUrls((prev) => {
const next = new Map(prev);
const existing = next.get(currentSceneNumber);
if (existing) {
URL.revokeObjectURL(existing);
}
next.set(currentSceneNumber, blobUrl);
return next;
});
} catch (err) {
console.warn('Failed to load animated video:', err);
setVideoLoadError((prev) => {
const next = new Set(prev);
next.add(currentSceneNumber);
return next;
});
}
};
loadVideo();
return () => {
cancelled = true;
};
}, [currentSceneNumber, currentSceneAnimatedVideoUrl, currentSceneAnimatedVideoBlobUrl, hasVideoLoadError]);
useEffect(() => {
return () => {
videoBlobUrls.forEach((blob) => {
URL.revokeObjectURL(blob);
});
};
}, [videoBlobUrls]);
useEffect(() => {
if (storySections.length > 0) {
setCurrentPageIndex(0);
@@ -502,7 +564,37 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
},
}}
>
{currentSceneImageFullUrl ? (
{showAnimatedVideo ? (
<Box
sx={{
width: '100%',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.18), 0 4px 8px rgba(0, 0, 0, 0.12)',
border: '3px solid rgba(120, 90, 60, 0.25)',
backgroundColor: '#000',
}}
>
<Box
component="video"
src={currentSceneAnimatedVideoBlobUrl ?? undefined}
poster={currentSceneImageFullUrl ?? undefined}
autoPlay
muted
loop
controls
playsInline
sx={{
width: '100%',
height: 'auto',
display: 'block',
minHeight: '300px',
maxHeight: '500px',
objectFit: 'cover',
}}
/>
</Box>
) : currentSceneImageFullUrl ? (
<Box
sx={{
width: '100%',

View File

@@ -123,23 +123,38 @@ export const StoryWriter: React.FC = () => {
setIsGeneratingVideo(true);
try {
const imageUrls: string[] = [];
const imageUrls: (string | null)[] = [];
const audioUrls: string[] = [];
const scenes = state.outlineScenes;
const videoUrls: (string | null)[] = [];
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);
const animatedVideoUrl = state.sceneAnimatedVideos?.get(sceneNumber);
if (imageUrl && audioUrl) {
imageUrls.push(imageUrl);
audioUrls.push(audioUrl);
if (!audioUrl) {
continue; // Skip scenes without audio
}
// Prefer animated video if available, otherwise use image
if (animatedVideoUrl) {
videoUrls.push(animatedVideoUrl);
imageUrls.push(null);
} else if (imageUrl) {
videoUrls.push(null);
imageUrls.push(imageUrl);
} else {
continue; // Skip scenes without image or video
}
audioUrls.push(audioUrl);
}
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
throw new Error('Number of images and audio files must match number of scenes');
throw new Error('Number of images/videos and audio files must match number of scenes');
}
// Switch to async flow so UI can poll progress messages
@@ -147,6 +162,8 @@ export const StoryWriter: React.FC = () => {
scenes: scenes,
image_urls: imageUrls,
audio_urls: audioUrls,
video_urls: videoUrls.length > 0 ? videoUrls : undefined,
ai_audio_urls: undefined, // TODO: Track AI audio separately in state
story_title: state.storySetting || 'Story',
fps: state.videoFps,
transition_duration: state.videoTransitionDuration,

View File

@@ -29,14 +29,30 @@ export const AudioPlayerList: React.FC<AudioPlayerListProps> = ({ scenes, sceneA
for (const [sceneNumber, audioPath] of entries) {
if (!audioPath) continue;
try {
const normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`;
// Normalize path - ensure it starts with /api/story/audio/
let normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`;
// If path doesn't include /api/story/audio/, add it
if (!normalizedPath.includes('/api/story/audio/')) {
// Extract filename from path
const filename = audioPath.split('/').pop() || audioPath;
normalizedPath = `/api/story/audio/${filename}`;
}
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);
} catch (err: any) {
console.error(`Failed to load audio blob for scene ${sceneNumber}:`, err);
console.error(`Audio path was: ${audioPath}`);
console.error(`Normalized path would be: ${audioPath.startsWith('/') ? audioPath : `/${audioPath}`}`);
// If auth error, log more details
if (err?.response?.status === 401) {
console.error(`Authentication failed for audio file. Make sure auth token is set.`);
}
}
}
@@ -87,13 +103,19 @@ export const AudioPlayerList: React.FC<AudioPlayerListProps> = ({ scenes, sceneA
<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>
{blobUrl ? (
<audio
controls
src={blobUrl}
style={{ width: '100%' }}
>
Your browser does not support the audio element.
</audio>
) : (
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
Loading audio...
</Typography>
)}
</Box>
);
})}

View File

@@ -2,15 +2,14 @@ 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 { OperationButton } from '../../shared/OperationButton';
import SceneVideoApproval from './SceneVideoApproval';
// Simple logger for frontend
@@ -94,14 +93,11 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
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,
@@ -240,14 +236,11 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
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,
@@ -303,45 +296,30 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
return (
<>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
Generate HD Animation with AI
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
Upgrade this storyboard into a highdefinition AI animation using Hugging Face texttovideo models.
Your draft was generated affordably (images + narration). This premium option uses an AI model to render motion.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600 }}>
Recommended models:
</Typography>
<Typography variant="caption" component="div" sx={{ display: 'block', mb: 1 }}>
tencent/HunyuanVideo<br />
Lightricks/LTX-Video<br />
Lightricks/LTX-Video-0.9.8-13B-distilled
</Typography>
<Typography variant="caption" sx={{ display: 'block', fontStyle: 'italic' }}>
This will generate HD videos for each scene one at a time. You'll review and approve each scene before the next one is generated.
</Typography>
</Box>
}
arrow
placement="top"
>
<span style={{ display: 'inline-flex' }}>
<Button
variant="contained"
startIcon={<SmartDisplayIcon />}
onClick={handleGenerateHdVideo}
disabled={isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'}
>
{isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'
? 'Generating HD Animation...'
: 'Generate HD Animation with AI'}
</Button>
</span>
</Tooltip>
<OperationButton
operation={{
provider: 'video',
model: 'tencent/HunyuanVideo',
tokens_requested: 0,
operation_type: 'video_generation',
actual_provider_name: 'huggingface',
}}
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' }}>

View File

@@ -40,7 +40,19 @@ export const VideoSection: React.FC<VideoSectionProps> = ({ state, error, onErro
// Load video blob URL when storyVideo changes
useEffect(() => {
if (state.storyVideo) {
fetchMediaBlobUrl(state.storyVideo).then(setVideoBlobUrl);
fetchMediaBlobUrl(state.storyVideo)
.then((blobUrl) => {
if (blobUrl) {
setVideoBlobUrl(blobUrl);
} else {
// File not found - clear the blob URL
setVideoBlobUrl(null);
}
})
.catch((err) => {
console.warn('Failed to load video blob:', err);
setVideoBlobUrl(null);
});
} else {
if (videoBlobUrl) {
URL.revokeObjectURL(videoBlobUrl);
@@ -76,31 +88,50 @@ export const VideoSection: React.FC<VideoSectionProps> = ({ state, error, onErro
setVideoMessage('');
try {
const imageUrls: string[] = [];
const imageUrls: (string | null)[] = [];
const audioUrls: string[] = [];
const scenes = state.outlineScenes;
const videoUrls: (string | null)[] = [];
const aiAudioUrls: (string | null)[] = [];
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);
const animatedVideoUrl = state.sceneAnimatedVideos?.get(sceneNumber);
if (imageUrl && audioUrl) {
imageUrls.push(imageUrl);
audioUrls.push(audioUrl);
} else {
throw new Error(`Missing image or audio for scene ${sceneNumber}`);
if (!audioUrl) {
throw new Error(`Missing audio for scene ${sceneNumber}`);
}
// Prefer animated video if available, otherwise use image
if (animatedVideoUrl) {
videoUrls.push(animatedVideoUrl);
imageUrls.push(null);
} else if (imageUrl) {
videoUrls.push(null);
imageUrls.push(imageUrl);
} else {
throw new Error(`Missing image or animated video for scene ${sceneNumber}`);
}
audioUrls.push(audioUrl);
// AI audio detection: check if URL contains 'ai' or 'wavespeed' (can be enhanced later)
// For now, pass null and backend will use available audio
aiAudioUrls.push(null);
}
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
throw new Error('Number of images and audio files must match number of scenes');
throw new Error('Number of images/videos and audio files must match number of scenes');
}
const start = await storyWriterApi.generateStoryVideoAsync({
scenes: scenes,
image_urls: imageUrls,
audio_urls: audioUrls,
video_urls: videoUrls.length > 0 ? videoUrls : undefined,
ai_audio_urls: undefined, // TODO: Track AI audio separately in state
story_title: state.storySetting || 'Story',
fps: state.videoFps,
transition_duration: state.videoTransitionDuration,
@@ -122,7 +153,9 @@ export const VideoSection: React.FC<VideoSectionProps> = ({ state, error, onErro
if (!finalUrl) throw new Error('Video URL not found in result');
state.setStoryVideo(finalUrl);
const blobUrl = await fetchMediaBlobUrl(finalUrl);
setVideoBlobUrl(blobUrl);
if (blobUrl) {
setVideoBlobUrl(blobUrl);
}
setVideoProgress(100);
setVideoMessage('Video generation complete');
state.setError(null);
@@ -160,6 +193,10 @@ export const VideoSection: React.FC<VideoSectionProps> = ({ state, error, onErro
const handleDownloadVideo = async () => {
if (state.storyVideo) {
const blobUrl = await fetchMediaBlobUrl(state.storyVideo);
if (!blobUrl) {
// File not found - skip download
return;
}
const a = document.createElement('a');
a.href = blobUrl;
a.download = `story-video-${Date.now()}.mp4`;

View File

@@ -0,0 +1,273 @@
import React, { useMemo } from 'react';
import {
Button,
ButtonProps,
Tooltip,
Box,
Typography,
CircularProgress,
} from '@mui/material';
import WarningIcon from '@mui/icons-material/Warning';
import { SxProps, Theme } from '@mui/material/styles';
import { usePreflightCheck, UsePreflightCheckOptions } from '../../hooks/usePreflightCheck';
import { PreflightOperation } from '../../services/billingService';
export interface OperationButtonProps {
// Operation definition
operation: PreflightOperation;
// Button configuration
label: string; // Base label (e.g., "Generate HD Video")
variant?: 'contained' | 'outlined' | 'text';
size?: 'small' | 'medium' | 'large';
color?: 'primary' | 'secondary' | 'success' | 'error';
startIcon?: React.ReactNode;
endIcon?: React.ReactNode;
// Pre-flight check behavior
showCost?: boolean; // Show cost in label (default: true)
checkOnHover?: boolean; // Check on hover (default: true)
checkOnMount?: boolean; // Check on mount (default: false)
// Callbacks
onClick: () => void;
onPreflightResult?: (canProceed: boolean) => void;
// Customization
disabled?: boolean; // Additional disabled state
loading?: boolean; // Loading state override
tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
// Styling
sx?: SxProps<Theme>;
fullWidth?: boolean;
// Additional button props
buttonProps?: Partial<ButtonProps>;
}
/**
* Reusable button component with pre-flight check and cost estimation.
*
* Features:
* - Shows estimated cost in button label
* - Performs pre-flight check on hover (debounced)
* - Shows detailed tooltip with limits/remaining quota
* - Disables button with messaging if blocked
*/
export const OperationButton: React.FC<OperationButtonProps> = ({
operation,
label,
variant = 'contained',
size = 'medium',
color = 'primary',
startIcon,
endIcon,
showCost = true,
checkOnHover = true,
checkOnMount = false,
onClick,
onPreflightResult,
disabled: externalDisabled = false,
loading: externalLoading = false,
tooltipPlacement = 'top',
sx,
fullWidth = false,
buttonProps = {},
}) => {
const preflightOptions: UsePreflightCheckOptions = {
operation,
enabled: checkOnHover || checkOnMount,
debounceMs: 300,
cacheTtl: 5000,
};
const {
canProceed,
estimatedCost,
limitInfo,
loading: preflightLoading,
error: preflightError,
checkOnHover: triggerCheckOnHover,
checkNow: triggerCheckNow,
} = usePreflightCheck(preflightOptions);
// Check on mount if requested
React.useEffect(() => {
if (checkOnMount) {
triggerCheckNow();
}
}, [checkOnMount, triggerCheckNow]);
// Notify parent of pre-flight result changes
React.useEffect(() => {
if (onPreflightResult) {
onPreflightResult(canProceed);
}
}, [canProceed, onPreflightResult]);
// Format cost as currency
const formattedCost = useMemo(() => {
if (!showCost || estimatedCost === 0) {
return null;
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(estimatedCost);
}, [estimatedCost, showCost]);
// Build button label with cost
const buttonLabel = useMemo(() => {
if (formattedCost) {
return `${label} ${formattedCost}`;
}
return label;
}, [label, formattedCost]);
// Determine if button should be disabled
const isDisabled = useMemo(() => {
return externalDisabled || externalLoading || preflightLoading || !canProceed;
}, [externalDisabled, externalLoading, preflightLoading, canProceed]);
// Build tooltip content
const tooltipContent = useMemo(() => {
const content: React.ReactNode[] = [];
if (preflightLoading) {
content.push(
<Typography key="loading" variant="body2" sx={{ mb: 1 }}>
Checking limits...
</Typography>
);
} else if (preflightError) {
content.push(
<Typography key="error" variant="body2" sx={{ mb: 1, color: 'error.main', fontWeight: 600 }}>
{preflightError}
</Typography>
);
} else if (limitInfo) {
const { current_usage, limit, remaining } = limitInfo;
const isUnlimited = limit === 0 || remaining === Infinity;
content.push(
<Box key="limits" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{canProceed ? '✅ Operation Allowed' : '❌ Operation Blocked'}
</Typography>
{isUnlimited ? (
<Typography variant="caption" sx={{ display: 'block' }}>
Usage: {current_usage} / Unlimited
</Typography>
) : (
<Typography variant="caption" sx={{ display: 'block' }}>
Usage: {current_usage} / {limit} ({remaining} remaining)
</Typography>
)}
{formattedCost && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, fontWeight: 600 }}>
Estimated Cost: {formattedCost}
</Typography>
)}
</Box>
);
}
if (preflightError && !canProceed) {
content.push(
<Typography key="message" variant="caption" sx={{ display: 'block', color: 'error.main' }}>
{preflightError}
</Typography>
);
}
return content.length > 0 ? <Box sx={{ p: 0.5 }}>{content}</Box> : null;
}, [canProceed, estimatedCost, formattedCost, limitInfo, preflightError, preflightLoading]);
// Handle hover
const handleMouseEnter = () => {
if (checkOnHover) {
triggerCheckOnHover();
}
};
// Handle click
const handleClick = () => {
if (!isDisabled && canProceed) {
onClick();
}
};
// Determine button color based on state
const buttonColor = useMemo(() => {
if (!canProceed) {
return 'error';
}
return color;
}, [canProceed, color]);
// Determine if we should show loading spinner
const showLoading = externalLoading || (preflightLoading && checkOnMount);
// Custom label override for loading state
const displayLabel = useMemo(() => {
if (externalLoading && buttonProps?.children) {
return buttonProps.children;
}
if (showLoading && !externalLoading) {
return 'Checking...';
}
if (!canProceed && preflightError) {
return preflightError;
}
return buttonLabel;
}, [externalLoading, showLoading, canProceed, preflightError, buttonLabel, buttonProps?.children]);
// Build button with icon
const button = (
<Button
variant={variant}
size={size}
color={buttonColor}
startIcon={
showLoading ? (
<CircularProgress size={16} color="inherit" />
) : !canProceed ? (
<WarningIcon fontSize="small" />
) : (
startIcon
)
}
endIcon={endIcon}
onClick={handleClick}
disabled={isDisabled}
fullWidth={fullWidth}
onMouseEnter={handleMouseEnter}
sx={sx}
{...buttonProps}
>
{displayLabel}
</Button>
);
// Wrap with tooltip if we have content
if (tooltipContent || checkOnHover) {
return (
<Tooltip
title={tooltipContent || 'Hover to check limits'}
arrow
placement={tooltipPlacement}
onOpen={handleMouseEnter}
>
<span style={{ display: 'inline-flex' }}>
{button}
</span>
</Tooltip>
);
}
return button;
};

View File

@@ -0,0 +1,257 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import {
checkPreflight,
PreflightOperation,
PreflightCheckResponse,
PreflightLimitInfo,
} from '../services/billingService';
export interface UsePreflightCheckOptions {
operation: PreflightOperation;
enabled?: boolean; // Whether to perform check on hover
debounceMs?: number; // Debounce delay (default: 300ms)
cacheTtl?: number; // Cache TTL in ms (default: 5000ms)
}
export interface UsePreflightCheckResult {
canProceed: boolean;
estimatedCost: number;
limitInfo: PreflightLimitInfo | null;
loading: boolean;
error: string | null;
checkOnHover: () => void;
checkNow: () => void; // Immediate check
reset: () => void;
}
interface CacheEntry {
data: PreflightCheckResponse;
timestamp: number;
}
/**
* React hook for pre-flight checking operations with cost estimation.
*
* Features:
* - Debounced hover checks (300ms default)
* - In-memory caching (5s default TTL)
* - Request cancellation on unmount
*/
export const usePreflightCheck = (
options: UsePreflightCheckOptions
): UsePreflightCheckResult => {
const {
operation,
enabled = true,
debounceMs = 300,
cacheTtl = 5000,
} = options;
const [canProceed, setCanProceed] = useState<boolean>(true);
const [estimatedCost, setEstimatedCost] = useState<number>(0);
const [limitInfo, setLimitInfo] = useState<PreflightLimitInfo | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Cache for pre-flight check results
const cacheRef = useRef<Map<string, CacheEntry>>(new Map());
// Debounce timer ref
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// Abort controller for request cancellation
const abortControllerRef = useRef<AbortController | null>(null);
// Generate cache key from operation
const getCacheKey = useCallback(() => {
return JSON.stringify(operation);
}, [operation]);
// Check if cached result is still valid
const getCachedResult = useCallback((): PreflightCheckResponse | null => {
const cacheKey = getCacheKey();
const cached = cacheRef.current.get(cacheKey);
if (cached) {
const age = Date.now() - cached.timestamp;
if (age < cacheTtl) {
return cached.data;
}
// Cache expired, remove it
cacheRef.current.delete(cacheKey);
}
return null;
}, [getCacheKey, cacheTtl]);
// Store result in cache
const setCache = useCallback((data: PreflightCheckResponse) => {
const cacheKey = getCacheKey();
cacheRef.current.set(cacheKey, {
data,
timestamp: Date.now(),
});
}, [getCacheKey]);
// Perform actual pre-flight check
const performCheck = useCallback(async (): Promise<void> => {
if (!enabled) {
return;
}
// Check cache first
const cached = getCachedResult();
if (cached) {
updateState(cached);
return;
}
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
const currentAbortController = abortControllerRef.current;
setLoading(true);
setError(null);
try {
const response = await checkPreflight(operation);
// Check if request was cancelled
if (currentAbortController.signal.aborted) {
return;
}
// Cache the result
setCache(response);
// Update state
updateState(response);
} catch (err: any) {
// Check if request was cancelled
if (currentAbortController.signal.aborted) {
return;
}
const errorMessage = err?.message || 'Pre-flight check failed';
setError(errorMessage);
setCanProceed(false);
setEstimatedCost(0);
setLimitInfo(null);
} finally {
if (!currentAbortController.signal.aborted) {
setLoading(false);
}
}
}, [operation, enabled, getCachedResult, setCache]);
// Update state from response
const updateState = useCallback((response: PreflightCheckResponse) => {
setCanProceed(response.can_proceed);
setEstimatedCost(response.estimated_cost);
// Get limit info from first operation (for single operation checks)
const firstOp = response.operations[0];
if (firstOp) {
setLimitInfo(firstOp.limit_info);
if (!response.can_proceed && firstOp.message) {
setError(firstOp.message);
} else {
setError(null);
}
} else {
setLimitInfo(null);
}
}, []);
// Debounced check for hover events
const checkOnHover = useCallback(() => {
if (!enabled) {
return;
}
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Check cache first (no debounce for cache hits)
const cached = getCachedResult();
if (cached) {
updateState(cached);
return;
}
// Debounce the actual API call
debounceTimerRef.current = setTimeout(() => {
performCheck();
}, debounceMs);
}, [enabled, debounceMs, getCachedResult, updateState, performCheck]);
// Immediate check (no debounce)
const checkNow = useCallback(() => {
if (!enabled) {
return;
}
// Clear any pending debounced check
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
performCheck();
}, [enabled, performCheck]);
// Reset state
const reset = useCallback(() => {
setCanProceed(true);
setEstimatedCost(0);
setLimitInfo(null);
setLoading(false);
setError(null);
// Clear debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
// Clear debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return {
canProceed,
estimatedCost,
limitInfo,
loading,
error,
checkOnHover,
checkNow,
reset,
};
};

View File

@@ -7,6 +7,13 @@ import {
StoryFullGenerationResponse,
} from '../services/storyWriterApi';
export interface SceneAnimationResume {
predictionId: string;
duration: 5 | 10;
message?: string;
createdAt?: string;
}
export interface StoryWriterState {
// Story parameters (Setup phase)
persona: string;
@@ -52,6 +59,8 @@ export interface StoryWriterState {
sceneAudio: Map<number, string> | null; // Generated audio URLs by scene number
storyVideo: string | null; // Generated video URL
sceneHdVideos: Map<number, string> | null; // Approved HD video URLs by scene number
sceneAnimatedVideos: Map<number, string> | null; // Animated scene preview videos
sceneAnimationResumables: Map<number, SceneAnimationResume> | null; // Pending resume info per scene
hdVideoGenerationStatus: 'idle' | 'generating' | 'awaiting_approval' | 'completed' | 'paused';
currentHdSceneIndex: number; // Which scene is currently being generated/reviewed
@@ -104,6 +113,8 @@ const DEFAULT_STATE: Partial<StoryWriterState> = {
sceneAudio: null,
storyVideo: null,
sceneHdVideos: null,
sceneAnimatedVideos: null,
sceneAnimationResumables: null,
hdVideoGenerationStatus: 'idle',
currentHdSceneIndex: 0,
currentTaskId: null,
@@ -148,6 +159,8 @@ export const useStoryWriterState = () => {
sceneImages: parsed.sceneImages ? new Map(parsed.sceneImages) : null,
sceneAudio: parsed.sceneAudio ? new Map(parsed.sceneAudio) : null,
sceneHdVideos: parsed.sceneHdVideos ? new Map(parsed.sceneHdVideos) : null,
sceneAnimatedVideos: parsed.sceneAnimatedVideos ? new Map(parsed.sceneAnimatedVideos) : null,
sceneAnimationResumables: parsed.sceneAnimationResumables ? new Map(parsed.sceneAnimationResumables) : null,
};
return restoredState as StoryWriterState;
@@ -193,6 +206,12 @@ export const useStoryWriterState = () => {
sceneImages: persistableState.sceneImages ? Array.from(persistableState.sceneImages.entries()) : null,
sceneAudio: persistableState.sceneAudio ? Array.from(persistableState.sceneAudio.entries()) : null,
sceneHdVideos: persistableState.sceneHdVideos ? Array.from(persistableState.sceneHdVideos.entries()) : null,
sceneAnimatedVideos: persistableState.sceneAnimatedVideos
? Array.from(persistableState.sceneAnimatedVideos.entries())
: null,
sceneAnimationResumables: persistableState.sceneAnimationResumables
? Array.from(persistableState.sceneAnimationResumables.entries())
: null,
};
localStorage.setItem('story_writer_state', JSON.stringify(serializableState));
@@ -337,6 +356,14 @@ export const useStoryWriterState = () => {
setState((prev) => ({ ...prev, sceneImages: images }));
}, []);
const setSceneAnimatedVideos = useCallback((videos: Map<number, string> | null) => {
setState((prev) => ({ ...prev, sceneAnimatedVideos: videos }));
}, []);
const setSceneAnimationResumables = useCallback((resumables: Map<number, SceneAnimationResume> | null) => {
setState((prev) => ({ ...prev, sceneAnimationResumables: resumables }));
}, []);
const setSceneAudio = useCallback((audio: Map<number, string> | null) => {
setState((prev) => ({ ...prev, sceneAudio: audio }));
}, []);
@@ -471,6 +498,8 @@ export const useStoryWriterState = () => {
setSceneAudio,
setStoryVideo,
setSceneHdVideos,
setSceneAnimatedVideos,
setSceneAnimationResumables,
setHdVideoGenerationStatus,
setCurrentHdSceneIndex,
setCurrentTaskId,

View File

@@ -587,6 +587,127 @@ export const formatCurrency = (amount: number): string => {
}).format(amount);
};
// Pre-flight check interfaces
export interface PreflightOperation {
provider: string;
model?: string;
tokens_requested?: number;
operation_type: string;
actual_provider_name?: string;
}
export interface PreflightLimitInfo {
current_usage: number;
limit: number;
remaining: number;
}
export interface PreflightOperationResult {
provider: string;
operation_type: string;
cost: number;
allowed: boolean;
limit_info: PreflightLimitInfo | null;
message: string | null;
}
export interface PreflightCheckResponse {
can_proceed: boolean;
estimated_cost: number;
operations: PreflightOperationResult[];
total_cost: number;
usage_summary: {
current_calls: number;
limit: number;
remaining: number;
} | null;
cached: boolean;
}
/**
* Check pre-flight validation for a single operation.
* Returns cost estimation, limits check, and usage information.
*/
export const checkPreflight = async (
operation: PreflightOperation
): Promise<PreflightCheckResponse> => {
try {
const response = await billingAPI.post<{ success: boolean; data: PreflightCheckResponse }>(
'/preflight-check',
{
operations: [operation]
}
);
if (!response.data.success) {
throw new Error('Pre-flight check failed');
}
return response.data.data;
} catch (error: any) {
console.error('[BillingService] Pre-flight check error:', error);
// Return a safe default response on error
return {
can_proceed: false,
estimated_cost: 0,
operations: [{
provider: operation.provider,
operation_type: operation.operation_type,
cost: 0,
allowed: false,
limit_info: null,
message: error?.response?.data?.detail || 'Pre-flight check failed'
}],
total_cost: 0,
usage_summary: null,
cached: false
};
}
};
/**
* Check pre-flight validation for multiple operations in a single request.
* Useful for pages with many buttons to reduce API calls.
*/
export const checkPreflightBatch = async (
operations: PreflightOperation[]
): Promise<PreflightCheckResponse> => {
try {
const response = await billingAPI.post<{ success: boolean; data: PreflightCheckResponse }>(
'/preflight-check',
{
operations
}
);
if (!response.data.success) {
throw new Error('Pre-flight check failed');
}
return response.data.data;
} catch (error: any) {
console.error('[BillingService] Pre-flight batch check error:', error);
// Return a safe default response on error
return {
can_proceed: false,
estimated_cost: 0,
operations: operations.map(op => ({
provider: op.provider,
operation_type: op.operation_type,
cost: 0,
allowed: false,
limit_info: null,
message: error?.response?.data?.detail || 'Pre-flight check failed'
})),
total_cost: 0,
usage_summary: null,
cached: false
};
}
};
export const formatNumber = (num: number): string => {
return new Intl.NumberFormat('en-US').format(num);
};

View File

@@ -204,8 +204,10 @@ export interface StoryAudioGenerationResponse {
export interface StoryVideoGenerationRequest {
scenes: StoryScene[];
image_urls: string[];
image_urls: (string | null)[];
audio_urls: string[];
video_urls?: (string | null)[] | null;
ai_audio_urls?: (string | null)[] | null;
story_title?: string;
fps?: number;
transition_duration?: number;
@@ -227,6 +229,38 @@ export interface StoryVideoGenerationResponse {
task_id?: string;
}
export interface AnimateSceneRequest {
scene_number: number;
scene_data: StoryScene;
story_context: Record<string, any>;
image_url: string;
duration?: 5 | 10;
}
export interface AnimateSceneVoiceoverRequest extends AnimateSceneRequest {
audio_url: string;
resolution?: '480p' | '720p';
prompt?: string;
}
export interface AnimateSceneResponse {
success: boolean;
scene_number: number;
video_filename: string;
video_url: string;
duration: number;
cost: number;
prompt_used: string;
provider: string;
prediction_id?: string;
}
export interface ResumeAnimateSceneRequest {
prediction_id: string;
scene_number: number;
duration?: 5 | 10;
}
class StoryWriterApi {
/**
* Generate 3 story setup options from a user's story idea
@@ -373,20 +407,63 @@ class StoryWriterApi {
return response.data;
}
/**
* Animate a single scene image into a short video preview
*/
async animateScene(request: AnimateSceneRequest): Promise<AnimateSceneResponse> {
const response = await aiApiClient.post<AnimateSceneResponse>(
"/api/story/animate-scene-preview",
request
);
return response.data;
}
/**
* Animate a scene image using WaveSpeed InfiniteTalk with voiceover (async)
* Returns task_id for polling since InfiniteTalk can take up to 10 minutes.
*/
async animateSceneVoiceover(request: AnimateSceneVoiceoverRequest): Promise<{ task_id: string; status: string; message: string }> {
const response = await aiApiClient.post<{ task_id: string; status: string; message: string }>(
"/api/story/animate-scene-voiceover",
request
);
return response.data;
}
/**
* Resume a timed-out scene animation download using the prediction id
*/
async resumeAnimateScene(request: ResumeAnimateSceneRequest): Promise<AnimateSceneResponse> {
const response = await aiApiClient.post<AnimateSceneResponse>(
"/api/story/animate-scene-resume",
request
);
return response.data;
}
private buildAbsoluteUrl(path: string): string {
if (!path) return path;
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const baseURL = aiApiClient.defaults.baseURL || '';
const cleanBaseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${cleanBaseURL}${cleanPath}`;
}
/**
* Get image URL for a scene image
*/
getImageUrl(imageUrl: string): string {
// If imageUrl is already a full URL, return it as-is
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
return this.buildAbsoluteUrl(imageUrl);
}
// Otherwise, prepend the base URL
const baseURL = aiApiClient.defaults.baseURL || '';
// Remove trailing slash from baseURL if present, and leading slash from imageUrl if present
const cleanBaseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
const cleanImageUrl = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
return `${cleanBaseURL}${cleanImageUrl}`;
/**
* Convert any relative media URL to absolute
*/
getMediaUrl(path: string): string {
return this.buildAbsoluteUrl(path);
}
/**
@@ -400,6 +477,165 @@ class StoryWriterApi {
return response.data;
}
/**
* Optimize an image prompt using WaveSpeed prompt optimizer
*/
async optimizePrompt(request: {
text: string;
mode?: 'image' | 'video';
style?: 'default' | 'artistic' | 'photographic' | 'technical' | 'anime' | 'realistic';
image?: string;
}): Promise<{ optimized_prompt: string; success: boolean }> {
const response = await aiApiClient.post<{ optimized_prompt: string; success: boolean }>(
"/api/story/optimize-prompt",
request
);
return response.data;
}
/**
* Regenerate a scene image using a direct prompt (no AI prompt generation)
*/
async regenerateSceneImage(request: {
scene_number: number;
scene_title: string;
prompt: string;
provider?: string;
width?: number;
height?: number;
model?: string;
}): Promise<{
scene_number: number;
scene_title: string;
image_filename: string;
image_url: string;
width: number;
height: number;
provider: string;
model?: string;
seed?: number;
success: boolean;
error?: string;
}> {
const response = await aiApiClient.post<{
scene_number: number;
scene_title: string;
image_filename: string;
image_url: string;
width: number;
height: number;
provider: string;
model?: string;
seed?: number;
success: boolean;
error?: string;
}>(
"/api/story/regenerate-images",
request
);
return response.data;
}
/**
* Generate AI audio for a single scene using WaveSpeed Minimax Speech 02 HD
*/
async generateAIAudio(request: {
scene_number: number;
scene_title: string;
text: string;
voice_id?: string;
speed?: number;
volume?: number;
pitch?: number;
emotion?: string;
}): Promise<{
scene_number: number;
scene_title: string;
audio_filename: string;
audio_url: string;
provider: string;
model: string;
voice_id: string;
text_length: number;
file_size: number;
cost: number;
success: boolean;
error?: string;
}> {
const response = await aiApiClient.post<{
scene_number: number;
scene_title: string;
audio_filename: string;
audio_url: string;
provider: string;
model: string;
voice_id: string;
text_length: number;
file_size: number;
cost: number;
success: boolean;
error?: string;
}>(
"/api/story/generate-ai-audio",
request
);
return response.data;
}
/**
* Generate free audio for a single scene using gTTS
*/
async generateFreeAudio(request: {
scene_number: number;
scene_title: string;
text: string;
provider?: string;
lang?: string;
slow?: boolean;
rate?: number;
}): Promise<{
scene_number: number;
scene_title: string;
audio_filename: string;
audio_url: string;
provider: string;
file_size: number;
success: boolean;
error?: string;
}> {
// Use existing generateSceneAudio endpoint but for a single scene
const response = await aiApiClient.post<StoryAudioGenerationResponse>(
"/api/story/generate-audio",
{
scenes: [{
scene_number: request.scene_number,
title: request.scene_title,
audio_narration: request.text,
}],
provider: request.provider || 'gtts',
lang: request.lang || 'en',
slow: request.slow || false,
rate: request.rate || 150,
}
);
const result = response.data;
if (result.success && result.audio_files && result.audio_files.length > 0) {
const audio = result.audio_files[0];
return {
scene_number: audio.scene_number,
scene_title: audio.scene_title,
audio_filename: audio.audio_filename,
audio_url: audio.audio_url,
provider: audio.provider,
file_size: audio.file_size,
success: true,
error: audio.error,
};
} else {
throw new Error(result.audio_files?.[0]?.error || 'Failed to generate audio');
}
}
/**
* Get audio URL for a scene audio file
*/
@@ -496,7 +732,6 @@ class StoryWriterApi {
scene_data: StoryScene;
story_context: Record<string, any>;
all_scenes: StoryScene[];
scene_image_url?: string;
provider?: string;
model?: string;
num_frames?: number;

View File

@@ -1,9 +1,19 @@
import { aiApiClient } from "../api/client";
export async function fetchMediaBlobUrl(pathOrUrl: string): Promise<string> {
const rel = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
const res = await aiApiClient.get(rel, { responseType: "blob" });
return URL.createObjectURL(res.data);
export async function fetchMediaBlobUrl(pathOrUrl: string): Promise<string | null> {
try {
const rel = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
const res = await aiApiClient.get(rel, { responseType: "blob" });
return URL.createObjectURL(res.data);
} catch (err: any) {
// Gracefully handle 404s and other errors - file might not exist or was regenerated
if (err?.response?.status === 404) {
console.warn(`Media file not found (404): ${pathOrUrl}`);
return null;
}
// Re-throw other errors
throw err;
}
}