Upload 25 files
Browse files- App.tsx +139 -30
- README.md +1 -1
- components/ApiKeySetup.tsx +30 -14
- components/ComicPreview.tsx +114 -8
- components/ComicSeriesManager.tsx +421 -0
- components/EnhancedLoadingIndicator.tsx +218 -0
- components/ExpandableImageModal.tsx +103 -0
- components/ExplorationPrompt.tsx +93 -0
- components/LazyImage.tsx +105 -0
- components/LoadingIndicator.tsx +13 -11
- components/PerformanceSummary.tsx +119 -0
- components/StoryInput.tsx +32 -1
- components/StoryProgressionSelector.tsx +324 -0
- index.css +374 -50
- index.html +0 -1
- package.json +1 -0
- services/geminiService.ts +783 -43
- services/pdfService.ts +345 -24
- types.ts +102 -0
- utils/imageOptimization.ts +223 -0
- vite.config.ts +3 -0
App.tsx
CHANGED
@@ -1,10 +1,22 @@
|
|
1 |
import React, { useState, useCallback, useEffect } from 'react';
|
2 |
import StoryInput from './components/StoryInput';
|
3 |
-
import
|
4 |
-
import
|
5 |
import ApiKeySetup from './components/ApiKeySetup';
|
|
|
6 |
import { generateMangaScriptAndImages } from './services/geminiService';
|
7 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
9 |
function App() {
|
10 |
const [apiKey, setApiKey] = useState<string>('');
|
@@ -12,9 +24,22 @@ function App() {
|
|
12 |
const [story, setStory] = useState('');
|
13 |
const [author, setAuthor] = useState('');
|
14 |
const [style, setStyle] = useState<MangaStyle>('Shonen');
|
15 |
-
const [
|
|
|
|
|
|
|
|
|
16 |
const [generatedImages, setGeneratedImages] = useState<string[]>([]);
|
|
|
|
|
|
|
|
|
|
|
17 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
18 |
|
19 |
// Check for stored API key on component mount
|
20 |
useEffect(() => {
|
@@ -29,21 +54,35 @@ function App() {
|
|
29 |
|
30 |
setError(null);
|
31 |
setGeneratedImages([]);
|
32 |
-
|
|
|
|
|
|
|
|
|
|
|
33 |
|
34 |
try {
|
35 |
-
const
|
36 |
-
{ title, story, author, style },
|
37 |
-
(progressUpdate) =>
|
38 |
apiKey
|
39 |
);
|
40 |
-
setGeneratedImages(images);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
} catch (err) {
|
42 |
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
43 |
setError(`Manga generation failed: ${errorMessage} Please check the console for details and try again.`);
|
44 |
console.error(err);
|
45 |
} finally {
|
46 |
-
|
47 |
}
|
48 |
}, [title, story, author, style]);
|
49 |
|
@@ -53,13 +92,46 @@ function App() {
|
|
53 |
setAuthor('');
|
54 |
setStyle('Shonen');
|
55 |
setGeneratedImages([]);
|
|
|
|
|
|
|
|
|
|
|
56 |
setError(null);
|
57 |
-
|
|
|
|
|
|
|
|
|
58 |
}, []);
|
59 |
|
60 |
const handleApiKeySet = useCallback((newApiKey: string) => {
|
61 |
setApiKey(newApiKey);
|
62 |
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
63 |
|
64 |
const renderContent = () => {
|
65 |
// Show API key setup if no key is available
|
@@ -67,11 +139,30 @@ function App() {
|
|
67 |
return <ApiKeySetup onApiKeySet={handleApiKeySet} />;
|
68 |
}
|
69 |
|
70 |
-
if (
|
71 |
-
return <
|
72 |
}
|
73 |
if (generatedImages.length > 0) {
|
74 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
75 |
}
|
76 |
return (
|
77 |
<StoryInput
|
@@ -84,26 +175,33 @@ function App() {
|
|
84 |
style={style}
|
85 |
onStyleChange={setStyle}
|
86 |
onGenerate={handleGenerate}
|
87 |
-
disabled={
|
|
|
|
|
88 |
/>
|
89 |
);
|
90 |
};
|
91 |
|
92 |
return (
|
93 |
<div className="min-h-screen text-white">
|
94 |
-
<header className="relative overflow-hidden py-
|
95 |
-
<div className="absolute inset-0 bg-gradient-to-br from-
|
96 |
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
97 |
<div className="text-center">
|
98 |
-
<h1 className="text-
|
99 |
Comic <span className="gradient-text">Genesis</span> AI
|
100 |
</h1>
|
101 |
-
<p className="text-xl sm:text-2xl text-white/
|
102 |
Professional Comic Book Creation Studio
|
103 |
</p>
|
104 |
-
<p className="text-lg text-white/
|
105 |
-
Transform your stories into stunning visual narratives with
|
106 |
</p>
|
|
|
|
|
|
|
|
|
|
|
107 |
</div>
|
108 |
</div>
|
109 |
</header>
|
@@ -111,16 +209,18 @@ function App() {
|
|
111 |
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
112 |
<div className="max-w-6xl mx-auto">
|
113 |
{error && (
|
114 |
-
<div className="glass-card border-red-400 bg-red-500/
|
115 |
-
<div className="flex items-
|
116 |
-
<div className="flex-shrink-0">
|
117 |
-
<
|
118 |
-
<
|
119 |
-
|
|
|
|
|
120 |
</div>
|
121 |
-
<div className="
|
122 |
-
<
|
123 |
-
<
|
124 |
</div>
|
125 |
</div>
|
126 |
</div>
|
@@ -129,6 +229,15 @@ function App() {
|
|
129 |
</div>
|
130 |
</main>
|
131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
132 |
<footer className="text-center py-8 px-4 text-white/50 text-sm border-t border-white/10">
|
133 |
<div className="max-w-4xl mx-auto">
|
134 |
<p className="mb-2">🎨 Powered by Google Gemini Nanobanana • Built for Professional Comic Creators</p>
|
|
|
1 |
import React, { useState, useCallback, useEffect } from 'react';
|
2 |
import StoryInput from './components/StoryInput';
|
3 |
+
import EnhancedLoadingIndicator from './components/EnhancedLoadingIndicator';
|
4 |
+
import ComicSeriesManager from './components/ComicSeriesManager';
|
5 |
import ApiKeySetup from './components/ApiKeySetup';
|
6 |
+
import ExplorationPrompt from './components/ExplorationPrompt';
|
7 |
import { generateMangaScriptAndImages } from './services/geminiService';
|
8 |
+
import { PerformanceTracker } from './utils/imageOptimization';
|
9 |
+
import { createMangaPdf } from './services/pdfService';
|
10 |
+
import type {
|
11 |
+
MangaStyle,
|
12 |
+
GenerationProgress,
|
13 |
+
CharacterProfile,
|
14 |
+
MangaPage,
|
15 |
+
CharacterSketch,
|
16 |
+
EnvironmentSketch,
|
17 |
+
StoryArcSummary,
|
18 |
+
PerformanceTiming
|
19 |
+
} from './types';
|
20 |
|
21 |
function App() {
|
22 |
const [apiKey, setApiKey] = useState<string>('');
|
|
|
24 |
const [story, setStory] = useState('');
|
25 |
const [author, setAuthor] = useState('');
|
26 |
const [style, setStyle] = useState<MangaStyle>('Shonen');
|
27 |
+
const [generationProgress, setGenerationProgress] = useState<GenerationProgress>({
|
28 |
+
phase: 'character_analysis',
|
29 |
+
message: '',
|
30 |
+
progress: 0
|
31 |
+
});
|
32 |
const [generatedImages, setGeneratedImages] = useState<string[]>([]);
|
33 |
+
const [generatedCharacters, setGeneratedCharacters] = useState<CharacterProfile[]>([]);
|
34 |
+
const [generatedPages, setGeneratedPages] = useState<MangaPage[]>([]);
|
35 |
+
const [characterSketches, setCharacterSketches] = useState<CharacterSketch[]>([]);
|
36 |
+
const [environmentSketches, setEnvironmentSketches] = useState<EnvironmentSketch[]>([]);
|
37 |
+
const [storyArcSummary, setStoryArcSummary] = useState<StoryArcSummary | null>(null);
|
38 |
const [error, setError] = useState<string | null>(null);
|
39 |
+
const [performanceTimings, setPerformanceTimings] = useState<PerformanceTiming[]>([]);
|
40 |
+
const [totalGenerationTime, setTotalGenerationTime] = useState<number>(0);
|
41 |
+
const [showExplorationPrompt, setShowExplorationPrompt] = useState(false);
|
42 |
+
const [includeDialogue, setIncludeDialogue] = useState<boolean>(true);
|
43 |
|
44 |
// Check for stored API key on component mount
|
45 |
useEffect(() => {
|
|
|
54 |
|
55 |
setError(null);
|
56 |
setGeneratedImages([]);
|
57 |
+
setGeneratedCharacters([]);
|
58 |
+
setGeneratedPages([]);
|
59 |
+
setCharacterSketches([]);
|
60 |
+
setEnvironmentSketches([]);
|
61 |
+
setStoryArcSummary(null);
|
62 |
+
setGenerationProgress({ phase: 'character_analysis', message: 'Starting...', progress: 0 });
|
63 |
|
64 |
try {
|
65 |
+
const result = await generateMangaScriptAndImages(
|
66 |
+
{ title, story, author, style, includeDialogue },
|
67 |
+
(progressUpdate) => setGenerationProgress(progressUpdate),
|
68 |
apiKey
|
69 |
);
|
70 |
+
setGeneratedImages(result.images);
|
71 |
+
setGeneratedCharacters(result.characters);
|
72 |
+
setGeneratedPages(result.pages);
|
73 |
+
setCharacterSketches(result.characterSketches);
|
74 |
+
setEnvironmentSketches(result.environmentSketches);
|
75 |
+
setStoryArcSummary(result.storyArcSummary);
|
76 |
+
setPerformanceTimings(result.performanceTimings);
|
77 |
+
setTotalGenerationTime(result.totalGenerationTime);
|
78 |
+
|
79 |
+
// Don't auto-show exploration prompt - only when user clicks download
|
80 |
} catch (err) {
|
81 |
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
82 |
setError(`Manga generation failed: ${errorMessage} Please check the console for details and try again.`);
|
83 |
console.error(err);
|
84 |
} finally {
|
85 |
+
setGenerationProgress({ phase: 'completion', message: '', progress: 0 });
|
86 |
}
|
87 |
}, [title, story, author, style]);
|
88 |
|
|
|
92 |
setAuthor('');
|
93 |
setStyle('Shonen');
|
94 |
setGeneratedImages([]);
|
95 |
+
setGeneratedCharacters([]);
|
96 |
+
setGeneratedPages([]);
|
97 |
+
setCharacterSketches([]);
|
98 |
+
setEnvironmentSketches([]);
|
99 |
+
setStoryArcSummary(null);
|
100 |
setError(null);
|
101 |
+
setPerformanceTimings([]);
|
102 |
+
setTotalGenerationTime(0);
|
103 |
+
setShowExplorationPrompt(false);
|
104 |
+
setIncludeDialogue(true);
|
105 |
+
setGenerationProgress({ phase: 'character_analysis', message: '', progress: 0 });
|
106 |
}, []);
|
107 |
|
108 |
const handleApiKeySet = useCallback((newApiKey: string) => {
|
109 |
setApiKey(newApiKey);
|
110 |
}, []);
|
111 |
+
|
112 |
+
const handleExplorationContinue = useCallback(() => {
|
113 |
+
setShowExplorationPrompt(false);
|
114 |
+
// The ComicSeriesManager will handle the continuation logic
|
115 |
+
}, []);
|
116 |
+
|
117 |
+
const handleExplorationFinish = useCallback((removeApiKey: boolean) => {
|
118 |
+
console.log('handleExplorationFinish called - downloading PDF');
|
119 |
+
setShowExplorationPrompt(false);
|
120 |
+
|
121 |
+
// Download PDF automatically
|
122 |
+
if (generatedImages.length > 0) {
|
123 |
+
console.log('Calling createMangaPdf...');
|
124 |
+
createMangaPdf(generatedImages, title);
|
125 |
+
} else {
|
126 |
+
console.warn('No generated images available for PDF creation');
|
127 |
+
}
|
128 |
+
|
129 |
+
// No API key removal - keep it stored for future use
|
130 |
+
}, [generatedImages, title]);
|
131 |
+
|
132 |
+
const handleDownloadClick = useCallback(() => {
|
133 |
+
setShowExplorationPrompt(true);
|
134 |
+
}, []);
|
135 |
|
136 |
const renderContent = () => {
|
137 |
// Show API key setup if no key is available
|
|
|
139 |
return <ApiKeySetup onApiKeySet={handleApiKeySet} />;
|
140 |
}
|
141 |
|
142 |
+
if (generationProgress.progress > 0 && generationProgress.progress < 100) {
|
143 |
+
return <EnhancedLoadingIndicator generationProgress={generationProgress} />;
|
144 |
}
|
145 |
if (generatedImages.length > 0) {
|
146 |
+
return (
|
147 |
+
<ComicSeriesManager
|
148 |
+
initialImages={generatedImages}
|
149 |
+
title={title}
|
150 |
+
story={story}
|
151 |
+
author={author}
|
152 |
+
style={style}
|
153 |
+
apiKey={apiKey}
|
154 |
+
onReset={handleReset}
|
155 |
+
characters={generatedCharacters}
|
156 |
+
pages={generatedPages}
|
157 |
+
characterSketches={characterSketches}
|
158 |
+
environmentSketches={environmentSketches}
|
159 |
+
storyArcSummary={storyArcSummary}
|
160 |
+
performanceTimings={performanceTimings}
|
161 |
+
totalGenerationTime={totalGenerationTime}
|
162 |
+
onDownloadClick={handleDownloadClick}
|
163 |
+
includeDialogue={includeDialogue}
|
164 |
+
/>
|
165 |
+
);
|
166 |
}
|
167 |
return (
|
168 |
<StoryInput
|
|
|
175 |
style={style}
|
176 |
onStyleChange={setStyle}
|
177 |
onGenerate={handleGenerate}
|
178 |
+
disabled={generationProgress.progress > 0 && generationProgress.progress < 100}
|
179 |
+
includeDialogue={includeDialogue}
|
180 |
+
onDialogueToggle={setIncludeDialogue}
|
181 |
/>
|
182 |
);
|
183 |
};
|
184 |
|
185 |
return (
|
186 |
<div className="min-h-screen text-white">
|
187 |
+
<header className="relative overflow-hidden py-12">
|
188 |
+
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-gray-900/20 backdrop-blur-sm"></div>
|
189 |
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
190 |
<div className="text-center">
|
191 |
+
<h1 className="text-5xl sm:text-7xl font-black mb-6 animate-fade-in tracking-tight">
|
192 |
Comic <span className="gradient-text">Genesis</span> AI
|
193 |
</h1>
|
194 |
+
<p className="text-xl sm:text-2xl text-white/90 mb-4 animate-slide-in font-medium">
|
195 |
Professional Comic Book Creation Studio
|
196 |
</p>
|
197 |
+
<p className="text-lg text-white/70 animate-slide-in max-w-2xl mx-auto leading-relaxed">
|
198 |
+
Transform your stories into stunning visual narratives with cutting-edge AI artistry
|
199 |
</p>
|
200 |
+
<div className="mt-8 flex justify-center">
|
201 |
+
<div className="glass-card px-6 py-3 text-sm text-white/80">
|
202 |
+
✨ Powered by Google Gemini 2.5 Flash • Professional Quality Results
|
203 |
+
</div>
|
204 |
+
</div>
|
205 |
</div>
|
206 |
</div>
|
207 |
</header>
|
|
|
209 |
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
210 |
<div className="max-w-6xl mx-auto">
|
211 |
{error && (
|
212 |
+
<div className="glass-card border-red-400/30 bg-red-500/10 text-red-100 px-8 py-6 mb-8 animate-fade-in" role="alert">
|
213 |
+
<div className="flex items-start space-x-4">
|
214 |
+
<div className="flex-shrink-0 mt-1">
|
215 |
+
<div className="w-8 h-8 bg-red-500/20 rounded-full flex items-center justify-center">
|
216 |
+
<svg className="w-5 h-5 text-red-300" fill="currentColor" viewBox="0 0 20 20">
|
217 |
+
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
218 |
+
</svg>
|
219 |
+
</div>
|
220 |
</div>
|
221 |
+
<div className="flex-1">
|
222 |
+
<h3 className="font-bold text-red-200 mb-2">Creation Error</h3>
|
223 |
+
<p className="text-red-100/90 text-sm leading-relaxed">{error}</p>
|
224 |
</div>
|
225 |
</div>
|
226 |
</div>
|
|
|
229 |
</div>
|
230 |
</main>
|
231 |
|
232 |
+
{/* Exploration Prompt */}
|
233 |
+
<ExplorationPrompt
|
234 |
+
showPrompt={showExplorationPrompt && generatedImages.length > 0 && !error}
|
235 |
+
onContinue={handleExplorationContinue}
|
236 |
+
onFinish={handleExplorationFinish}
|
237 |
+
generationTime={totalGenerationTime}
|
238 |
+
totalPages={generatedImages.length}
|
239 |
+
/>
|
240 |
+
|
241 |
<footer className="text-center py-8 px-4 text-white/50 text-sm border-t border-white/10">
|
242 |
<div className="max-w-4xl mx-auto">
|
243 |
<p className="mb-2">🎨 Powered by Google Gemini Nanobanana • Built for Professional Comic Creators</p>
|
README.md
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
---
|
2 |
-
title:
|
3 |
emoji: 🎨
|
4 |
colorFrom: purple
|
5 |
colorTo: pink
|
|
|
1 |
---
|
2 |
+
title: Comic Genesis AI
|
3 |
emoji: 🎨
|
4 |
colorFrom: purple
|
5 |
colorTo: pink
|
components/ApiKeySetup.tsx
CHANGED
@@ -42,9 +42,14 @@ const ApiKeySetup: React.FC<ApiKeySetupProps> = ({ onApiKeySet }) => {
|
|
42 |
className="w-full input-glass"
|
43 |
required
|
44 |
/>
|
45 |
-
<
|
46 |
-
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
48 |
</div>
|
49 |
|
50 |
<button
|
@@ -65,17 +70,28 @@ const ApiKeySetup: React.FC<ApiKeySetupProps> = ({ onApiKeySet }) => {
|
|
65 |
</button>
|
66 |
|
67 |
{showInstructions && (
|
68 |
-
<div className="mt-4
|
69 |
-
<h3 className="font-
|
70 |
-
<
|
71 |
-
<
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
<
|
76 |
-
|
77 |
-
|
78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
79 |
⚡ <strong>Free Tier:</strong> Google provides generous free usage for Gemini API - perfect for creating comics!
|
80 |
</p>
|
81 |
</div>
|
|
|
42 |
className="w-full input-glass"
|
43 |
required
|
44 |
/>
|
45 |
+
<div className="flex items-center mt-2 text-white/60 text-xs">
|
46 |
+
<div className="w-4 h-4 bg-green-500/20 rounded-full flex items-center justify-center mr-2">
|
47 |
+
<svg className="w-2.5 h-2.5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
48 |
+
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
49 |
+
</svg>
|
50 |
+
</div>
|
51 |
+
<span>Your API key is stored locally and never sent to our servers</span>
|
52 |
+
</div>
|
53 |
</div>
|
54 |
|
55 |
<button
|
|
|
70 |
</button>
|
71 |
|
72 |
{showInstructions && (
|
73 |
+
<div className="mt-4 glass-card p-6 text-sm text-white/80 animate-fade-in">
|
74 |
+
<h3 className="font-bold mb-4 gradient-text">📝 Getting Your Gemini API Key:</h3>
|
75 |
+
<div className="space-y-3">
|
76 |
+
<div className="flex items-start space-x-3">
|
77 |
+
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">1</div>
|
78 |
+
<div>Visit <a href="https://makersuite.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-blue-300 hover:text-blue-200 underline font-medium">Google AI Studio</a></div>
|
79 |
+
</div>
|
80 |
+
<div className="flex items-start space-x-3">
|
81 |
+
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">2</div>
|
82 |
+
<div>Sign in with your Google account</div>
|
83 |
+
</div>
|
84 |
+
<div className="flex items-start space-x-3">
|
85 |
+
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">3</div>
|
86 |
+
<div>Click "Create API Key" and copy the generated key</div>
|
87 |
+
</div>
|
88 |
+
<div className="flex items-start space-x-3">
|
89 |
+
<div className="w-6 h-6 bg-white/20 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">4</div>
|
90 |
+
<div>Paste it above to start creating comics!</div>
|
91 |
+
</div>
|
92 |
+
</div>
|
93 |
+
<div className="mt-6 glass p-4 rounded-lg">
|
94 |
+
<p className="text-yellow-200 text-xs font-medium">
|
95 |
⚡ <strong>Free Tier:</strong> Google provides generous free usage for Gemini API - perfect for creating comics!
|
96 |
</p>
|
97 |
</div>
|
components/ComicPreview.tsx
CHANGED
@@ -1,10 +1,15 @@
|
|
1 |
-
import React from 'react';
|
2 |
import { createMangaPdf } from '../services/pdfService';
|
|
|
|
|
|
|
3 |
|
4 |
interface ComicPreviewProps {
|
5 |
images: string[];
|
6 |
title: string;
|
7 |
onReset: () => void;
|
|
|
|
|
8 |
}
|
9 |
|
10 |
const getPageLabel = (index: number, totalImages: number): string => {
|
@@ -18,9 +23,25 @@ const getPageLabel = (index: number, totalImages: number): string => {
|
|
18 |
};
|
19 |
|
20 |
|
21 |
-
const ComicPreview: React.FC<ComicPreviewProps> = ({ images, title, onReset }) => {
|
|
|
|
|
22 |
const handleDownload = () => {
|
23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
};
|
25 |
|
26 |
return (
|
@@ -51,15 +72,91 @@ const ComicPreview: React.FC<ComicPreviewProps> = ({ images, title, onReset }) =
|
|
51 |
</div>
|
52 |
</div>
|
53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
{/* Comic Pages Gallery */}
|
55 |
<div className="panel-grid">
|
56 |
{images.map((imgData, index) => (
|
57 |
-
<div
|
|
|
|
|
|
|
|
|
58 |
<div className="relative overflow-hidden">
|
59 |
-
<
|
60 |
-
src={
|
61 |
alt={`Generated comic page ${index + 1}`}
|
62 |
className="w-full h-auto object-contain transition-transform duration-500 group-hover:scale-105"
|
|
|
63 |
/>
|
64 |
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
65 |
<div className="absolute bottom-4 left-4 right-4 transform translate-y-8 group-hover:translate-y-0 transition-transform duration-300">
|
@@ -68,7 +165,7 @@ const ComicPreview: React.FC<ComicPreviewProps> = ({ images, title, onReset }) =
|
|
68 |
{getPageLabel(index, images.length)}
|
69 |
</p>
|
70 |
<p className="text-white/80 text-xs">
|
71 |
-
Page {index + 1} of {images.length}
|
72 |
</p>
|
73 |
</div>
|
74 |
</div>
|
@@ -89,6 +186,15 @@ const ComicPreview: React.FC<ComicPreviewProps> = ({ images, title, onReset }) =
|
|
89 |
))}
|
90 |
</div>
|
91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
92 |
{/* Stats Section */}
|
93 |
<div className="glass-card p-6">
|
94 |
<div className="flex justify-center items-center gap-8 text-center">
|
@@ -110,6 +216,6 @@ const ComicPreview: React.FC<ComicPreviewProps> = ({ images, title, onReset }) =
|
|
110 |
</div>
|
111 |
</div>
|
112 |
);
|
113 |
-
};
|
114 |
|
115 |
export default ComicPreview;
|
|
|
1 |
+
import React, { useState, memo } from 'react';
|
2 |
import { createMangaPdf } from '../services/pdfService';
|
3 |
+
import ExpandableImageModal from './ExpandableImageModal';
|
4 |
+
import LazyImage from './LazyImage';
|
5 |
+
import type { StoryArcSummary } from '../types';
|
6 |
|
7 |
interface ComicPreviewProps {
|
8 |
images: string[];
|
9 |
title: string;
|
10 |
onReset: () => void;
|
11 |
+
storyArcSummary?: StoryArcSummary;
|
12 |
+
onDownloadClick?: () => void;
|
13 |
}
|
14 |
|
15 |
const getPageLabel = (index: number, totalImages: number): string => {
|
|
|
23 |
};
|
24 |
|
25 |
|
26 |
+
const ComicPreview: React.FC<ComicPreviewProps> = memo(({ images, title, onReset, storyArcSummary, onDownloadClick }) => {
|
27 |
+
const [selectedImage, setSelectedImage] = useState<{ data: string; title: string; description?: string } | null>(null);
|
28 |
+
|
29 |
const handleDownload = () => {
|
30 |
+
if (onDownloadClick) {
|
31 |
+
onDownloadClick();
|
32 |
+
} else {
|
33 |
+
createMangaPdf(images, title);
|
34 |
+
}
|
35 |
+
};
|
36 |
+
|
37 |
+
const handleImageClick = (imgData: string, index: number) => {
|
38 |
+
setSelectedImage({
|
39 |
+
data: imgData,
|
40 |
+
title: getPageLabel(index, images.length),
|
41 |
+
description: index === 0 ? 'Opening cover with title and characters' :
|
42 |
+
index === images.length - 1 ? 'Story conclusion and finale' :
|
43 |
+
'Story development and character progression'
|
44 |
+
});
|
45 |
};
|
46 |
|
47 |
return (
|
|
|
72 |
</div>
|
73 |
</div>
|
74 |
|
75 |
+
{/* Story Arc Summary - Compact Version */}
|
76 |
+
{storyArcSummary && (
|
77 |
+
<div className="glass-card p-6">
|
78 |
+
<h3 className="text-xl font-bold text-white mb-4 text-center gradient-text">
|
79 |
+
📖 Story Analysis
|
80 |
+
</h3>
|
81 |
+
|
82 |
+
{/* Compact Theme Display */}
|
83 |
+
<div className="bg-white/10 rounded-lg p-4 mb-4">
|
84 |
+
<div className="flex items-start space-x-3">
|
85 |
+
<span className="text-2xl">🎯</span>
|
86 |
+
<div className="flex-1">
|
87 |
+
<h4 className="text-sm font-semibold text-white mb-1">Central Theme</h4>
|
88 |
+
<p className="text-white/70 text-xs leading-relaxed">
|
89 |
+
{storyArcSummary.overallTheme.length > 120
|
90 |
+
? `${storyArcSummary.overallTheme.slice(0, 120)}...`
|
91 |
+
: storyArcSummary.overallTheme}
|
92 |
+
</p>
|
93 |
+
</div>
|
94 |
+
</div>
|
95 |
+
</div>
|
96 |
+
|
97 |
+
{/* Compact Events & Development */}
|
98 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
99 |
+
<div className="bg-white/10 rounded-lg p-4">
|
100 |
+
<div className="flex items-center space-x-2 mb-3">
|
101 |
+
<span className="text-lg">🎬</span>
|
102 |
+
<h4 className="text-sm font-semibold text-white">Key Events</h4>
|
103 |
+
</div>
|
104 |
+
<div className="space-y-1">
|
105 |
+
{storyArcSummary.keyEvents.slice(0, 3).map((event, index) => (
|
106 |
+
<div key={index} className="text-white/70 text-xs flex items-start">
|
107 |
+
<span className="text-white/50 mr-1 mt-0.5">•</span>
|
108 |
+
<span className="leading-tight">
|
109 |
+
{event.length > 50 ? `${event.slice(0, 50)}...` : event}
|
110 |
+
</span>
|
111 |
+
</div>
|
112 |
+
))}
|
113 |
+
{storyArcSummary.keyEvents.length > 3 && (
|
114 |
+
<div className="text-white/50 text-xs italic">
|
115 |
+
+{storyArcSummary.keyEvents.length - 3} more events
|
116 |
+
</div>
|
117 |
+
)}
|
118 |
+
</div>
|
119 |
+
</div>
|
120 |
+
|
121 |
+
<div className="bg-white/10 rounded-lg p-4">
|
122 |
+
<div className="flex items-center space-x-2 mb-3">
|
123 |
+
<span className="text-lg">👥</span>
|
124 |
+
<h4 className="text-sm font-semibold text-white">Character Growth</h4>
|
125 |
+
</div>
|
126 |
+
<div className="space-y-1">
|
127 |
+
{storyArcSummary.characterDevelopment.slice(0, 3).map((development, index) => (
|
128 |
+
<div key={index} className="text-white/70 text-xs flex items-start">
|
129 |
+
<span className="text-white/50 mr-1 mt-0.5">•</span>
|
130 |
+
<span className="leading-tight">
|
131 |
+
{development.length > 50 ? `${development.slice(0, 50)}...` : development}
|
132 |
+
</span>
|
133 |
+
</div>
|
134 |
+
))}
|
135 |
+
{storyArcSummary.characterDevelopment.length > 3 && (
|
136 |
+
<div className="text-white/50 text-xs italic">
|
137 |
+
+{storyArcSummary.characterDevelopment.length - 3} more developments
|
138 |
+
</div>
|
139 |
+
)}
|
140 |
+
</div>
|
141 |
+
</div>
|
142 |
+
</div>
|
143 |
+
</div>
|
144 |
+
)}
|
145 |
+
|
146 |
{/* Comic Pages Gallery */}
|
147 |
<div className="panel-grid">
|
148 |
{images.map((imgData, index) => (
|
149 |
+
<div
|
150 |
+
key={index}
|
151 |
+
className="panel-item group cursor-pointer"
|
152 |
+
onClick={() => handleImageClick(imgData, index)}
|
153 |
+
>
|
154 |
<div className="relative overflow-hidden">
|
155 |
+
<LazyImage
|
156 |
+
src={imgData}
|
157 |
alt={`Generated comic page ${index + 1}`}
|
158 |
className="w-full h-auto object-contain transition-transform duration-500 group-hover:scale-105"
|
159 |
+
quality="compressed"
|
160 |
/>
|
161 |
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
162 |
<div className="absolute bottom-4 left-4 right-4 transform translate-y-8 group-hover:translate-y-0 transition-transform duration-300">
|
|
|
165 |
{getPageLabel(index, images.length)}
|
166 |
</p>
|
167 |
<p className="text-white/80 text-xs">
|
168 |
+
Page {index + 1} of {images.length} • Click to expand
|
169 |
</p>
|
170 |
</div>
|
171 |
</div>
|
|
|
186 |
))}
|
187 |
</div>
|
188 |
|
189 |
+
{/* Expandable Image Modal */}
|
190 |
+
<ExpandableImageModal
|
191 |
+
isOpen={selectedImage !== null}
|
192 |
+
onClose={() => setSelectedImage(null)}
|
193 |
+
imageData={selectedImage?.data || ''}
|
194 |
+
title={selectedImage?.title || ''}
|
195 |
+
description={selectedImage?.description}
|
196 |
+
/>
|
197 |
+
|
198 |
{/* Stats Section */}
|
199 |
<div className="glass-card p-6">
|
200 |
<div className="flex justify-center items-center gap-8 text-center">
|
|
|
216 |
</div>
|
217 |
</div>
|
218 |
);
|
219 |
+
});
|
220 |
|
221 |
export default ComicPreview;
|
components/ComicSeriesManager.tsx
ADDED
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect, memo } from 'react';
|
2 |
+
import ComicPreview from './ComicPreview';
|
3 |
+
import StoryProgressionSelector from './StoryProgressionSelector';
|
4 |
+
import LoadingIndicator from './LoadingIndicator';
|
5 |
+
import PerformanceSummary from './PerformanceSummary';
|
6 |
+
import { analyzeStoryProgressions, generateComicContinuation } from '../services/geminiService';
|
7 |
+
import { createComicSeriesPdf, createMangaPdf } from '../services/pdfService';
|
8 |
+
import type {
|
9 |
+
ComicIteration,
|
10 |
+
ComicSeries,
|
11 |
+
StoryProgression,
|
12 |
+
LoadingState,
|
13 |
+
MangaPage,
|
14 |
+
CharacterProfile,
|
15 |
+
MangaStyle,
|
16 |
+
CharacterSketch,
|
17 |
+
EnvironmentSketch,
|
18 |
+
StoryArcSummary,
|
19 |
+
PerformanceTiming
|
20 |
+
} from '../types';
|
21 |
+
|
22 |
+
interface ComicSeriesManagerProps {
|
23 |
+
initialImages: string[];
|
24 |
+
title: string;
|
25 |
+
story: string;
|
26 |
+
author: string;
|
27 |
+
style: MangaStyle;
|
28 |
+
apiKey: string;
|
29 |
+
onReset: () => void;
|
30 |
+
characters?: CharacterProfile[];
|
31 |
+
pages?: MangaPage[];
|
32 |
+
characterSketches?: CharacterSketch[];
|
33 |
+
environmentSketches?: EnvironmentSketch[];
|
34 |
+
storyArcSummary?: StoryArcSummary | null;
|
35 |
+
performanceTimings?: PerformanceTiming[];
|
36 |
+
totalGenerationTime?: number;
|
37 |
+
onDownloadClick?: () => void;
|
38 |
+
includeDialogue?: boolean;
|
39 |
+
}
|
40 |
+
|
41 |
+
type SeriesState = 'preview' | 'analyzing' | 'selecting_progression' | 'generating_continuation' | 'series_complete';
|
42 |
+
|
43 |
+
const ComicSeriesManager: React.FC<ComicSeriesManagerProps> = ({
|
44 |
+
initialImages,
|
45 |
+
title,
|
46 |
+
story,
|
47 |
+
author,
|
48 |
+
style,
|
49 |
+
apiKey,
|
50 |
+
onReset,
|
51 |
+
characters = [],
|
52 |
+
pages = [],
|
53 |
+
characterSketches = [],
|
54 |
+
environmentSketches = [],
|
55 |
+
storyArcSummary = null,
|
56 |
+
performanceTimings = [],
|
57 |
+
totalGenerationTime = 0,
|
58 |
+
onDownloadClick,
|
59 |
+
includeDialogue = true
|
60 |
+
}) => {
|
61 |
+
const [seriesState, setSeriesState] = useState<SeriesState>('preview');
|
62 |
+
const [comicSeries, setComicSeries] = useState<ComicSeries | null>(null);
|
63 |
+
const [currentProgressions, setCurrentProgressions] = useState<StoryProgression[]>([]);
|
64 |
+
const [loadingState, setLoadingState] = useState<LoadingState>({ isLoading: false, message: '', progress: 0 });
|
65 |
+
const [error, setError] = useState<string | null>(null);
|
66 |
+
|
67 |
+
// Initialize the comic series with the first iteration
|
68 |
+
useEffect(() => {
|
69 |
+
const firstIteration: ComicIteration = {
|
70 |
+
id: 'chapter_1',
|
71 |
+
title,
|
72 |
+
story,
|
73 |
+
author,
|
74 |
+
style,
|
75 |
+
includeDialogue,
|
76 |
+
images: initialImages,
|
77 |
+
characters,
|
78 |
+
pages,
|
79 |
+
generatedAt: new Date()
|
80 |
+
};
|
81 |
+
|
82 |
+
const series: ComicSeries = {
|
83 |
+
seriesTitle: title,
|
84 |
+
author,
|
85 |
+
iterations: [firstIteration],
|
86 |
+
totalPages: initialImages.length,
|
87 |
+
createdAt: new Date(),
|
88 |
+
lastUpdated: new Date(),
|
89 |
+
totalGenerationTime: totalGenerationTime,
|
90 |
+
performanceSummary: performanceTimings
|
91 |
+
};
|
92 |
+
|
93 |
+
setComicSeries(series);
|
94 |
+
}, [initialImages, title, story, author, style, characters, pages, performanceTimings, totalGenerationTime, includeDialogue]);
|
95 |
+
|
96 |
+
const handleContinueStory = async () => {
|
97 |
+
if (!comicSeries || comicSeries.iterations.length === 0) return;
|
98 |
+
|
99 |
+
setError(null);
|
100 |
+
setSeriesState('analyzing');
|
101 |
+
setLoadingState({ isLoading: true, message: 'Analyzing your story for continuation possibilities...', progress: 20 });
|
102 |
+
|
103 |
+
try {
|
104 |
+
const currentIteration = comicSeries.iterations[comicSeries.iterations.length - 1];
|
105 |
+
const progressions = await analyzeStoryProgressions(currentIteration, apiKey);
|
106 |
+
|
107 |
+
setCurrentProgressions(progressions);
|
108 |
+
setSeriesState('selecting_progression');
|
109 |
+
setLoadingState({ isLoading: false, message: '', progress: 0 });
|
110 |
+
} catch (err) {
|
111 |
+
const errorMessage = err instanceof Error ? err.message : 'Failed to analyze story progressions.';
|
112 |
+
setError(errorMessage);
|
113 |
+
setSeriesState('preview');
|
114 |
+
setLoadingState({ isLoading: false, message: '', progress: 0 });
|
115 |
+
}
|
116 |
+
};
|
117 |
+
|
118 |
+
const handleProgressionSelect = async (selectedProgression: StoryProgression) => {
|
119 |
+
if (!comicSeries || comicSeries.iterations.length === 0) return;
|
120 |
+
|
121 |
+
setError(null);
|
122 |
+
setSeriesState('generating_continuation');
|
123 |
+
|
124 |
+
try {
|
125 |
+
const currentIteration = comicSeries.iterations[comicSeries.iterations.length - 1];
|
126 |
+
const newIteration = await generateComicContinuation(
|
127 |
+
currentIteration,
|
128 |
+
selectedProgression,
|
129 |
+
setLoadingState,
|
130 |
+
apiKey
|
131 |
+
);
|
132 |
+
|
133 |
+
// Update the series with the new iteration
|
134 |
+
const updatedSeries: ComicSeries = {
|
135 |
+
...comicSeries,
|
136 |
+
iterations: [...comicSeries.iterations, newIteration],
|
137 |
+
totalPages: comicSeries.totalPages + newIteration.images.length,
|
138 |
+
lastUpdated: new Date()
|
139 |
+
};
|
140 |
+
|
141 |
+
setComicSeries(updatedSeries);
|
142 |
+
setSeriesState('series_complete');
|
143 |
+
setLoadingState({ isLoading: false, message: '', progress: 0 });
|
144 |
+
} catch (err) {
|
145 |
+
const errorMessage = err instanceof Error ? err.message : 'Failed to generate story continuation.';
|
146 |
+
setError(errorMessage);
|
147 |
+
setSeriesState('selecting_progression');
|
148 |
+
setLoadingState({ isLoading: false, message: '', progress: 0 });
|
149 |
+
}
|
150 |
+
};
|
151 |
+
|
152 |
+
const handleSkipContinuation = () => {
|
153 |
+
setSeriesState('preview');
|
154 |
+
};
|
155 |
+
|
156 |
+
const handleDownloadSingleComic = () => {
|
157 |
+
if (comicSeries && comicSeries.iterations.length > 0) {
|
158 |
+
createMangaPdf(comicSeries.iterations[0].images, comicSeries.seriesTitle);
|
159 |
+
}
|
160 |
+
};
|
161 |
+
|
162 |
+
const handleDownloadCompleteSeries = () => {
|
163 |
+
if (comicSeries) {
|
164 |
+
createComicSeriesPdf(comicSeries);
|
165 |
+
}
|
166 |
+
};
|
167 |
+
|
168 |
+
const handleContinueAgain = () => {
|
169 |
+
setSeriesState('preview');
|
170 |
+
setCurrentProgressions([]);
|
171 |
+
};
|
172 |
+
|
173 |
+
const getAllImages = (): string[] => {
|
174 |
+
if (!comicSeries) return [];
|
175 |
+
return comicSeries.iterations.flatMap(iteration => iteration.images);
|
176 |
+
};
|
177 |
+
|
178 |
+
const getCurrentTitle = (): string => {
|
179 |
+
if (!comicSeries || seriesState === 'preview') {
|
180 |
+
return title;
|
181 |
+
}
|
182 |
+
return `${comicSeries.seriesTitle} - Complete Series`;
|
183 |
+
};
|
184 |
+
|
185 |
+
// Render based on current state
|
186 |
+
const renderContent = () => {
|
187 |
+
if (loadingState.isLoading) {
|
188 |
+
return <LoadingIndicator loadingState={loadingState} />;
|
189 |
+
}
|
190 |
+
|
191 |
+
switch (seriesState) {
|
192 |
+
case 'selecting_progression':
|
193 |
+
return (
|
194 |
+
<StoryProgressionSelector
|
195 |
+
progressions={currentProgressions}
|
196 |
+
onProgressionSelect={handleProgressionSelect}
|
197 |
+
onSkip={handleSkipContinuation}
|
198 |
+
disabled={loadingState.isLoading}
|
199 |
+
currentIteration={comicSeries?.iterations[0]}
|
200 |
+
storyArcSummary={storyArcSummary}
|
201 |
+
/>
|
202 |
+
);
|
203 |
+
|
204 |
+
case 'series_complete':
|
205 |
+
return (
|
206 |
+
<div className="space-y-8 animate-fade-in">
|
207 |
+
{/* Series Complete Header */}
|
208 |
+
<div className="glass-card p-8">
|
209 |
+
<div className="text-center">
|
210 |
+
<h2 className="text-4xl font-bold text-white mb-4">
|
211 |
+
🎉 Series Complete!
|
212 |
+
</h2>
|
213 |
+
<p className="text-xl text-white/80 mb-2">
|
214 |
+
Your comic series "{comicSeries?.seriesTitle}" is ready
|
215 |
+
</p>
|
216 |
+
<p className="text-white/60">
|
217 |
+
{comicSeries?.iterations.length} chapters • {comicSeries?.totalPages} total pages
|
218 |
+
</p>
|
219 |
+
</div>
|
220 |
+
</div>
|
221 |
+
|
222 |
+
{/* Series Preview */}
|
223 |
+
<ComicSeriesPreview comicSeries={comicSeries!} />
|
224 |
+
|
225 |
+
{/* Performance Summary */}
|
226 |
+
{comicSeries?.performanceSummary && comicSeries.performanceSummary.length > 0 && (
|
227 |
+
<PerformanceSummary
|
228 |
+
timings={comicSeries.performanceSummary}
|
229 |
+
totalTime={comicSeries.totalGenerationTime || 0}
|
230 |
+
totalPages={comicSeries.totalPages}
|
231 |
+
/>
|
232 |
+
)}
|
233 |
+
|
234 |
+
{/* Action Buttons */}
|
235 |
+
<div className="glass-card p-8">
|
236 |
+
<div className="flex flex-col sm:flex-row justify-center items-center gap-4">
|
237 |
+
<button
|
238 |
+
onClick={handleDownloadCompleteSeries}
|
239 |
+
className="btn-primary px-8 py-4 text-lg font-bold hover:scale-105 transition-all duration-300"
|
240 |
+
>
|
241 |
+
📚 Download Complete Series PDF
|
242 |
+
</button>
|
243 |
+
|
244 |
+
<button
|
245 |
+
onClick={handleContinueAgain}
|
246 |
+
className="btn-secondary px-6 py-3 hover:scale-105 transition-all duration-300"
|
247 |
+
>
|
248 |
+
➕ Continue Story Further
|
249 |
+
</button>
|
250 |
+
|
251 |
+
<button
|
252 |
+
onClick={onReset}
|
253 |
+
className="btn-secondary px-6 py-3 hover:scale-105 transition-all duration-300"
|
254 |
+
>
|
255 |
+
✨ Create New Comic
|
256 |
+
</button>
|
257 |
+
</div>
|
258 |
+
</div>
|
259 |
+
</div>
|
260 |
+
);
|
261 |
+
|
262 |
+
default: // 'preview'
|
263 |
+
return (
|
264 |
+
<div className="space-y-8">
|
265 |
+
{/* Enhanced Comic Preview with Continue Option */}
|
266 |
+
<ComicPreview
|
267 |
+
images={comicSeries?.iterations[0]?.images || []}
|
268 |
+
title={getCurrentTitle()}
|
269 |
+
onReset={onReset}
|
270 |
+
storyArcSummary={storyArcSummary}
|
271 |
+
onDownloadClick={onDownloadClick}
|
272 |
+
/>
|
273 |
+
|
274 |
+
{/* Performance Summary for single comic */}
|
275 |
+
{comicSeries?.performanceSummary && comicSeries.performanceSummary.length > 0 && (
|
276 |
+
<PerformanceSummary
|
277 |
+
timings={comicSeries.performanceSummary}
|
278 |
+
totalTime={comicSeries.totalGenerationTime || 0}
|
279 |
+
totalPages={comicSeries.totalPages}
|
280 |
+
/>
|
281 |
+
)}
|
282 |
+
|
283 |
+
{/* Story Continuation Prompt */}
|
284 |
+
<div className="glass-card p-8 animate-fade-in">
|
285 |
+
<div className="text-center">
|
286 |
+
<h3 className="text-2xl font-bold text-white mb-4">
|
287 |
+
📖 Continue Your Story?
|
288 |
+
</h3>
|
289 |
+
<p className="text-white/80 mb-6">
|
290 |
+
Your comic is complete, but every great story can continue!
|
291 |
+
Let AI analyze your story and suggest compelling directions to expand it into a series.
|
292 |
+
</p>
|
293 |
+
|
294 |
+
<div className="flex flex-col sm:flex-row justify-center items-center gap-4">
|
295 |
+
<button
|
296 |
+
onClick={handleContinueStory}
|
297 |
+
className="btn-primary px-8 py-4 text-lg font-bold hover:scale-105 transition-all duration-300"
|
298 |
+
>
|
299 |
+
🚀 Explore Story Progressions
|
300 |
+
</button>
|
301 |
+
|
302 |
+
<button
|
303 |
+
onClick={handleDownloadSingleComic}
|
304 |
+
className="btn-secondary px-6 py-3 hover:scale-105 transition-all duration-300"
|
305 |
+
>
|
306 |
+
📥 Download Current Comic Only
|
307 |
+
</button>
|
308 |
+
</div>
|
309 |
+
</div>
|
310 |
+
</div>
|
311 |
+
</div>
|
312 |
+
);
|
313 |
+
}
|
314 |
+
};
|
315 |
+
|
316 |
+
return (
|
317 |
+
<div className="space-y-8">
|
318 |
+
{error && (
|
319 |
+
<div className="glass-card border-red-400 bg-red-500/20 text-red-100 px-6 py-4 animate-fade-in" role="alert">
|
320 |
+
<div className="flex items-center">
|
321 |
+
<div className="flex-shrink-0">
|
322 |
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
323 |
+
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
324 |
+
</svg>
|
325 |
+
</div>
|
326 |
+
<div className="ml-3">
|
327 |
+
<strong className="font-bold">Story Continuation Error: </strong>
|
328 |
+
<span>{error}</span>
|
329 |
+
</div>
|
330 |
+
</div>
|
331 |
+
</div>
|
332 |
+
)}
|
333 |
+
|
334 |
+
{renderContent()}
|
335 |
+
</div>
|
336 |
+
);
|
337 |
+
};
|
338 |
+
|
339 |
+
// Component to preview the complete series
|
340 |
+
const ComicSeriesPreview: React.FC<{ comicSeries: ComicSeries }> = ({ comicSeries }) => {
|
341 |
+
const [selectedIteration, setSelectedIteration] = useState(0);
|
342 |
+
|
343 |
+
return (
|
344 |
+
<div className="space-y-6">
|
345 |
+
{/* Series Navigation */}
|
346 |
+
<div className="glass-card p-6">
|
347 |
+
<h3 className="text-xl font-bold text-white mb-4 text-center">Series Chapters</h3>
|
348 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
349 |
+
{comicSeries.iterations.map((iteration, index) => (
|
350 |
+
<button
|
351 |
+
key={iteration.id}
|
352 |
+
onClick={() => setSelectedIteration(index)}
|
353 |
+
className={`p-4 rounded-lg transition-all duration-300 ${
|
354 |
+
selectedIteration === index
|
355 |
+
? 'bg-white/20 border-2 border-white/50'
|
356 |
+
: 'bg-white/10 hover:bg-white/15 border-2 border-transparent'
|
357 |
+
}`}
|
358 |
+
>
|
359 |
+
<div className="text-white font-semibold">Chapter {index + 1}</div>
|
360 |
+
<div className="text-white/80 text-sm mt-1">{iteration.title}</div>
|
361 |
+
<div className="text-white/60 text-xs mt-1">{iteration.images.length} pages</div>
|
362 |
+
</button>
|
363 |
+
))}
|
364 |
+
</div>
|
365 |
+
</div>
|
366 |
+
|
367 |
+
{/* Selected Chapter Preview */}
|
368 |
+
<div className="panel-grid">
|
369 |
+
{comicSeries.iterations[selectedIteration].images.map((imgData, index) => (
|
370 |
+
<div key={index} className="panel-item group">
|
371 |
+
<div className="relative overflow-hidden">
|
372 |
+
<img
|
373 |
+
src={`data:image/jpeg;base64,${imgData}`}
|
374 |
+
alt={`Chapter ${selectedIteration + 1} Page ${index + 1}`}
|
375 |
+
className="w-full h-auto object-contain transition-transform duration-500 group-hover:scale-105"
|
376 |
+
/>
|
377 |
+
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
378 |
+
<div className="absolute bottom-4 left-4 right-4 transform translate-y-8 group-hover:translate-y-0 transition-transform duration-300">
|
379 |
+
<div className="bg-white/20 backdrop-blur-sm rounded-lg px-3 py-2">
|
380 |
+
<p className="text-white font-semibold text-sm">
|
381 |
+
Chapter {selectedIteration + 1} - Page {index + 1}
|
382 |
+
</p>
|
383 |
+
</div>
|
384 |
+
</div>
|
385 |
+
</div>
|
386 |
+
<div className="p-4">
|
387 |
+
<h3 className="text-white font-semibold">
|
388 |
+
Page {index + 1}
|
389 |
+
</h3>
|
390 |
+
<p className="text-white/70 text-sm mt-1">
|
391 |
+
{comicSeries.iterations[selectedIteration].title}
|
392 |
+
</p>
|
393 |
+
</div>
|
394 |
+
</div>
|
395 |
+
))}
|
396 |
+
</div>
|
397 |
+
|
398 |
+
{/* Series Stats */}
|
399 |
+
<div className="glass-card p-6">
|
400 |
+
<div className="flex justify-center items-center gap-8 text-center">
|
401 |
+
<div>
|
402 |
+
<div className="text-2xl font-bold text-white">{comicSeries.iterations.length}</div>
|
403 |
+
<div className="text-white/70 text-sm">Chapters</div>
|
404 |
+
</div>
|
405 |
+
<div className="h-8 w-px bg-white/20"></div>
|
406 |
+
<div>
|
407 |
+
<div className="text-2xl font-bold text-white">{comicSeries.totalPages}</div>
|
408 |
+
<div className="text-white/70 text-sm">Total Pages</div>
|
409 |
+
</div>
|
410 |
+
<div className="h-8 w-px bg-white/20"></div>
|
411 |
+
<div>
|
412 |
+
<div className="text-2xl font-bold text-white">📚</div>
|
413 |
+
<div className="text-white/70 text-sm">Complete Series</div>
|
414 |
+
</div>
|
415 |
+
</div>
|
416 |
+
</div>
|
417 |
+
</div>
|
418 |
+
);
|
419 |
+
};
|
420 |
+
|
421 |
+
export default ComicSeriesManager;
|
components/EnhancedLoadingIndicator.tsx
ADDED
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, memo } from 'react';
|
2 |
+
import LazyImage from './LazyImage';
|
3 |
+
import type { GenerationProgress } from '../types';
|
4 |
+
|
5 |
+
interface EnhancedLoadingIndicatorProps {
|
6 |
+
generationProgress: GenerationProgress;
|
7 |
+
}
|
8 |
+
|
9 |
+
const EnhancedLoadingIndicator: React.FC<EnhancedLoadingIndicatorProps> = memo(({ generationProgress }) => {
|
10 |
+
const [selectedSketchIndex, setSelectedSketchIndex] = useState(0);
|
11 |
+
const [selectedPageIndex, setSelectedPageIndex] = useState(0);
|
12 |
+
|
13 |
+
const getPhaseInfo = (phase: GenerationProgress['phase']) => {
|
14 |
+
switch (phase) {
|
15 |
+
case 'character_analysis':
|
16 |
+
return { icon: '🧠', title: 'Character Analysis', description: 'AI is analyzing your story and developing character personalities' };
|
17 |
+
case 'character_sketches':
|
18 |
+
return { icon: '🎨', title: 'Character Design', description: 'Creating visual designs for your main characters' };
|
19 |
+
case 'environment_sketches':
|
20 |
+
return { icon: '🏞️', title: 'Environment Design', description: 'Designing key locations and settings for your story' };
|
21 |
+
case 'script_writing':
|
22 |
+
return { icon: '📝', title: 'Script Writing', description: 'Writing professional manga script with panel layouts' };
|
23 |
+
case 'page_generation':
|
24 |
+
return { icon: '🖼️', title: 'Page Generation', description: 'Bringing your story to life with stunning artwork' };
|
25 |
+
case 'completion':
|
26 |
+
return { icon: '✨', title: 'Finalizing', description: 'Adding finishing touches and analyzing story arc' };
|
27 |
+
default:
|
28 |
+
return { icon: '🔄', title: 'Processing', description: 'Working on your comic...' };
|
29 |
+
}
|
30 |
+
};
|
31 |
+
|
32 |
+
const phaseInfo = getPhaseInfo(generationProgress.phase);
|
33 |
+
|
34 |
+
return (
|
35 |
+
<div className="min-h-screen flex items-center justify-center py-8">
|
36 |
+
<div className="max-w-6xl mx-auto px-4">
|
37 |
+
{/* Main Loading Header */}
|
38 |
+
<div className="glass-card p-8 mb-8 text-center animate-fade-in">
|
39 |
+
<div className="text-6xl mb-4 animate-pulse-slow">{phaseInfo.icon}</div>
|
40 |
+
<h2 className="text-3xl font-bold text-white mb-2">{phaseInfo.title}</h2>
|
41 |
+
<p className="text-white/80 text-lg mb-6">{generationProgress.message}</p>
|
42 |
+
<p className="text-white/60 text-sm mb-4">{phaseInfo.description}</p>
|
43 |
+
|
44 |
+
{/* Progress Bar */}
|
45 |
+
<div className="w-full bg-white/20 rounded-full h-6 mb-4 overflow-hidden">
|
46 |
+
<div
|
47 |
+
className="h-6 rounded-full transition-all duration-1000 ease-out bg-gradient-to-r from-gray-300 via-white to-gray-400 relative overflow-hidden"
|
48 |
+
style={{ width: `${generationProgress.progress}%` }}
|
49 |
+
>
|
50 |
+
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-pulse"></div>
|
51 |
+
</div>
|
52 |
+
</div>
|
53 |
+
<div className="text-white/70 text-sm">
|
54 |
+
{generationProgress.progress}% Complete
|
55 |
+
</div>
|
56 |
+
</div>
|
57 |
+
|
58 |
+
{/* Character Sketches Display */}
|
59 |
+
{generationProgress.characterSketches && generationProgress.characterSketches.length > 0 && (
|
60 |
+
<div className="glass-card p-8 mb-8 animate-fade-in">
|
61 |
+
<h3 className="text-2xl font-bold text-white mb-6 text-center">
|
62 |
+
🎭 Character Designs
|
63 |
+
</h3>
|
64 |
+
|
65 |
+
{/* Character Selection */}
|
66 |
+
<div className="flex justify-center mb-6">
|
67 |
+
<div className="flex gap-2 bg-white/10 backdrop-blur-sm rounded-xl p-3">
|
68 |
+
{generationProgress.characterSketches.map((character, index) => (
|
69 |
+
<button
|
70 |
+
key={index}
|
71 |
+
onClick={() => setSelectedSketchIndex(index)}
|
72 |
+
className={`px-4 py-2 rounded-lg transition-all duration-300 font-medium ${
|
73 |
+
selectedSketchIndex === index
|
74 |
+
? 'bg-white/30 text-white scale-105 shadow-lg'
|
75 |
+
: 'bg-white/10 text-white/80 hover:bg-white/20 hover:scale-102'
|
76 |
+
}`}
|
77 |
+
>
|
78 |
+
{character.characterName}
|
79 |
+
</button>
|
80 |
+
))}
|
81 |
+
</div>
|
82 |
+
</div>
|
83 |
+
|
84 |
+
{/* Selected Character Display */}
|
85 |
+
<div className="flex flex-col lg:flex-row gap-8 items-center">
|
86 |
+
<div className="flex-1 max-w-md">
|
87 |
+
<div className="glass rounded-xl p-4">
|
88 |
+
<LazyImage
|
89 |
+
src={generationProgress.characterSketches[selectedSketchIndex].sketchImage}
|
90 |
+
alt={generationProgress.characterSketches[selectedSketchIndex].characterName}
|
91 |
+
className="w-full rounded-lg shadow-lg"
|
92 |
+
quality="compressed"
|
93 |
+
/>
|
94 |
+
</div>
|
95 |
+
</div>
|
96 |
+
<div className="flex-1 glass rounded-xl p-6">
|
97 |
+
<h4 className="text-2xl font-bold text-white mb-4 gradient-text">
|
98 |
+
{generationProgress.characterSketches[selectedSketchIndex].characterName}
|
99 |
+
</h4>
|
100 |
+
<p className="text-white/80 leading-relaxed text-sm">
|
101 |
+
{generationProgress.characterSketches[selectedSketchIndex].description}
|
102 |
+
</p>
|
103 |
+
</div>
|
104 |
+
</div>
|
105 |
+
</div>
|
106 |
+
)}
|
107 |
+
|
108 |
+
{/* Environment Sketches Display */}
|
109 |
+
{generationProgress.environmentSketches && generationProgress.environmentSketches.length > 0 && (
|
110 |
+
<div className="glass-card p-8 mb-8 animate-fade-in">
|
111 |
+
<h3 className="text-2xl font-bold text-white mb-6 text-center">
|
112 |
+
🏗️ Environment Designs
|
113 |
+
</h3>
|
114 |
+
|
115 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
116 |
+
{generationProgress.environmentSketches.map((environment, index) => (
|
117 |
+
<div key={index} className="glass-card hover:scale-102 transition-all duration-300">
|
118 |
+
<div className="aspect-video mb-4 overflow-hidden rounded-lg">
|
119 |
+
<LazyImage
|
120 |
+
src={environment.sketchImage}
|
121 |
+
alt={environment.environmentName}
|
122 |
+
className="w-full h-full object-cover"
|
123 |
+
quality="compressed"
|
124 |
+
/>
|
125 |
+
</div>
|
126 |
+
<h4 className="text-lg font-bold text-white mb-2 gradient-text-subtle">
|
127 |
+
{environment.environmentName}
|
128 |
+
</h4>
|
129 |
+
<p className="text-white/70 text-sm leading-relaxed">
|
130 |
+
{environment.description}
|
131 |
+
</p>
|
132 |
+
</div>
|
133 |
+
))}
|
134 |
+
</div>
|
135 |
+
</div>
|
136 |
+
)}
|
137 |
+
|
138 |
+
{/* Generated Pages Display */}
|
139 |
+
{generationProgress.generatedPages && generationProgress.generatedPages.length > 0 && (
|
140 |
+
<div className="glass-card p-8 mb-8 animate-fade-in">
|
141 |
+
<h3 className="text-2xl font-bold text-white mb-6 text-center">
|
142 |
+
📚 Pages Generated
|
143 |
+
</h3>
|
144 |
+
|
145 |
+
{/* Page Navigation */}
|
146 |
+
<div className="flex justify-center mb-6">
|
147 |
+
<div className="flex gap-2 glass-card p-3 max-w-full overflow-x-auto">
|
148 |
+
{generationProgress.generatedPages.map((page, index) => (
|
149 |
+
<button
|
150 |
+
key={index}
|
151 |
+
onClick={() => setSelectedPageIndex(index)}
|
152 |
+
className={`px-4 py-2 rounded-lg transition-all duration-300 whitespace-nowrap font-medium ${
|
153 |
+
selectedPageIndex === index
|
154 |
+
? 'bg-white/30 text-white scale-105 shadow-lg'
|
155 |
+
: 'bg-white/10 text-white/80 hover:bg-white/20 hover:scale-102'
|
156 |
+
}`}
|
157 |
+
>
|
158 |
+
Page {index + 1}
|
159 |
+
</button>
|
160 |
+
))}
|
161 |
+
</div>
|
162 |
+
</div>
|
163 |
+
|
164 |
+
{/* Selected Page Display */}
|
165 |
+
<div className="text-center">
|
166 |
+
<div className="max-w-md mx-auto glass rounded-xl p-4">
|
167 |
+
<LazyImage
|
168 |
+
src={generationProgress.generatedPages[selectedPageIndex].image}
|
169 |
+
alt={generationProgress.generatedPages[selectedPageIndex].title}
|
170 |
+
className="w-full rounded-lg shadow-lg mb-4 hover:scale-105 transition-transform duration-300 cursor-pointer"
|
171 |
+
quality="compressed"
|
172 |
+
/>
|
173 |
+
<h4 className="text-lg font-bold text-white gradient-text-subtle">
|
174 |
+
{generationProgress.generatedPages[selectedPageIndex].title}
|
175 |
+
</h4>
|
176 |
+
</div>
|
177 |
+
</div>
|
178 |
+
|
179 |
+
{/* Progress Summary */}
|
180 |
+
<div className="mt-6 text-center">
|
181 |
+
<p className="text-white/70">
|
182 |
+
Generated {generationProgress.generatedPages.length} page{generationProgress.generatedPages.length !== 1 ? 's' : ''} so far
|
183 |
+
</p>
|
184 |
+
</div>
|
185 |
+
</div>
|
186 |
+
)}
|
187 |
+
|
188 |
+
{/* Story Arc Summary Display */}
|
189 |
+
{generationProgress.storyArcSummary && (
|
190 |
+
<div className="glass-card p-8 animate-fade-in">
|
191 |
+
<h3 className="text-2xl font-bold text-white mb-6 text-center">
|
192 |
+
📖 Story Arc Summary
|
193 |
+
</h3>
|
194 |
+
|
195 |
+
<div className="glass p-6 rounded-xl">
|
196 |
+
<div className="text-white/80 leading-relaxed text-sm space-y-4">
|
197 |
+
{generationProgress.storyArcSummary.split('\n\n').map((section, index) => (
|
198 |
+
<div key={index} className="bg-white/5 rounded-lg p-4">
|
199 |
+
<p className="whitespace-pre-wrap">{section.trim()}</p>
|
200 |
+
</div>
|
201 |
+
))}
|
202 |
+
</div>
|
203 |
+
</div>
|
204 |
+
</div>
|
205 |
+
)}
|
206 |
+
|
207 |
+
{/* Spinner for ongoing processes */}
|
208 |
+
{generationProgress.progress < 100 && (
|
209 |
+
<div className="flex justify-center mt-8">
|
210 |
+
<div className="spinner"></div>
|
211 |
+
</div>
|
212 |
+
)}
|
213 |
+
</div>
|
214 |
+
</div>
|
215 |
+
);
|
216 |
+
});
|
217 |
+
|
218 |
+
export default EnhancedLoadingIndicator;
|
components/ExpandableImageModal.tsx
ADDED
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect } from 'react';
|
2 |
+
|
3 |
+
interface ExpandableImageModalProps {
|
4 |
+
isOpen: boolean;
|
5 |
+
onClose: () => void;
|
6 |
+
imageData: string;
|
7 |
+
title: string;
|
8 |
+
description?: string;
|
9 |
+
}
|
10 |
+
|
11 |
+
const ExpandableImageModal: React.FC<ExpandableImageModalProps> = ({
|
12 |
+
isOpen,
|
13 |
+
onClose,
|
14 |
+
imageData,
|
15 |
+
title,
|
16 |
+
description
|
17 |
+
}) => {
|
18 |
+
// Handle escape key press
|
19 |
+
useEffect(() => {
|
20 |
+
const handleEscape = (event: KeyboardEvent) => {
|
21 |
+
if (event.key === 'Escape') {
|
22 |
+
onClose();
|
23 |
+
}
|
24 |
+
};
|
25 |
+
|
26 |
+
if (isOpen) {
|
27 |
+
document.addEventListener('keydown', handleEscape);
|
28 |
+
// Prevent body scrolling when modal is open
|
29 |
+
document.body.style.overflow = 'hidden';
|
30 |
+
}
|
31 |
+
|
32 |
+
return () => {
|
33 |
+
document.removeEventListener('keydown', handleEscape);
|
34 |
+
document.body.style.overflow = 'unset';
|
35 |
+
};
|
36 |
+
}, [isOpen, onClose]);
|
37 |
+
|
38 |
+
if (!isOpen) return null;
|
39 |
+
|
40 |
+
return (
|
41 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
42 |
+
{/* Backdrop */}
|
43 |
+
<div
|
44 |
+
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
45 |
+
onClick={onClose}
|
46 |
+
/>
|
47 |
+
|
48 |
+
{/* Modal Content */}
|
49 |
+
<div className="relative z-10 max-w-6xl max-h-full w-full glass-card p-6 animate-fade-in">
|
50 |
+
{/* Close Button */}
|
51 |
+
<button
|
52 |
+
onClick={onClose}
|
53 |
+
className="absolute top-4 right-4 z-20 w-10 h-10 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center transition-all duration-300"
|
54 |
+
>
|
55 |
+
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
56 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
57 |
+
</svg>
|
58 |
+
</button>
|
59 |
+
|
60 |
+
{/* Title */}
|
61 |
+
<div className="mb-4">
|
62 |
+
<h2 className="text-2xl font-bold text-white">{title}</h2>
|
63 |
+
{description && (
|
64 |
+
<p className="text-white/70 mt-2">{description}</p>
|
65 |
+
)}
|
66 |
+
</div>
|
67 |
+
|
68 |
+
{/* Image Container */}
|
69 |
+
<div className="flex justify-center items-center max-h-[80vh] overflow-hidden rounded-lg">
|
70 |
+
<img
|
71 |
+
src={`data:image/jpeg;base64,${imageData}`}
|
72 |
+
alt={title}
|
73 |
+
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
|
74 |
+
style={{ maxHeight: 'calc(80vh - 100px)' }}
|
75 |
+
/>
|
76 |
+
</div>
|
77 |
+
|
78 |
+
{/* Controls */}
|
79 |
+
<div className="flex justify-center mt-4 space-x-4">
|
80 |
+
<button
|
81 |
+
onClick={() => {
|
82 |
+
const link = document.createElement('a');
|
83 |
+
link.href = `data:image/jpeg;base64,${imageData}`;
|
84 |
+
link.download = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.jpg`;
|
85 |
+
link.click();
|
86 |
+
}}
|
87 |
+
className="btn-secondary px-4 py-2 text-sm hover:scale-105 transition-all duration-300"
|
88 |
+
>
|
89 |
+
📥 Download Image
|
90 |
+
</button>
|
91 |
+
<button
|
92 |
+
onClick={onClose}
|
93 |
+
className="btn-primary px-4 py-2 text-sm hover:scale-105 transition-all duration-300"
|
94 |
+
>
|
95 |
+
Close
|
96 |
+
</button>
|
97 |
+
</div>
|
98 |
+
</div>
|
99 |
+
</div>
|
100 |
+
);
|
101 |
+
};
|
102 |
+
|
103 |
+
export default ExpandableImageModal;
|
components/ExplorationPrompt.tsx
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import type { ExplorationPrompt } from '../types';
|
3 |
+
|
4 |
+
const ExplorationPromptComponent: React.FC<ExplorationPrompt> = ({
|
5 |
+
showPrompt,
|
6 |
+
onContinue,
|
7 |
+
onFinish,
|
8 |
+
generationTime,
|
9 |
+
totalPages
|
10 |
+
}) => {
|
11 |
+
if (!showPrompt) return null;
|
12 |
+
|
13 |
+
const formatTime = (milliseconds: number): string => {
|
14 |
+
const seconds = Math.floor(milliseconds / 1000);
|
15 |
+
const minutes = Math.floor(seconds / 60);
|
16 |
+
const remainingSeconds = seconds % 60;
|
17 |
+
|
18 |
+
if (minutes > 0) {
|
19 |
+
return `${minutes}m ${remainingSeconds}s`;
|
20 |
+
}
|
21 |
+
return `${remainingSeconds}s`;
|
22 |
+
};
|
23 |
+
|
24 |
+
const handleDownloadFinish = () => {
|
25 |
+
// Trigger PDF download without any API key removal
|
26 |
+
onFinish(false);
|
27 |
+
};
|
28 |
+
|
29 |
+
return (
|
30 |
+
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in">
|
31 |
+
<div className="glass-card p-8 max-w-lg mx-4 animate-slide-in">
|
32 |
+
{/* Main Exploration Prompt */}
|
33 |
+
<div className="text-center mb-8">
|
34 |
+
<div className="text-4xl mb-4">🎉</div>
|
35 |
+
<h2 className="text-2xl font-bold text-white mb-4">
|
36 |
+
Comic Generation Complete!
|
37 |
+
</h2>
|
38 |
+
<div className="glass rounded-lg p-4 mb-6">
|
39 |
+
<div className="grid grid-cols-2 gap-4 text-center">
|
40 |
+
<div>
|
41 |
+
<div className="text-xl font-bold text-white">{totalPages}</div>
|
42 |
+
<div className="text-white/70 text-sm">Pages Created</div>
|
43 |
+
</div>
|
44 |
+
<div>
|
45 |
+
<div className="text-xl font-bold text-white">{formatTime(generationTime)}</div>
|
46 |
+
<div className="text-white/70 text-sm">Generation Time</div>
|
47 |
+
</div>
|
48 |
+
</div>
|
49 |
+
</div>
|
50 |
+
<p className="text-white/80 mb-2">
|
51 |
+
Would you like to continue exploring story progressions?
|
52 |
+
</p>
|
53 |
+
<p className="text-white/60 text-sm">
|
54 |
+
You can create sequels, prequels, side stories, or alternate endings!
|
55 |
+
</p>
|
56 |
+
</div>
|
57 |
+
|
58 |
+
{/* Action Buttons */}
|
59 |
+
<div className="space-y-4">
|
60 |
+
<button
|
61 |
+
onClick={onContinue}
|
62 |
+
className="w-full btn-primary text-lg font-bold py-4 px-6 hover:scale-105 transition-all duration-300"
|
63 |
+
>
|
64 |
+
🚀 Yes, Continue Exploring!
|
65 |
+
</button>
|
66 |
+
|
67 |
+
<button
|
68 |
+
onClick={handleDownloadFinish}
|
69 |
+
className="w-full btn-secondary py-3 px-6 hover:scale-105 transition-all duration-300"
|
70 |
+
>
|
71 |
+
📥 No, Download & Finish
|
72 |
+
</button>
|
73 |
+
</div>
|
74 |
+
|
75 |
+
{/* Privacy Info */}
|
76 |
+
<div className="mt-6 glass p-3 rounded-lg">
|
77 |
+
<p className="text-white/60 text-xs text-center">
|
78 |
+
🛡️ Your API key is stored locally in your browser only and never sent to our servers
|
79 |
+
</p>
|
80 |
+
</div>
|
81 |
+
|
82 |
+
{/* Info */}
|
83 |
+
<div className="mt-4 text-center">
|
84 |
+
<p className="text-white/50 text-xs">
|
85 |
+
💡 Continuing will analyze your story and suggest new directions
|
86 |
+
</p>
|
87 |
+
</div>
|
88 |
+
</div>
|
89 |
+
</div>
|
90 |
+
);
|
91 |
+
};
|
92 |
+
|
93 |
+
export default ExplorationPromptComponent;
|
components/LazyImage.tsx
ADDED
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
2 |
+
import { createLazyLoader } from '../utils/imageOptimization';
|
3 |
+
|
4 |
+
interface LazyImageProps {
|
5 |
+
src: string;
|
6 |
+
alt: string;
|
7 |
+
className?: string;
|
8 |
+
fallbackClassName?: string;
|
9 |
+
onLoad?: () => void;
|
10 |
+
onError?: () => void;
|
11 |
+
quality?: 'compressed' | 'full';
|
12 |
+
}
|
13 |
+
|
14 |
+
const LazyImage: React.FC<LazyImageProps> = ({
|
15 |
+
src,
|
16 |
+
alt,
|
17 |
+
className = '',
|
18 |
+
fallbackClassName = 'bg-white/10 animate-pulse',
|
19 |
+
onLoad,
|
20 |
+
onError,
|
21 |
+
quality = 'compressed'
|
22 |
+
}) => {
|
23 |
+
const [isLoaded, setIsLoaded] = useState(false);
|
24 |
+
const [hasError, setHasError] = useState(false);
|
25 |
+
const imgRef = useRef<HTMLImageElement>(null);
|
26 |
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
27 |
+
|
28 |
+
useEffect(() => {
|
29 |
+
if (!imgRef.current) return;
|
30 |
+
|
31 |
+
// Create lazy loader
|
32 |
+
observerRef.current = createLazyLoader({
|
33 |
+
rootMargin: '100px',
|
34 |
+
threshold: 0.1
|
35 |
+
}) as IntersectionObserver;
|
36 |
+
|
37 |
+
// Set up lazy loading
|
38 |
+
const img = imgRef.current;
|
39 |
+
img.dataset.src = `data:image/jpeg;base64,${src}`;
|
40 |
+
img.classList.add('lazy');
|
41 |
+
|
42 |
+
// Observe the image
|
43 |
+
if (observerRef.current) {
|
44 |
+
observerRef.current.observe(img);
|
45 |
+
}
|
46 |
+
|
47 |
+
return () => {
|
48 |
+
if (observerRef.current) {
|
49 |
+
observerRef.current.disconnect();
|
50 |
+
}
|
51 |
+
};
|
52 |
+
}, [src]);
|
53 |
+
|
54 |
+
const handleLoad = () => {
|
55 |
+
setIsLoaded(true);
|
56 |
+
setHasError(false);
|
57 |
+
onLoad?.();
|
58 |
+
};
|
59 |
+
|
60 |
+
const handleError = () => {
|
61 |
+
setHasError(true);
|
62 |
+
setIsLoaded(false);
|
63 |
+
onError?.();
|
64 |
+
};
|
65 |
+
|
66 |
+
return (
|
67 |
+
<div className="relative overflow-hidden">
|
68 |
+
{/* Loading placeholder */}
|
69 |
+
{!isLoaded && !hasError && (
|
70 |
+
<div className={`absolute inset-0 ${fallbackClassName} flex items-center justify-center`}>
|
71 |
+
<div className="flex flex-col items-center space-y-2">
|
72 |
+
<div className="spinner w-6 h-6"></div>
|
73 |
+
<span className="text-white/60 text-xs">Loading...</span>
|
74 |
+
</div>
|
75 |
+
</div>
|
76 |
+
)}
|
77 |
+
|
78 |
+
{/* Error placeholder */}
|
79 |
+
{hasError && (
|
80 |
+
<div className={`absolute inset-0 bg-red-500/20 flex items-center justify-center`}>
|
81 |
+
<div className="flex flex-col items-center space-y-2 text-red-300">
|
82 |
+
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
|
83 |
+
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
84 |
+
</svg>
|
85 |
+
<span className="text-xs">Failed to load</span>
|
86 |
+
</div>
|
87 |
+
</div>
|
88 |
+
)}
|
89 |
+
|
90 |
+
{/* Actual image */}
|
91 |
+
<img
|
92 |
+
ref={imgRef}
|
93 |
+
alt={alt}
|
94 |
+
className={`transition-opacity duration-500 ${
|
95 |
+
isLoaded ? 'opacity-100' : 'opacity-0'
|
96 |
+
} ${className}`}
|
97 |
+
onLoad={handleLoad}
|
98 |
+
onError={handleError}
|
99 |
+
loading="lazy"
|
100 |
+
/>
|
101 |
+
</div>
|
102 |
+
);
|
103 |
+
};
|
104 |
+
|
105 |
+
export default LazyImage;
|
components/LoadingIndicator.tsx
CHANGED
@@ -33,7 +33,7 @@ const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ loadingState }) =>
|
|
33 |
</div>
|
34 |
|
35 |
{/* Progress Bar */}
|
36 |
-
<div className="w-full bg-white/20 rounded-full h-6 overflow-hidden
|
37 |
<div
|
38 |
className="h-6 rounded-full transition-all duration-1000 ease-out bg-gradient-to-r from-gray-300 via-white to-gray-400 relative overflow-hidden"
|
39 |
style={{ width: `${loadingState.progress}%` }}
|
@@ -59,14 +59,14 @@ const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ loadingState }) =>
|
|
59 |
].map((item, index) => (
|
60 |
<div
|
61 |
key={index}
|
62 |
-
className={`
|
63 |
item.active
|
64 |
-
? 'bg-white/20 border-white/40 text-white'
|
65 |
: 'bg-white/5 border-white/10 text-white/50'
|
66 |
}`}
|
67 |
>
|
68 |
-
<div className="text-
|
69 |
-
<div className="text-sm font-
|
70 |
</div>
|
71 |
))}
|
72 |
</div>
|
@@ -74,12 +74,14 @@ const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ loadingState }) =>
|
|
74 |
|
75 |
{/* Footer */}
|
76 |
<div className="pt-6 border-t border-white/10">
|
77 |
-
<
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
|
|
|
|
83 |
</div>
|
84 |
</div>
|
85 |
</div>
|
|
|
33 |
</div>
|
34 |
|
35 |
{/* Progress Bar */}
|
36 |
+
<div className="w-full bg-white/20 rounded-full h-6 overflow-hidden">
|
37 |
<div
|
38 |
className="h-6 rounded-full transition-all duration-1000 ease-out bg-gradient-to-r from-gray-300 via-white to-gray-400 relative overflow-hidden"
|
39 |
style={{ width: `${loadingState.progress}%` }}
|
|
|
59 |
].map((item, index) => (
|
60 |
<div
|
61 |
key={index}
|
62 |
+
className={`glass rounded-xl p-4 transition-all duration-500 ${
|
63 |
item.active
|
64 |
+
? 'bg-white/20 border-white/40 text-white scale-105 shadow-lg'
|
65 |
: 'bg-white/5 border-white/10 text-white/50'
|
66 |
}`}
|
67 |
>
|
68 |
+
<div className="text-3xl mb-3 ${item.active ? 'animate-pulse-slow' : ''}">{item.icon}</div>
|
69 |
+
<div className="text-sm font-semibold">{item.step}</div>
|
70 |
</div>
|
71 |
))}
|
72 |
</div>
|
|
|
74 |
|
75 |
{/* Footer */}
|
76 |
<div className="pt-6 border-t border-white/10">
|
77 |
+
<div className="glass-card p-4 text-center">
|
78 |
+
<p className="text-sm text-white/70 font-medium">
|
79 |
+
⚡ Powered by Google Gemini 2.5 Flash • Professional Comic Creation
|
80 |
+
</p>
|
81 |
+
<p className="text-xs text-white/50 mt-2">
|
82 |
+
Each page is carefully composed with advanced AI artistry
|
83 |
+
</p>
|
84 |
+
</div>
|
85 |
</div>
|
86 |
</div>
|
87 |
</div>
|
components/PerformanceSummary.tsx
ADDED
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { memo } from 'react';
|
2 |
+
import type { PerformanceTiming } from '../types';
|
3 |
+
|
4 |
+
interface PerformanceSummaryProps {
|
5 |
+
timings: PerformanceTiming[];
|
6 |
+
totalTime: number;
|
7 |
+
totalPages: number;
|
8 |
+
}
|
9 |
+
|
10 |
+
const PerformanceSummary: React.FC<PerformanceSummaryProps> = memo(({
|
11 |
+
timings,
|
12 |
+
totalTime,
|
13 |
+
totalPages
|
14 |
+
}) => {
|
15 |
+
if (timings.length === 0) return null;
|
16 |
+
|
17 |
+
const formatTime = (ms: number): string => {
|
18 |
+
if (ms >= 60000) {
|
19 |
+
const minutes = Math.floor(ms / 60000);
|
20 |
+
const seconds = Math.floor((ms % 60000) / 1000);
|
21 |
+
return `${minutes}m ${seconds}s`;
|
22 |
+
}
|
23 |
+
return `${(ms / 1000).toFixed(1)}s`;
|
24 |
+
};
|
25 |
+
|
26 |
+
const getPerformanceClass = (duration: number, stepName: string): string => {
|
27 |
+
// Define performance thresholds based on step type
|
28 |
+
let threshold = { good: 10000, poor: 30000 }; // default
|
29 |
+
|
30 |
+
if (stepName.includes('analysis') || stepName.includes('script')) {
|
31 |
+
threshold = { good: 15000, poor: 45000 };
|
32 |
+
} else if (stepName.includes('generation') || stepName.includes('page')) {
|
33 |
+
threshold = { good: 60000, poor: 120000 };
|
34 |
+
} else if (stepName.includes('sketch')) {
|
35 |
+
threshold = { good: 20000, poor: 60000 };
|
36 |
+
}
|
37 |
+
|
38 |
+
if (duration <= threshold.good) return 'perf-excellent';
|
39 |
+
if (duration <= threshold.poor) return 'perf-good';
|
40 |
+
return 'perf-poor';
|
41 |
+
};
|
42 |
+
|
43 |
+
const averageTimePerPage = totalPages > 0 ? totalTime / totalPages : 0;
|
44 |
+
|
45 |
+
return (
|
46 |
+
<div className="glass-card p-6 animate-fade-in">
|
47 |
+
<h3 className="text-lg font-bold text-white mb-4 flex items-center">
|
48 |
+
📊 Performance Summary
|
49 |
+
</h3>
|
50 |
+
|
51 |
+
{/* Overall Stats */}
|
52 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
53 |
+
<div className="glass rounded-lg p-4 text-center">
|
54 |
+
<div className="text-2xl font-bold text-white">{formatTime(totalTime)}</div>
|
55 |
+
<div className="text-white/70 text-sm">Total Time</div>
|
56 |
+
</div>
|
57 |
+
<div className="glass rounded-lg p-4 text-center">
|
58 |
+
<div className="text-2xl font-bold text-white">{totalPages}</div>
|
59 |
+
<div className="text-white/70 text-sm">Pages Generated</div>
|
60 |
+
</div>
|
61 |
+
<div className="glass rounded-lg p-4 text-center">
|
62 |
+
<div className="text-2xl font-bold text-white">{formatTime(averageTimePerPage)}</div>
|
63 |
+
<div className="text-white/70 text-sm">Avg per Page</div>
|
64 |
+
</div>
|
65 |
+
</div>
|
66 |
+
|
67 |
+
{/* Step Breakdown */}
|
68 |
+
<div className="space-y-2">
|
69 |
+
<h4 className="text-sm font-semibold text-white/90 mb-3">Generation Steps</h4>
|
70 |
+
{timings.map((timing, index) => {
|
71 |
+
const percentage = totalTime > 0 ? (timing.duration / totalTime) * 100 : 0;
|
72 |
+
const perfClass = getPerformanceClass(timing.duration, timing.stepName);
|
73 |
+
|
74 |
+
return (
|
75 |
+
<div key={index} className="flex items-center justify-between py-2 px-3 glass rounded-lg">
|
76 |
+
<div className="flex-1">
|
77 |
+
<div className="flex items-center justify-between">
|
78 |
+
<span className="text-white/90 text-sm font-medium">
|
79 |
+
{timing.stepName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
80 |
+
</span>
|
81 |
+
<div className="flex items-center space-x-2">
|
82 |
+
<span className={`text-sm font-semibold ${perfClass}`}>
|
83 |
+
{formatTime(timing.duration)}
|
84 |
+
</span>
|
85 |
+
<span className="text-white/50 text-xs">
|
86 |
+
{percentage.toFixed(1)}%
|
87 |
+
</span>
|
88 |
+
</div>
|
89 |
+
</div>
|
90 |
+
{/* Progress bar */}
|
91 |
+
<div className="mt-2 w-full bg-white/10 rounded-full h-1.5">
|
92 |
+
<div
|
93 |
+
className={`h-1.5 rounded-full transition-all duration-500 ${
|
94 |
+
perfClass === 'perf-excellent' ? 'bg-green-500' :
|
95 |
+
perfClass === 'perf-good' ? 'bg-yellow-500' : 'bg-red-500'
|
96 |
+
}`}
|
97 |
+
style={{ width: `${percentage}%` }}
|
98 |
+
/>
|
99 |
+
</div>
|
100 |
+
</div>
|
101 |
+
</div>
|
102 |
+
);
|
103 |
+
})}
|
104 |
+
</div>
|
105 |
+
|
106 |
+
{/* Performance Tips */}
|
107 |
+
<div className="mt-4 p-3 bg-white/5 rounded-lg">
|
108 |
+
<h5 className="text-xs font-semibold text-white/80 mb-2">💡 Performance Tips</h5>
|
109 |
+
<div className="text-xs text-white/60 space-y-1">
|
110 |
+
<div>• Generation time depends on story complexity and API response time</div>
|
111 |
+
<div>• Character sketches improve consistency but add generation time</div>
|
112 |
+
<div>• Simpler stories with fewer characters generate faster</div>
|
113 |
+
</div>
|
114 |
+
</div>
|
115 |
+
</div>
|
116 |
+
);
|
117 |
+
});
|
118 |
+
|
119 |
+
export default PerformanceSummary;
|
components/StoryInput.tsx
CHANGED
@@ -12,6 +12,8 @@ interface StoryInputProps {
|
|
12 |
onStyleChange: (style: MangaStyle) => void;
|
13 |
onGenerate: () => void;
|
14 |
disabled: boolean;
|
|
|
|
|
15 |
}
|
16 |
|
17 |
const styles: { name: MangaStyle; description: string; color: string }[] = [
|
@@ -80,7 +82,7 @@ const storyTemplates: StoryTemplate[] = [
|
|
80 |
}
|
81 |
];
|
82 |
|
83 |
-
const StoryInput: React.FC<StoryInputProps> = ({ title, onTitleChange, story, onStoryChange, author, onAuthorChange, style, onStyleChange, onGenerate, disabled }) => {
|
84 |
const [selectedTemplate, setSelectedTemplate] = useState<string>('custom');
|
85 |
const [showAdvanced, setShowAdvanced] = useState(false);
|
86 |
|
@@ -191,6 +193,35 @@ const StoryInput: React.FC<StoryInputProps> = ({ title, onTitleChange, story, on
|
|
191 |
/>
|
192 |
</div>
|
193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
194 |
<div className="space-y-4">
|
195 |
<h3 className="text-lg font-semibold text-white/90 flex items-center">
|
196 |
🎨 Art Style
|
|
|
12 |
onStyleChange: (style: MangaStyle) => void;
|
13 |
onGenerate: () => void;
|
14 |
disabled: boolean;
|
15 |
+
includeDialogue: boolean;
|
16 |
+
onDialogueToggle: (include: boolean) => void;
|
17 |
}
|
18 |
|
19 |
const styles: { name: MangaStyle; description: string; color: string }[] = [
|
|
|
82 |
}
|
83 |
];
|
84 |
|
85 |
+
const StoryInput: React.FC<StoryInputProps> = ({ title, onTitleChange, story, onStoryChange, author, onAuthorChange, style, onStyleChange, onGenerate, disabled, includeDialogue, onDialogueToggle }) => {
|
86 |
const [selectedTemplate, setSelectedTemplate] = useState<string>('custom');
|
87 |
const [showAdvanced, setShowAdvanced] = useState(false);
|
88 |
|
|
|
193 |
/>
|
194 |
</div>
|
195 |
|
196 |
+
{/* Dialogue Toggle Section */}
|
197 |
+
<div className="space-y-4">
|
198 |
+
<h3 className="text-lg font-semibold text-white/90 flex items-center">
|
199 |
+
💬 Text Options
|
200 |
+
</h3>
|
201 |
+
<div className="glass-card p-4">
|
202 |
+
<div className="flex items-center justify-between">
|
203 |
+
<div className="flex-1">
|
204 |
+
<h4 className="text-white font-semibold mb-1">Include Dialogue & Text</h4>
|
205 |
+
<p className="text-white/70 text-sm">
|
206 |
+
{includeDialogue
|
207 |
+
? 'AI will generate dialogue and text within speech bubbles'
|
208 |
+
: 'Generate visual panels only - you can add text manually later'}
|
209 |
+
</p>
|
210 |
+
</div>
|
211 |
+
<label className="relative inline-flex items-center cursor-pointer ml-4">
|
212 |
+
<input
|
213 |
+
type="checkbox"
|
214 |
+
checked={includeDialogue}
|
215 |
+
onChange={(e) => onDialogueToggle(e.target.checked)}
|
216 |
+
disabled={disabled}
|
217 |
+
className="sr-only peer"
|
218 |
+
/>
|
219 |
+
<div className="w-14 h-8 bg-white/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-6 peer-checked:after:border-white after:content-[''] after:absolute after:top-1 after:left-1 after:bg-white after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-blue-600"></div>
|
220 |
+
</label>
|
221 |
+
</div>
|
222 |
+
</div>
|
223 |
+
</div>
|
224 |
+
|
225 |
<div className="space-y-4">
|
226 |
<h3 className="text-lg font-semibold text-white/90 flex items-center">
|
227 |
🎨 Art Style
|
components/StoryProgressionSelector.tsx
ADDED
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState } from 'react';
|
2 |
+
import type { StoryProgression, StoryArcSummary, ComicIteration } from '../types';
|
3 |
+
|
4 |
+
interface StoryProgressionSelectorProps {
|
5 |
+
progressions: StoryProgression[];
|
6 |
+
onProgressionSelect: (progression: StoryProgression) => void;
|
7 |
+
onSkip: () => void;
|
8 |
+
disabled: boolean;
|
9 |
+
currentIteration?: ComicIteration;
|
10 |
+
storyArcSummary?: StoryArcSummary | null;
|
11 |
+
}
|
12 |
+
|
13 |
+
const getProgressionIcon = (direction: StoryProgression['direction']): string => {
|
14 |
+
switch (direction) {
|
15 |
+
case 'sequel': return '⏭️';
|
16 |
+
case 'prequel': return '⏮️';
|
17 |
+
case 'side_story': return '🔀';
|
18 |
+
case 'alternate_ending': return '🔄';
|
19 |
+
default: return '📖';
|
20 |
+
}
|
21 |
+
};
|
22 |
+
|
23 |
+
const getProgressionColor = (direction: StoryProgression['direction']): string => {
|
24 |
+
switch (direction) {
|
25 |
+
case 'sequel': return 'from-blue-500 to-cyan-500';
|
26 |
+
case 'prequel': return 'from-purple-500 to-violet-500';
|
27 |
+
case 'side_story': return 'from-green-500 to-emerald-500';
|
28 |
+
case 'alternate_ending': return 'from-orange-500 to-red-500';
|
29 |
+
default: return 'from-gray-500 to-gray-600';
|
30 |
+
}
|
31 |
+
};
|
32 |
+
|
33 |
+
const getDirectionLabel = (direction: StoryProgression['direction']): string => {
|
34 |
+
switch (direction) {
|
35 |
+
case 'sequel': return 'Sequel';
|
36 |
+
case 'prequel': return 'Prequel';
|
37 |
+
case 'side_story': return 'Side Story';
|
38 |
+
case 'alternate_ending': return 'Alternate Ending';
|
39 |
+
default: return 'Story';
|
40 |
+
}
|
41 |
+
};
|
42 |
+
|
43 |
+
const StoryProgressionSelector: React.FC<StoryProgressionSelectorProps> = ({
|
44 |
+
progressions,
|
45 |
+
onProgressionSelect,
|
46 |
+
onSkip,
|
47 |
+
disabled,
|
48 |
+
currentIteration,
|
49 |
+
storyArcSummary
|
50 |
+
}) => {
|
51 |
+
const [selectedProgression, setSelectedProgression] = useState<StoryProgression | null>(null);
|
52 |
+
|
53 |
+
const handleSelect = (progression: StoryProgression) => {
|
54 |
+
if (!disabled) {
|
55 |
+
setSelectedProgression(progression);
|
56 |
+
}
|
57 |
+
};
|
58 |
+
|
59 |
+
const handleConfirm = () => {
|
60 |
+
if (selectedProgression) {
|
61 |
+
onProgressionSelect(selectedProgression);
|
62 |
+
}
|
63 |
+
};
|
64 |
+
|
65 |
+
return (
|
66 |
+
<div className="space-y-8 animate-fade-in">
|
67 |
+
{/* Header Section */}
|
68 |
+
<div className="glass-card p-8">
|
69 |
+
<div className="text-center">
|
70 |
+
<h2 className="text-4xl font-bold text-white mb-4">
|
71 |
+
📚 Continue Your Story
|
72 |
+
</h2>
|
73 |
+
<p className="text-xl text-white/80 mb-2">
|
74 |
+
Your comic is complete! Would you like to continue the adventure?
|
75 |
+
</p>
|
76 |
+
<p className="text-white/60">
|
77 |
+
Choose one of these professionally crafted story progressions to expand your comic into a series
|
78 |
+
</p>
|
79 |
+
</div>
|
80 |
+
</div>
|
81 |
+
|
82 |
+
{/* Story Till Now Section - Compact */}
|
83 |
+
{storyArcSummary && (
|
84 |
+
<div className="glass-card p-6">
|
85 |
+
<h3 className="text-lg font-bold text-white mb-4 text-center gradient-text">
|
86 |
+
📖 Story Till Now
|
87 |
+
</h3>
|
88 |
+
|
89 |
+
{/* Compact Single Row Layout */}
|
90 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
91 |
+
{/* Theme */}
|
92 |
+
<div className="glass rounded-lg p-4">
|
93 |
+
<div className="flex items-center space-x-2 mb-2">
|
94 |
+
<span className="text-xl">🎯</span>
|
95 |
+
<h4 className="text-sm font-bold text-white">Theme</h4>
|
96 |
+
</div>
|
97 |
+
<p className="text-white/70 text-xs leading-tight">
|
98 |
+
{storyArcSummary.overallTheme.length > 80
|
99 |
+
? `${storyArcSummary.overallTheme.slice(0, 80)}...`
|
100 |
+
: storyArcSummary.overallTheme}
|
101 |
+
</p>
|
102 |
+
</div>
|
103 |
+
|
104 |
+
{/* Key Events */}
|
105 |
+
<div className="glass rounded-lg p-4">
|
106 |
+
<div className="flex items-center space-x-2 mb-2">
|
107 |
+
<span className="text-xl">🎬</span>
|
108 |
+
<h4 className="text-sm font-bold text-white">Key Events</h4>
|
109 |
+
</div>
|
110 |
+
<div className="space-y-1">
|
111 |
+
{storyArcSummary.keyEvents.slice(0, 2).map((event, index) => (
|
112 |
+
<div key={index} className="text-white/70 text-xs flex items-start">
|
113 |
+
<span className="text-white/50 mr-1 mt-0.5">•</span>
|
114 |
+
<span>{event.length > 40 ? `${event.slice(0, 40)}...` : event}</span>
|
115 |
+
</div>
|
116 |
+
))}
|
117 |
+
{storyArcSummary.keyEvents.length > 2 && (
|
118 |
+
<div className="text-white/50 text-xs italic">
|
119 |
+
+{storyArcSummary.keyEvents.length - 2} more
|
120 |
+
</div>
|
121 |
+
)}
|
122 |
+
</div>
|
123 |
+
</div>
|
124 |
+
|
125 |
+
{/* Character Growth */}
|
126 |
+
<div className="glass rounded-lg p-4">
|
127 |
+
<div className="flex items-center space-x-2 mb-2">
|
128 |
+
<span className="text-xl">👥</span>
|
129 |
+
<h4 className="text-sm font-bold text-white">Growth</h4>
|
130 |
+
</div>
|
131 |
+
<div className="space-y-1">
|
132 |
+
{storyArcSummary.characterDevelopment.slice(0, 2).map((dev, index) => (
|
133 |
+
<div key={index} className="text-white/70 text-xs flex items-start">
|
134 |
+
<span className="text-white/50 mr-1 mt-0.5">•</span>
|
135 |
+
<span>{dev.length > 40 ? `${dev.slice(0, 40)}...` : dev}</span>
|
136 |
+
</div>
|
137 |
+
))}
|
138 |
+
{storyArcSummary.characterDevelopment.length > 2 && (
|
139 |
+
<div className="text-white/50 text-xs italic">
|
140 |
+
+{storyArcSummary.characterDevelopment.length - 2} more
|
141 |
+
</div>
|
142 |
+
)}
|
143 |
+
</div>
|
144 |
+
</div>
|
145 |
+
</div>
|
146 |
+
|
147 |
+
{/* Current Story Stats - Compact */}
|
148 |
+
{currentIteration && (
|
149 |
+
<div className="glass rounded-lg p-4">
|
150 |
+
<div className="flex justify-center items-center gap-6 text-center">
|
151 |
+
<div>
|
152 |
+
<div className="text-xl font-bold text-white">{currentIteration.images.length}</div>
|
153 |
+
<div className="text-white/70 text-xs font-medium">Pages</div>
|
154 |
+
</div>
|
155 |
+
<div className="h-6 w-px bg-white/20"></div>
|
156 |
+
<div>
|
157 |
+
<div className="text-xl font-bold text-white">{currentIteration.characters.length}</div>
|
158 |
+
<div className="text-white/70 text-xs font-medium">Characters</div>
|
159 |
+
</div>
|
160 |
+
<div className="h-6 w-px bg-white/20"></div>
|
161 |
+
<div>
|
162 |
+
<div className="text-xl font-bold text-white">{currentIteration.pages.length}</div>
|
163 |
+
<div className="text-white/70 text-xs font-medium">Scenes</div>
|
164 |
+
</div>
|
165 |
+
</div>
|
166 |
+
</div>
|
167 |
+
)}
|
168 |
+
</div>
|
169 |
+
)}
|
170 |
+
|
171 |
+
{/* Progression Options */}
|
172 |
+
<div className="space-y-6">
|
173 |
+
<h3 className="text-2xl font-bold text-white text-center">
|
174 |
+
🎭 Story Progression Options
|
175 |
+
</h3>
|
176 |
+
|
177 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
178 |
+
{progressions.map((progression, index) => (
|
179 |
+
<div
|
180 |
+
key={progression.id}
|
181 |
+
onClick={() => handleSelect(progression)}
|
182 |
+
className={`progression-card cursor-pointer ${
|
183 |
+
selectedProgression?.id === progression.id
|
184 |
+
? 'selected ring-2 ring-white/50 scale-105'
|
185 |
+
: 'hover:bg-white/20 hover:scale-102'
|
186 |
+
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
187 |
+
>
|
188 |
+
{/* Header */}
|
189 |
+
<div className="flex items-center justify-between mb-4">
|
190 |
+
<div className={`px-3 py-1 rounded-full text-sm font-semibold bg-gradient-to-r ${getProgressionColor(progression.direction)} text-white`}>
|
191 |
+
{getProgressionIcon(progression.direction)} {getDirectionLabel(progression.direction)}
|
192 |
+
</div>
|
193 |
+
<div className="text-white/60 text-sm">
|
194 |
+
~{progression.estimatedPages} pages
|
195 |
+
</div>
|
196 |
+
</div>
|
197 |
+
|
198 |
+
{/* Title */}
|
199 |
+
<h4 className="text-xl font-bold text-white mb-3">
|
200 |
+
{progression.title}
|
201 |
+
</h4>
|
202 |
+
|
203 |
+
{/* Description */}
|
204 |
+
<p className="text-white/80 text-sm mb-4 leading-relaxed">
|
205 |
+
{progression.description}
|
206 |
+
</p>
|
207 |
+
|
208 |
+
{/* Synopsis */}
|
209 |
+
<div className="bg-white/10 rounded-lg p-3 mb-4">
|
210 |
+
<h5 className="text-white font-semibold text-sm mb-2">📖 Synopsis</h5>
|
211 |
+
<p className="text-white/70 text-xs leading-relaxed">
|
212 |
+
{progression.synopsis}
|
213 |
+
</p>
|
214 |
+
</div>
|
215 |
+
|
216 |
+
{/* Thematic Focus */}
|
217 |
+
<div className="mb-4">
|
218 |
+
<h5 className="text-white font-semibold text-sm mb-2">🎯 Focus</h5>
|
219 |
+
<p className="text-white/70 text-xs">
|
220 |
+
{progression.thematicFocus}
|
221 |
+
</p>
|
222 |
+
</div>
|
223 |
+
|
224 |
+
{/* Plot Hooks */}
|
225 |
+
<div className="mb-4">
|
226 |
+
<h5 className="text-white font-semibold text-sm mb-2">🪝 Key Elements</h5>
|
227 |
+
<div className="flex flex-wrap gap-1">
|
228 |
+
{progression.plotHooks.slice(0, 3).map((hook, hookIndex) => (
|
229 |
+
<span
|
230 |
+
key={hookIndex}
|
231 |
+
className="bg-white/20 text-white/80 text-xs px-2 py-1 rounded-full"
|
232 |
+
>
|
233 |
+
{hook.length > 20 ? `${hook.slice(0, 20)}...` : hook}
|
234 |
+
</span>
|
235 |
+
))}
|
236 |
+
</div>
|
237 |
+
</div>
|
238 |
+
|
239 |
+
{/* New Characters */}
|
240 |
+
{progression.newCharacters && progression.newCharacters.length > 0 && (
|
241 |
+
<div>
|
242 |
+
<h5 className="text-white font-semibold text-sm mb-2">👥 New Characters</h5>
|
243 |
+
<div className="flex flex-wrap gap-1">
|
244 |
+
{progression.newCharacters.slice(0, 3).map((character, charIndex) => (
|
245 |
+
<span
|
246 |
+
key={charIndex}
|
247 |
+
className="bg-gradient-to-r from-yellow-400/20 to-orange-400/20 text-yellow-100 text-xs px-2 py-1 rounded-full"
|
248 |
+
>
|
249 |
+
{character}
|
250 |
+
</span>
|
251 |
+
))}
|
252 |
+
{progression.newCharacters.length > 3 && (
|
253 |
+
<span className="text-white/60 text-xs px-2 py-1">
|
254 |
+
+{progression.newCharacters.length - 3} more
|
255 |
+
</span>
|
256 |
+
)}
|
257 |
+
</div>
|
258 |
+
</div>
|
259 |
+
)}
|
260 |
+
|
261 |
+
{/* Selection Indicator */}
|
262 |
+
{selectedProgression?.id === progression.id && (
|
263 |
+
<div className="absolute top-4 right-4 w-6 h-6 bg-white rounded-full flex items-center justify-center">
|
264 |
+
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
265 |
+
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
266 |
+
</svg>
|
267 |
+
</div>
|
268 |
+
)}
|
269 |
+
</div>
|
270 |
+
))}
|
271 |
+
</div>
|
272 |
+
</div>
|
273 |
+
|
274 |
+
{/* Action Buttons */}
|
275 |
+
<div className="glass-card p-8">
|
276 |
+
<div className="flex flex-col sm:flex-row justify-center items-center gap-4">
|
277 |
+
<button
|
278 |
+
onClick={handleConfirm}
|
279 |
+
disabled={disabled || !selectedProgression}
|
280 |
+
className={`btn-primary px-8 py-4 text-lg font-bold disabled:opacity-50 disabled:cursor-not-allowed ${
|
281 |
+
!disabled && selectedProgression ? 'hover:scale-105' : ''
|
282 |
+
} transition-all duration-300`}
|
283 |
+
>
|
284 |
+
{disabled ? (
|
285 |
+
<span className="flex items-center">
|
286 |
+
<div className="spinner mr-3"></div>
|
287 |
+
Generating Continuation...
|
288 |
+
</span>
|
289 |
+
) : selectedProgression ? (
|
290 |
+
<span className="flex items-center">
|
291 |
+
🚀 Continue with "{selectedProgression.title}"
|
292 |
+
</span>
|
293 |
+
) : (
|
294 |
+
<span>Select a Progression to Continue</span>
|
295 |
+
)}
|
296 |
+
</button>
|
297 |
+
|
298 |
+
<button
|
299 |
+
onClick={onSkip}
|
300 |
+
disabled={disabled}
|
301 |
+
className={`btn-secondary px-6 py-3 ${
|
302 |
+
!disabled ? 'hover:scale-105' : 'opacity-50 cursor-not-allowed'
|
303 |
+
} transition-all duration-300`}
|
304 |
+
>
|
305 |
+
📥 Skip & Download Current Comic
|
306 |
+
</button>
|
307 |
+
</div>
|
308 |
+
</div>
|
309 |
+
|
310 |
+
{/* Info Section */}
|
311 |
+
<div className="glass-card p-6">
|
312 |
+
<div className="text-center">
|
313 |
+
<h4 className="text-white font-semibold mb-2">🎨 What happens next?</h4>
|
314 |
+
<p className="text-white/70 text-sm">
|
315 |
+
When you select a progression, AI will generate a new comic chapter that continues your story.
|
316 |
+
All chapters will be compiled into a complete series PDF for download.
|
317 |
+
</p>
|
318 |
+
</div>
|
319 |
+
</div>
|
320 |
+
</div>
|
321 |
+
);
|
322 |
+
};
|
323 |
+
|
324 |
+
export default StoryProgressionSelector;
|
index.css
CHANGED
@@ -1,13 +1,41 @@
|
|
1 |
-
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
2 |
|
3 |
:root {
|
4 |
-
|
5 |
-
--
|
6 |
-
--
|
7 |
-
--
|
8 |
-
--
|
9 |
-
--
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
}
|
12 |
|
13 |
* {
|
@@ -18,34 +46,53 @@ body {
|
|
18 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
19 |
margin: 0;
|
20 |
padding: 0;
|
21 |
-
background:
|
22 |
background-attachment: fixed;
|
23 |
min-height: 100vh;
|
|
|
|
|
|
|
24 |
}
|
25 |
|
26 |
-
/* Glass
|
27 |
.glass {
|
28 |
background: var(--glass-bg);
|
29 |
-
backdrop-filter: blur(
|
30 |
-
-webkit-backdrop-filter: blur(
|
31 |
-
border-radius:
|
32 |
border: 1px solid var(--glass-border);
|
33 |
box-shadow: var(--glass-shadow);
|
|
|
34 |
}
|
35 |
|
36 |
.glass-card {
|
37 |
-
background:
|
38 |
-
backdrop-filter: blur(
|
39 |
-
-webkit-backdrop-filter: blur(
|
40 |
-
border-radius:
|
41 |
-
border: 1px solid
|
42 |
-
box-shadow:
|
43 |
-
transition: all 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
}
|
45 |
|
46 |
.glass-card:hover {
|
47 |
-
transform: translateY(-
|
48 |
-
|
|
|
|
|
49 |
}
|
50 |
|
51 |
/* Animation utilities */
|
@@ -92,59 +139,102 @@ body {
|
|
92 |
animation: pulse 2s infinite;
|
93 |
}
|
94 |
|
95 |
-
/* Modern
|
96 |
.btn-primary {
|
97 |
background: var(--primary-gradient);
|
98 |
border: none;
|
99 |
-
border-radius:
|
100 |
color: white;
|
101 |
font-weight: 600;
|
102 |
-
padding:
|
103 |
-
transition: all 0.
|
104 |
-
box-shadow:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
105 |
}
|
106 |
|
107 |
.btn-primary:hover {
|
108 |
transform: translateY(-2px);
|
109 |
-
box-shadow:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
110 |
}
|
111 |
|
112 |
.btn-secondary {
|
113 |
-
background:
|
114 |
-
backdrop-filter: blur(
|
115 |
-
border: 1px solid
|
116 |
-
border-radius:
|
117 |
color: white;
|
118 |
-
font-weight:
|
119 |
-
padding:
|
120 |
-
transition: all 0.
|
|
|
|
|
121 |
}
|
122 |
|
123 |
.btn-secondary:hover {
|
124 |
-
background:
|
|
|
125 |
transform: translateY(-2px);
|
126 |
}
|
127 |
|
128 |
-
|
|
|
|
|
|
|
|
|
129 |
.input-glass {
|
130 |
-
background:
|
131 |
-
backdrop-filter: blur(
|
132 |
-
border: 1px solid
|
133 |
-
border-radius:
|
134 |
color: white;
|
135 |
-
padding:
|
136 |
-
transition: all 0.
|
|
|
|
|
|
|
137 |
}
|
138 |
|
139 |
.input-glass::placeholder {
|
140 |
-
color: rgba(255, 255, 255, 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
141 |
}
|
142 |
|
143 |
.input-glass:focus {
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
|
|
|
|
|
|
148 |
}
|
149 |
|
150 |
/* Loading spinner */
|
@@ -162,6 +252,51 @@ body {
|
|
162 |
100% { transform: rotate(360deg); }
|
163 |
}
|
164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
165 |
/* Panel composition grid */
|
166 |
.panel-grid {
|
167 |
display: grid;
|
@@ -194,9 +329,22 @@ body {
|
|
194 |
}
|
195 |
}
|
196 |
|
197 |
-
/*
|
198 |
.gradient-text {
|
199 |
-
background:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
-webkit-background-clip: text;
|
201 |
-webkit-text-fill-color: transparent;
|
202 |
background-clip: text;
|
@@ -222,4 +370,180 @@ body {
|
|
222 |
.template-card.selected {
|
223 |
background: rgba(255, 255, 255, 0.3);
|
224 |
border-color: rgba(255, 255, 255, 0.5);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
225 |
}
|
|
|
1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
2 |
|
3 |
:root {
|
4 |
+
/* Modern Black-to-White Gradient System */
|
5 |
+
--bg-gradient: linear-gradient(135deg, #000000 0%, #1a1a1a 25%, #2d2d2d 75%, #1a1a1a 100%);
|
6 |
+
--primary-gradient: linear-gradient(135deg, #000000 0%, #2d2d2d 50%, #4a4a4a 100%);
|
7 |
+
--secondary-gradient: linear-gradient(135deg, #1a1a1a 0%, #3d3d3d 100%);
|
8 |
+
--accent-gradient: linear-gradient(135deg, #2d2d2d 0%, #5a5a5a 100%);
|
9 |
+
--text-gradient: linear-gradient(135deg, #ffffff 0%, #e5e5e5 50%, #cccccc 100%);
|
10 |
+
|
11 |
+
/* Glass Morphism - Enhanced */
|
12 |
+
--glass-bg: rgba(255, 255, 255, 0.08);
|
13 |
+
--glass-bg-strong: rgba(255, 255, 255, 0.12);
|
14 |
+
--glass-border: rgba(255, 255, 255, 0.16);
|
15 |
+
--glass-border-strong: rgba(255, 255, 255, 0.24);
|
16 |
+
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
17 |
+
--glass-shadow-strong: 0 16px 40px rgba(0, 0, 0, 0.6);
|
18 |
+
|
19 |
+
/* Interactive States */
|
20 |
+
--hover-bg: rgba(255, 255, 255, 0.16);
|
21 |
+
--active-bg: rgba(255, 255, 255, 0.20);
|
22 |
+
--focus-ring: rgba(255, 255, 255, 0.24);
|
23 |
+
|
24 |
+
/* Modern Spacing Scale */
|
25 |
+
--space-xs: 0.25rem;
|
26 |
+
--space-sm: 0.5rem;
|
27 |
+
--space-md: 1rem;
|
28 |
+
--space-lg: 1.5rem;
|
29 |
+
--space-xl: 2rem;
|
30 |
+
--space-2xl: 3rem;
|
31 |
+
--space-3xl: 4rem;
|
32 |
+
|
33 |
+
/* Border Radius Scale */
|
34 |
+
--radius-sm: 8px;
|
35 |
+
--radius-md: 12px;
|
36 |
+
--radius-lg: 16px;
|
37 |
+
--radius-xl: 20px;
|
38 |
+
--radius-2xl: 24px;
|
39 |
}
|
40 |
|
41 |
* {
|
|
|
46 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
47 |
margin: 0;
|
48 |
padding: 0;
|
49 |
+
background: var(--bg-gradient);
|
50 |
background-attachment: fixed;
|
51 |
min-height: 100vh;
|
52 |
+
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
53 |
+
-webkit-font-smoothing: antialiased;
|
54 |
+
-moz-osx-font-smoothing: grayscale;
|
55 |
}
|
56 |
|
57 |
+
/* Modern Glass Morphism System */
|
58 |
.glass {
|
59 |
background: var(--glass-bg);
|
60 |
+
backdrop-filter: blur(20px);
|
61 |
+
-webkit-backdrop-filter: blur(20px);
|
62 |
+
border-radius: var(--radius-xl);
|
63 |
border: 1px solid var(--glass-border);
|
64 |
box-shadow: var(--glass-shadow);
|
65 |
+
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
66 |
}
|
67 |
|
68 |
.glass-card {
|
69 |
+
background: var(--glass-bg-strong);
|
70 |
+
backdrop-filter: blur(24px);
|
71 |
+
-webkit-backdrop-filter: blur(24px);
|
72 |
+
border-radius: var(--radius-lg);
|
73 |
+
border: 1px solid var(--glass-border-strong);
|
74 |
+
box-shadow: var(--glass-shadow-strong);
|
75 |
+
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
76 |
+
position: relative;
|
77 |
+
overflow: hidden;
|
78 |
+
}
|
79 |
+
|
80 |
+
.glass-card::before {
|
81 |
+
content: '';
|
82 |
+
position: absolute;
|
83 |
+
top: 0;
|
84 |
+
left: 0;
|
85 |
+
right: 0;
|
86 |
+
height: 1px;
|
87 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
88 |
+
opacity: 0.6;
|
89 |
}
|
90 |
|
91 |
.glass-card:hover {
|
92 |
+
transform: translateY(-2px);
|
93 |
+
background: var(--hover-bg);
|
94 |
+
border-color: var(--glass-border-strong);
|
95 |
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7);
|
96 |
}
|
97 |
|
98 |
/* Animation utilities */
|
|
|
139 |
animation: pulse 2s infinite;
|
140 |
}
|
141 |
|
142 |
+
/* Modern Button System */
|
143 |
.btn-primary {
|
144 |
background: var(--primary-gradient);
|
145 |
border: none;
|
146 |
+
border-radius: var(--radius-md);
|
147 |
color: white;
|
148 |
font-weight: 600;
|
149 |
+
padding: var(--space-lg) var(--space-xl);
|
150 |
+
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
151 |
+
box-shadow: var(--glass-shadow);
|
152 |
+
position: relative;
|
153 |
+
overflow: hidden;
|
154 |
+
cursor: pointer;
|
155 |
+
outline: none;
|
156 |
+
}
|
157 |
+
|
158 |
+
.btn-primary::before {
|
159 |
+
content: '';
|
160 |
+
position: absolute;
|
161 |
+
top: 0;
|
162 |
+
left: -100%;
|
163 |
+
width: 100%;
|
164 |
+
height: 100%;
|
165 |
+
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
166 |
+
transition: left 0.6s;
|
167 |
}
|
168 |
|
169 |
.btn-primary:hover {
|
170 |
transform: translateY(-2px);
|
171 |
+
box-shadow: var(--glass-shadow-strong);
|
172 |
+
background: linear-gradient(135deg, #1a1a1a 0%, #3d3d3d 50%, #5a5a5a 100%);
|
173 |
+
}
|
174 |
+
|
175 |
+
.btn-primary:hover::before {
|
176 |
+
left: 100%;
|
177 |
+
}
|
178 |
+
|
179 |
+
.btn-primary:focus {
|
180 |
+
box-shadow: var(--glass-shadow-strong), 0 0 0 3px var(--focus-ring);
|
181 |
}
|
182 |
|
183 |
.btn-secondary {
|
184 |
+
background: var(--glass-bg-strong);
|
185 |
+
backdrop-filter: blur(12px);
|
186 |
+
border: 1px solid var(--glass-border);
|
187 |
+
border-radius: var(--radius-md);
|
188 |
color: white;
|
189 |
+
font-weight: 500;
|
190 |
+
padding: var(--space-lg) var(--space-xl);
|
191 |
+
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
192 |
+
cursor: pointer;
|
193 |
+
outline: none;
|
194 |
}
|
195 |
|
196 |
.btn-secondary:hover {
|
197 |
+
background: var(--hover-bg);
|
198 |
+
border-color: var(--glass-border-strong);
|
199 |
transform: translateY(-2px);
|
200 |
}
|
201 |
|
202 |
+
.btn-secondary:focus {
|
203 |
+
box-shadow: 0 0 0 3px var(--focus-ring);
|
204 |
+
}
|
205 |
+
|
206 |
+
/* Modern Input System */
|
207 |
.input-glass {
|
208 |
+
background: var(--glass-bg);
|
209 |
+
backdrop-filter: blur(12px);
|
210 |
+
border: 1px solid var(--glass-border);
|
211 |
+
border-radius: var(--radius-md);
|
212 |
color: white;
|
213 |
+
padding: var(--space-lg) var(--space-lg);
|
214 |
+
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
215 |
+
font-size: 1rem;
|
216 |
+
font-weight: 400;
|
217 |
+
outline: none;
|
218 |
}
|
219 |
|
220 |
.input-glass::placeholder {
|
221 |
+
color: rgba(255, 255, 255, 0.6);
|
222 |
+
font-weight: 400;
|
223 |
+
}
|
224 |
+
|
225 |
+
.input-glass:hover {
|
226 |
+
background: var(--glass-bg-strong);
|
227 |
+
border-color: var(--glass-border-strong);
|
228 |
}
|
229 |
|
230 |
.input-glass:focus {
|
231 |
+
border-color: var(--focus-ring);
|
232 |
+
background: var(--glass-bg-strong);
|
233 |
+
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.12);
|
234 |
+
}
|
235 |
+
|
236 |
+
.input-glass:focus::placeholder {
|
237 |
+
color: rgba(255, 255, 255, 0.4);
|
238 |
}
|
239 |
|
240 |
/* Loading spinner */
|
|
|
252 |
100% { transform: rotate(360deg); }
|
253 |
}
|
254 |
|
255 |
+
/* Skeleton loading animation */
|
256 |
+
.skeleton-loader {
|
257 |
+
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 75%);
|
258 |
+
background-size: 200% 100%;
|
259 |
+
animation: skeleton-loading 1.5s infinite;
|
260 |
+
}
|
261 |
+
|
262 |
+
@keyframes skeleton-loading {
|
263 |
+
0% {
|
264 |
+
background-position: 200% 0;
|
265 |
+
}
|
266 |
+
100% {
|
267 |
+
background-position: -200% 0;
|
268 |
+
}
|
269 |
+
}
|
270 |
+
|
271 |
+
/* Lazy image loading states */
|
272 |
+
.lazy {
|
273 |
+
filter: blur(5px);
|
274 |
+
transition: filter 0.3s;
|
275 |
+
}
|
276 |
+
|
277 |
+
.lazy.loaded {
|
278 |
+
filter: none;
|
279 |
+
}
|
280 |
+
|
281 |
+
/* Performance timing display */
|
282 |
+
.timing-badge {
|
283 |
+
background: rgba(0, 255, 0, 0.1);
|
284 |
+
border: 1px solid rgba(0, 255, 0, 0.3);
|
285 |
+
color: rgba(0, 255, 0, 0.8);
|
286 |
+
padding: 0.25rem 0.5rem;
|
287 |
+
border-radius: 0.5rem;
|
288 |
+
font-size: 0.75rem;
|
289 |
+
font-weight: 600;
|
290 |
+
display: inline-flex;
|
291 |
+
align-items: center;
|
292 |
+
gap: 0.25rem;
|
293 |
+
}
|
294 |
+
|
295 |
+
.timing-badge::before {
|
296 |
+
content: '⏱️';
|
297 |
+
font-size: 0.625rem;
|
298 |
+
}
|
299 |
+
|
300 |
/* Panel composition grid */
|
301 |
.panel-grid {
|
302 |
display: grid;
|
|
|
329 |
}
|
330 |
}
|
331 |
|
332 |
+
/* Modern Text Gradients */
|
333 |
.gradient-text {
|
334 |
+
background: var(--text-gradient);
|
335 |
+
-webkit-background-clip: text;
|
336 |
+
-webkit-text-fill-color: transparent;
|
337 |
+
background-clip: text;
|
338 |
+
font-weight: 700;
|
339 |
+
}
|
340 |
+
|
341 |
+
/* Performance indicator colors */
|
342 |
+
.perf-excellent { color: #10b981; }
|
343 |
+
.perf-good { color: #f59e0b; }
|
344 |
+
.perf-poor { color: #ef4444; }
|
345 |
+
|
346 |
+
.gradient-text-subtle {
|
347 |
+
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 255, 255, 0.7) 100%);
|
348 |
-webkit-background-clip: text;
|
349 |
-webkit-text-fill-color: transparent;
|
350 |
background-clip: text;
|
|
|
370 |
.template-card.selected {
|
371 |
background: rgba(255, 255, 255, 0.3);
|
372 |
border-color: rgba(255, 255, 255, 0.5);
|
373 |
+
}
|
374 |
+
|
375 |
+
/* Story progression cards */
|
376 |
+
.progression-card {
|
377 |
+
background: rgba(255, 255, 255, 0.1);
|
378 |
+
backdrop-filter: blur(15px);
|
379 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
380 |
+
border-radius: 16px;
|
381 |
+
padding: 24px;
|
382 |
+
transition: all 0.3s ease;
|
383 |
+
cursor: pointer;
|
384 |
+
position: relative;
|
385 |
+
min-height: 400px;
|
386 |
+
}
|
387 |
+
|
388 |
+
.progression-card:hover {
|
389 |
+
background: rgba(255, 255, 255, 0.2);
|
390 |
+
transform: translateY(-6px) scale(1.02);
|
391 |
+
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
392 |
+
}
|
393 |
+
|
394 |
+
.progression-card.selected {
|
395 |
+
background: rgba(255, 255, 255, 0.25);
|
396 |
+
border-color: rgba(255, 255, 255, 0.5);
|
397 |
+
box-shadow: 0 0 30px rgba(255, 255, 255, 0.2);
|
398 |
+
}
|
399 |
+
|
400 |
+
/* Series chapter navigation */
|
401 |
+
.chapter-nav-button {
|
402 |
+
background: rgba(255, 255, 255, 0.1);
|
403 |
+
backdrop-filter: blur(10px);
|
404 |
+
border: 2px solid transparent;
|
405 |
+
border-radius: 12px;
|
406 |
+
padding: 16px;
|
407 |
+
transition: all 0.3s ease;
|
408 |
+
cursor: pointer;
|
409 |
+
}
|
410 |
+
|
411 |
+
.chapter-nav-button:hover {
|
412 |
+
background: rgba(255, 255, 255, 0.15);
|
413 |
+
transform: translateY(-2px);
|
414 |
+
}
|
415 |
+
|
416 |
+
.chapter-nav-button.active {
|
417 |
+
background: rgba(255, 255, 255, 0.2);
|
418 |
+
border-color: rgba(255, 255, 255, 0.5);
|
419 |
+
}
|
420 |
+
|
421 |
+
/* Enhanced animations for story progression */
|
422 |
+
@keyframes progressionSlideIn {
|
423 |
+
from {
|
424 |
+
opacity: 0;
|
425 |
+
transform: translateY(30px) scale(0.95);
|
426 |
+
}
|
427 |
+
to {
|
428 |
+
opacity: 1;
|
429 |
+
transform: translateY(0) scale(1);
|
430 |
+
}
|
431 |
+
}
|
432 |
+
|
433 |
+
.progression-card {
|
434 |
+
animation: progressionSlideIn 0.6s ease-out;
|
435 |
+
}
|
436 |
+
|
437 |
+
.progression-card:nth-child(2) {
|
438 |
+
animation-delay: 0.1s;
|
439 |
+
}
|
440 |
+
|
441 |
+
.progression-card:nth-child(3) {
|
442 |
+
animation-delay: 0.2s;
|
443 |
+
}
|
444 |
+
|
445 |
+
/* Series completion celebration effects */
|
446 |
+
@keyframes celebrationGlow {
|
447 |
+
0%, 100% {
|
448 |
+
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
|
449 |
+
}
|
450 |
+
50% {
|
451 |
+
box-shadow: 0 0 40px rgba(255, 255, 255, 0.6);
|
452 |
+
}
|
453 |
+
}
|
454 |
+
|
455 |
+
.series-complete-glow {
|
456 |
+
animation: celebrationGlow 2s ease-in-out infinite;
|
457 |
+
}
|
458 |
+
|
459 |
+
/* Enhanced hover effects */
|
460 |
+
@keyframes scaleHover {
|
461 |
+
to {
|
462 |
+
transform: scale(1.02);
|
463 |
+
}
|
464 |
+
}
|
465 |
+
|
466 |
+
.hover\:scale-102:hover {
|
467 |
+
animation: scaleHover 0.2s ease-out forwards;
|
468 |
+
}
|
469 |
+
|
470 |
+
.hover\:scale-105:hover {
|
471 |
+
transform: scale(1.05);
|
472 |
+
}
|
473 |
+
|
474 |
+
/* Progress indicators */
|
475 |
+
.progress-step {
|
476 |
+
background: rgba(255, 255, 255, 0.2);
|
477 |
+
border-radius: 50%;
|
478 |
+
width: 12px;
|
479 |
+
height: 12px;
|
480 |
+
transition: all 0.3s ease;
|
481 |
+
}
|
482 |
+
|
483 |
+
.progress-step.active {
|
484 |
+
background: rgba(255, 255, 255, 0.8);
|
485 |
+
box-shadow: 0 0 15px rgba(255, 255, 255, 0.5);
|
486 |
+
}
|
487 |
+
|
488 |
+
.progress-step.completed {
|
489 |
+
background: rgba(34, 197, 94, 0.8);
|
490 |
+
box-shadow: 0 0 15px rgba(34, 197, 94, 0.5);
|
491 |
+
}
|
492 |
+
|
493 |
+
/* Series statistics display */
|
494 |
+
.series-stats {
|
495 |
+
display: flex;
|
496 |
+
justify-content: center;
|
497 |
+
align-items: center;
|
498 |
+
gap: 2rem;
|
499 |
+
padding: 1.5rem;
|
500 |
+
background: rgba(255, 255, 255, 0.1);
|
501 |
+
backdrop-filter: blur(10px);
|
502 |
+
border-radius: 16px;
|
503 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
504 |
+
}
|
505 |
+
|
506 |
+
.series-stat-item {
|
507 |
+
text-align: center;
|
508 |
+
padding: 0 1rem;
|
509 |
+
}
|
510 |
+
|
511 |
+
.series-stat-value {
|
512 |
+
font-size: 2rem;
|
513 |
+
font-weight: 800;
|
514 |
+
color: white;
|
515 |
+
margin-bottom: 0.5rem;
|
516 |
+
}
|
517 |
+
|
518 |
+
.series-stat-label {
|
519 |
+
font-size: 0.875rem;
|
520 |
+
color: rgba(255, 255, 255, 0.7);
|
521 |
+
}
|
522 |
+
|
523 |
+
.series-stat-divider {
|
524 |
+
width: 1px;
|
525 |
+
height: 2rem;
|
526 |
+
background: rgba(255, 255, 255, 0.2);
|
527 |
+
}
|
528 |
+
|
529 |
+
/* Mobile responsiveness for new components */
|
530 |
+
@media (max-width: 768px) {
|
531 |
+
.progression-card {
|
532 |
+
padding: 16px;
|
533 |
+
min-height: 300px;
|
534 |
+
}
|
535 |
+
|
536 |
+
.series-stats {
|
537 |
+
flex-direction: column;
|
538 |
+
gap: 1rem;
|
539 |
+
}
|
540 |
+
|
541 |
+
.series-stat-divider {
|
542 |
+
width: 100%;
|
543 |
+
height: 1px;
|
544 |
+
}
|
545 |
+
|
546 |
+
.chapter-nav-button {
|
547 |
+
padding: 12px;
|
548 |
+
}
|
549 |
}
|
index.html
CHANGED
@@ -6,7 +6,6 @@
|
|
6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
<title>AI Manga Generator</title>
|
8 |
<script src="https://cdn.tailwindcss.com"></script>
|
9 |
-
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
10 |
<script type="importmap">
|
11 |
{
|
12 |
"imports": {
|
|
|
6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
<title>AI Manga Generator</title>
|
8 |
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
9 |
<script type="importmap">
|
10 |
{
|
11 |
"imports": {
|
package.json
CHANGED
@@ -13,6 +13,7 @@
|
|
13 |
},
|
14 |
"dependencies": {
|
15 |
"@google/genai": "^1.17.0",
|
|
|
16 |
"react": "^19.1.1",
|
17 |
"react-dom": "^19.1.1"
|
18 |
},
|
|
|
13 |
},
|
14 |
"dependencies": {
|
15 |
"@google/genai": "^1.17.0",
|
16 |
+
"jspdf": "^3.0.2",
|
17 |
"react": "^19.1.1",
|
18 |
"react-dom": "^19.1.1"
|
19 |
},
|
services/geminiService.ts
CHANGED
@@ -1,5 +1,18 @@
|
|
1 |
import { GoogleGenAI, Type, Modality } from "@google/genai";
|
2 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
|
4 |
// Initialize GoogleGenAI client - will be created dynamically with user's API key
|
5 |
let ai: GoogleGenAI;
|
@@ -9,6 +22,7 @@ interface StoryDetails {
|
|
9 |
story: string;
|
10 |
author: string;
|
11 |
style: MangaStyle;
|
|
|
12 |
}
|
13 |
|
14 |
// Schema for character profile generation
|
@@ -65,14 +79,56 @@ const mangaPageSchema = {
|
|
65 |
},
|
66 |
};
|
67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
68 |
/**
|
69 |
* Generates character profiles from a story premise with advanced character development.
|
70 |
*/
|
71 |
const generateCharacterProfiles = async (story: string, style: MangaStyle): Promise<CharacterProfile[]> => {
|
|
|
|
|
72 |
const prompt = `You are a master character designer and development expert specializing in compelling visual storytelling. Create rich, multi-dimensional character profiles that will serve as the foundation for a professional comic series.
|
73 |
|
74 |
ADVANCED CHARACTER DEVELOPMENT BRIEF:
|
75 |
-
Style: ${
|
76 |
Story Context: "${story}"
|
77 |
|
78 |
CHARACTER CREATION GUIDELINES:
|
@@ -85,7 +141,7 @@ CHARACTER CREATION GUIDELINES:
|
|
85 |
TECHNICAL SPECIFICATIONS:
|
86 |
- Create 2-4 main characters (protagonists, deuteragonists, key supporting characters)
|
87 |
- Each character needs comprehensive visual and personality descriptions
|
88 |
-
- Include distinctive features that work well in ${
|
89 |
- Consider character relationships and visual contrast between characters
|
90 |
- Ensure characters can drive the story forward through their actions and conflicts
|
91 |
|
@@ -184,14 +240,30 @@ const analyzeNarrativeStructure = (story: string): { structure: string; keyBeats
|
|
184 |
/**
|
185 |
* Generates a manga script from a story premise and character profiles.
|
186 |
*/
|
187 |
-
const generateMangaScript = async (story: string, style: MangaStyle, characters: CharacterProfile[]): Promise<MangaPage[]> => {
|
|
|
|
|
188 |
const characterDescriptions = characters.map(c => `- ${c.name}: ${c.description}`).join('\n');
|
189 |
const narrativeAnalysis = analyzeNarrativeStructure(story);
|
190 |
|
191 |
const prompt = `You are a master storyteller and comic book editor with decades of experience creating compelling visual narratives. Your expertise spans multiple genres and you understand the delicate balance between visual storytelling and narrative pacing.
|
192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
193 |
ADVANCED STORYTELLING ANALYSIS:
|
194 |
Story Structure Detected: ${narrativeAnalysis.structure}
|
|
|
195 |
Key Narrative Beats: ${narrativeAnalysis.keyBeats.join(' → ')}
|
196 |
|
197 |
STORY ENHANCEMENT MISSION:
|
@@ -205,10 +277,10 @@ Transform this premise into a professional comic script that maximizes emotional
|
|
205 |
6. CLIMAX: Build to a satisfying emotional and visual climax
|
206 |
|
207 |
TECHNICAL SPECIFICATIONS:
|
208 |
-
- Visual Style: ${
|
209 |
- Page Count: 4-5 pages optimized for digital reading
|
210 |
- Panel Density: 2-4 panels per page with dynamic layouts
|
211 |
-
- Dialogue: Concise, character-driven, emotionally resonant
|
212 |
|
213 |
CHARACTER CAST:
|
214 |
${characterDescriptions}
|
@@ -221,7 +293,7 @@ INSTRUCTIONS:
|
|
221 |
2. Identify the key emotional moments and visual set pieces
|
222 |
3. Plan panel compositions that support the narrative flow
|
223 |
4. Create a script where each panel serves both story and visual impact
|
224 |
-
5. Ensure dialogue feels natural and advances both plot and character
|
225 |
|
226 |
Generate a professional comic script that elevates this premise into a compelling visual narrative.`;
|
227 |
|
@@ -282,6 +354,10 @@ const analyzePageComposition = (page: MangaPage, style: MangaStyle): string => {
|
|
282 |
return "SHOJO AESTHETIC LAYOUT: Flowing, organic panel shapes with decorative elements. Focus on character expressions.";
|
283 |
} else if (style === 'Seinen') {
|
284 |
return "MATURE COMPOSITION: Clean, sophisticated panel layouts with subtle visual metaphors and realistic proportions.";
|
|
|
|
|
|
|
|
|
285 |
} else {
|
286 |
return "BALANCED COMPOSITION: Professional comic layout with clear visual hierarchy and optimal reading flow.";
|
287 |
}
|
@@ -290,15 +366,24 @@ const analyzePageComposition = (page: MangaPage, style: MangaStyle): string => {
|
|
290 |
/**
|
291 |
* Generates a single manga page image using gemini-2.5-flash-image-preview.
|
292 |
*/
|
293 |
-
const generatePageImage = async (prompt: string, previousPageImage?: string, page?: MangaPage, style?: MangaStyle): Promise<string> => {
|
294 |
try {
|
295 |
const parts: any[] = [];
|
296 |
|
297 |
// Add composition guidance if page data is available
|
298 |
let enhancedPrompt = prompt;
|
299 |
if (page && style) {
|
300 |
-
const
|
301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
302 |
}
|
303 |
|
304 |
if (previousPageImage) {
|
@@ -340,45 +425,309 @@ const generatePageImage = async (prompt: string, previousPageImage?: string, pag
|
|
340 |
};
|
341 |
|
342 |
/**
|
343 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
344 |
*/
|
345 |
export const generateMangaScriptAndImages = async (
|
346 |
storyDetails: StoryDetails,
|
347 |
-
updateProgress: (state:
|
348 |
apiKey: string
|
349 |
-
): Promise<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
350 |
// Initialize AI client with user's API key
|
351 |
ai = new GoogleGenAI({ apiKey });
|
352 |
-
const { title, story, author, style } = storyDetails;
|
|
|
|
|
353 |
const allImages: string[] = [];
|
354 |
|
355 |
// Step 1: Generate Character Profiles
|
356 |
-
|
357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
358 |
|
359 |
-
// Step
|
360 |
-
|
361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
362 |
if (scriptPages.length === 0) {
|
363 |
throw new Error("The generated script was empty. Please try a different story.");
|
364 |
}
|
365 |
|
366 |
const totalPagesToGenerate = scriptPages.length + 2; // script pages + title + conclusion
|
367 |
let pagesGenerated = 0;
|
|
|
368 |
|
369 |
const characterPromptPart = characters.map(c => `${c.name}: ${c.description}`).join('; ');
|
370 |
|
371 |
const updatePageProgress = () => {
|
372 |
pagesGenerated++;
|
373 |
-
// Progress from
|
374 |
-
const progress =
|
375 |
return progress;
|
376 |
};
|
377 |
|
378 |
-
// Step
|
379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
380 |
const titlePagePrompt = `TASK: Create a dynamic manga title page.
|
381 |
-
STYLE: ${
|
382 |
ASPECT RATIO: 3:4 vertical.
|
383 |
TITLE: "${title}"
|
384 |
AUTHOR: "${author}"
|
@@ -387,61 +736,110 @@ INSTRUCTIONS: Create a compelling cover image. Generate ONLY the image.`;
|
|
387 |
|
388 |
let titleImage: string;
|
389 |
try {
|
390 |
-
titleImage = await generatePageImage(titlePagePrompt);
|
391 |
} catch (error) {
|
392 |
console.warn('Title page generation failed, retrying once...', error);
|
393 |
-
titleImage = await generatePageImage(titlePagePrompt);
|
394 |
}
|
395 |
allImages.push(titleImage);
|
|
|
|
|
|
|
|
|
|
|
396 |
|
397 |
-
|
398 |
-
// Step 4: Generate each page from the script
|
399 |
for (const page of scriptPages) {
|
400 |
const progress = updatePageProgress();
|
401 |
-
updateProgress({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
402 |
|
403 |
const panelPrompts = page.panels.map(p => {
|
404 |
let panelString = `Panel ${p.panelNumber}: ${p.description}`;
|
405 |
-
if (p.dialogue && p.speaker) {
|
406 |
const speakingCharacter = characters.find(c => c.name === p.speaker);
|
407 |
if (speakingCharacter) {
|
408 |
-
//
|
409 |
panelString += `\n - Dialogue: The character speaking is ${speakingCharacter.name} (${speakingCharacter.description}). They say: "${p.dialogue}"`;
|
410 |
} else {
|
411 |
panelString += `\n - Dialogue (${p.speaker}): "${p.dialogue}"`;
|
412 |
}
|
|
|
|
|
413 |
}
|
414 |
return panelString;
|
415 |
}).join('\n\n');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
416 |
|
417 |
const pagePrompt = `TASK: Create a manga page image.
|
418 |
-
STYLE: ${
|
419 |
ASPECT RATIO: 3:4 vertical.
|
420 |
CHARACTERS: ${characterPromptPart}.
|
421 |
-
PAGE: ${page.pageNumber}, with ${page.panels.length} panels
|
422 |
|
423 |
PANEL INSTRUCTIONS:
|
424 |
${panelPrompts}
|
425 |
|
426 |
-
FINAL INSTRUCTIONS: Arrange panels dynamically. Place dialogue in speech bubbles for the correct characters. Generate ONLY the image.`;
|
427 |
|
428 |
// Pass the last generated image as a reference for consistency
|
429 |
const previousImage = allImages[allImages.length - 1];
|
|
|
430 |
let pageImage: string;
|
431 |
try {
|
432 |
-
pageImage = await generatePageImage(pagePrompt, previousImage, page,
|
433 |
} catch (error) {
|
434 |
console.warn(`Page ${page.pageNumber} generation failed, retrying once...`, error);
|
435 |
-
pageImage = await generatePageImage(pagePrompt, previousImage, page,
|
436 |
}
|
|
|
437 |
allImages.push(pageImage);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
438 |
}
|
439 |
|
440 |
-
// Step
|
441 |
const conclusionProgress = updatePageProgress();
|
442 |
-
updateProgress({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
443 |
const conclusionPagePrompt = `TASK: Create a final, evocative manga page.
|
444 |
-
STYLE: ${
|
445 |
ASPECT RATIO: 3:4 vertical.
|
446 |
STORY CONTEXT: The story was about "${story}".
|
447 |
INSTRUCTIONS: The page should feel like a conclusion. Maybe a character looking towards the horizon or a symbolic image related to the story. Include a small, stylized "The End" text. Generate ONLY the image.`;
|
@@ -450,13 +848,355 @@ INSTRUCTIONS: The page should feel like a conclusion. Maybe a character looking
|
|
450 |
const lastContentPage = allImages[allImages.length - 1];
|
451 |
let conclusionImage: string;
|
452 |
try {
|
453 |
-
conclusionImage = await generatePageImage(conclusionPagePrompt, lastContentPage);
|
454 |
} catch (error) {
|
455 |
console.warn('Conclusion page generation failed, retrying once...', error);
|
456 |
-
conclusionImage = await generatePageImage(conclusionPagePrompt, lastContentPage);
|
457 |
}
|
458 |
allImages.push(conclusionImage);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
459 |
|
460 |
-
|
461 |
-
return allImages;
|
462 |
};
|
|
|
1 |
import { GoogleGenAI, Type, Modality } from "@google/genai";
|
2 |
+
import { PerformanceTracker } from '../utils/imageOptimization';
|
3 |
+
import type {
|
4 |
+
LoadingState,
|
5 |
+
MangaPage,
|
6 |
+
CharacterProfile,
|
7 |
+
MangaStyle,
|
8 |
+
StoryProgression,
|
9 |
+
ComicIteration,
|
10 |
+
CharacterSketch,
|
11 |
+
EnvironmentSketch,
|
12 |
+
GenerationProgress,
|
13 |
+
StoryArcSummary,
|
14 |
+
PerformanceTiming
|
15 |
+
} from '../types';
|
16 |
|
17 |
// Initialize GoogleGenAI client - will be created dynamically with user's API key
|
18 |
let ai: GoogleGenAI;
|
|
|
22 |
story: string;
|
23 |
author: string;
|
24 |
style: MangaStyle;
|
25 |
+
includeDialogue: boolean;
|
26 |
}
|
27 |
|
28 |
// Schema for character profile generation
|
|
|
79 |
},
|
80 |
};
|
81 |
|
82 |
+
/**
|
83 |
+
* Automatically selects the most appropriate manga style based on story content
|
84 |
+
*/
|
85 |
+
const selectOptimalStyle = (story: string): MangaStyle => {
|
86 |
+
const storyLower = story.toLowerCase();
|
87 |
+
|
88 |
+
// Action/adventure keywords suggest Shonen
|
89 |
+
const actionKeywords = ['fight', 'battle', 'war', 'hero', 'power', 'strength', 'adventure', 'quest', 'enemy'];
|
90 |
+
if (actionKeywords.some(keyword => storyLower.includes(keyword))) {
|
91 |
+
return 'Shonen';
|
92 |
+
}
|
93 |
+
|
94 |
+
// Romance/emotion keywords suggest Shojo
|
95 |
+
const romanceKeywords = ['love', 'romance', 'heart', 'relationship', 'feelings', 'beautiful', 'flower', 'tender'];
|
96 |
+
if (romanceKeywords.some(keyword => storyLower.includes(keyword))) {
|
97 |
+
return 'Shojo';
|
98 |
+
}
|
99 |
+
|
100 |
+
// Mature/complex keywords suggest Seinen
|
101 |
+
const matureKeywords = ['complex', 'psychology', 'society', 'work', 'adult', 'realistic', 'dark', 'serious'];
|
102 |
+
if (matureKeywords.some(keyword => storyLower.includes(keyword))) {
|
103 |
+
return 'Seinen';
|
104 |
+
}
|
105 |
+
|
106 |
+
// Wacky/absurd keywords suggest Wacky style
|
107 |
+
const wackyKeywords = ['funny', 'crazy', 'weird', 'silly', 'absurd', 'rubber', 'stretchy', 'pirates', 'treasure'];
|
108 |
+
if (wackyKeywords.some(keyword => storyLower.includes(keyword))) {
|
109 |
+
return 'Wacky';
|
110 |
+
}
|
111 |
+
|
112 |
+
// Cute/simple keywords suggest Chibi
|
113 |
+
const chibiKeywords = ['cute', 'small', 'tiny', 'children', 'school', 'friends', 'simple', 'innocent'];
|
114 |
+
if (chibiKeywords.some(keyword => storyLower.includes(keyword))) {
|
115 |
+
return 'Chibi';
|
116 |
+
}
|
117 |
+
|
118 |
+
// Default to Shonen for general stories
|
119 |
+
return 'Shonen';
|
120 |
+
};
|
121 |
+
|
122 |
/**
|
123 |
* Generates character profiles from a story premise with advanced character development.
|
124 |
*/
|
125 |
const generateCharacterProfiles = async (story: string, style: MangaStyle): Promise<CharacterProfile[]> => {
|
126 |
+
// Auto-select style if "I don't know" is chosen
|
127 |
+
const actualStyle = style === "I don't know" ? selectOptimalStyle(story) : style;
|
128 |
const prompt = `You are a master character designer and development expert specializing in compelling visual storytelling. Create rich, multi-dimensional character profiles that will serve as the foundation for a professional comic series.
|
129 |
|
130 |
ADVANCED CHARACTER DEVELOPMENT BRIEF:
|
131 |
+
Style: ${actualStyle} manga aesthetic with professional character design principles
|
132 |
Story Context: "${story}"
|
133 |
|
134 |
CHARACTER CREATION GUIDELINES:
|
|
|
141 |
TECHNICAL SPECIFICATIONS:
|
142 |
- Create 2-4 main characters (protagonists, deuteragonists, key supporting characters)
|
143 |
- Each character needs comprehensive visual and personality descriptions
|
144 |
+
- Include distinctive features that work well in ${actualStyle} art style
|
145 |
- Consider character relationships and visual contrast between characters
|
146 |
- Ensure characters can drive the story forward through their actions and conflicts
|
147 |
|
|
|
240 |
/**
|
241 |
* Generates a manga script from a story premise and character profiles.
|
242 |
*/
|
243 |
+
const generateMangaScript = async (story: string, style: MangaStyle, characters: CharacterProfile[], includeDialogue: boolean): Promise<MangaPage[]> => {
|
244 |
+
// Auto-select style if "I don't know" is chosen
|
245 |
+
const actualStyle = style === "I don't know" ? selectOptimalStyle(story) : style;
|
246 |
const characterDescriptions = characters.map(c => `- ${c.name}: ${c.description}`).join('\n');
|
247 |
const narrativeAnalysis = analyzeNarrativeStructure(story);
|
248 |
|
249 |
const prompt = `You are a master storyteller and comic book editor with decades of experience creating compelling visual narratives. Your expertise spans multiple genres and you understand the delicate balance between visual storytelling and narrative pacing.
|
250 |
|
251 |
+
TEXT GENERATION OPTIONS:
|
252 |
+
${includeDialogue ? `- Keep ALL dialogue extremely simple and short (max 8-10 words per speech bubble)
|
253 |
+
- Use basic vocabulary that's easy to understand
|
254 |
+
- Avoid complex sentence structures
|
255 |
+
- Break long thoughts into multiple short sentences
|
256 |
+
- Prioritize clear, simple communication over elaborate prose` : `- ABSOLUTELY NO DIALOGUE, speech bubbles, or any text content whatsoever
|
257 |
+
- ZERO text generation - this is purely visual storytelling
|
258 |
+
- Do NOT include any words, phrases, sound effects, or onomatopoeia
|
259 |
+
- Focus entirely on visual composition, character expressions, and body language
|
260 |
+
- Describe scenes as if they are silent films
|
261 |
+
- NO SPEECH BUBBLES, NO TEXT BALLOONS, NO WRITTEN WORDS OF ANY KIND
|
262 |
+
- Leave all text elements completely empty for manual addition later`}
|
263 |
+
|
264 |
ADVANCED STORYTELLING ANALYSIS:
|
265 |
Story Structure Detected: ${narrativeAnalysis.structure}
|
266 |
+
Selected Art Style: ${actualStyle} (${style === "I don't know" ? "auto-selected" : "user-chosen"})
|
267 |
Key Narrative Beats: ${narrativeAnalysis.keyBeats.join(' → ')}
|
268 |
|
269 |
STORY ENHANCEMENT MISSION:
|
|
|
277 |
6. CLIMAX: Build to a satisfying emotional and visual climax
|
278 |
|
279 |
TECHNICAL SPECIFICATIONS:
|
280 |
+
- Visual Style: ${actualStyle} manga aesthetic with professional composition
|
281 |
- Page Count: 4-5 pages optimized for digital reading
|
282 |
- Panel Density: 2-4 panels per page with dynamic layouts
|
283 |
+
- Dialogue: ${includeDialogue ? 'Concise, character-driven, emotionally resonant' : 'COMPLETELY FORBIDDEN - absolutely no text or speech of any kind'}
|
284 |
|
285 |
CHARACTER CAST:
|
286 |
${characterDescriptions}
|
|
|
293 |
2. Identify the key emotional moments and visual set pieces
|
294 |
3. Plan panel compositions that support the narrative flow
|
295 |
4. Create a script where each panel serves both story and visual impact
|
296 |
+
5. ${includeDialogue ? 'Ensure dialogue feels natural and advances both plot and character' : 'CRITICAL: Generate ZERO dialogue or text - tell the story purely through visual elements, poses, expressions, and actions'}
|
297 |
|
298 |
Generate a professional comic script that elevates this premise into a compelling visual narrative.`;
|
299 |
|
|
|
354 |
return "SHOJO AESTHETIC LAYOUT: Flowing, organic panel shapes with decorative elements. Focus on character expressions.";
|
355 |
} else if (style === 'Seinen') {
|
356 |
return "MATURE COMPOSITION: Clean, sophisticated panel layouts with subtle visual metaphors and realistic proportions.";
|
357 |
+
} else if (style === 'Wacky') {
|
358 |
+
return "WACKY ONE PIECE LAYOUT: Exaggerated, rubber-hose animation style panels with impossible physics and zany expressions. Characters can stretch and deform wildly.";
|
359 |
+
} else if (style === 'Chibi') {
|
360 |
+
return "CHIBI CUTE LAYOUT: Simple, rounded panels with adorable character designs and minimal backgrounds. Focus on expressions.";
|
361 |
} else {
|
362 |
return "BALANCED COMPOSITION: Professional comic layout with clear visual hierarchy and optimal reading flow.";
|
363 |
}
|
|
|
366 |
/**
|
367 |
* Generates a single manga page image using gemini-2.5-flash-image-preview.
|
368 |
*/
|
369 |
+
const generatePageImage = async (prompt: string, previousPageImage?: string, page?: MangaPage, style?: MangaStyle, includeDialogue: boolean = true): Promise<string> => {
|
370 |
try {
|
371 |
const parts: any[] = [];
|
372 |
|
373 |
// Add composition guidance if page data is available
|
374 |
let enhancedPrompt = prompt;
|
375 |
if (page && style) {
|
376 |
+
const actualStyle = style === "I don't know" ? selectOptimalStyle("action adventure") : style; // fallback for image generation
|
377 |
+
const compositionGuide = analyzePageComposition(page, actualStyle);
|
378 |
+
|
379 |
+
let styleSpecificInstructions = "";
|
380 |
+
if (actualStyle === 'Wacky') {
|
381 |
+
styleSpecificInstructions = "\nWACKY STYLE SPECIFICS:\n- Characters can have impossible body proportions and physics-defying actions\n- Exaggerated facial expressions with bulging eyes and dropped jaws\n- Rubber-like stretching and deformation of characters\n- Cartoonish impact effects and motion lines\n- Bright, vibrant colors with high contrast";
|
382 |
+
} else if (actualStyle === 'Chibi') {
|
383 |
+
styleSpecificInstructions = "\nCHIBI STYLE SPECIFICS:\n- All characters have large heads (1:2 or 1:3 head-to-body ratio)\n- Simplified features with dot eyes and minimal noses\n- Rounded, soft shapes throughout\n- Pastel or bright cheerful colors\n- Minimal detailed backgrounds";
|
384 |
+
}
|
385 |
+
|
386 |
+
enhancedPrompt = `${prompt}\n\nCOMPOSITION GUIDANCE:\n${compositionGuide}${styleSpecificInstructions}\n\nPROFESSIONAL COMIC TECHNIQUES:\n- Use proper panel gutters and borders\n- Maintain clear reading flow (left to right, top to bottom)\n- Balance text and visual elements\n- Apply comic book color theory and contrast\n- Ensure character consistency and proportions\n- Use appropriate camera angles and perspectives\n\nTEXT HANDLING:\n${includeDialogue ? '- Keep all speech bubbles short and simple\n- Use basic vocabulary\n- Maximum 8-10 words per dialogue bubble' : '- ABSOLUTELY FORBIDDEN: No speech bubbles, dialogue, text, words, or any written content\n- CRITICAL: Do not include ANY text elements, sound effects, or onomatopoeia\n- MANDATORY: Focus exclusively on visual storytelling through character expressions, body language, and visual metaphors\n- IMPORTANT: Characters can mouth words but NO text should be visible\n- ESSENTIAL: Tell the story purely through visual composition and character acting\n- FINAL RULE: Zero text content - this is a silent comic panel'}`;
|
387 |
}
|
388 |
|
389 |
if (previousPageImage) {
|
|
|
425 |
};
|
426 |
|
427 |
/**
|
428 |
+
* Generates character sketches for main characters
|
429 |
+
*/
|
430 |
+
const generateCharacterSketches = async (characters: CharacterProfile[], style: MangaStyle): Promise<CharacterSketch[]> => {
|
431 |
+
const sketches: CharacterSketch[] = [];
|
432 |
+
|
433 |
+
for (const character of characters.slice(0, 4)) { // Limit to 4 main characters
|
434 |
+
try {
|
435 |
+
const styleDescription = style === 'Wacky' ?
|
436 |
+
"Wacky One Piece-style with exaggerated, rubber-hose animation aesthetics" :
|
437 |
+
style === 'Chibi' ?
|
438 |
+
"Chibi style with cute, simplified characters having large heads" :
|
439 |
+
style;
|
440 |
+
|
441 |
+
const sketchPrompt = `TASK: Create a character design sketch.
|
442 |
+
STYLE: ${styleDescription} manga, black and white sketch style with clean line art.
|
443 |
+
CHARACTER: ${character.name} - ${character.description}
|
444 |
+
INSTRUCTIONS: Create a full-body character reference sketch showing the character from front view. Include facial expressions and key design elements. This is a character design sheet, not a comic panel. Generate ONLY the image.`;
|
445 |
+
|
446 |
+
const sketchImage = await generatePageImage(sketchPrompt);
|
447 |
+
sketches.push({
|
448 |
+
characterName: character.name,
|
449 |
+
sketchImage: sketchImage,
|
450 |
+
description: character.description
|
451 |
+
});
|
452 |
+
} catch (error) {
|
453 |
+
console.warn(`Failed to generate sketch for character ${character.name}:`, error);
|
454 |
+
}
|
455 |
+
}
|
456 |
+
|
457 |
+
return sketches;
|
458 |
+
};
|
459 |
+
|
460 |
+
/**
|
461 |
+
* Generates environment sketches for key locations
|
462 |
+
*/
|
463 |
+
const generateEnvironmentSketches = async (story: string, style: MangaStyle): Promise<EnvironmentSketch[]> => {
|
464 |
+
const environments = analyzeStoryEnvironments(story);
|
465 |
+
const sketches: EnvironmentSketch[] = [];
|
466 |
+
|
467 |
+
for (const env of environments.slice(0, 3)) { // Limit to 3 key environments
|
468 |
+
try {
|
469 |
+
const styleDescription = style === 'Wacky' ?
|
470 |
+
"Wacky One Piece-style with exaggerated, cartoonish environments" :
|
471 |
+
style === 'Chibi' ?
|
472 |
+
"Chibi style with simple, cute environments" :
|
473 |
+
style;
|
474 |
+
|
475 |
+
const envPrompt = `TASK: Create an environment/location sketch.
|
476 |
+
STYLE: ${styleDescription} manga, black and white sketch style.
|
477 |
+
ENVIRONMENT: ${env.name} - ${env.description}
|
478 |
+
INSTRUCTIONS: Create a detailed background/environment sketch showing the key location. Focus on atmosphere and architectural/natural elements. This is a location reference sheet. Generate ONLY the image.`;
|
479 |
+
|
480 |
+
const envImage = await generatePageImage(envPrompt);
|
481 |
+
sketches.push({
|
482 |
+
environmentName: env.name,
|
483 |
+
sketchImage: envImage,
|
484 |
+
description: env.description
|
485 |
+
});
|
486 |
+
} catch (error) {
|
487 |
+
console.warn(`Failed to generate environment sketch for ${env.name}:`, error);
|
488 |
+
}
|
489 |
+
}
|
490 |
+
|
491 |
+
return sketches;
|
492 |
+
};
|
493 |
+
|
494 |
+
/**
|
495 |
+
* Analyzes story to identify key environments
|
496 |
+
*/
|
497 |
+
const analyzeStoryEnvironments = (story: string): { name: string; description: string }[] => {
|
498 |
+
const storyLower = story.toLowerCase();
|
499 |
+
const environments: { name: string; description: string }[] = [];
|
500 |
+
|
501 |
+
// Common environment patterns
|
502 |
+
if (storyLower.includes('school') || storyLower.includes('classroom') || storyLower.includes('student')) {
|
503 |
+
environments.push({ name: 'School', description: 'Educational institution with classrooms and hallways' });
|
504 |
+
}
|
505 |
+
if (storyLower.includes('home') || storyLower.includes('house') || storyLower.includes('room')) {
|
506 |
+
environments.push({ name: 'Home', description: 'Residential living space with personal belongings' });
|
507 |
+
}
|
508 |
+
if (storyLower.includes('city') || storyLower.includes('urban') || storyLower.includes('street')) {
|
509 |
+
environments.push({ name: 'City Street', description: 'Urban environment with buildings and bustling activity' });
|
510 |
+
}
|
511 |
+
if (storyLower.includes('forest') || storyLower.includes('tree') || storyLower.includes('nature')) {
|
512 |
+
environments.push({ name: 'Forest', description: 'Natural woodland environment with trees and wildlife' });
|
513 |
+
}
|
514 |
+
if (storyLower.includes('space') || storyLower.includes('ship') || storyLower.includes('station')) {
|
515 |
+
environments.push({ name: 'Spaceship', description: 'Futuristic spacecraft interior with advanced technology' });
|
516 |
+
}
|
517 |
+
if (storyLower.includes('castle') || storyLower.includes('kingdom') || storyLower.includes('medieval')) {
|
518 |
+
environments.push({ name: 'Castle', description: 'Medieval fortress with stone walls and grand halls' });
|
519 |
+
}
|
520 |
+
|
521 |
+
// Default environments if none detected
|
522 |
+
if (environments.length === 0) {
|
523 |
+
environments.push(
|
524 |
+
{ name: 'Main Setting', description: 'The primary location where the story takes place' },
|
525 |
+
{ name: 'Character Home', description: 'Personal living space of the main character' }
|
526 |
+
);
|
527 |
+
}
|
528 |
+
|
529 |
+
return environments;
|
530 |
+
};
|
531 |
+
|
532 |
+
/**
|
533 |
+
* Generates story arc summary
|
534 |
+
*/
|
535 |
+
const generateStoryArcSummary = async (pages: MangaPage[], characters: CharacterProfile[]): Promise<StoryArcSummary> => {
|
536 |
+
const storyContent = pages.map(page =>
|
537 |
+
page.panels.map(panel => `${panel.description}${panel.dialogue ? ` - "${panel.dialogue}"` : ''}`).join(' ')
|
538 |
+
).join('\n');
|
539 |
+
|
540 |
+
const prompt = `Analyze this comic story and create a comprehensive story arc summary.
|
541 |
+
|
542 |
+
STORY CONTENT:
|
543 |
+
${storyContent}
|
544 |
+
|
545 |
+
CHARACTERS:
|
546 |
+
${characters.map(c => `${c.name}: ${c.description}`).join('; ')}
|
547 |
+
|
548 |
+
Create a detailed analysis covering:
|
549 |
+
1. Overall theme and central message
|
550 |
+
2. Key story events in chronological order
|
551 |
+
3. Character development and growth arcs
|
552 |
+
4. Narrative progression and story structure
|
553 |
+
5. Emotional journey of the characters
|
554 |
+
|
555 |
+
Provide insights into how the story unfolds and what makes it compelling.`;
|
556 |
+
|
557 |
+
try {
|
558 |
+
const response = await ai.models.generateContent({
|
559 |
+
model: "gemini-2.5-flash",
|
560 |
+
contents: prompt,
|
561 |
+
});
|
562 |
+
|
563 |
+
const summaryText = response.text.trim();
|
564 |
+
|
565 |
+
// Parse the summary into structured format
|
566 |
+
const lines = summaryText.split('\n').filter(line => line.trim());
|
567 |
+
let currentSection = '';
|
568 |
+
const summary: StoryArcSummary = {
|
569 |
+
overallTheme: '',
|
570 |
+
keyEvents: [],
|
571 |
+
characterDevelopment: [],
|
572 |
+
narrativeProgression: '',
|
573 |
+
emotionalJourney: ''
|
574 |
+
};
|
575 |
+
|
576 |
+
for (const line of lines) {
|
577 |
+
if (line.toLowerCase().includes('theme') || line.toLowerCase().includes('message')) {
|
578 |
+
currentSection = 'theme';
|
579 |
+
summary.overallTheme += line.replace(/^\d+\.\s*/, '').replace(/^[^:]*:?\s*/, '') + ' ';
|
580 |
+
} else if (line.toLowerCase().includes('key') && line.toLowerCase().includes('event')) {
|
581 |
+
currentSection = 'events';
|
582 |
+
} else if (line.toLowerCase().includes('character') && line.toLowerCase().includes('development')) {
|
583 |
+
currentSection = 'character';
|
584 |
+
} else if (line.toLowerCase().includes('narrative') || line.toLowerCase().includes('progression')) {
|
585 |
+
currentSection = 'narrative';
|
586 |
+
summary.narrativeProgression += line.replace(/^\d+\.\s*/, '').replace(/^[^:]*:?\s*/, '') + ' ';
|
587 |
+
} else if (line.toLowerCase().includes('emotional') || line.toLowerCase().includes('journey')) {
|
588 |
+
currentSection = 'emotional';
|
589 |
+
summary.emotionalJourney += line.replace(/^\d+\.\s*/, '').replace(/^[^:]*:?\s*/, '') + ' ';
|
590 |
+
} else if (line.trim().startsWith('-') || line.trim().startsWith('•')) {
|
591 |
+
const cleanLine = line.replace(/^[-•]\s*/, '');
|
592 |
+
if (currentSection === 'events') {
|
593 |
+
summary.keyEvents.push(cleanLine);
|
594 |
+
} else if (currentSection === 'character') {
|
595 |
+
summary.characterDevelopment.push(cleanLine);
|
596 |
+
}
|
597 |
+
}
|
598 |
+
}
|
599 |
+
|
600 |
+
return summary;
|
601 |
+
} catch (error) {
|
602 |
+
console.warn('Failed to generate story arc summary:', error);
|
603 |
+
return {
|
604 |
+
overallTheme: 'A compelling narrative exploring themes of growth and adventure',
|
605 |
+
keyEvents: ['Story begins', 'Conflict arises', 'Resolution achieved'],
|
606 |
+
characterDevelopment: ['Characters face challenges', 'Personal growth occurs'],
|
607 |
+
narrativeProgression: 'The story follows a classic narrative structure with rising action leading to climax and resolution',
|
608 |
+
emotionalJourney: 'Characters experience a range of emotions leading to personal transformation'
|
609 |
+
};
|
610 |
+
}
|
611 |
+
};
|
612 |
+
|
613 |
+
/**
|
614 |
+
* Creates the main manga generation orchestrator with enhanced progress updates.
|
615 |
*/
|
616 |
export const generateMangaScriptAndImages = async (
|
617 |
storyDetails: StoryDetails,
|
618 |
+
updateProgress: (state: GenerationProgress) => void,
|
619 |
apiKey: string
|
620 |
+
): Promise<{
|
621 |
+
images: string[];
|
622 |
+
characters: CharacterProfile[];
|
623 |
+
pages: MangaPage[];
|
624 |
+
characterSketches: CharacterSketch[];
|
625 |
+
environmentSketches: EnvironmentSketch[];
|
626 |
+
storyArcSummary: StoryArcSummary;
|
627 |
+
performanceTimings: PerformanceTiming[];
|
628 |
+
totalGenerationTime: number;
|
629 |
+
}> => {
|
630 |
+
const performanceTracker = new PerformanceTracker();
|
631 |
+
const startTime = Date.now();
|
632 |
+
performanceTracker.startTimer('total_generation');
|
633 |
// Initialize AI client with user's API key
|
634 |
ai = new GoogleGenAI({ apiKey });
|
635 |
+
const { title, story, author, style, includeDialogue } = storyDetails;
|
636 |
+
// Auto-select style if "I don't know" is chosen
|
637 |
+
const actualStyle = style === "I don't know" ? selectOptimalStyle(story) : style;
|
638 |
const allImages: string[] = [];
|
639 |
|
640 |
// Step 1: Generate Character Profiles
|
641 |
+
performanceTracker.startTimer('character_analysis');
|
642 |
+
updateProgress({
|
643 |
+
phase: 'character_analysis',
|
644 |
+
message: 'Analyzing story and developing characters...',
|
645 |
+
progress: 10,
|
646 |
+
currentStepStartTime: Date.now(),
|
647 |
+
stepTimings: performanceTracker.getTimings()
|
648 |
+
});
|
649 |
+
const characters = await generateCharacterProfiles(story, actualStyle);
|
650 |
+
const charAnalysisTiming = performanceTracker.endTimer('character_analysis');
|
651 |
+
|
652 |
+
// Step 2: Generate Character Sketches
|
653 |
+
performanceTracker.startTimer('character_sketches');
|
654 |
+
updateProgress({
|
655 |
+
phase: 'character_sketches',
|
656 |
+
message: `Found ${characters.length} characters, creating design sketches (${(charAnalysisTiming.duration / 1000).toFixed(1)}s)...`,
|
657 |
+
progress: 20,
|
658 |
+
currentStepStartTime: Date.now(),
|
659 |
+
stepTimings: performanceTracker.getTimings()
|
660 |
+
});
|
661 |
+
const characterSketches = await generateCharacterSketches(characters, actualStyle);
|
662 |
+
const sketchTiming = performanceTracker.endTimer('character_sketches');
|
663 |
|
664 |
+
// Step 3: Generate Environment Sketches
|
665 |
+
performanceTracker.startTimer('environment_sketches');
|
666 |
+
updateProgress({
|
667 |
+
phase: 'environment_sketches',
|
668 |
+
message: `Generated ${characterSketches.length} character sketches (${(sketchTiming.duration / 1000).toFixed(1)}s), designing environments...`,
|
669 |
+
progress: 30,
|
670 |
+
characterSketches: characterSketches,
|
671 |
+
currentStepStartTime: Date.now(),
|
672 |
+
stepTimings: performanceTracker.getTimings()
|
673 |
+
});
|
674 |
+
const environmentSketches = await generateEnvironmentSketches(story, actualStyle);
|
675 |
+
const envTiming = performanceTracker.endTimer('environment_sketches');
|
676 |
+
|
677 |
+
// Step 4: Generate Manga Script
|
678 |
+
performanceTracker.startTimer('script_writing');
|
679 |
+
updateProgress({
|
680 |
+
phase: 'script_writing',
|
681 |
+
message: `Generated ${environmentSketches.length} environment designs (${(envTiming.duration / 1000).toFixed(1)}s), writing script...`,
|
682 |
+
progress: 40,
|
683 |
+
characterSketches: characterSketches,
|
684 |
+
environmentSketches: environmentSketches,
|
685 |
+
currentStepStartTime: Date.now(),
|
686 |
+
stepTimings: performanceTracker.getTimings()
|
687 |
+
});
|
688 |
+
const scriptPages = await generateMangaScript(story, actualStyle, characters, includeDialogue);
|
689 |
+
const scriptTiming = performanceTracker.endTimer('script_writing');
|
690 |
if (scriptPages.length === 0) {
|
691 |
throw new Error("The generated script was empty. Please try a different story.");
|
692 |
}
|
693 |
|
694 |
const totalPagesToGenerate = scriptPages.length + 2; // script pages + title + conclusion
|
695 |
let pagesGenerated = 0;
|
696 |
+
const generatedPages: { pageNumber: number; image: string; title: string }[] = [];
|
697 |
|
698 |
const characterPromptPart = characters.map(c => `${c.name}: ${c.description}`).join('; ');
|
699 |
|
700 |
const updatePageProgress = () => {
|
701 |
pagesGenerated++;
|
702 |
+
// Progress from 50% to 95% is for image generation
|
703 |
+
const progress = 50 + Math.round((pagesGenerated / totalPagesToGenerate) * 40);
|
704 |
return progress;
|
705 |
};
|
706 |
|
707 |
+
// Step 5: Generate Title Page
|
708 |
+
performanceTracker.startTimer('page_generation');
|
709 |
+
updateProgress({
|
710 |
+
phase: 'page_generation',
|
711 |
+
message: `Created ${scriptPages.length} page script (${(scriptTiming.duration / 1000).toFixed(1)}s), generating title page...`,
|
712 |
+
progress: 50,
|
713 |
+
characterSketches: characterSketches,
|
714 |
+
environmentSketches: environmentSketches,
|
715 |
+
generatedPages: generatedPages,
|
716 |
+
currentStepStartTime: Date.now(),
|
717 |
+
stepTimings: performanceTracker.getTimings()
|
718 |
+
});
|
719 |
+
|
720 |
+
let styleDescription = "";
|
721 |
+
if (actualStyle === 'Wacky') {
|
722 |
+
styleDescription = "Wacky One Piece-style with exaggerated, rubber-hose animation aesthetics, impossible physics, and zany character designs";
|
723 |
+
} else if (actualStyle === 'Chibi') {
|
724 |
+
styleDescription = "Chibi style with cute, simplified characters having large heads and minimal features";
|
725 |
+
} else {
|
726 |
+
styleDescription = actualStyle;
|
727 |
+
}
|
728 |
+
|
729 |
const titlePagePrompt = `TASK: Create a dynamic manga title page.
|
730 |
+
STYLE: ${styleDescription}, black and white.
|
731 |
ASPECT RATIO: 3:4 vertical.
|
732 |
TITLE: "${title}"
|
733 |
AUTHOR: "${author}"
|
|
|
736 |
|
737 |
let titleImage: string;
|
738 |
try {
|
739 |
+
titleImage = await generatePageImage(titlePagePrompt, undefined, undefined, undefined, includeDialogue);
|
740 |
} catch (error) {
|
741 |
console.warn('Title page generation failed, retrying once...', error);
|
742 |
+
titleImage = await generatePageImage(titlePagePrompt, undefined, undefined, undefined, includeDialogue);
|
743 |
}
|
744 |
allImages.push(titleImage);
|
745 |
+
generatedPages.push({
|
746 |
+
pageNumber: 0,
|
747 |
+
image: titleImage,
|
748 |
+
title: 'Title Page'
|
749 |
+
});
|
750 |
|
751 |
+
// Step 6: Generate each page from the script
|
|
|
752 |
for (const page of scriptPages) {
|
753 |
const progress = updatePageProgress();
|
754 |
+
updateProgress({
|
755 |
+
phase: 'page_generation',
|
756 |
+
message: `Drawing page ${page.pageNumber} of ${scriptPages.length}...`,
|
757 |
+
progress: progress,
|
758 |
+
characterSketches: characterSketches,
|
759 |
+
environmentSketches: environmentSketches,
|
760 |
+
generatedPages: generatedPages
|
761 |
+
});
|
762 |
|
763 |
const panelPrompts = page.panels.map(p => {
|
764 |
let panelString = `Panel ${p.panelNumber}: ${p.description}`;
|
765 |
+
if (includeDialogue && p.dialogue && p.speaker) {
|
766 |
const speakingCharacter = characters.find(c => c.name === p.speaker);
|
767 |
if (speakingCharacter) {
|
768 |
+
// Link the visual description directly to the dialogue
|
769 |
panelString += `\n - Dialogue: The character speaking is ${speakingCharacter.name} (${speakingCharacter.description}). They say: "${p.dialogue}"`;
|
770 |
} else {
|
771 |
panelString += `\n - Dialogue (${p.speaker}): "${p.dialogue}"`;
|
772 |
}
|
773 |
+
} else if (!includeDialogue) {
|
774 |
+
panelString += `\n - NO DIALOGUE: Focus on visual storytelling and character expressions`;
|
775 |
}
|
776 |
return panelString;
|
777 |
}).join('\n\n');
|
778 |
+
|
779 |
+
// Enhanced prompt for first two pages with character/environment sketches
|
780 |
+
let additionalContext = "";
|
781 |
+
if (page.pageNumber <= 2) {
|
782 |
+
if (characterSketches.length > 0) {
|
783 |
+
additionalContext += `\n\nCHARACTER DESIGN REFERENCES (use these for visual consistency):\n${characterSketches.map(sketch => `${sketch.characterName}: ${sketch.description}`).join('\n')}`;
|
784 |
+
}
|
785 |
+
if (environmentSketches.length > 0) {
|
786 |
+
additionalContext += `\n\nENVIRONMENT REFERENCES (use for setting details):\n${environmentSketches.map(env => `${env.environmentName}: ${env.description}`).join('\n')}`;
|
787 |
+
}
|
788 |
+
}
|
789 |
|
790 |
const pagePrompt = `TASK: Create a manga page image.
|
791 |
+
STYLE: ${styleDescription}, black and white.
|
792 |
ASPECT RATIO: 3:4 vertical.
|
793 |
CHARACTERS: ${characterPromptPart}.
|
794 |
+
PAGE: ${page.pageNumber}, with ${page.panels.length} panels.${additionalContext}
|
795 |
|
796 |
PANEL INSTRUCTIONS:
|
797 |
${panelPrompts}
|
798 |
|
799 |
+
FINAL INSTRUCTIONS: Arrange panels dynamically. ${includeDialogue ? 'Place dialogue in speech bubbles for the correct characters.' : 'NO speech bubbles or text - pure visual storytelling.'} ${page.pageNumber <= 2 ? 'Use the character and environment references above for accurate visual consistency.' : ''} Generate ONLY the image.`;
|
800 |
|
801 |
// Pass the last generated image as a reference for consistency
|
802 |
const previousImage = allImages[allImages.length - 1];
|
803 |
+
const pageStartTime = Date.now();
|
804 |
let pageImage: string;
|
805 |
try {
|
806 |
+
pageImage = await generatePageImage(pagePrompt, previousImage, page, actualStyle, includeDialogue);
|
807 |
} catch (error) {
|
808 |
console.warn(`Page ${page.pageNumber} generation failed, retrying once...`, error);
|
809 |
+
pageImage = await generatePageImage(pagePrompt, previousImage, page, actualStyle, includeDialogue);
|
810 |
}
|
811 |
+
const pageGenTime = Date.now() - pageStartTime;
|
812 |
allImages.push(pageImage);
|
813 |
+
generatedPages.push({
|
814 |
+
pageNumber: page.pageNumber,
|
815 |
+
image: pageImage,
|
816 |
+
title: `Page ${page.pageNumber}`
|
817 |
+
});
|
818 |
+
|
819 |
+
// Update with timing info
|
820 |
+
updateProgress({
|
821 |
+
phase: 'page_generation',
|
822 |
+
message: `Generated page ${page.pageNumber} of ${scriptPages.length} (${(pageGenTime / 1000).toFixed(1)}s)`,
|
823 |
+
progress: progress,
|
824 |
+
characterSketches: characterSketches,
|
825 |
+
environmentSketches: environmentSketches,
|
826 |
+
generatedPages: generatedPages,
|
827 |
+
stepTimings: performanceTracker.getTimings()
|
828 |
+
});
|
829 |
}
|
830 |
|
831 |
+
// Step 7: Generate Conclusion Page
|
832 |
const conclusionProgress = updatePageProgress();
|
833 |
+
updateProgress({
|
834 |
+
phase: 'page_generation',
|
835 |
+
message: 'Creating conclusion page...',
|
836 |
+
progress: conclusionProgress,
|
837 |
+
characterSketches: characterSketches,
|
838 |
+
environmentSketches: environmentSketches,
|
839 |
+
generatedPages: generatedPages
|
840 |
+
});
|
841 |
const conclusionPagePrompt = `TASK: Create a final, evocative manga page.
|
842 |
+
STYLE: ${styleDescription}, black and white.
|
843 |
ASPECT RATIO: 3:4 vertical.
|
844 |
STORY CONTEXT: The story was about "${story}".
|
845 |
INSTRUCTIONS: The page should feel like a conclusion. Maybe a character looking towards the horizon or a symbolic image related to the story. Include a small, stylized "The End" text. Generate ONLY the image.`;
|
|
|
848 |
const lastContentPage = allImages[allImages.length - 1];
|
849 |
let conclusionImage: string;
|
850 |
try {
|
851 |
+
conclusionImage = await generatePageImage(conclusionPagePrompt, lastContentPage, undefined, undefined, includeDialogue);
|
852 |
} catch (error) {
|
853 |
console.warn('Conclusion page generation failed, retrying once...', error);
|
854 |
+
conclusionImage = await generatePageImage(conclusionPagePrompt, lastContentPage, undefined, undefined, includeDialogue);
|
855 |
}
|
856 |
allImages.push(conclusionImage);
|
857 |
+
generatedPages.push({
|
858 |
+
pageNumber: scriptPages.length + 1,
|
859 |
+
image: conclusionImage,
|
860 |
+
title: 'Conclusion'
|
861 |
+
});
|
862 |
+
|
863 |
+
// Step 8: Generate Story Arc Summary
|
864 |
+
performanceTracker.startTimer('story_analysis');
|
865 |
+
updateProgress({
|
866 |
+
phase: 'completion',
|
867 |
+
message: 'Analyzing story arc and creating summary...',
|
868 |
+
progress: 95,
|
869 |
+
characterSketches: characterSketches,
|
870 |
+
environmentSketches: environmentSketches,
|
871 |
+
generatedPages: generatedPages,
|
872 |
+
currentStepStartTime: Date.now(),
|
873 |
+
stepTimings: performanceTracker.getTimings()
|
874 |
+
});
|
875 |
+
const storyArcSummary = await generateStoryArcSummary(scriptPages, characters);
|
876 |
+
const analysisTiming = performanceTracker.endTimer('story_analysis');
|
877 |
+
const pageGenTiming = performanceTracker.endTimer('page_generation');
|
878 |
+
const totalTiming = performanceTracker.endTimer('total_generation');
|
879 |
+
const totalTime = Date.now() - startTime;
|
880 |
+
|
881 |
+
updateProgress({
|
882 |
+
phase: 'completion',
|
883 |
+
message: `Comic generation complete! (Total: ${(totalTime / 1000).toFixed(1)}s)`,
|
884 |
+
progress: 100,
|
885 |
+
characterSketches: characterSketches,
|
886 |
+
environmentSketches: environmentSketches,
|
887 |
+
generatedPages: generatedPages,
|
888 |
+
storyArcSummary: `Theme: ${storyArcSummary.overallTheme}\n\nKey Events:\n${storyArcSummary.keyEvents.map(event => `• ${event}`).join('\n')}\n\nCharacter Development:\n${storyArcSummary.characterDevelopment.map(dev => `• ${dev}`).join('\n')}`,
|
889 |
+
stepTimings: performanceTracker.getTimings(),
|
890 |
+
estimatedTimeRemaining: 0
|
891 |
+
});
|
892 |
+
|
893 |
+
return {
|
894 |
+
images: allImages,
|
895 |
+
characters: characters,
|
896 |
+
pages: scriptPages,
|
897 |
+
characterSketches: characterSketches,
|
898 |
+
environmentSketches: environmentSketches,
|
899 |
+
storyArcSummary: storyArcSummary,
|
900 |
+
performanceTimings: performanceTracker.getTimings(),
|
901 |
+
totalGenerationTime: totalTime
|
902 |
+
};
|
903 |
+
};
|
904 |
+
|
905 |
+
// Schema for story progression generation
|
906 |
+
const storyProgressionSchema = {
|
907 |
+
type: Type.ARRAY,
|
908 |
+
items: {
|
909 |
+
type: Type.OBJECT,
|
910 |
+
properties: {
|
911 |
+
id: {
|
912 |
+
type: Type.STRING,
|
913 |
+
description: "Unique identifier for this progression option."
|
914 |
+
},
|
915 |
+
title: {
|
916 |
+
type: Type.STRING,
|
917 |
+
description: "Compelling title for this story progression."
|
918 |
+
},
|
919 |
+
description: {
|
920 |
+
type: Type.STRING,
|
921 |
+
description: "Brief description of what this progression offers."
|
922 |
+
},
|
923 |
+
direction: {
|
924 |
+
type: Type.STRING,
|
925 |
+
description: "Type of story progression: sequel, prequel, side_story, or alternate_ending"
|
926 |
+
},
|
927 |
+
synopsis: {
|
928 |
+
type: Type.STRING,
|
929 |
+
description: "Detailed synopsis of the progression storyline."
|
930 |
+
},
|
931 |
+
estimatedPages: {
|
932 |
+
type: Type.INTEGER,
|
933 |
+
description: "Estimated number of pages this progression would require."
|
934 |
+
},
|
935 |
+
thematicFocus: {
|
936 |
+
type: Type.STRING,
|
937 |
+
description: "Main theme or focus of this progression."
|
938 |
+
},
|
939 |
+
newCharacters: {
|
940 |
+
type: Type.ARRAY,
|
941 |
+
items: {
|
942 |
+
type: Type.STRING
|
943 |
+
},
|
944 |
+
description: "Names of potential new characters to be introduced."
|
945 |
+
},
|
946 |
+
plotHooks: {
|
947 |
+
type: Type.ARRAY,
|
948 |
+
items: {
|
949 |
+
type: Type.STRING
|
950 |
+
},
|
951 |
+
description: "Key plot hooks that would drive this progression."
|
952 |
+
}
|
953 |
+
},
|
954 |
+
required: ['id', 'title', 'description', 'direction', 'synopsis', 'estimatedPages', 'thematicFocus', 'plotHooks']
|
955 |
+
}
|
956 |
+
};
|
957 |
+
|
958 |
+
/**
|
959 |
+
* Analyzes a completed comic story and generates three compelling progression options
|
960 |
+
*/
|
961 |
+
export const analyzeStoryProgressions = async (
|
962 |
+
currentIteration: ComicIteration,
|
963 |
+
apiKey: string
|
964 |
+
): Promise<StoryProgression[]> => {
|
965 |
+
ai = new GoogleGenAI({ apiKey });
|
966 |
+
|
967 |
+
const characterDescriptions = currentIteration.characters.map(c => `${c.name}: ${c.description}`).join('; ');
|
968 |
+
const storyContext = currentIteration.pages.map(page =>
|
969 |
+
page.panels.map(panel => `Panel ${panel.panelNumber}: ${panel.description}${panel.dialogue ? ` - "${panel.dialogue}"` : ''}`).join(' ')
|
970 |
+
).join('\n');
|
971 |
+
|
972 |
+
const prompt = `You are a master story architect and creative director specializing in compelling narrative continuation and expansion. You understand how to build upon existing stories while maintaining thematic coherence and character development.
|
973 |
+
|
974 |
+
STORY ANALYSIS MISSION:
|
975 |
+
Analyze this completed comic story and generate exactly 3 distinct, professionally compelling progression options that would make readers excited to continue the journey.
|
976 |
+
|
977 |
+
CURRENT STORY CONTEXT:
|
978 |
+
Title: "${currentIteration.title}"
|
979 |
+
Author: ${currentIteration.author}
|
980 |
+
Style: ${currentIteration.style}
|
981 |
+
Characters: ${characterDescriptions}
|
982 |
+
|
983 |
+
STORY CONTENT:
|
984 |
+
${storyContext}
|
985 |
+
|
986 |
+
PROGRESSION REQUIREMENTS:
|
987 |
+
1. Each option must offer a genuinely different storytelling approach
|
988 |
+
2. Maintain thematic consistency with the original story
|
989 |
+
3. Introduce compelling new conflicts or deepen existing ones
|
990 |
+
4. Consider character growth opportunities
|
991 |
+
5. Ensure each progression has strong visual storytelling potential
|
992 |
+
6. Balance familiar elements with fresh narrative directions
|
993 |
+
|
994 |
+
PROGRESSION TYPES TO CONSIDER:
|
995 |
+
- SEQUEL: Continue the story forward in time, exploring consequences and new challenges
|
996 |
+
- PREQUEL: Explore backstory or origins that add depth to current events
|
997 |
+
- SIDE_STORY: Focus on secondary characters or parallel events during the main story
|
998 |
+
- ALTERNATE_ENDING: Explore "what if" scenarios from a key decision point
|
999 |
+
|
1000 |
+
CREATIVE GUIDELINES:
|
1001 |
+
- Think like a professional comic editor planning a series
|
1002 |
+
- Each progression should feel like a natural extension of the story world
|
1003 |
+
- Consider reader engagement and emotional investment
|
1004 |
+
- Include potential for visual spectacle and character development
|
1005 |
+
- Ensure each option offers unique thematic exploration
|
1006 |
+
|
1007 |
+
QUALITY STANDARDS:
|
1008 |
+
- Professional comic book industry storytelling standards
|
1009 |
+
- Character-driven narrative development
|
1010 |
+
- Visual storytelling opportunities
|
1011 |
+
- Reader engagement and page-turning moments
|
1012 |
+
- Thematic depth and emotional resonance
|
1013 |
+
|
1014 |
+
Generate 3 distinct story progression options that would make this comic into a compelling series.`;
|
1015 |
+
|
1016 |
+
try {
|
1017 |
+
const response = await ai.models.generateContent({
|
1018 |
+
model: "gemini-2.5-flash",
|
1019 |
+
contents: prompt,
|
1020 |
+
config: {
|
1021 |
+
responseMimeType: "application/json",
|
1022 |
+
responseSchema: storyProgressionSchema,
|
1023 |
+
},
|
1024 |
+
});
|
1025 |
+
|
1026 |
+
const jsonText = response.text.trim();
|
1027 |
+
return JSON.parse(jsonText) as StoryProgression[];
|
1028 |
+
} catch (e) {
|
1029 |
+
console.error("Error generating story progressions:", e);
|
1030 |
+
throw new Error("Failed to generate story progression options.");
|
1031 |
+
}
|
1032 |
+
};
|
1033 |
+
|
1034 |
+
/**
|
1035 |
+
* Generates a comic continuation based on selected story progression
|
1036 |
+
*/
|
1037 |
+
export const generateComicContinuation = async (
|
1038 |
+
previousIteration: ComicIteration,
|
1039 |
+
selectedProgression: StoryProgression,
|
1040 |
+
updateProgress: (state: LoadingState) => void,
|
1041 |
+
apiKey: string
|
1042 |
+
): Promise<ComicIteration> => {
|
1043 |
+
ai = new GoogleGenAI({ apiKey });
|
1044 |
+
|
1045 |
+
updateProgress({ isLoading: true, message: 'Developing continuation story...', progress: 10 });
|
1046 |
+
|
1047 |
+
// Create the continuation story details
|
1048 |
+
const continuationStory = `${selectedProgression.synopsis}\n\nBuilding upon: "${previousIteration.story}"\nProgression Focus: ${selectedProgression.thematicFocus}`;
|
1049 |
+
|
1050 |
+
// Generate new character profiles (combining existing + new characters)
|
1051 |
+
updateProgress({ isLoading: true, message: 'Expanding character cast...', progress: 25 });
|
1052 |
+
let allCharacters = [...previousIteration.characters];
|
1053 |
+
|
1054 |
+
if (selectedProgression.newCharacters && selectedProgression.newCharacters.length > 0) {
|
1055 |
+
const newCharacterPrompt = `Create detailed character profiles for these new characters to be introduced in the story continuation: ${selectedProgression.newCharacters.join(', ')}
|
1056 |
+
|
1057 |
+
Context: This is a continuation of "${previousIteration.title}" in the ${previousIteration.style} style.
|
1058 |
+
Story Direction: ${selectedProgression.direction}
|
1059 |
+
Thematic Focus: ${selectedProgression.thematicFocus}
|
1060 |
+
|
1061 |
+
Existing Characters: ${previousIteration.characters.map(c => `${c.name}: ${c.description}`).join('; ')}
|
1062 |
+
|
1063 |
+
Create characters that complement the existing cast and serve the new storyline.`;
|
1064 |
+
|
1065 |
+
try {
|
1066 |
+
const newCharacterResponse = await ai.models.generateContent({
|
1067 |
+
model: "gemini-2.5-flash",
|
1068 |
+
contents: newCharacterPrompt,
|
1069 |
+
config: {
|
1070 |
+
responseMimeType: "application/json",
|
1071 |
+
responseSchema: characterProfileSchema,
|
1072 |
+
},
|
1073 |
+
});
|
1074 |
+
|
1075 |
+
const newCharacters = JSON.parse(newCharacterResponse.text.trim()) as CharacterProfile[];
|
1076 |
+
allCharacters = [...allCharacters, ...newCharacters];
|
1077 |
+
} catch (e) {
|
1078 |
+
console.warn("Failed to generate new characters, continuing with existing cast:", e);
|
1079 |
+
}
|
1080 |
+
}
|
1081 |
+
|
1082 |
+
// Generate manga script for continuation
|
1083 |
+
updateProgress({ isLoading: true, message: 'Writing continuation script...', progress: 40 });
|
1084 |
+
const continuationPages = await generateMangaScript(continuationStory, previousIteration.style, allCharacters, previousIteration.includeDialogue ?? true);
|
1085 |
+
|
1086 |
+
if (continuationPages.length === 0) {
|
1087 |
+
throw new Error("The continuation script was empty. Please try a different progression.");
|
1088 |
+
}
|
1089 |
+
|
1090 |
+
// Generate images for continuation
|
1091 |
+
const totalPagesToGenerate = continuationPages.length + 1; // story pages + conclusion
|
1092 |
+
let pagesGenerated = 0;
|
1093 |
+
const allImages: string[] = [];
|
1094 |
+
|
1095 |
+
const updatePageProgress = () => {
|
1096 |
+
pagesGenerated++;
|
1097 |
+
const progress = 50 + Math.round((pagesGenerated / totalPagesToGenerate) * 45);
|
1098 |
+
return progress;
|
1099 |
+
};
|
1100 |
+
|
1101 |
+
const characterPromptPart = allCharacters.map(c => `${c.name}: ${c.description}`).join('; ');
|
1102 |
+
|
1103 |
+
// Use the last image from previous iteration as style reference
|
1104 |
+
const styleReference = previousIteration.images[previousIteration.images.length - 1];
|
1105 |
+
|
1106 |
+
// Generate each page from the continuation script
|
1107 |
+
for (const page of continuationPages) {
|
1108 |
+
const progress = updatePageProgress();
|
1109 |
+
updateProgress({ isLoading: true, message: `Drawing continuation page ${page.pageNumber} of ${continuationPages.length}...`, progress });
|
1110 |
+
|
1111 |
+
const panelPrompts = page.panels.map(p => {
|
1112 |
+
let panelString = `Panel ${p.panelNumber}: ${p.description}`;
|
1113 |
+
if (p.dialogue && p.speaker) {
|
1114 |
+
const speakingCharacter = allCharacters.find(c => c.name === p.speaker);
|
1115 |
+
if (speakingCharacter) {
|
1116 |
+
panelString += `\n - Dialogue: The character speaking is ${speakingCharacter.name} (${speakingCharacter.description}). They say: "${p.dialogue}"`;
|
1117 |
+
} else {
|
1118 |
+
panelString += `\n - Dialogue (${p.speaker}): "${p.dialogue}"`;
|
1119 |
+
}
|
1120 |
+
}
|
1121 |
+
return panelString;
|
1122 |
+
}).join('\n\n');
|
1123 |
+
|
1124 |
+
let styleDescription = "";
|
1125 |
+
if (previousIteration.style === 'Wacky') {
|
1126 |
+
styleDescription = "Wacky One Piece-style with exaggerated, rubber-hose animation aesthetics, impossible physics, and zany character designs";
|
1127 |
+
} else if (previousIteration.style === 'Chibi') {
|
1128 |
+
styleDescription = "Chibi style with cute, simplified characters having large heads and minimal features";
|
1129 |
+
} else {
|
1130 |
+
styleDescription = previousIteration.style;
|
1131 |
+
}
|
1132 |
+
|
1133 |
+
const pagePrompt = `TASK: Create a manga continuation page image.
|
1134 |
+
STYLE: ${styleDescription}, black and white.
|
1135 |
+
ASPECT RATIO: 3:4 vertical.
|
1136 |
+
CHARACTERS: ${characterPromptPart}.
|
1137 |
+
PAGE: ${page.pageNumber}, with ${page.panels.length} panels.
|
1138 |
+
STORY CONTINUATION: This continues the story "${previousIteration.title}" with progression: ${selectedProgression.title}
|
1139 |
+
|
1140 |
+
PANEL INSTRUCTIONS:
|
1141 |
+
${panelPrompts}
|
1142 |
+
|
1143 |
+
FINAL INSTRUCTIONS: Maintain visual consistency with the original story. Arrange panels dynamically. ${(previousIteration.includeDialogue ?? true) ? 'Place dialogue in speech bubbles for the correct characters.' : 'DO NOT include any dialogue, speech bubbles, or text - this is pure visual storytelling.'} Generate ONLY the image.`;
|
1144 |
+
|
1145 |
+
let pageImage: string;
|
1146 |
+
try {
|
1147 |
+
pageImage = await generatePageImage(pagePrompt, styleReference, page, previousIteration.style, previousIteration.includeDialogue ?? true);
|
1148 |
+
} catch (error) {
|
1149 |
+
console.warn(`Continuation page ${page.pageNumber} generation failed, retrying once...`, error);
|
1150 |
+
pageImage = await generatePageImage(pagePrompt, styleReference, page, previousIteration.style, previousIteration.includeDialogue ?? true);
|
1151 |
+
}
|
1152 |
+
allImages.push(pageImage);
|
1153 |
+
}
|
1154 |
+
|
1155 |
+
// Generate conclusion page for continuation
|
1156 |
+
const conclusionProgress = updatePageProgress();
|
1157 |
+
updateProgress({ isLoading: true, message: 'Creating continuation conclusion...', progress: conclusionProgress });
|
1158 |
+
|
1159 |
+
let styleDescription = "";
|
1160 |
+
if (previousIteration.style === 'Wacky') {
|
1161 |
+
styleDescription = "Wacky One Piece-style with exaggerated, rubber-hose animation aesthetics, impossible physics, and zany character designs";
|
1162 |
+
} else if (previousIteration.style === 'Chibi') {
|
1163 |
+
styleDescription = "Chibi style with cute, simplified characters having large heads and minimal features";
|
1164 |
+
} else {
|
1165 |
+
styleDescription = previousIteration.style;
|
1166 |
+
}
|
1167 |
+
|
1168 |
+
const conclusionPagePrompt = `TASK: Create a final, evocative manga conclusion page for the continuation.
|
1169 |
+
STYLE: ${styleDescription}, black and white.
|
1170 |
+
ASPECT RATIO: 3:4 vertical.
|
1171 |
+
STORY CONTEXT: This concludes the continuation "${selectedProgression.title}" of the original story "${previousIteration.title}".
|
1172 |
+
THEMATIC FOCUS: ${selectedProgression.thematicFocus}
|
1173 |
+
INSTRUCTIONS: Create a satisfying conclusion that reflects the continuation's thematic focus. Include a small, stylized "To Be Continued..." or "End of Chapter" text. Generate ONLY the image.`;
|
1174 |
+
|
1175 |
+
const lastContentPage = allImages[allImages.length - 1];
|
1176 |
+
let conclusionImage: string;
|
1177 |
+
try {
|
1178 |
+
conclusionImage = await generatePageImage(conclusionPagePrompt, lastContentPage, undefined, undefined, previousIteration.includeDialogue ?? true);
|
1179 |
+
} catch (error) {
|
1180 |
+
console.warn('Continuation conclusion page generation failed, retrying once...', error);
|
1181 |
+
conclusionImage = await generatePageImage(conclusionPagePrompt, lastContentPage, undefined, undefined, previousIteration.includeDialogue ?? true);
|
1182 |
+
}
|
1183 |
+
allImages.push(conclusionImage);
|
1184 |
+
|
1185 |
+
updateProgress({ isLoading: true, message: 'Finalizing continuation...', progress: 100 });
|
1186 |
+
|
1187 |
+
// Create the new iteration
|
1188 |
+
const newIteration: ComicIteration = {
|
1189 |
+
id: `${previousIteration.id}_${selectedProgression.id}`,
|
1190 |
+
title: selectedProgression.title,
|
1191 |
+
story: continuationStory,
|
1192 |
+
author: previousIteration.author,
|
1193 |
+
style: previousIteration.style,
|
1194 |
+
includeDialogue: previousIteration.includeDialogue ?? true,
|
1195 |
+
images: allImages,
|
1196 |
+
characters: allCharacters,
|
1197 |
+
pages: continuationPages,
|
1198 |
+
generatedAt: new Date()
|
1199 |
+
};
|
1200 |
|
1201 |
+
return newIteration;
|
|
|
1202 |
};
|
services/pdfService.ts
CHANGED
@@ -1,39 +1,360 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
declare const jspdf: any;
|
4 |
|
5 |
export const createMangaPdf = (images: string[], title: string): void => {
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
const doc = new jsPDF({
|
11 |
-
orientation: 'portrait',
|
12 |
unit: 'mm',
|
13 |
format: [pdfWidth, pdfHeight]
|
14 |
});
|
15 |
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
-
|
24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
|
26 |
-
|
27 |
-
|
28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
}
|
30 |
|
31 |
-
|
32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
|
34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
});
|
36 |
|
37 |
-
|
38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
};
|
|
|
1 |
+
import { jsPDF } from 'jspdf';
|
2 |
+
import type { ComicSeries, ComicIteration } from '../types';
|
|
|
3 |
|
4 |
export const createMangaPdf = (images: string[], title: string): void => {
|
5 |
+
console.log('createMangaPdf called with:', { imageCount: images.length, title });
|
6 |
+
|
7 |
+
if (images.length === 0) {
|
8 |
+
console.error('No images provided for PDF creation');
|
9 |
+
return;
|
10 |
+
}
|
11 |
+
|
12 |
+
// Create PDF with first image dimensions to maintain aspect ratio
|
13 |
+
let doc: jsPDF | null = null;
|
14 |
+
|
15 |
+
images.forEach((imgData, index) => {
|
16 |
+
if (index === 0) {
|
17 |
+
// Use jsPDF's built-in method to get image properties
|
18 |
+
try {
|
19 |
+
const imgProps = (doc as any)?.getImageProperties ?
|
20 |
+
(doc as any).getImageProperties(`data:image/jpeg;base64,${imgData}`) :
|
21 |
+
null;
|
22 |
+
|
23 |
+
// If we can't get properties, create a temporary jsPDF instance to get them
|
24 |
+
if (!imgProps) {
|
25 |
+
const tempDoc = new jsPDF();
|
26 |
+
const tempImgProps = (tempDoc as any).getImageProperties(`data:image/jpeg;base64,${imgData}`);
|
27 |
+
var aspectRatio = tempImgProps.width / tempImgProps.height;
|
28 |
+
console.log('Image dimensions from temp doc:', { width: tempImgProps.width, height: tempImgProps.height, aspectRatio });
|
29 |
+
} else {
|
30 |
+
var aspectRatio = imgProps.width / imgProps.height;
|
31 |
+
console.log('Image dimensions:', { width: imgProps.width, height: imgProps.height, aspectRatio });
|
32 |
+
}
|
33 |
+
} catch (error) {
|
34 |
+
// Fallback: assume standard comic book aspect ratio (3:4 portrait)
|
35 |
+
console.warn('Could not determine image dimensions, using default comic aspect ratio:', error);
|
36 |
+
var aspectRatio = 3 / 4; // Standard comic book ratio
|
37 |
+
}
|
38 |
+
|
39 |
+
// Scale to reasonable print size while maintaining aspect ratio
|
40 |
+
// Use a base size and scale according to aspect ratio
|
41 |
+
const baseSize = 200; // mm - base dimension for the larger side
|
42 |
+
|
43 |
+
let pageWidth, pageHeight;
|
44 |
+
if (aspectRatio > 1) {
|
45 |
+
// Landscape/Wide format
|
46 |
+
pageWidth = baseSize;
|
47 |
+
pageHeight = baseSize / aspectRatio;
|
48 |
+
} else {
|
49 |
+
// Portrait/Tall format
|
50 |
+
pageHeight = baseSize;
|
51 |
+
pageWidth = baseSize * aspectRatio;
|
52 |
+
}
|
53 |
+
|
54 |
+
console.log('PDF page dimensions:', { pageWidth, pageHeight, aspectRatio });
|
55 |
+
|
56 |
+
doc = new jsPDF({
|
57 |
+
orientation: aspectRatio > 1 ? 'landscape' : 'portrait',
|
58 |
+
unit: 'mm',
|
59 |
+
format: [pageWidth, pageHeight]
|
60 |
+
});
|
61 |
+
|
62 |
+
// Add first image filling entire page with no margins
|
63 |
+
doc.addImage(`data:image/jpeg;base64,${imgData}`, 'JPEG', 0, 0, pageWidth, pageHeight);
|
64 |
+
} else {
|
65 |
+
// For subsequent pages, maintain the same format as the first page
|
66 |
+
if (doc) {
|
67 |
+
doc.addPage();
|
68 |
+
const pageSize = doc.internal.pageSize;
|
69 |
+
const currentWidth = pageSize.getWidth();
|
70 |
+
const currentHeight = pageSize.getHeight();
|
71 |
+
doc.addImage(`data:image/jpeg;base64,${imgData}`, 'JPEG', 0, 0, currentWidth, currentHeight);
|
72 |
+
}
|
73 |
+
}
|
74 |
+
});
|
75 |
+
|
76 |
+
const safeTitle = title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
77 |
+
const filename = `${safeTitle}_manga.pdf`;
|
78 |
+
console.log('Saving PDF with filename:', filename);
|
79 |
+
|
80 |
+
try {
|
81 |
+
doc.save(filename);
|
82 |
+
console.log('PDF save command executed successfully');
|
83 |
+
} catch (error) {
|
84 |
+
console.error('Error saving PDF:', error);
|
85 |
+
}
|
86 |
+
};
|
87 |
+
|
88 |
+
/**
|
89 |
+
* Creates a comprehensive PDF from a complete comic series with multiple iterations
|
90 |
+
* Uses the aspect ratio of the first image in the series
|
91 |
+
*/
|
92 |
+
export const createComicSeriesPdf = (comicSeries: ComicSeries): void => {
|
93 |
+
console.log('createComicSeriesPdf called with:', { iterations: comicSeries.iterations.length, seriesTitle: comicSeries.seriesTitle });
|
94 |
+
|
95 |
+
// Get dimensions from the first image in the first iteration
|
96 |
+
const firstIteration = comicSeries.iterations[0];
|
97 |
+
if (!firstIteration || firstIteration.images.length === 0) {
|
98 |
+
console.error('No images found in comic series');
|
99 |
+
return;
|
100 |
+
}
|
101 |
+
|
102 |
+
const firstImageData = firstIteration.images[0];
|
103 |
+
|
104 |
+
// Use jsPDF's built-in method to get image properties
|
105 |
+
let aspectRatio;
|
106 |
+
try {
|
107 |
+
const tempDoc = new jsPDF();
|
108 |
+
const imgProps = (tempDoc as any).getImageProperties(`data:image/jpeg;base64,${firstImageData}`);
|
109 |
+
aspectRatio = imgProps.width / imgProps.height;
|
110 |
+
console.log('Series image dimensions:', { width: imgProps.width, height: imgProps.height, aspectRatio });
|
111 |
+
} catch (error) {
|
112 |
+
console.warn('Could not determine series image dimensions, using default comic aspect ratio:', error);
|
113 |
+
aspectRatio = 3 / 4; // Standard comic book ratio
|
114 |
+
}
|
115 |
+
|
116 |
+
// Use same sizing logic as single comic PDF
|
117 |
+
const baseSize = 200;
|
118 |
+
let pdfWidth, pdfHeight;
|
119 |
+
if (aspectRatio > 1) {
|
120 |
+
pdfWidth = baseSize;
|
121 |
+
pdfHeight = baseSize / aspectRatio;
|
122 |
+
} else {
|
123 |
+
pdfHeight = baseSize;
|
124 |
+
pdfWidth = baseSize * aspectRatio;
|
125 |
+
}
|
126 |
+
|
127 |
+
console.log('Series PDF dimensions:', { pdfWidth, pdfHeight, aspectRatio });
|
128 |
+
|
129 |
const doc = new jsPDF({
|
130 |
+
orientation: aspectRatio > 1 ? 'landscape' : 'portrait',
|
131 |
unit: 'mm',
|
132 |
format: [pdfWidth, pdfHeight]
|
133 |
});
|
134 |
|
135 |
+
let isFirstPage = true;
|
136 |
+
let totalPages = 0;
|
137 |
+
|
138 |
+
// Add title page for the series
|
139 |
+
if (isFirstPage) {
|
140 |
+
// Create a series title page
|
141 |
+
doc.setFillColor(20, 20, 30);
|
142 |
+
doc.rect(0, 0, pdfWidth, pdfHeight, 'F');
|
143 |
+
|
144 |
+
// Title
|
145 |
+
doc.setTextColor(255, 255, 255);
|
146 |
+
doc.setFontSize(24);
|
147 |
+
doc.setFont('helvetica', 'bold');
|
148 |
+
const titleLines = doc.splitTextToSize(comicSeries.seriesTitle, pdfWidth - 20);
|
149 |
+
const titleY = pdfHeight / 2 - 30;
|
150 |
+
doc.text(titleLines, pdfWidth / 2, titleY, { align: 'center' });
|
151 |
+
|
152 |
+
// Author
|
153 |
+
doc.setFontSize(16);
|
154 |
+
doc.setFont('helvetica', 'normal');
|
155 |
+
doc.text(`By ${comicSeries.author}`, pdfWidth / 2, titleY + 20, { align: 'center' });
|
156 |
+
|
157 |
+
// Series info
|
158 |
+
doc.setFontSize(12);
|
159 |
+
doc.text(`Complete Series • ${comicSeries.iterations.length} Chapters • ${comicSeries.totalPages} Pages`,
|
160 |
+
pdfWidth / 2, titleY + 35, { align: 'center' });
|
161 |
|
162 |
+
// Created date
|
163 |
+
doc.setFontSize(10);
|
164 |
+
doc.setTextColor(180, 180, 180);
|
165 |
+
doc.text(`Created: ${comicSeries.createdAt.toLocaleDateString()}`,
|
166 |
+
pdfWidth / 2, titleY + 50, { align: 'center' });
|
167 |
+
|
168 |
+
// "Comic Genesis AI" attribution
|
169 |
+
doc.text('Generated by Comic Genesis AI',
|
170 |
+
pdfWidth / 2, pdfHeight - 20, { align: 'center' });
|
171 |
+
|
172 |
+
isFirstPage = false;
|
173 |
+
totalPages = 1;
|
174 |
+
}
|
175 |
|
176 |
+
// Add each iteration with separator pages
|
177 |
+
comicSeries.iterations.forEach((iteration, iterationIndex) => {
|
178 |
+
// Add chapter divider page (except for the first iteration)
|
179 |
+
if (iterationIndex > 0) {
|
180 |
+
doc.addPage();
|
181 |
+
totalPages++;
|
182 |
+
|
183 |
+
// Chapter divider page
|
184 |
+
doc.setFillColor(30, 30, 40);
|
185 |
+
doc.rect(0, 0, pdfWidth, pdfHeight, 'F');
|
186 |
+
|
187 |
+
doc.setTextColor(255, 255, 255);
|
188 |
+
doc.setFontSize(20);
|
189 |
+
doc.setFont('helvetica', 'bold');
|
190 |
+
|
191 |
+
const chapterTitle = `Chapter ${iterationIndex + 1}`;
|
192 |
+
doc.text(chapterTitle, pdfWidth / 2, pdfHeight / 2 - 20, { align: 'center' });
|
193 |
+
|
194 |
+
doc.setFontSize(16);
|
195 |
+
doc.setFont('helvetica', 'normal');
|
196 |
+
const iterationTitleLines = doc.splitTextToSize(iteration.title, pdfWidth - 20);
|
197 |
+
doc.text(iterationTitleLines, pdfWidth / 2, pdfHeight / 2, { align: 'center' });
|
198 |
+
|
199 |
+
doc.setFontSize(10);
|
200 |
+
doc.setTextColor(180, 180, 180);
|
201 |
+
doc.text(`${iteration.images.length} pages`,
|
202 |
+
pdfWidth / 2, pdfHeight / 2 + 20, { align: 'center' });
|
203 |
}
|
204 |
|
205 |
+
// Add all images from this iteration
|
206 |
+
iteration.images.forEach((imgData, imageIndex) => {
|
207 |
+
if (!isFirstPage || imageIndex > 0) {
|
208 |
+
doc.addPage();
|
209 |
+
totalPages++;
|
210 |
+
}
|
211 |
+
|
212 |
+
try {
|
213 |
+
// Fill entire page with image
|
214 |
+
doc.addImage(`data:image/jpeg;base64,${imgData}`, 'JPEG', 0, 0, pdfWidth, pdfHeight);
|
215 |
+
|
216 |
+
// Add page number footer
|
217 |
+
doc.setFontSize(8);
|
218 |
+
doc.setTextColor(120, 120, 120);
|
219 |
+
doc.text(`${totalPages}`, pdfWidth - 10, pdfHeight - 5, { align: 'right' });
|
220 |
+
|
221 |
+
} catch (error) {
|
222 |
+
console.warn(`Failed to add image ${imageIndex} from iteration ${iterationIndex}:`, error);
|
223 |
+
|
224 |
+
// Add error page
|
225 |
+
doc.setFillColor(50, 50, 60);
|
226 |
+
doc.rect(0, 0, pdfWidth, pdfHeight, 'F');
|
227 |
+
doc.setTextColor(200, 200, 200);
|
228 |
+
doc.setFontSize(12);
|
229 |
+
doc.text('Image could not be loaded', pdfWidth / 2, pdfHeight / 2, { align: 'center' });
|
230 |
+
}
|
231 |
+
|
232 |
+
if (isFirstPage) {
|
233 |
+
isFirstPage = false;
|
234 |
+
}
|
235 |
+
});
|
236 |
+
});
|
237 |
+
|
238 |
+
// Add final credits/summary page
|
239 |
+
doc.addPage();
|
240 |
+
totalPages++;
|
241 |
+
|
242 |
+
doc.setFillColor(15, 15, 25);
|
243 |
+
doc.rect(0, 0, pdfWidth, pdfHeight, 'F');
|
244 |
+
|
245 |
+
doc.setTextColor(255, 255, 255);
|
246 |
+
doc.setFontSize(18);
|
247 |
+
doc.setFont('helvetica', 'bold');
|
248 |
+
doc.text('The End', pdfWidth / 2, pdfHeight / 2 - 30, { align: 'center' });
|
249 |
+
|
250 |
+
doc.setFontSize(12);
|
251 |
+
doc.setFont('helvetica', 'normal');
|
252 |
+
doc.text(`Complete Series: ${comicSeries.seriesTitle}`, pdfWidth / 2, pdfHeight / 2 - 10, { align: 'center' });
|
253 |
+
doc.text(`By ${comicSeries.author}`, pdfWidth / 2, pdfHeight / 2 + 5, { align: 'center' });
|
254 |
+
|
255 |
+
doc.setFontSize(10);
|
256 |
+
doc.setTextColor(180, 180, 180);
|
257 |
+
doc.text(`${comicSeries.iterations.length} chapters • ${totalPages} total pages`,
|
258 |
+
pdfWidth / 2, pdfHeight / 2 + 25, { align: 'center' });
|
259 |
+
|
260 |
+
doc.text(`Generated: ${new Date().toLocaleDateString()}`,
|
261 |
+
pdfWidth / 2, pdfHeight / 2 + 40, { align: 'center' });
|
262 |
+
|
263 |
+
doc.text('Powered by Comic Genesis AI & Google Gemini',
|
264 |
+
pdfWidth / 2, pdfHeight - 15, { align: 'center' });
|
265 |
|
266 |
+
// Save the PDF
|
267 |
+
const safeTitle = comicSeries.seriesTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
268 |
+
doc.save(`${safeTitle}_complete_series.pdf`);
|
269 |
+
};
|
270 |
+
|
271 |
+
/**
|
272 |
+
* Creates a PDF from a single comic iteration with chapter formatting
|
273 |
+
* Uses the aspect ratio of the first image in the iteration
|
274 |
+
*/
|
275 |
+
export const createIterationPdf = (iteration: ComicIteration, chapterNumber?: number): void => {
|
276 |
+
if (iteration.images.length === 0) {
|
277 |
+
console.error('No images found in iteration');
|
278 |
+
return;
|
279 |
+
}
|
280 |
+
|
281 |
+
// Get dimensions from the first image using jsPDF
|
282 |
+
const firstImageData = iteration.images[0];
|
283 |
+
|
284 |
+
let aspectRatio;
|
285 |
+
try {
|
286 |
+
const tempDoc = new jsPDF();
|
287 |
+
const imgProps = (tempDoc as any).getImageProperties(`data:image/jpeg;base64,${firstImageData}`);
|
288 |
+
aspectRatio = imgProps.width / imgProps.height;
|
289 |
+
console.log('Iteration image dimensions:', { width: imgProps.width, height: imgProps.height, aspectRatio });
|
290 |
+
} catch (error) {
|
291 |
+
console.warn('Could not determine iteration image dimensions, using default comic aspect ratio:', error);
|
292 |
+
aspectRatio = 3 / 4; // Standard comic book ratio
|
293 |
+
}
|
294 |
+
|
295 |
+
// Use same sizing logic as other PDF functions
|
296 |
+
const baseSize = 200;
|
297 |
+
let pdfWidth, pdfHeight;
|
298 |
+
if (aspectRatio > 1) {
|
299 |
+
pdfWidth = baseSize;
|
300 |
+
pdfHeight = baseSize / aspectRatio;
|
301 |
+
} else {
|
302 |
+
pdfHeight = baseSize;
|
303 |
+
pdfWidth = baseSize * aspectRatio;
|
304 |
+
}
|
305 |
+
|
306 |
+
const doc = new jsPDF({
|
307 |
+
orientation: aspectRatio > 1 ? 'landscape' : 'portrait',
|
308 |
+
unit: 'mm',
|
309 |
+
format: [pdfWidth, pdfHeight]
|
310 |
});
|
311 |
|
312 |
+
let isFirstPage = true;
|
313 |
+
|
314 |
+
// Add chapter title page if chapter number is provided
|
315 |
+
if (chapterNumber) {
|
316 |
+
doc.setFillColor(20, 20, 30);
|
317 |
+
doc.rect(0, 0, pdfWidth, pdfHeight, 'F');
|
318 |
+
|
319 |
+
doc.setTextColor(255, 255, 255);
|
320 |
+
doc.setFontSize(20);
|
321 |
+
doc.setFont('helvetica', 'bold');
|
322 |
+
doc.text(`Chapter ${chapterNumber}`, pdfWidth / 2, pdfHeight / 2 - 20, { align: 'center' });
|
323 |
+
|
324 |
+
doc.setFontSize(16);
|
325 |
+
doc.setFont('helvetica', 'normal');
|
326 |
+
const titleLines = doc.splitTextToSize(iteration.title, pdfWidth - 20);
|
327 |
+
doc.text(titleLines, pdfWidth / 2, pdfHeight / 2, { align: 'center' });
|
328 |
+
|
329 |
+
doc.setFontSize(10);
|
330 |
+
doc.text(`By ${iteration.author}`, pdfWidth / 2, pdfHeight / 2 + 20, { align: 'center' });
|
331 |
+
|
332 |
+
isFirstPage = false;
|
333 |
+
}
|
334 |
+
|
335 |
+
// Add all images
|
336 |
+
iteration.images.forEach((imgData, index) => {
|
337 |
+
if (!isFirstPage || index > 0) {
|
338 |
+
doc.addPage();
|
339 |
+
}
|
340 |
+
|
341 |
+
try {
|
342 |
+
// Fill entire page with image
|
343 |
+
doc.addImage(`data:image/jpeg;base64,${imgData}`, 'JPEG', 0, 0, pdfWidth, pdfHeight);
|
344 |
+
|
345 |
+
} catch (error) {
|
346 |
+
console.warn(`Failed to add image ${index}:`, error);
|
347 |
+
}
|
348 |
+
|
349 |
+
if (isFirstPage) {
|
350 |
+
isFirstPage = false;
|
351 |
+
}
|
352 |
+
});
|
353 |
+
|
354 |
+
const safeTitle = iteration.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
355 |
+
const fileName = chapterNumber ?
|
356 |
+
`${safeTitle}_chapter_${chapterNumber}.pdf` :
|
357 |
+
`${safeTitle}.pdf`;
|
358 |
+
|
359 |
+
doc.save(fileName);
|
360 |
};
|
types.ts
CHANGED
@@ -33,9 +33,111 @@ export interface StoryTemplate {
|
|
33 |
icon: string;
|
34 |
}
|
35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
export interface NarrativeStructure {
|
37 |
act: number;
|
38 |
name: string;
|
39 |
description: string;
|
40 |
keyElements: string[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
}
|
|
|
33 |
icon: string;
|
34 |
}
|
35 |
|
36 |
+
export interface ComicGenerationOptions {
|
37 |
+
includeDialogue: boolean;
|
38 |
+
title: string;
|
39 |
+
story: string;
|
40 |
+
author: string;
|
41 |
+
style: MangaStyle;
|
42 |
+
}
|
43 |
+
|
44 |
export interface NarrativeStructure {
|
45 |
act: number;
|
46 |
name: string;
|
47 |
description: string;
|
48 |
keyElements: string[];
|
49 |
+
}
|
50 |
+
|
51 |
+
export interface StoryProgression {
|
52 |
+
id: string;
|
53 |
+
title: string;
|
54 |
+
description: string;
|
55 |
+
direction: 'sequel' | 'prequel' | 'side_story' | 'alternate_ending';
|
56 |
+
synopsis: string;
|
57 |
+
estimatedPages: number;
|
58 |
+
thematicFocus: string;
|
59 |
+
newCharacters?: string[];
|
60 |
+
plotHooks: string[];
|
61 |
+
}
|
62 |
+
|
63 |
+
export interface ComicIteration {
|
64 |
+
id: string;
|
65 |
+
title: string;
|
66 |
+
story: string;
|
67 |
+
author: string;
|
68 |
+
style: MangaStyle;
|
69 |
+
includeDialogue?: boolean;
|
70 |
+
images: string[];
|
71 |
+
characters: CharacterProfile[];
|
72 |
+
pages: MangaPage[];
|
73 |
+
generatedAt: Date;
|
74 |
+
}
|
75 |
+
|
76 |
+
export interface ComicSeries {
|
77 |
+
seriesTitle: string;
|
78 |
+
author: string;
|
79 |
+
iterations: ComicIteration[];
|
80 |
+
totalPages: number;
|
81 |
+
createdAt: Date;
|
82 |
+
lastUpdated: Date;
|
83 |
+
totalGenerationTime?: number;
|
84 |
+
performanceSummary?: PerformanceTiming[];
|
85 |
+
}
|
86 |
+
|
87 |
+
export interface ExplorationPrompt {
|
88 |
+
showPrompt: boolean;
|
89 |
+
onContinue: () => void;
|
90 |
+
onFinish: (removeApiKey: boolean) => void;
|
91 |
+
generationTime: number;
|
92 |
+
totalPages: number;
|
93 |
+
}
|
94 |
+
|
95 |
+
export interface OptimizedImage {
|
96 |
+
compressed: string; // For display (smaller size)
|
97 |
+
full: string; // For PDF generation (full quality)
|
98 |
+
width: number;
|
99 |
+
height: number;
|
100 |
+
compressedSize: number;
|
101 |
+
fullSize: number;
|
102 |
+
}
|
103 |
+
|
104 |
+
export interface CharacterSketch {
|
105 |
+
characterName: string;
|
106 |
+
sketchImage: string;
|
107 |
+
description: string;
|
108 |
+
}
|
109 |
+
|
110 |
+
export interface EnvironmentSketch {
|
111 |
+
environmentName: string;
|
112 |
+
sketchImage: string;
|
113 |
+
description: string;
|
114 |
+
}
|
115 |
+
|
116 |
+
export interface GenerationProgress {
|
117 |
+
phase: 'character_analysis' | 'character_sketches' | 'environment_sketches' | 'script_writing' | 'page_generation' | 'completion';
|
118 |
+
message: string;
|
119 |
+
progress: number;
|
120 |
+
characterSketches?: CharacterSketch[];
|
121 |
+
environmentSketches?: EnvironmentSketch[];
|
122 |
+
generatedPages?: { pageNumber: number; image: string; title: string }[];
|
123 |
+
storyArcSummary?: string;
|
124 |
+
currentStepStartTime?: number;
|
125 |
+
stepTimings?: PerformanceTiming[];
|
126 |
+
estimatedTimeRemaining?: number;
|
127 |
+
}
|
128 |
+
|
129 |
+
export interface PerformanceTiming {
|
130 |
+
stepName: string;
|
131 |
+
startTime: number;
|
132 |
+
endTime: number;
|
133 |
+
duration: number;
|
134 |
+
timestamp: string;
|
135 |
+
}
|
136 |
+
|
137 |
+
export interface StoryArcSummary {
|
138 |
+
overallTheme: string;
|
139 |
+
keyEvents: string[];
|
140 |
+
characterDevelopment: string[];
|
141 |
+
narrativeProgression: string;
|
142 |
+
emotionalJourney: string;
|
143 |
}
|
utils/imageOptimization.ts
ADDED
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export interface OptimizedImage {
|
2 |
+
compressed: string; // For display (smaller size)
|
3 |
+
full: string; // For PDF generation (full quality)
|
4 |
+
width: number;
|
5 |
+
height: number;
|
6 |
+
compressedSize: number;
|
7 |
+
fullSize: number;
|
8 |
+
}
|
9 |
+
|
10 |
+
export interface PerformanceTiming {
|
11 |
+
stepName: string;
|
12 |
+
startTime: number;
|
13 |
+
endTime: number;
|
14 |
+
duration: number;
|
15 |
+
timestamp: string;
|
16 |
+
}
|
17 |
+
|
18 |
+
/**
|
19 |
+
* Compress base64 image for display purposes while maintaining quality for PDF
|
20 |
+
*/
|
21 |
+
export async function optimizeImage(base64Image: string, quality: number = 0.7): Promise<OptimizedImage> {
|
22 |
+
return new Promise((resolve, reject) => {
|
23 |
+
try {
|
24 |
+
const img = new Image();
|
25 |
+
img.onload = () => {
|
26 |
+
// Create canvas for compression
|
27 |
+
const canvas = document.createElement('canvas');
|
28 |
+
const ctx = canvas.getContext('2d')!;
|
29 |
+
|
30 |
+
// Set canvas size to image size
|
31 |
+
canvas.width = img.width;
|
32 |
+
canvas.height = img.height;
|
33 |
+
|
34 |
+
// Draw image on canvas
|
35 |
+
ctx.drawImage(img, 0, 0);
|
36 |
+
|
37 |
+
// Create compressed version for display
|
38 |
+
const compressedBase64 = canvas.toDataURL('image/jpeg', quality);
|
39 |
+
|
40 |
+
// Calculate sizes
|
41 |
+
const fullSize = base64Image.length * 0.75; // Approximate bytes
|
42 |
+
const compressedSize = compressedBase64.split(',')[1].length * 0.75;
|
43 |
+
|
44 |
+
resolve({
|
45 |
+
compressed: compressedBase64.split(',')[1], // Remove data:image/jpeg;base64, prefix
|
46 |
+
full: base64Image,
|
47 |
+
width: img.width,
|
48 |
+
height: img.height,
|
49 |
+
compressedSize: Math.round(compressedSize),
|
50 |
+
fullSize: Math.round(fullSize)
|
51 |
+
});
|
52 |
+
};
|
53 |
+
|
54 |
+
img.onerror = () => reject(new Error('Failed to load image for optimization'));
|
55 |
+
img.src = `data:image/jpeg;base64,${base64Image}`;
|
56 |
+
} catch (error) {
|
57 |
+
reject(error);
|
58 |
+
}
|
59 |
+
});
|
60 |
+
}
|
61 |
+
|
62 |
+
/**
|
63 |
+
* Batch optimize multiple images
|
64 |
+
*/
|
65 |
+
export async function optimizeImages(images: string[], quality: number = 0.7): Promise<OptimizedImage[]> {
|
66 |
+
const optimizationPromises = images.map(img => optimizeImage(img, quality));
|
67 |
+
return Promise.all(optimizationPromises);
|
68 |
+
}
|
69 |
+
|
70 |
+
/**
|
71 |
+
* Performance timing utilities
|
72 |
+
*/
|
73 |
+
export class PerformanceTracker {
|
74 |
+
private timings: PerformanceTiming[] = [];
|
75 |
+
private activeTimers: Map<string, number> = new Map();
|
76 |
+
|
77 |
+
startTimer(stepName: string): void {
|
78 |
+
const startTime = performance.now();
|
79 |
+
this.activeTimers.set(stepName, startTime);
|
80 |
+
}
|
81 |
+
|
82 |
+
endTimer(stepName: string): PerformanceTiming {
|
83 |
+
const endTime = performance.now();
|
84 |
+
const startTime = this.activeTimers.get(stepName);
|
85 |
+
|
86 |
+
if (!startTime) {
|
87 |
+
throw new Error(`Timer for "${stepName}" was not started`);
|
88 |
+
}
|
89 |
+
|
90 |
+
const timing: PerformanceTiming = {
|
91 |
+
stepName,
|
92 |
+
startTime,
|
93 |
+
endTime,
|
94 |
+
duration: endTime - startTime,
|
95 |
+
timestamp: new Date().toISOString()
|
96 |
+
};
|
97 |
+
|
98 |
+
this.timings.push(timing);
|
99 |
+
this.activeTimers.delete(stepName);
|
100 |
+
|
101 |
+
return timing;
|
102 |
+
}
|
103 |
+
|
104 |
+
getTimings(): PerformanceTiming[] {
|
105 |
+
return [...this.timings];
|
106 |
+
}
|
107 |
+
|
108 |
+
getTotalDuration(): number {
|
109 |
+
return this.timings.reduce((total, timing) => total + timing.duration, 0);
|
110 |
+
}
|
111 |
+
|
112 |
+
getTimingByStep(stepName: string): PerformanceTiming | undefined {
|
113 |
+
return this.timings.find(timing => timing.stepName === stepName);
|
114 |
+
}
|
115 |
+
|
116 |
+
clear(): void {
|
117 |
+
this.timings = [];
|
118 |
+
this.activeTimers.clear();
|
119 |
+
}
|
120 |
+
|
121 |
+
getFormattedSummary(): string {
|
122 |
+
if (this.timings.length === 0) return 'No performance data available';
|
123 |
+
|
124 |
+
const summary = this.timings.map(timing =>
|
125 |
+
`${timing.stepName}: ${(timing.duration / 1000).toFixed(2)}s`
|
126 |
+
).join('\n');
|
127 |
+
|
128 |
+
const total = (this.getTotalDuration() / 1000).toFixed(2);
|
129 |
+
return `${summary}\n\nTotal: ${total}s`;
|
130 |
+
}
|
131 |
+
}
|
132 |
+
|
133 |
+
/**
|
134 |
+
* Lazy loading utilities
|
135 |
+
*/
|
136 |
+
export interface LazyLoadOptions {
|
137 |
+
rootMargin?: string;
|
138 |
+
threshold?: number;
|
139 |
+
fallbackDelay?: number;
|
140 |
+
}
|
141 |
+
|
142 |
+
export function createLazyLoader(options: LazyLoadOptions = {}) {
|
143 |
+
const { rootMargin = '50px', threshold = 0.1, fallbackDelay = 300 } = options;
|
144 |
+
|
145 |
+
if ('IntersectionObserver' in window) {
|
146 |
+
return new IntersectionObserver((entries) => {
|
147 |
+
entries.forEach(entry => {
|
148 |
+
if (entry.isIntersecting) {
|
149 |
+
const img = entry.target as HTMLImageElement;
|
150 |
+
const src = img.dataset.src;
|
151 |
+
if (src) {
|
152 |
+
img.src = src;
|
153 |
+
img.classList.remove('lazy');
|
154 |
+
img.classList.add('loaded');
|
155 |
+
}
|
156 |
+
}
|
157 |
+
});
|
158 |
+
}, { rootMargin, threshold });
|
159 |
+
}
|
160 |
+
|
161 |
+
// Fallback for older browsers
|
162 |
+
return {
|
163 |
+
observe: (element: Element) => {
|
164 |
+
setTimeout(() => {
|
165 |
+
const img = element as HTMLImageElement;
|
166 |
+
const src = img.dataset.src;
|
167 |
+
if (src) {
|
168 |
+
img.src = src;
|
169 |
+
img.classList.remove('lazy');
|
170 |
+
img.classList.add('loaded');
|
171 |
+
}
|
172 |
+
}, fallbackDelay);
|
173 |
+
},
|
174 |
+
disconnect: () => {},
|
175 |
+
unobserve: () => {}
|
176 |
+
};
|
177 |
+
}
|
178 |
+
|
179 |
+
/**
|
180 |
+
* Memory usage monitoring
|
181 |
+
*/
|
182 |
+
export function getMemoryUsage(): { used: number; total: number; percentage: number } | null {
|
183 |
+
if ('memory' in performance) {
|
184 |
+
const memory = (performance as any).memory;
|
185 |
+
return {
|
186 |
+
used: Math.round(memory.usedJSHeapSize / 1024 / 1024), // MB
|
187 |
+
total: Math.round(memory.totalJSHeapSize / 1024 / 1024), // MB
|
188 |
+
percentage: Math.round((memory.usedJSHeapSize / memory.totalJSHeapSize) * 100)
|
189 |
+
};
|
190 |
+
}
|
191 |
+
return null;
|
192 |
+
}
|
193 |
+
|
194 |
+
/**
|
195 |
+
* Debounce utility for performance
|
196 |
+
*/
|
197 |
+
export function debounce<T extends (...args: any[]) => any>(
|
198 |
+
func: T,
|
199 |
+
wait: number
|
200 |
+
): (...args: Parameters<T>) => void {
|
201 |
+
let timeout: NodeJS.Timeout;
|
202 |
+
return (...args: Parameters<T>) => {
|
203 |
+
clearTimeout(timeout);
|
204 |
+
timeout = setTimeout(() => func(...args), wait);
|
205 |
+
};
|
206 |
+
}
|
207 |
+
|
208 |
+
/**
|
209 |
+
* Throttle utility for performance
|
210 |
+
*/
|
211 |
+
export function throttle<T extends (...args: any[]) => any>(
|
212 |
+
func: T,
|
213 |
+
limit: number
|
214 |
+
): (...args: Parameters<T>) => void {
|
215 |
+
let inThrottle: boolean;
|
216 |
+
return (...args: Parameters<T>) => {
|
217 |
+
if (!inThrottle) {
|
218 |
+
func(...args);
|
219 |
+
inThrottle = true;
|
220 |
+
setTimeout(() => inThrottle = false, limit);
|
221 |
+
}
|
222 |
+
};
|
223 |
+
}
|
vite.config.ts
CHANGED
@@ -15,6 +15,9 @@ export default defineConfig(({ mode }) => {
|
|
15 |
alias: {
|
16 |
'@': path.resolve(__dirname, '.'),
|
17 |
}
|
|
|
|
|
|
|
18 |
}
|
19 |
};
|
20 |
});
|
|
|
15 |
alias: {
|
16 |
'@': path.resolve(__dirname, '.'),
|
17 |
}
|
18 |
+
},
|
19 |
+
build: {
|
20 |
+
chunkSizeWarningLimit: 1000
|
21 |
}
|
22 |
};
|
23 |
});
|