update
Browse files- .gitignore +2 -1
- client/src/App.jsx +188 -140
- client/src/components/StoryManager.jsx +182 -0
- client/src/context/ComicContext.jsx +88 -0
- client/src/hooks/useImageGeneration.js +65 -0
- client/src/layouts/ComicLayout.jsx +34 -86
- client/src/layouts/Panel.jsx +141 -0
- client/src/layouts/config.js +63 -39
- client/src/layouts/utils.js +49 -41
- client/src/main.jsx +4 -6
- server/api_clients.py +39 -22
- server/game/game_logic.py +151 -31
- server/server.py +78 -30
.gitignore
CHANGED
@@ -1,3 +1,4 @@
|
|
1 |
/client/node_modules
|
2 |
.env
|
3 |
-
/node_modules
|
|
|
|
1 |
/client/node_modules
|
2 |
.env
|
3 |
+
/node_modules
|
4 |
+
ai-comic-factory/
|
client/src/App.jsx
CHANGED
@@ -10,7 +10,11 @@ import {
|
|
10 |
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
11 |
import axios from "axios";
|
12 |
import { ComicLayout } from "./layouts/ComicLayout";
|
13 |
-
import {
|
|
|
|
|
|
|
|
|
14 |
|
15 |
// Get API URL from environment or default to localhost in development
|
16 |
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
@@ -43,123 +47,171 @@ function App() {
|
|
43 |
const [currentChoices, setCurrentChoices] = useState([]);
|
44 |
const [isLoading, setIsLoading] = useState(false);
|
45 |
const [isDebugMode, setIsDebugMode] = useState(false);
|
46 |
-
const isInitializedRef = useRef(false);
|
47 |
const currentImageRequestRef = useRef(null);
|
48 |
const pendingImageRequests = useRef(new Set()); // Track pending image requests
|
49 |
const audioRef = useRef(new Audio());
|
50 |
|
51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
try {
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
77 |
|
78 |
-
|
79 |
-
|
80 |
-
|
|
|
|
|
|
|
|
|
81 |
|
82 |
-
|
83 |
-
|
84 |
-
{
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
91 |
}
|
92 |
-
);
|
93 |
|
94 |
-
|
95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
96 |
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
if (axios.isCancel(error)) {
|
103 |
-
console.log("Image request cancelled for segment", segmentIndex);
|
104 |
-
// On met quand même à jour le segment pour arrêter le spinner
|
105 |
-
setStorySegments((prev) => {
|
106 |
-
const updatedSegments = [...prev];
|
107 |
-
if (updatedSegments[segmentIndex]) {
|
108 |
-
updatedSegments[segmentIndex] = {
|
109 |
-
...updatedSegments[segmentIndex],
|
110 |
-
image_base64: null,
|
111 |
-
imageRequestCancelled: true, // Flag pour indiquer que la requête a été annulée
|
112 |
-
};
|
113 |
}
|
114 |
-
|
115 |
-
});
|
116 |
-
} else {
|
117 |
-
console.error("Error generating image:", error);
|
118 |
-
}
|
119 |
-
pendingImageRequests.current.delete(segmentIndex);
|
120 |
-
return null;
|
121 |
-
}
|
122 |
-
};
|
123 |
-
|
124 |
-
const playAudio = async (text) => {
|
125 |
-
try {
|
126 |
-
console.log("Requesting audio for text:", text);
|
127 |
-
const response = await api.post(`${API_URL}/api/text-to-speech`, {
|
128 |
-
text: text,
|
129 |
-
});
|
130 |
-
|
131 |
-
if (response.data.success) {
|
132 |
-
console.log("Audio received successfully");
|
133 |
-
// Arrêter l'audio en cours s'il y en a un
|
134 |
-
audioRef.current.pause();
|
135 |
-
audioRef.current.currentTime = 0;
|
136 |
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
|
|
|
|
|
|
|
|
142 |
|
143 |
-
|
144 |
-
|
145 |
-
|
|
|
|
|
146 |
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
153 |
}
|
154 |
-
|
155 |
-
// Nettoyer l'URL une fois l'audio terminé
|
156 |
-
audioRef.current.onended = () => {
|
157 |
-
URL.revokeObjectURL(audioUrl);
|
158 |
-
console.log("Audio finished, URL cleaned up");
|
159 |
-
};
|
160 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
161 |
} catch (error) {
|
162 |
-
console.error("Error
|
|
|
163 |
}
|
164 |
};
|
165 |
|
@@ -175,7 +227,7 @@ function App() {
|
|
175 |
}
|
176 |
);
|
177 |
|
178 |
-
// 2. Créer le nouveau segment sans
|
179 |
const newSegment = {
|
180 |
text: formatTextWithBold(response.data.story_text),
|
181 |
isChoice: false,
|
@@ -184,50 +236,54 @@ function App() {
|
|
184 |
radiationLevel: response.data.radiation_level,
|
185 |
is_first_step: response.data.is_first_step,
|
186 |
is_last_step: response.data.is_last_step,
|
187 |
-
|
|
|
|
|
188 |
};
|
189 |
|
|
|
190 |
let segmentIndex;
|
191 |
-
|
|
|
192 |
if (action === "restart") {
|
193 |
-
setStorySegments([newSegment]);
|
194 |
segmentIndex = 0;
|
|
|
195 |
} else {
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
});
|
200 |
}
|
201 |
|
|
|
|
|
|
|
202 |
// 4. Mettre à jour les choix immédiatement
|
203 |
setCurrentChoices(response.data.choices);
|
204 |
|
205 |
// 5. Désactiver le loading car l'histoire est affichée
|
206 |
setIsLoading(false);
|
207 |
|
208 |
-
// 6.
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
}
|
229 |
-
} catch (imageError) {
|
230 |
-
console.error("Error generating image:", imageError);
|
231 |
}
|
232 |
} catch (error) {
|
233 |
console.error("Error:", error);
|
@@ -241,7 +297,7 @@ function App() {
|
|
241 |
storySegments.length > 0
|
242 |
? storySegments[storySegments.length - 1].radiationLevel
|
243 |
: 0,
|
244 |
-
|
245 |
};
|
246 |
|
247 |
// Ajouter le segment d'erreur et permettre de réessayer
|
@@ -258,14 +314,6 @@ function App() {
|
|
258 |
}
|
259 |
};
|
260 |
|
261 |
-
// Start the story when the component mounts
|
262 |
-
useEffect(() => {
|
263 |
-
if (!isInitializedRef.current) {
|
264 |
-
handleStoryAction("restart");
|
265 |
-
isInitializedRef.current = true;
|
266 |
-
}
|
267 |
-
}, []); // Empty dependency array since we're using a ref
|
268 |
-
|
269 |
const handleChoice = async (choiceId) => {
|
270 |
// Si c'est l'option "Réessayer", on relance la dernière action
|
271 |
if (currentChoices.length === 1 && currentChoices[0].text === "Réessayer") {
|
|
|
10 |
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
11 |
import axios from "axios";
|
12 |
import { ComicLayout } from "./layouts/ComicLayout";
|
13 |
+
import {
|
14 |
+
getNextPanelDimensions,
|
15 |
+
groupSegmentsIntoLayouts,
|
16 |
+
} from "./layouts/utils";
|
17 |
+
import { LAYOUTS } from "./layouts/config";
|
18 |
|
19 |
// Get API URL from environment or default to localhost in development
|
20 |
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
|
|
47 |
const [currentChoices, setCurrentChoices] = useState([]);
|
48 |
const [isLoading, setIsLoading] = useState(false);
|
49 |
const [isDebugMode, setIsDebugMode] = useState(false);
|
|
|
50 |
const currentImageRequestRef = useRef(null);
|
51 |
const pendingImageRequests = useRef(new Set()); // Track pending image requests
|
52 |
const audioRef = useRef(new Audio());
|
53 |
|
54 |
+
// Start the story on first render
|
55 |
+
useEffect(() => {
|
56 |
+
handleStoryAction("restart");
|
57 |
+
}, []); // Empty dependency array for first render only
|
58 |
+
|
59 |
+
const generateImagesForStory = async (
|
60 |
+
imagePrompts,
|
61 |
+
segmentIndex,
|
62 |
+
currentSegments
|
63 |
+
) => {
|
64 |
try {
|
65 |
+
console.log("[generateImagesForStory] Starting with:", {
|
66 |
+
promptsCount: imagePrompts.length,
|
67 |
+
segmentIndex,
|
68 |
+
segmentsCount: currentSegments.length,
|
69 |
+
});
|
70 |
+
console.log("Image prompts:", imagePrompts);
|
71 |
+
console.log("Current segments:", currentSegments);
|
72 |
+
|
73 |
+
let localSegments = [...currentSegments];
|
74 |
+
|
75 |
+
// Traiter chaque prompt un par un
|
76 |
+
for (
|
77 |
+
let promptIndex = 0;
|
78 |
+
promptIndex < imagePrompts.length;
|
79 |
+
promptIndex++
|
80 |
+
) {
|
81 |
+
// Recalculer le layout actuel pour chaque image
|
82 |
+
const layouts = groupSegmentsIntoLayouts(localSegments);
|
83 |
+
console.log("[Layout] Current layouts:", layouts);
|
84 |
+
const currentLayout = layouts[layouts.length - 1];
|
85 |
+
const layoutType = currentLayout?.type || "COVER";
|
86 |
+
console.log("[Layout] Current type:", layoutType);
|
87 |
+
|
88 |
+
// Vérifier si nous avons de la place dans le layout actuel
|
89 |
+
const currentSegmentImages =
|
90 |
+
currentLayout.segments[currentLayout.segments.length - 1].images ||
|
91 |
+
[];
|
92 |
+
const actualImagesCount = currentSegmentImages.filter(
|
93 |
+
(img) => img !== null
|
94 |
+
).length;
|
95 |
+
console.log("[Layout] Current segment images:", {
|
96 |
+
total: currentSegmentImages.length,
|
97 |
+
actual: actualImagesCount,
|
98 |
+
hasImages: currentSegmentImages.some((img) => img !== null),
|
99 |
+
currentImages: currentSegmentImages.map((img) =>
|
100 |
+
img ? "image" : "null"
|
101 |
+
),
|
102 |
+
});
|
103 |
|
104 |
+
const panelDimensions = LAYOUTS[layoutType].panels[promptIndex];
|
105 |
+
console.log(
|
106 |
+
"[Layout] Panel dimensions for prompt",
|
107 |
+
promptIndex,
|
108 |
+
":",
|
109 |
+
panelDimensions
|
110 |
+
);
|
111 |
|
112 |
+
// Ne créer une nouvelle page que si nous avons encore des prompts à traiter
|
113 |
+
// et qu'il n'y a plus de place dans le layout actuel
|
114 |
+
if (!panelDimensions && promptIndex < imagePrompts.length - 1) {
|
115 |
+
console.log(
|
116 |
+
"[Layout] Creating new page - No space in current layout"
|
117 |
+
);
|
118 |
+
// Créer un nouveau segment pour la nouvelle page
|
119 |
+
const newSegment = {
|
120 |
+
...localSegments[segmentIndex],
|
121 |
+
images: Array(imagePrompts.length - promptIndex).fill(null),
|
122 |
+
};
|
123 |
+
localSegments = [...localSegments, newSegment];
|
124 |
+
segmentIndex = localSegments.length - 1;
|
125 |
+
console.log("[Layout] New segment created:", {
|
126 |
+
segmentIndex,
|
127 |
+
totalSegments: localSegments.length,
|
128 |
+
imagesArray: newSegment.images,
|
129 |
+
});
|
130 |
+
// Mettre à jour l'état avec le nouveau segment
|
131 |
+
setStorySegments(localSegments);
|
132 |
+
continue; // Recommencer la boucle avec le nouveau segment
|
133 |
}
|
|
|
134 |
|
135 |
+
// Si nous n'avons pas de dimensions de panneau et c'est le dernier prompt,
|
136 |
+
// ne pas continuer
|
137 |
+
if (!panelDimensions) {
|
138 |
+
console.log(
|
139 |
+
"[Layout] Stopping - No more space and no more prompts to process"
|
140 |
+
);
|
141 |
+
break;
|
142 |
+
}
|
143 |
|
144 |
+
console.log(
|
145 |
+
`[Image] Generating image ${promptIndex + 1}/${imagePrompts.length}:`,
|
146 |
+
{
|
147 |
+
prompt: imagePrompts[promptIndex],
|
148 |
+
dimensions: panelDimensions,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
149 |
}
|
150 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
151 |
|
152 |
+
try {
|
153 |
+
const result = await api.post(
|
154 |
+
`${API_URL}/api/generate-image-direct`,
|
155 |
+
{
|
156 |
+
prompt: imagePrompts[promptIndex],
|
157 |
+
width: panelDimensions.width,
|
158 |
+
height: panelDimensions.height,
|
159 |
+
}
|
160 |
+
);
|
161 |
|
162 |
+
console.log(`[Image] Response for image ${promptIndex + 1}:`, {
|
163 |
+
success: result.data.success,
|
164 |
+
hasImage: !!result.data.image_base64,
|
165 |
+
imageLength: result.data.image_base64?.length,
|
166 |
+
});
|
167 |
|
168 |
+
if (result.data.success) {
|
169 |
+
console.log(
|
170 |
+
`[Image] Image ${promptIndex + 1} generated successfully`
|
171 |
+
);
|
172 |
+
// Mettre à jour les segments locaux
|
173 |
+
const currentImages = [
|
174 |
+
...(localSegments[segmentIndex].images || []),
|
175 |
+
];
|
176 |
+
// Remplacer le null à l'index du prompt par la nouvelle image
|
177 |
+
currentImages[promptIndex] = result.data.image_base64;
|
178 |
+
|
179 |
+
localSegments[segmentIndex] = {
|
180 |
+
...localSegments[segmentIndex],
|
181 |
+
images: currentImages,
|
182 |
+
};
|
183 |
+
console.log("[State] Updating segments with new image:", {
|
184 |
+
segmentIndex,
|
185 |
+
imageIndex: promptIndex,
|
186 |
+
imagesArray: currentImages.map((img) => (img ? "image" : "null")),
|
187 |
+
});
|
188 |
+
// Mettre à jour l'état avec les segments mis à jour
|
189 |
+
setStorySegments([...localSegments]);
|
190 |
+
} else {
|
191 |
+
console.error(
|
192 |
+
`[Image] Generation failed for image ${promptIndex + 1}:`,
|
193 |
+
result.data.error
|
194 |
+
);
|
195 |
+
}
|
196 |
+
} catch (error) {
|
197 |
+
console.error(
|
198 |
+
`[Image] Error generating image ${promptIndex + 1}:`,
|
199 |
+
error
|
200 |
+
);
|
201 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
202 |
}
|
203 |
+
|
204 |
+
console.log(
|
205 |
+
"[generateImagesForStory] Completed. Final segments:",
|
206 |
+
localSegments.map((seg) => ({
|
207 |
+
...seg,
|
208 |
+
images: seg.images?.map((img) => (img ? "image" : "null")),
|
209 |
+
}))
|
210 |
+
);
|
211 |
+
return localSegments[segmentIndex]?.images || [];
|
212 |
} catch (error) {
|
213 |
+
console.error("[generateImagesForStory] Error:", error);
|
214 |
+
return [];
|
215 |
}
|
216 |
};
|
217 |
|
|
|
227 |
}
|
228 |
);
|
229 |
|
230 |
+
// 2. Créer le nouveau segment sans images
|
231 |
const newSegment = {
|
232 |
text: formatTextWithBold(response.data.story_text),
|
233 |
isChoice: false,
|
|
|
236 |
radiationLevel: response.data.radiation_level,
|
237 |
is_first_step: response.data.is_first_step,
|
238 |
is_last_step: response.data.is_last_step,
|
239 |
+
images: response.data.image_prompts
|
240 |
+
? Array(response.data.image_prompts.length).fill(null)
|
241 |
+
: [], // Pré-remplir avec null pour les spinners
|
242 |
};
|
243 |
|
244 |
+
// 3. Calculer le nouvel index et les segments mis à jour
|
245 |
let segmentIndex;
|
246 |
+
let updatedSegments;
|
247 |
+
|
248 |
if (action === "restart") {
|
|
|
249 |
segmentIndex = 0;
|
250 |
+
updatedSegments = [newSegment];
|
251 |
} else {
|
252 |
+
// Récupérer l'état actuel de manière synchrone
|
253 |
+
segmentIndex = storySegments.length;
|
254 |
+
updatedSegments = [...storySegments, newSegment];
|
|
|
255 |
}
|
256 |
|
257 |
+
// Mettre à jour l'état avec les nouveaux segments
|
258 |
+
setStorySegments(updatedSegments);
|
259 |
+
|
260 |
// 4. Mettre à jour les choix immédiatement
|
261 |
setCurrentChoices(response.data.choices);
|
262 |
|
263 |
// 5. Désactiver le loading car l'histoire est affichée
|
264 |
setIsLoading(false);
|
265 |
|
266 |
+
// 6. Générer les images en parallèle
|
267 |
+
if (
|
268 |
+
response.data.image_prompts &&
|
269 |
+
response.data.image_prompts.length > 0
|
270 |
+
) {
|
271 |
+
try {
|
272 |
+
console.log(
|
273 |
+
"Starting image generation with prompts:",
|
274 |
+
response.data.image_prompts,
|
275 |
+
"for segment",
|
276 |
+
segmentIndex
|
277 |
+
);
|
278 |
+
// generateImagesForStory met déjà à jour le state au fur et à mesure
|
279 |
+
await generateImagesForStory(
|
280 |
+
response.data.image_prompts,
|
281 |
+
segmentIndex,
|
282 |
+
updatedSegments
|
283 |
+
);
|
284 |
+
} catch (imageError) {
|
285 |
+
console.error("Error generating images:", imageError);
|
286 |
}
|
|
|
|
|
287 |
}
|
288 |
} catch (error) {
|
289 |
console.error("Error:", error);
|
|
|
297 |
storySegments.length > 0
|
298 |
? storySegments[storySegments.length - 1].radiationLevel
|
299 |
: 0,
|
300 |
+
images: [],
|
301 |
};
|
302 |
|
303 |
// Ajouter le segment d'erreur et permettre de réessayer
|
|
|
314 |
}
|
315 |
};
|
316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
317 |
const handleChoice = async (choiceId) => {
|
318 |
// Si c'est l'option "Réessayer", on relance la dernière action
|
319 |
if (currentChoices.length === 1 && currentChoices[0].text === "Réessayer") {
|
client/src/components/StoryManager.jsx
ADDED
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect } from "react";
|
2 |
+
import { useComic } from "../context/ComicContext";
|
3 |
+
import { useImageGeneration } from "../hooks/useImageGeneration";
|
4 |
+
import { groupSegmentsIntoLayouts } from "../layouts/utils";
|
5 |
+
import { LAYOUTS } from "../layouts/config";
|
6 |
+
import axios from "axios";
|
7 |
+
|
8 |
+
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
9 |
+
|
10 |
+
// Create axios instance with default config
|
11 |
+
const api = axios.create({
|
12 |
+
headers: {
|
13 |
+
"x-client-id": `client_${Math.random().toString(36).substring(2)}`,
|
14 |
+
},
|
15 |
+
});
|
16 |
+
|
17 |
+
// Function to convert text with ** to bold elements
|
18 |
+
const formatTextWithBold = (text) => {
|
19 |
+
if (!text) return "";
|
20 |
+
const parts = text.split(/(\*\*.*?\*\*)/g);
|
21 |
+
return parts.map((part, index) => {
|
22 |
+
if (part.startsWith("**") && part.endsWith("**")) {
|
23 |
+
return <strong key={index}>{part.slice(2, -2)}</strong>;
|
24 |
+
}
|
25 |
+
return part;
|
26 |
+
});
|
27 |
+
};
|
28 |
+
|
29 |
+
export function StoryManager() {
|
30 |
+
const { state, updateSegments, updateSegment, setChoices, setLoading } =
|
31 |
+
useComic();
|
32 |
+
const { generateImagesForSegment } = useImageGeneration();
|
33 |
+
|
34 |
+
const handleStoryAction = async (action, choiceId = null) => {
|
35 |
+
setLoading(true);
|
36 |
+
try {
|
37 |
+
// 1. Obtenir l'histoire
|
38 |
+
const response = await api.post(`${API_URL}/api/chat`, {
|
39 |
+
message: action,
|
40 |
+
choice_id: choiceId,
|
41 |
+
});
|
42 |
+
|
43 |
+
// 2. Créer le nouveau segment
|
44 |
+
const newSegment = {
|
45 |
+
text: formatTextWithBold(response.data.story_text),
|
46 |
+
isChoice: false,
|
47 |
+
isDeath: response.data.is_death,
|
48 |
+
isVictory: response.data.is_victory,
|
49 |
+
radiationLevel: response.data.radiation_level,
|
50 |
+
is_first_step: response.data.is_first_step,
|
51 |
+
is_last_step: response.data.is_last_step,
|
52 |
+
images: response.data.image_prompts
|
53 |
+
? Array(response.data.image_prompts.length).fill(null)
|
54 |
+
: [],
|
55 |
+
};
|
56 |
+
|
57 |
+
// 3. Mettre à jour les segments
|
58 |
+
const segmentIndex = action === "restart" ? 0 : state.segments.length;
|
59 |
+
const updatedSegments =
|
60 |
+
action === "restart" ? [newSegment] : [...state.segments, newSegment];
|
61 |
+
|
62 |
+
updateSegments(updatedSegments);
|
63 |
+
|
64 |
+
// 4. Mettre à jour les choix
|
65 |
+
setChoices(response.data.choices);
|
66 |
+
setLoading(false);
|
67 |
+
|
68 |
+
// 5. Générer les images si nécessaire
|
69 |
+
if (response.data.image_prompts?.length > 0) {
|
70 |
+
const prompts = response.data.image_prompts;
|
71 |
+
let currentPromptIndex = 0;
|
72 |
+
let currentSegmentIndex = segmentIndex;
|
73 |
+
|
74 |
+
while (currentPromptIndex < prompts.length) {
|
75 |
+
// Recalculer les layouts avec les segments actuels
|
76 |
+
const layouts = groupSegmentsIntoLayouts(updatedSegments);
|
77 |
+
let currentLayout = layouts[layouts.length - 1];
|
78 |
+
|
79 |
+
// Pour un layout COVER, ne prendre que le premier prompt
|
80 |
+
if (currentLayout.type === "COVER") {
|
81 |
+
const promptsToUse = [prompts[0]];
|
82 |
+
console.log("COVER layout: using only first prompt");
|
83 |
+
|
84 |
+
const images = await generateImagesForSegment(
|
85 |
+
promptsToUse,
|
86 |
+
currentLayout
|
87 |
+
);
|
88 |
+
|
89 |
+
if (images && images.length > 0) {
|
90 |
+
const currentSegment = updatedSegments[currentSegmentIndex];
|
91 |
+
const updatedSegment = {
|
92 |
+
...currentSegment,
|
93 |
+
images: [images[0]], // Ne garder que la première image
|
94 |
+
};
|
95 |
+
updatedSegments[currentSegmentIndex] = updatedSegment;
|
96 |
+
updateSegments(updatedSegments);
|
97 |
+
}
|
98 |
+
break; // Sortir de la boucle car nous n'avons besoin que d'une image
|
99 |
+
}
|
100 |
+
|
101 |
+
// Pour les autres layouts, continuer normalement
|
102 |
+
const remainingPanels =
|
103 |
+
LAYOUTS[currentLayout.type].panels.length -
|
104 |
+
(currentLayout.segments[currentLayout.segments.length - 1].images
|
105 |
+
?.length || 0);
|
106 |
+
|
107 |
+
if (remainingPanels === 0) {
|
108 |
+
// Créer un nouveau segment pour la nouvelle page
|
109 |
+
const newPageSegment = {
|
110 |
+
...newSegment,
|
111 |
+
images: Array(prompts.length - currentPromptIndex).fill(null),
|
112 |
+
};
|
113 |
+
updatedSegments.push(newPageSegment);
|
114 |
+
currentSegmentIndex = updatedSegments.length - 1;
|
115 |
+
updateSegments(updatedSegments);
|
116 |
+
continue;
|
117 |
+
}
|
118 |
+
|
119 |
+
// Générer les images pour ce layout
|
120 |
+
const promptsForCurrentLayout = prompts.slice(
|
121 |
+
currentPromptIndex,
|
122 |
+
currentPromptIndex + remainingPanels
|
123 |
+
);
|
124 |
+
|
125 |
+
console.log("Generating images for layout:", {
|
126 |
+
segmentIndex: currentSegmentIndex,
|
127 |
+
layoutType: currentLayout.type,
|
128 |
+
prompts: promptsForCurrentLayout,
|
129 |
+
remainingPanels,
|
130 |
+
});
|
131 |
+
|
132 |
+
// Générer les images
|
133 |
+
const images = await generateImagesForSegment(
|
134 |
+
promptsForCurrentLayout,
|
135 |
+
currentLayout
|
136 |
+
);
|
137 |
+
|
138 |
+
// Mettre à jour le segment avec les nouvelles images
|
139 |
+
if (images && images.length > 0) {
|
140 |
+
const currentSegment = updatedSegments[currentSegmentIndex];
|
141 |
+
const updatedSegment = {
|
142 |
+
...currentSegment,
|
143 |
+
images: [...(currentSegment.images || []), ...images],
|
144 |
+
};
|
145 |
+
updatedSegments[currentSegmentIndex] = updatedSegment;
|
146 |
+
updateSegments(updatedSegments);
|
147 |
+
}
|
148 |
+
|
149 |
+
currentPromptIndex += promptsForCurrentLayout.length;
|
150 |
+
}
|
151 |
+
}
|
152 |
+
} catch (error) {
|
153 |
+
console.error("Error:", error);
|
154 |
+
const errorSegment = {
|
155 |
+
text: "Le conteur d'histoires est temporairement indisponible. Veuillez réessayer dans quelques instants...",
|
156 |
+
isChoice: false,
|
157 |
+
isDeath: false,
|
158 |
+
isVictory: false,
|
159 |
+
radiationLevel:
|
160 |
+
state.segments.length > 0
|
161 |
+
? state.segments[state.segments.length - 1].radiationLevel
|
162 |
+
: 0,
|
163 |
+
images: [],
|
164 |
+
};
|
165 |
+
|
166 |
+
updateSegments(
|
167 |
+
action === "restart"
|
168 |
+
? [errorSegment]
|
169 |
+
: [...state.segments, errorSegment]
|
170 |
+
);
|
171 |
+
setChoices([{ id: 1, text: "Réessayer" }]);
|
172 |
+
setLoading(false);
|
173 |
+
}
|
174 |
+
};
|
175 |
+
|
176 |
+
// Démarrer l'histoire au montage
|
177 |
+
useEffect(() => {
|
178 |
+
handleStoryAction("restart");
|
179 |
+
}, []);
|
180 |
+
|
181 |
+
return null; // Ce composant ne rend rien, il gère juste la logique
|
182 |
+
}
|
client/src/context/ComicContext.jsx
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createContext, useContext, useReducer } from "react";
|
2 |
+
import { groupSegmentsIntoLayouts } from "../layouts/utils";
|
3 |
+
|
4 |
+
const ComicContext = createContext();
|
5 |
+
|
6 |
+
const initialState = {
|
7 |
+
segments: [],
|
8 |
+
currentChoices: [],
|
9 |
+
isLoading: false,
|
10 |
+
pendingImages: new Set(),
|
11 |
+
};
|
12 |
+
|
13 |
+
function comicReducer(state, action) {
|
14 |
+
switch (action.type) {
|
15 |
+
case "UPDATE_SEGMENTS":
|
16 |
+
return {
|
17 |
+
...state,
|
18 |
+
segments: action.payload,
|
19 |
+
};
|
20 |
+
|
21 |
+
case "UPDATE_SEGMENT":
|
22 |
+
return {
|
23 |
+
...state,
|
24 |
+
segments: state.segments.map((segment, index) =>
|
25 |
+
index === action.payload.index ? action.payload.segment : segment
|
26 |
+
),
|
27 |
+
};
|
28 |
+
|
29 |
+
case "SET_CHOICES":
|
30 |
+
return {
|
31 |
+
...state,
|
32 |
+
currentChoices: action.payload,
|
33 |
+
};
|
34 |
+
|
35 |
+
case "SET_LOADING":
|
36 |
+
return {
|
37 |
+
...state,
|
38 |
+
isLoading: action.payload,
|
39 |
+
};
|
40 |
+
|
41 |
+
default:
|
42 |
+
return state;
|
43 |
+
}
|
44 |
+
}
|
45 |
+
|
46 |
+
export function ComicProvider({ children }) {
|
47 |
+
const [state, dispatch] = useReducer(comicReducer, initialState);
|
48 |
+
|
49 |
+
const updateSegments = (segments) => {
|
50 |
+
dispatch({ type: "UPDATE_SEGMENTS", payload: segments });
|
51 |
+
};
|
52 |
+
|
53 |
+
const updateSegment = (index, segment) => {
|
54 |
+
dispatch({ type: "UPDATE_SEGMENT", payload: { index, segment } });
|
55 |
+
};
|
56 |
+
|
57 |
+
const setChoices = (choices) => {
|
58 |
+
dispatch({ type: "SET_CHOICES", payload: choices });
|
59 |
+
};
|
60 |
+
|
61 |
+
const setLoading = (isLoading) => {
|
62 |
+
dispatch({ type: "SET_LOADING", payload: isLoading });
|
63 |
+
};
|
64 |
+
|
65 |
+
// Calculer les layouts à partir des segments
|
66 |
+
const layouts = groupSegmentsIntoLayouts(state.segments);
|
67 |
+
|
68 |
+
const value = {
|
69 |
+
state,
|
70 |
+
layouts,
|
71 |
+
updateSegments,
|
72 |
+
updateSegment,
|
73 |
+
setChoices,
|
74 |
+
setLoading,
|
75 |
+
};
|
76 |
+
|
77 |
+
return (
|
78 |
+
<ComicContext.Provider value={value}>{children}</ComicContext.Provider>
|
79 |
+
);
|
80 |
+
}
|
81 |
+
|
82 |
+
export const useComic = () => {
|
83 |
+
const context = useContext(ComicContext);
|
84 |
+
if (!context) {
|
85 |
+
throw new Error("useComic must be used within a ComicProvider");
|
86 |
+
}
|
87 |
+
return context;
|
88 |
+
};
|
client/src/hooks/useImageGeneration.js
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import axios from "axios";
|
2 |
+
|
3 |
+
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
4 |
+
|
5 |
+
// Create axios instance with default config
|
6 |
+
const api = axios.create({
|
7 |
+
headers: {
|
8 |
+
"x-client-id": `client_${Math.random().toString(36).substring(2)}`,
|
9 |
+
},
|
10 |
+
});
|
11 |
+
|
12 |
+
export function useImageGeneration() {
|
13 |
+
const generateImage = async (prompt, dimensions) => {
|
14 |
+
try {
|
15 |
+
console.log("Generating image with dimensions:", dimensions);
|
16 |
+
|
17 |
+
const result = await api.post(`${API_URL}/api/generate-image-direct`, {
|
18 |
+
prompt,
|
19 |
+
width: dimensions.width,
|
20 |
+
height: dimensions.height,
|
21 |
+
});
|
22 |
+
|
23 |
+
if (result.data.success) {
|
24 |
+
return result.data.image_base64;
|
25 |
+
}
|
26 |
+
return null;
|
27 |
+
} catch (error) {
|
28 |
+
console.error("Error generating image:", error);
|
29 |
+
return null;
|
30 |
+
}
|
31 |
+
};
|
32 |
+
|
33 |
+
const generateImagesForSegment = async (prompts, currentLayout) => {
|
34 |
+
try {
|
35 |
+
if (!currentLayout) {
|
36 |
+
console.error("No valid layout found");
|
37 |
+
return null;
|
38 |
+
}
|
39 |
+
|
40 |
+
const layoutType = currentLayout.type;
|
41 |
+
console.log("Generating images for layout type:", layoutType);
|
42 |
+
|
43 |
+
// Pour chaque prompt, générer une image avec les dimensions appropriées
|
44 |
+
const results = [];
|
45 |
+
for (let i = 0; i < prompts.length; i++) {
|
46 |
+
const panelDimensions = currentLayout.panels[i];
|
47 |
+
if (!panelDimensions) {
|
48 |
+
console.error(`No dimensions for panel ${i} in layout ${layoutType}`);
|
49 |
+
continue;
|
50 |
+
}
|
51 |
+
|
52 |
+
const image = await generateImage(prompts[i], panelDimensions);
|
53 |
+
if (image) {
|
54 |
+
results.push(image);
|
55 |
+
}
|
56 |
+
}
|
57 |
+
return results;
|
58 |
+
} catch (error) {
|
59 |
+
console.error("Error in generateImagesForSegment:", error);
|
60 |
+
return [];
|
61 |
+
}
|
62 |
+
};
|
63 |
+
|
64 |
+
return { generateImagesForSegment };
|
65 |
+
}
|
client/src/layouts/ComicLayout.jsx
CHANGED
@@ -1,88 +1,16 @@
|
|
1 |
-
import { Box
|
2 |
import { LAYOUTS } from "./config";
|
3 |
import { groupSegmentsIntoLayouts } from "./utils";
|
4 |
import { useEffect, useRef } from "react";
|
5 |
-
|
6 |
-
// Component for displaying a single panel
|
7 |
-
function Panel({ segment, panel }) {
|
8 |
-
return (
|
9 |
-
<Box
|
10 |
-
sx={{
|
11 |
-
position: "relative",
|
12 |
-
width: "100%",
|
13 |
-
height: "100%",
|
14 |
-
gridColumn: panel.gridColumn,
|
15 |
-
gridRow: panel.gridRow,
|
16 |
-
bgcolor: "white",
|
17 |
-
border: "1px solid",
|
18 |
-
borderColor: "grey.200",
|
19 |
-
borderRadius: "8px",
|
20 |
-
overflow: "hidden",
|
21 |
-
}}
|
22 |
-
>
|
23 |
-
{segment ? (
|
24 |
-
<>
|
25 |
-
{segment.image_base64 ? (
|
26 |
-
<img
|
27 |
-
src={`data:image/jpeg;base64,${segment.image_base64}`}
|
28 |
-
alt="Story scene"
|
29 |
-
style={{
|
30 |
-
width: "100%",
|
31 |
-
height: "100%",
|
32 |
-
objectFit: "cover",
|
33 |
-
borderRadius: "8px",
|
34 |
-
opacity: 0,
|
35 |
-
transition: "opacity 0.5s ease-in-out",
|
36 |
-
}}
|
37 |
-
onLoad={(e) => {
|
38 |
-
e.target.style.opacity = "1";
|
39 |
-
}}
|
40 |
-
/>
|
41 |
-
) : (
|
42 |
-
<Box
|
43 |
-
sx={{
|
44 |
-
width: "100%",
|
45 |
-
height: "100%",
|
46 |
-
display: "flex",
|
47 |
-
alignItems: "center",
|
48 |
-
justifyContent: "center",
|
49 |
-
flexDirection: "column",
|
50 |
-
gap: 1,
|
51 |
-
}}
|
52 |
-
>
|
53 |
-
{!segment.imageRequestCancelled && (
|
54 |
-
<CircularProgress sx={{ opacity: 0.3 }} />
|
55 |
-
)}
|
56 |
-
{segment.imageRequestCancelled && (
|
57 |
-
<Typography variant="caption" color="text.secondary">
|
58 |
-
Image non chargée
|
59 |
-
</Typography>
|
60 |
-
)}
|
61 |
-
</Box>
|
62 |
-
)}
|
63 |
-
<Box
|
64 |
-
sx={{
|
65 |
-
position: "absolute",
|
66 |
-
bottom: "20px",
|
67 |
-
left: "20px",
|
68 |
-
right: "20px",
|
69 |
-
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
70 |
-
fontSize: ".9rem",
|
71 |
-
padding: "24px",
|
72 |
-
borderRadius: "8px",
|
73 |
-
boxShadow: "0 -2px 4px rgba(0,0,0,0.1)",
|
74 |
-
}}
|
75 |
-
>
|
76 |
-
{segment.text}
|
77 |
-
</Box>
|
78 |
-
</>
|
79 |
-
) : null}
|
80 |
-
</Box>
|
81 |
-
);
|
82 |
-
}
|
83 |
|
84 |
// Component for displaying a page of panels
|
85 |
function ComicPage({ layout, layoutIndex }) {
|
|
|
|
|
|
|
|
|
|
|
86 |
return (
|
87 |
<Box
|
88 |
key={layoutIndex}
|
@@ -100,13 +28,33 @@ function ComicPage({ layout, layoutIndex }) {
|
|
100 |
flexShrink: 0,
|
101 |
}}
|
102 |
>
|
103 |
-
{LAYOUTS[layout.type].panels
|
104 |
-
|
105 |
-
|
106 |
-
panel
|
107 |
-
|
108 |
-
|
109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
110 |
</Box>
|
111 |
);
|
112 |
}
|
|
|
1 |
+
import { Box } from "@mui/material";
|
2 |
import { LAYOUTS } from "./config";
|
3 |
import { groupSegmentsIntoLayouts } from "./utils";
|
4 |
import { useEffect, useRef } from "react";
|
5 |
+
import { Panel } from "./Panel";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
|
7 |
// Component for displaying a page of panels
|
8 |
function ComicPage({ layout, layoutIndex }) {
|
9 |
+
// Calculer le nombre total d'images dans tous les segments de ce layout
|
10 |
+
const totalImages = layout.segments.reduce((total, segment) => {
|
11 |
+
return total + (segment.images?.length || 0);
|
12 |
+
}, 0);
|
13 |
+
|
14 |
return (
|
15 |
<Box
|
16 |
key={layoutIndex}
|
|
|
28 |
flexShrink: 0,
|
29 |
}}
|
30 |
>
|
31 |
+
{LAYOUTS[layout.type].panels
|
32 |
+
.slice(0, totalImages)
|
33 |
+
.map((panel, panelIndex) => {
|
34 |
+
// Trouver le segment qui contient l'image pour ce panel
|
35 |
+
let currentImageIndex = 0;
|
36 |
+
let targetSegment = null;
|
37 |
+
let targetImageIndex = 0;
|
38 |
+
|
39 |
+
for (const segment of layout.segments) {
|
40 |
+
const segmentImageCount = segment.images?.length || 0;
|
41 |
+
if (currentImageIndex + segmentImageCount > panelIndex) {
|
42 |
+
targetSegment = segment;
|
43 |
+
targetImageIndex = panelIndex - currentImageIndex;
|
44 |
+
break;
|
45 |
+
}
|
46 |
+
currentImageIndex += segmentImageCount;
|
47 |
+
}
|
48 |
+
|
49 |
+
return (
|
50 |
+
<Panel
|
51 |
+
key={panelIndex}
|
52 |
+
panel={panel}
|
53 |
+
segment={targetSegment}
|
54 |
+
panelIndex={targetImageIndex}
|
55 |
+
/>
|
56 |
+
);
|
57 |
+
})}
|
58 |
</Box>
|
59 |
);
|
60 |
}
|
client/src/layouts/Panel.jsx
ADDED
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Box, CircularProgress, Typography } from "@mui/material";
|
2 |
+
import { useEffect, useState } from "react";
|
3 |
+
|
4 |
+
// Component for displaying a single panel
|
5 |
+
export function Panel({ segment, panel, panelIndex }) {
|
6 |
+
const [imageLoaded, setImageLoaded] = useState(false);
|
7 |
+
const [isLoading, setIsLoading] = useState(true);
|
8 |
+
|
9 |
+
// Reset states when the image changes
|
10 |
+
useEffect(() => {
|
11 |
+
const hasImage = !!segment?.images?.[panelIndex];
|
12 |
+
console.log(`[Panel ${panelIndex}] Image changed:`, {
|
13 |
+
hasSegment: !!segment,
|
14 |
+
hasImage,
|
15 |
+
imageContent: segment?.images?.[panelIndex]?.slice(0, 50),
|
16 |
+
});
|
17 |
+
|
18 |
+
// Ne réinitialiser les états que si on n'a pas d'image
|
19 |
+
if (!hasImage) {
|
20 |
+
setImageLoaded(false);
|
21 |
+
setIsLoading(true);
|
22 |
+
}
|
23 |
+
}, [segment?.images?.[panelIndex]]);
|
24 |
+
|
25 |
+
// Log component state changes
|
26 |
+
useEffect(() => {
|
27 |
+
console.log(`[Panel ${panelIndex}] State updated:`, {
|
28 |
+
imageLoaded,
|
29 |
+
isLoading,
|
30 |
+
hasSegment: !!segment,
|
31 |
+
hasImage: !!segment?.images?.[panelIndex],
|
32 |
+
});
|
33 |
+
}, [imageLoaded, isLoading, segment, panelIndex]);
|
34 |
+
|
35 |
+
const handleImageLoad = () => {
|
36 |
+
console.log(`[Panel ${panelIndex}] Image loaded successfully`);
|
37 |
+
setImageLoaded(true);
|
38 |
+
setIsLoading(false);
|
39 |
+
};
|
40 |
+
|
41 |
+
const handleImageError = (error) => {
|
42 |
+
console.error(`[Panel ${panelIndex}] Image loading error:`, error);
|
43 |
+
setIsLoading(false);
|
44 |
+
};
|
45 |
+
|
46 |
+
return (
|
47 |
+
<Box
|
48 |
+
sx={{
|
49 |
+
position: "relative",
|
50 |
+
width: "100%",
|
51 |
+
height: "100%",
|
52 |
+
gridColumn: panel.gridColumn,
|
53 |
+
gridRow: panel.gridRow,
|
54 |
+
bgcolor: "white",
|
55 |
+
border: "1px solid",
|
56 |
+
borderColor: "grey.200",
|
57 |
+
borderRadius: "8px",
|
58 |
+
overflow: "hidden",
|
59 |
+
transition: "all 0.3s ease-in-out",
|
60 |
+
}}
|
61 |
+
>
|
62 |
+
{segment && (
|
63 |
+
<>
|
64 |
+
{/* Image avec fade in */}
|
65 |
+
{segment.images?.[panelIndex] && (
|
66 |
+
<Box
|
67 |
+
sx={{
|
68 |
+
position: "relative",
|
69 |
+
width: "100%",
|
70 |
+
height: "100%",
|
71 |
+
opacity: imageLoaded ? 1 : 0,
|
72 |
+
transition: "opacity 0.5s ease-in-out",
|
73 |
+
}}
|
74 |
+
>
|
75 |
+
<img
|
76 |
+
src={`data:image/jpeg;base64,${segment.images[panelIndex]}`}
|
77 |
+
alt={`Story scene ${panelIndex + 1}`}
|
78 |
+
style={{
|
79 |
+
width: "100%",
|
80 |
+
height: "100%",
|
81 |
+
objectFit: "cover",
|
82 |
+
borderRadius: "8px",
|
83 |
+
}}
|
84 |
+
onLoad={handleImageLoad}
|
85 |
+
onError={handleImageError}
|
86 |
+
/>
|
87 |
+
</Box>
|
88 |
+
)}
|
89 |
+
|
90 |
+
{/* Spinner de chargement toujours affiché quand l'image n'est pas chargée */}
|
91 |
+
{(!segment.images?.[panelIndex] || !imageLoaded) && (
|
92 |
+
<Box
|
93 |
+
sx={{
|
94 |
+
position: "absolute",
|
95 |
+
top: 0,
|
96 |
+
left: 0,
|
97 |
+
width: "100%",
|
98 |
+
height: "100%",
|
99 |
+
display: "flex",
|
100 |
+
alignItems: "center",
|
101 |
+
justifyContent: "center",
|
102 |
+
flexDirection: "column",
|
103 |
+
gap: 1,
|
104 |
+
opacity: 0.7,
|
105 |
+
backgroundColor: "white",
|
106 |
+
zIndex: 1,
|
107 |
+
}}
|
108 |
+
>
|
109 |
+
<CircularProgress size={30} />
|
110 |
+
<Typography variant="caption" color="text.secondary">
|
111 |
+
{!segment.images?.[panelIndex]
|
112 |
+
? "Génération en cours..."
|
113 |
+
: "Chargement de l'image..."}
|
114 |
+
</Typography>
|
115 |
+
</Box>
|
116 |
+
)}
|
117 |
+
|
118 |
+
{/* Texte du segment (uniquement sur le premier panel) */}
|
119 |
+
{panelIndex === 0 && segment.text && (
|
120 |
+
<Box
|
121 |
+
sx={{
|
122 |
+
position: "absolute",
|
123 |
+
bottom: "20px",
|
124 |
+
left: "20px",
|
125 |
+
right: "20px",
|
126 |
+
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
127 |
+
fontSize: ".9rem",
|
128 |
+
padding: "24px",
|
129 |
+
borderRadius: "8px",
|
130 |
+
boxShadow: "0 -2px 4px rgba(0,0,0,0.1)",
|
131 |
+
zIndex: 2,
|
132 |
+
}}
|
133 |
+
>
|
134 |
+
{segment.text}
|
135 |
+
</Box>
|
136 |
+
)}
|
137 |
+
</>
|
138 |
+
)}
|
139 |
+
</Box>
|
140 |
+
);
|
141 |
+
}
|
client/src/layouts/config.js
CHANGED
@@ -42,77 +42,101 @@ export const LAYOUTS = {
|
|
42 |
gridCols: 1,
|
43 |
gridRows: 1,
|
44 |
panels: [
|
45 |
-
{ width:
|
46 |
],
|
47 |
},
|
48 |
LAYOUT_1: {
|
49 |
gridCols: 2,
|
50 |
gridRows: 2,
|
51 |
panels: [
|
52 |
-
{ width: 1024, height: 768, gridColumn: "1", gridRow: "1" }, //
|
53 |
-
{ width: 768, height: 1024, gridColumn: "2", gridRow: "1" }, //
|
54 |
-
{ width: 1024, height: 768, gridColumn: "1", gridRow: "2" }, //
|
55 |
-
{ width: 768, height: 1024, gridColumn: "2", gridRow: "2" }, //
|
56 |
],
|
57 |
},
|
58 |
LAYOUT_2: {
|
59 |
gridCols: 3,
|
60 |
gridRows: 2,
|
61 |
panels: [
|
62 |
-
{ width: 1024, height: 1024, gridColumn: "1 / span 2", gridRow: "1" }, //
|
63 |
-
{ width: 512, height: 1024, gridColumn: "3", gridRow: "1" }, //
|
64 |
-
{ width: 1024, height: 768, gridColumn: "1 / span 3", gridRow: "2" }, //
|
65 |
],
|
66 |
},
|
67 |
LAYOUT_3: {
|
68 |
gridCols: 3,
|
69 |
gridRows: 2,
|
70 |
panels: [
|
71 |
-
{ width: 1024, height: 768, gridColumn: "1 / span 2", gridRow: "1" }, //
|
72 |
-
{ width: 512, height: 1024, gridColumn: "3", gridRow: "1" }, //
|
73 |
-
{ width: 512, height: 1024, gridColumn: "1", gridRow: "2" }, //
|
74 |
-
{ width: 1024, height: 768, gridColumn: "2 / span 2", gridRow: "2" }, //
|
75 |
],
|
76 |
},
|
77 |
LAYOUT_4: {
|
78 |
-
gridCols:
|
79 |
-
gridRows:
|
80 |
panels: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
81 |
{
|
82 |
-
width:
|
83 |
-
height: 768,
|
84 |
-
gridColumn: "1 / span 3",
|
85 |
-
gridRow: "1 / span 3",
|
86 |
-
}, // 1. Square top left
|
87 |
-
{
|
88 |
-
width: 768,
|
89 |
-
height: 1024,
|
90 |
-
gridColumn: "1 / span 3",
|
91 |
-
gridRow: "4 / span 5",
|
92 |
-
}, // 2. Long portrait bottom left
|
93 |
-
{
|
94 |
-
width: 768,
|
95 |
height: 1024,
|
96 |
-
gridColumn: "
|
97 |
-
gridRow: "
|
98 |
-
}, //
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
|
|
|
|
|
|
|
|
105 |
],
|
106 |
},
|
107 |
};
|
108 |
|
109 |
export const defaultLayout = "LAYOUT_1";
|
110 |
export const nonRandomLayouts = Object.keys(LAYOUTS).filter(
|
111 |
-
(layout) => layout !== "
|
112 |
);
|
113 |
|
114 |
// Helper functions for layout configuration
|
115 |
-
export const getNextLayoutType = (currentLayoutCount) =>
|
116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
117 |
export const getLayoutDimensions = (layoutType, panelIndex) =>
|
118 |
LAYOUTS[layoutType]?.panels[panelIndex];
|
|
|
42 |
gridCols: 1,
|
43 |
gridRows: 1,
|
44 |
panels: [
|
45 |
+
{ width: 512, height: 1024, gridColumn: "1", gridRow: "1" }, // Format portrait
|
46 |
],
|
47 |
},
|
48 |
LAYOUT_1: {
|
49 |
gridCols: 2,
|
50 |
gridRows: 2,
|
51 |
panels: [
|
52 |
+
{ width: 1024, height: 768, gridColumn: "1", gridRow: "1" }, // Landscape top left
|
53 |
+
{ width: 768, height: 1024, gridColumn: "2", gridRow: "1" }, // Portrait top right
|
54 |
+
{ width: 1024, height: 768, gridColumn: "1", gridRow: "2" }, // Landscape middle left
|
55 |
+
{ width: 768, height: 1024, gridColumn: "2", gridRow: "2" }, // Portrait right
|
56 |
],
|
57 |
},
|
58 |
LAYOUT_2: {
|
59 |
gridCols: 3,
|
60 |
gridRows: 2,
|
61 |
panels: [
|
62 |
+
{ width: 1024, height: 1024, gridColumn: "1 / span 2", gridRow: "1" }, // Large square top left
|
63 |
+
{ width: 512, height: 1024, gridColumn: "3", gridRow: "1" }, // Portrait top right
|
64 |
+
{ width: 1024, height: 768, gridColumn: "1 / span 3", gridRow: "2" }, // Full width landscape bottom
|
65 |
],
|
66 |
},
|
67 |
LAYOUT_3: {
|
68 |
gridCols: 3,
|
69 |
gridRows: 2,
|
70 |
panels: [
|
71 |
+
{ width: 1024, height: 768, gridColumn: "1 / span 2", gridRow: "1" }, // Wide landscape top left
|
72 |
+
{ width: 512, height: 1024, gridColumn: "3", gridRow: "1" }, // Portrait top right
|
73 |
+
{ width: 512, height: 1024, gridColumn: "1", gridRow: "2" }, // Portrait bottom left
|
74 |
+
{ width: 1024, height: 768, gridColumn: "2 / span 2", gridRow: "2" }, // Wide landscape bottom right
|
75 |
],
|
76 |
},
|
77 |
LAYOUT_4: {
|
78 |
+
gridCols: 2,
|
79 |
+
gridRows: 3,
|
80 |
panels: [
|
81 |
+
{ width: 1024, height: 512, gridColumn: "1 / span 2", gridRow: "1" }, // Wide panoramic top
|
82 |
+
{ width: 512, height: 1024, gridColumn: "1", gridRow: "2 / span 2" }, // Tall portrait left
|
83 |
+
{ width: 512, height: 512, gridColumn: "2", gridRow: "2" }, // Square middle right
|
84 |
+
{ width: 512, height: 512, gridColumn: "2", gridRow: "3" }, // Square bottom right
|
85 |
+
],
|
86 |
+
},
|
87 |
+
LAYOUT_5: {
|
88 |
+
gridCols: 3,
|
89 |
+
gridRows: 3,
|
90 |
+
panels: [
|
91 |
+
{ width: 1024, height: 512, gridColumn: "1 / span 3", gridRow: "1" }, // Wide panoramic top
|
92 |
+
{ width: 512, height: 1024, gridColumn: "1", gridRow: "2 / span 2" }, // Tall portrait left
|
93 |
{
|
94 |
+
width: 1024,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
95 |
height: 1024,
|
96 |
+
gridColumn: "2 / span 2",
|
97 |
+
gridRow: "2 / span 2",
|
98 |
+
}, // Large square right
|
99 |
+
],
|
100 |
+
},
|
101 |
+
LAYOUT_6: {
|
102 |
+
gridCols: 3,
|
103 |
+
gridRows: 2,
|
104 |
+
panels: [
|
105 |
+
{ width: 512, height: 1024, gridColumn: "1", gridRow: "1 / span 2" }, // Tall portrait left
|
106 |
+
{ width: 512, height: 512, gridColumn: "2", gridRow: "1" }, // Square top middle
|
107 |
+
{ width: 512, height: 1024, gridColumn: "3", gridRow: "1 / span 2" }, // Tall portrait right
|
108 |
+
{ width: 512, height: 512, gridColumn: "2", gridRow: "2" }, // Square bottom middle
|
109 |
],
|
110 |
},
|
111 |
};
|
112 |
|
113 |
export const defaultLayout = "LAYOUT_1";
|
114 |
export const nonRandomLayouts = Object.keys(LAYOUTS).filter(
|
115 |
+
(layout) => layout !== "COVER"
|
116 |
);
|
117 |
|
118 |
// Helper functions for layout configuration
|
119 |
+
export const getNextLayoutType = (currentLayoutCount) => {
|
120 |
+
// Get all available layouts except COVER
|
121 |
+
const availableLayouts = Object.keys(LAYOUTS).filter(
|
122 |
+
(layout) => layout !== "COVER"
|
123 |
+
);
|
124 |
+
|
125 |
+
// Use a pseudo-random selection based on the current count
|
126 |
+
// but avoid repeating the same layout twice in a row
|
127 |
+
const previousLayout = `LAYOUT_${
|
128 |
+
(currentLayoutCount % availableLayouts.length) + 1
|
129 |
+
}`;
|
130 |
+
let nextLayout;
|
131 |
+
|
132 |
+
do {
|
133 |
+
const randomIndex =
|
134 |
+
Math.floor(Math.random() * (availableLayouts.length - 1)) + 1;
|
135 |
+
nextLayout = `LAYOUT_${randomIndex}`;
|
136 |
+
} while (nextLayout === previousLayout);
|
137 |
+
|
138 |
+
return nextLayout;
|
139 |
+
};
|
140 |
+
|
141 |
export const getLayoutDimensions = (layoutType, panelIndex) =>
|
142 |
LAYOUTS[layoutType]?.panels[panelIndex];
|
client/src/layouts/utils.js
CHANGED
@@ -1,52 +1,50 @@
|
|
1 |
import { LAYOUTS, getNextLayoutType } from "./config";
|
2 |
|
|
|
|
|
|
|
3 |
// Function to group segments into layouts
|
4 |
export function groupSegmentsIntoLayouts(segments) {
|
5 |
-
if (segments.length === 0) return [];
|
6 |
|
7 |
const layouts = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
type
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
let currentIndex = 0;
|
21 |
-
|
22 |
-
while (currentIndex < middleSegments.length) {
|
23 |
-
const segment = middleSegments[currentIndex];
|
24 |
-
|
25 |
-
// Si c'est le dernier segment (mort ou victoire), on le met en COVER
|
26 |
-
if (segment.is_last_step) {
|
27 |
-
layouts.push({
|
28 |
-
type: "COVER",
|
29 |
-
segments: [segment],
|
30 |
-
});
|
31 |
-
} else {
|
32 |
-
// Sinon on utilise un layout normal
|
33 |
-
const layoutType = getNextLayoutType(layouts.length);
|
34 |
-
const maxPanels = LAYOUTS[layoutType].panels.length;
|
35 |
-
const availableSegments = middleSegments
|
36 |
-
.slice(currentIndex)
|
37 |
-
.filter((s) => !s.is_last_step);
|
38 |
-
|
39 |
-
if (availableSegments.length > 0) {
|
40 |
-
layouts.push({
|
41 |
-
type: layoutType,
|
42 |
-
segments: availableSegments.slice(0, maxPanels),
|
43 |
-
});
|
44 |
-
currentIndex += Math.min(maxPanels, availableSegments.length) - 1;
|
45 |
}
|
|
|
|
|
|
|
46 |
}
|
47 |
|
48 |
-
|
49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
|
51 |
return layouts;
|
52 |
}
|
@@ -74,8 +72,13 @@ export function getNextPanelDimensions(segments) {
|
|
74 |
const lastLayout = layouts[layouts.length - 1];
|
75 |
const segmentsInLastLayout = lastLayout ? lastLayout.segments.length : 0;
|
76 |
|
77 |
-
//
|
78 |
-
const
|
|
|
|
|
|
|
|
|
|
|
79 |
const nextPanelIndex = segmentsInLastLayout;
|
80 |
|
81 |
// Si le dernier layout est plein, prendre le premier panneau du prochain layout
|
@@ -89,3 +92,8 @@ export function getNextPanelDimensions(segments) {
|
|
89 |
// Sinon, prendre le prochain panneau du layout courant
|
90 |
return LAYOUTS[lastLayout.type].panels[nextPanelIndex];
|
91 |
}
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import { LAYOUTS, getNextLayoutType } from "./config";
|
2 |
|
3 |
+
// Map to store layout types for each page
|
4 |
+
const pageLayoutMap = new Map();
|
5 |
+
|
6 |
// Function to group segments into layouts
|
7 |
export function groupSegmentsIntoLayouts(segments) {
|
8 |
+
if (!segments || segments.length === 0) return [];
|
9 |
|
10 |
const layouts = [];
|
11 |
+
let currentLayout = null;
|
12 |
+
let currentPanelIndex = 0;
|
13 |
+
|
14 |
+
segments.forEach((segment) => {
|
15 |
+
// Si c'est le premier segment, créer un layout COVER
|
16 |
+
if (segment.is_first_step) {
|
17 |
+
currentLayout = { type: "COVER", segments: [segment] };
|
18 |
+
layouts.push(currentLayout);
|
19 |
+
currentPanelIndex = segment.images?.length || 0;
|
20 |
+
return;
|
21 |
+
}
|
22 |
|
23 |
+
// Si pas de layout courant ou si tous les panels sont remplis, en créer un nouveau
|
24 |
+
if (
|
25 |
+
!currentLayout ||
|
26 |
+
currentPanelIndex >= LAYOUTS[currentLayout.type].panels.length
|
27 |
+
) {
|
28 |
+
// Utiliser le layout existant pour cette page ou en créer un nouveau
|
29 |
+
const pageIndex = layouts.length;
|
30 |
+
let nextType = pageLayoutMap.get(pageIndex);
|
31 |
+
if (!nextType) {
|
32 |
+
nextType = getNextLayoutType(layouts.length);
|
33 |
+
pageLayoutMap.set(pageIndex, nextType);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
}
|
35 |
+
currentLayout = { type: nextType, segments: [] };
|
36 |
+
layouts.push(currentLayout);
|
37 |
+
currentPanelIndex = 0;
|
38 |
}
|
39 |
|
40 |
+
// Ajouter le segment au layout courant
|
41 |
+
currentLayout.segments.push(segment);
|
42 |
+
|
43 |
+
// Mettre à jour l'index du panel pour le prochain segment
|
44 |
+
if (segment.images) {
|
45 |
+
currentPanelIndex += segment.images.length;
|
46 |
+
}
|
47 |
+
});
|
48 |
|
49 |
return layouts;
|
50 |
}
|
|
|
72 |
const lastLayout = layouts[layouts.length - 1];
|
73 |
const segmentsInLastLayout = lastLayout ? lastLayout.segments.length : 0;
|
74 |
|
75 |
+
// Utiliser le layout existant ou en créer un nouveau
|
76 |
+
const pageIndex = layouts.length;
|
77 |
+
let nextLayoutType = pageLayoutMap.get(pageIndex);
|
78 |
+
if (!nextLayoutType) {
|
79 |
+
nextLayoutType = getNextLayoutType(layouts.length);
|
80 |
+
pageLayoutMap.set(pageIndex, nextLayoutType);
|
81 |
+
}
|
82 |
const nextPanelIndex = segmentsInLastLayout;
|
83 |
|
84 |
// Si le dernier layout est plein, prendre le premier panneau du prochain layout
|
|
|
92 |
// Sinon, prendre le prochain panneau du layout courant
|
93 |
return LAYOUTS[lastLayout.type].panels[nextPanelIndex];
|
94 |
}
|
95 |
+
|
96 |
+
// Function to reset layout map (call this when starting a new story)
|
97 |
+
export function resetLayoutMap() {
|
98 |
+
pageLayoutMap.clear();
|
99 |
+
}
|
client/src/main.jsx
CHANGED
@@ -15,10 +15,8 @@ const theme = createTheme({
|
|
15 |
});
|
16 |
|
17 |
ReactDOM.createRoot(document.getElementById("root")).render(
|
18 |
-
<
|
19 |
-
<
|
20 |
-
|
21 |
-
|
22 |
-
</ThemeProvider>
|
23 |
-
</React.StrictMode>
|
24 |
);
|
|
|
15 |
});
|
16 |
|
17 |
ReactDOM.createRoot(document.getElementById("root")).render(
|
18 |
+
<ThemeProvider theme={theme}>
|
19 |
+
<CssBaseline />
|
20 |
+
<App />
|
21 |
+
</ThemeProvider>
|
|
|
|
|
22 |
);
|
server/api_clients.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1 |
import os
|
2 |
import requests
|
3 |
import asyncio
|
|
|
4 |
from typing import Optional
|
5 |
from langchain_mistralai.chat_models import ChatMistralAI
|
6 |
from langchain.schema import SystemMessage, HumanMessage
|
@@ -62,8 +63,14 @@ class FluxClient:
|
|
62 |
def __init__(self, api_key: str):
|
63 |
self.api_key = api_key
|
64 |
self.endpoint = os.getenv("FLUX_ENDPOINT")
|
|
|
65 |
|
66 |
-
def
|
|
|
|
|
|
|
|
|
|
|
67 |
prompt: str,
|
68 |
width: int,
|
69 |
height: int,
|
@@ -78,44 +85,54 @@ class FluxClient:
|
|
78 |
print(f"Sending request to Hugging Face API: {self.endpoint}")
|
79 |
print(f"Headers: Authorization: Bearer {self.api_key[:4]}...")
|
80 |
print(f"Request body: {prompt[:100]}...")
|
|
|
|
|
81 |
|
82 |
-
|
|
|
|
|
83 |
self.endpoint,
|
84 |
headers={
|
85 |
"Authorization": f"Bearer {self.api_key}",
|
86 |
"Accept": "image/jpeg"
|
87 |
},
|
88 |
json={
|
89 |
-
"inputs": prompt,
|
90 |
"parameters": {
|
91 |
"num_inference_steps": num_inference_steps,
|
92 |
"guidance_scale": guidance_scale,
|
93 |
"width": width,
|
94 |
"height": height,
|
95 |
-
"negative_prompt": "
|
96 |
}
|
97 |
}
|
98 |
-
)
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
|
|
|
|
|
|
109 |
else:
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
print(f"Response content: {response.content}")
|
115 |
-
return None
|
116 |
|
117 |
except Exception as e:
|
118 |
print(f"Error in FluxClient.generate_image: {str(e)}")
|
119 |
import traceback
|
120 |
print(f"Traceback: {traceback.format_exc()}")
|
121 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import os
|
2 |
import requests
|
3 |
import asyncio
|
4 |
+
import aiohttp
|
5 |
from typing import Optional
|
6 |
from langchain_mistralai.chat_models import ChatMistralAI
|
7 |
from langchain.schema import SystemMessage, HumanMessage
|
|
|
63 |
def __init__(self, api_key: str):
|
64 |
self.api_key = api_key
|
65 |
self.endpoint = os.getenv("FLUX_ENDPOINT")
|
66 |
+
self._session = None
|
67 |
|
68 |
+
async def _get_session(self):
|
69 |
+
if self._session is None:
|
70 |
+
self._session = aiohttp.ClientSession()
|
71 |
+
return self._session
|
72 |
+
|
73 |
+
async def generate_image(self,
|
74 |
prompt: str,
|
75 |
width: int,
|
76 |
height: int,
|
|
|
85 |
print(f"Sending request to Hugging Face API: {self.endpoint}")
|
86 |
print(f"Headers: Authorization: Bearer {self.api_key[:4]}...")
|
87 |
print(f"Request body: {prompt[:100]}...")
|
88 |
+
|
89 |
+
prefix = "François Schuiten comic book artist."
|
90 |
|
91 |
+
|
92 |
+
session = await self._get_session()
|
93 |
+
async with session.post(
|
94 |
self.endpoint,
|
95 |
headers={
|
96 |
"Authorization": f"Bearer {self.api_key}",
|
97 |
"Accept": "image/jpeg"
|
98 |
},
|
99 |
json={
|
100 |
+
"inputs": "in the style of " + prefix + " --- content: " + prompt,
|
101 |
"parameters": {
|
102 |
"num_inference_steps": num_inference_steps,
|
103 |
"guidance_scale": guidance_scale,
|
104 |
"width": width,
|
105 |
"height": height,
|
106 |
+
"negative_prompt": "Bubbles, text, caption. Do not include bright or clean clothing."
|
107 |
}
|
108 |
}
|
109 |
+
) as response:
|
110 |
+
print(f"Response status code: {response.status}")
|
111 |
+
print(f"Response headers: {response.headers}")
|
112 |
+
print(f"Response content type: {response.headers.get('content-type', 'unknown')}")
|
113 |
+
|
114 |
+
if response.status == 200:
|
115 |
+
content = await response.read()
|
116 |
+
content_length = len(content)
|
117 |
+
print(f"Received successful response with content length: {content_length}")
|
118 |
+
if isinstance(content, bytes):
|
119 |
+
print("Response content is bytes (correct)")
|
120 |
+
else:
|
121 |
+
print(f"Warning: Response content is {type(content)}")
|
122 |
+
return content
|
123 |
else:
|
124 |
+
error_content = await response.text()
|
125 |
+
print(f"Error from Flux API: {response.status}")
|
126 |
+
print(f"Response content: {error_content}")
|
127 |
+
return None
|
|
|
|
|
128 |
|
129 |
except Exception as e:
|
130 |
print(f"Error in FluxClient.generate_image: {str(e)}")
|
131 |
import traceback
|
132 |
print(f"Traceback: {traceback.format_exc()}")
|
133 |
+
return None
|
134 |
+
|
135 |
+
async def close(self):
|
136 |
+
if self._session:
|
137 |
+
await self._session.close()
|
138 |
+
# self._session = None where there is a post apocalypse scene, and my main character is a tough female survivor in a post-apocalyptic world with short, messy hair, dirt-smeared skin, and rugged clothing. She wears a leather jacket, utility pants, and carries makeshift weapons she is infront of an abandoned hospital
|
server/game/game_logic.py
CHANGED
@@ -18,36 +18,32 @@ class GameState:
|
|
18 |
def __init__(self):
|
19 |
self.story_beat = 0
|
20 |
self.radiation_level = 0
|
|
|
21 |
|
22 |
def reset(self):
|
23 |
self.story_beat = 0
|
24 |
self.radiation_level = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
|
26 |
# Story output structure
|
27 |
-
class
|
28 |
story_text: str = Field(description="The next segment of the story. No more than 15 words THIS IS MANDATORY. Use bold formatting (like **this**) ONLY for proper nouns (like **Sarah**, **Vault 15**, **New Eden**) and important locations.")
|
29 |
choices: List[str] = Field(description="Exactly two possible choices for the player", min_items=2, max_items=2)
|
30 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
31 |
radiation_increase: int = Field(description="How much radiation this segment adds (0-3)", ge=0, le=3, default=1)
|
|
|
32 |
|
33 |
# Prompt templates
|
34 |
-
SYSTEM_ART_PROMPT = """You are an expert in image generation prompts.
|
35 |
-
Transform the story into a short and precise prompt.
|
36 |
-
|
37 |
-
Strict format:
|
38 |
-
"color comic panel, style of Hergé, [main scene in 5-7 words], french comic panel"
|
39 |
-
|
40 |
-
Example:
|
41 |
-
"color comic panel, style of Hergé, detective running through dark alley, french comic panel"
|
42 |
-
|
43 |
-
Rules:
|
44 |
-
- Maximum 20 words to describe the scene
|
45 |
-
- No superfluous adjectives
|
46 |
-
- Capture only the main action"""
|
47 |
-
|
48 |
class StoryGenerator:
|
49 |
def __init__(self, api_key: str):
|
50 |
-
self.parser = PydanticOutputParser(pydantic_object=
|
51 |
self.mistral_client = MistralClient(api_key)
|
52 |
|
53 |
self.fixing_parser = OutputFixingParser.from_llm(
|
@@ -60,7 +56,13 @@ class StoryGenerator:
|
|
60 |
def _create_prompt(self) -> ChatPromptTemplate:
|
61 |
system_template = """You are narrating a brutal dystopian story where **Sarah** must survive in a radioactive wasteland. This is a comic book story.
|
62 |
|
63 |
-
IMPORTANT:
|
|
|
|
|
|
|
|
|
|
|
|
|
64 |
|
65 |
RADIATION SYSTEM:
|
66 |
You must set a radiation_increase value for each segment based on the environment and situation:
|
@@ -94,32 +96,137 @@ IMPORTANT FORMATTING RULES:
|
|
94 |
|
95 |
Each response MUST contain:
|
96 |
1. A detailed story segment that:
|
97 |
-
-
|
|
|
98 |
- Shows immediate dangers
|
99 |
- Details **Sarah**'s physical state (based on radiation_level)
|
100 |
- Reflects her mental state and previous choices
|
101 |
- Uses bold ONLY for proper nouns and locations
|
102 |
|
103 |
-
2. Exactly two VERY CONCISE choices (max 10 words each):
|
104 |
-
|
105 |
-
- "Explore the **Medical Center**" vs "Search the **Residential Zone**"
|
106 |
-
- "Trust the survivor from **Vault 15**" vs "Keep your distance"
|
107 |
-
- "Use the **AI Core**" vs "Find a manual solution"
|
108 |
-
|
109 |
-
Each choice must:
|
110 |
-
- Be direct and brief
|
111 |
- Never mention radiation numbers
|
112 |
-
- Feel meaningful
|
113 |
- Present different risk levels
|
114 |
- Use bold ONLY for location names
|
115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
116 |
{format_instructions}"""
|
117 |
|
118 |
human_template = """Current story beat: {story_beat}
|
119 |
Current radiation level: {radiation_level}/10
|
120 |
Previous choice: {previous_choice}
|
121 |
|
122 |
-
|
|
|
|
|
|
|
123 |
|
124 |
return ChatPromptTemplate(
|
125 |
messages=[
|
@@ -129,11 +236,24 @@ Generate the next story segment and choices. If this is story_beat 0, create an
|
|
129 |
partial_variables={"format_instructions": self.parser.get_format_instructions()}
|
130 |
)
|
131 |
|
132 |
-
async def generate_story_segment(self, game_state: GameState, previous_choice: str) ->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
133 |
messages = self.prompt.format_messages(
|
134 |
story_beat=game_state.story_beat,
|
135 |
radiation_level=game_state.radiation_level,
|
136 |
-
previous_choice=previous_choice
|
|
|
137 |
)
|
138 |
|
139 |
max_retries = 3
|
@@ -179,7 +299,7 @@ Generate the next story segment and choices. If this is story_beat 0, create an
|
|
179 |
async def transform_story_to_art_prompt(self, story_text: str) -> str:
|
180 |
return await self.mistral_client.transform_prompt(story_text, SYSTEM_ART_PROMPT)
|
181 |
|
182 |
-
def process_radiation_death(self, segment:
|
183 |
segment.is_death = True
|
184 |
segment.story_text += "\n\nThe end... ?"
|
185 |
return segment
|
|
|
18 |
def __init__(self):
|
19 |
self.story_beat = 0
|
20 |
self.radiation_level = 0
|
21 |
+
self.story_history = []
|
22 |
|
23 |
def reset(self):
|
24 |
self.story_beat = 0
|
25 |
self.radiation_level = 0
|
26 |
+
self.story_history = []
|
27 |
+
|
28 |
+
def add_to_history(self, segment_text: str, choice_made: str, image_prompts: List[str]):
|
29 |
+
self.story_history.append({
|
30 |
+
"segment": segment_text,
|
31 |
+
"choice": choice_made,
|
32 |
+
"image_prompts": image_prompts
|
33 |
+
})
|
34 |
|
35 |
# Story output structure
|
36 |
+
class StoryLLMResponse(BaseModel):
|
37 |
story_text: str = Field(description="The next segment of the story. No more than 15 words THIS IS MANDATORY. Use bold formatting (like **this**) ONLY for proper nouns (like **Sarah**, **Vault 15**, **New Eden**) and important locations.")
|
38 |
choices: List[str] = Field(description="Exactly two possible choices for the player", min_items=2, max_items=2)
|
39 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
40 |
radiation_increase: int = Field(description="How much radiation this segment adds (0-3)", ge=0, le=3, default=1)
|
41 |
+
image_prompts: List[str] = Field(description="List of 1 to 3 comic panel descriptions that illustrate the key moments of the scene", min_items=1, max_items=3)
|
42 |
|
43 |
# Prompt templates
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
class StoryGenerator:
|
45 |
def __init__(self, api_key: str):
|
46 |
+
self.parser = PydanticOutputParser(pydantic_object=StoryLLMResponse)
|
47 |
self.mistral_client = MistralClient(api_key)
|
48 |
|
49 |
self.fixing_parser = OutputFixingParser.from_llm(
|
|
|
56 |
def _create_prompt(self) -> ChatPromptTemplate:
|
57 |
system_template = """You are narrating a brutal dystopian story where **Sarah** must survive in a radioactive wasteland. This is a comic book story.
|
58 |
|
59 |
+
IMPORTANT: Each story segment MUST be unique and advance the plot. Never repeat the same descriptions or situations.
|
60 |
+
|
61 |
+
STORY PROGRESSION:
|
62 |
+
- story_beat 0: Introduction setting up the horror atmosphere
|
63 |
+
- story_beat 1-2: Early exploration and discovery of immediate threats
|
64 |
+
- story_beat 3-4: Complications and increasing danger
|
65 |
+
- story_beat 5+: Climactic situations leading to potential victory
|
66 |
|
67 |
RADIATION SYSTEM:
|
68 |
You must set a radiation_increase value for each segment based on the environment and situation:
|
|
|
96 |
|
97 |
Each response MUST contain:
|
98 |
1. A detailed story segment that:
|
99 |
+
- Advances the plot based on previous choices
|
100 |
+
- Never repeats previous descriptions
|
101 |
- Shows immediate dangers
|
102 |
- Details **Sarah**'s physical state (based on radiation_level)
|
103 |
- Reflects her mental state and previous choices
|
104 |
- Uses bold ONLY for proper nouns and locations
|
105 |
|
106 |
+
2. Exactly two VERY CONCISE choices (max 10 words each) that:
|
107 |
+
- Are direct and brief
|
|
|
|
|
|
|
|
|
|
|
|
|
108 |
- Never mention radiation numbers
|
109 |
+
- Feel meaningful and different from previous choices
|
110 |
- Present different risk levels
|
111 |
- Use bold ONLY for location names
|
112 |
|
113 |
+
3. Generate 1 to 3 comic panels based on narrative needs:
|
114 |
+
|
115 |
+
NARRATIVE TECHNIQUES:
|
116 |
+
- Use 1 panel for:
|
117 |
+
* A powerful singular moment
|
118 |
+
* An impactful revelation
|
119 |
+
* A dramatic pause
|
120 |
+
|
121 |
+
- Use 2 panels for:
|
122 |
+
* Cause and effect
|
123 |
+
* Action and reaction
|
124 |
+
* Before and after
|
125 |
+
* Shot/reverse shot (character POV vs what they see)
|
126 |
+
* Tension building (wide shot then detail)
|
127 |
+
|
128 |
+
- Use 3 panels for:
|
129 |
+
* Complete story beats (setup/conflict/resolution)
|
130 |
+
* Progressive reveals
|
131 |
+
* Multiple simultaneous actions
|
132 |
+
* Environmental storytelling sequences
|
133 |
+
|
134 |
+
SHOT VALUES:
|
135 |
+
- Extreme Close-Up (ECU):
|
136 |
+
* Eyes, small objects
|
137 |
+
* Extreme emotional moments
|
138 |
+
* Critical details (detector readings)
|
139 |
+
|
140 |
+
- Close-Up (CU):
|
141 |
+
* Face and expressions
|
142 |
+
* Important objects
|
143 |
+
* Emotional impact
|
144 |
+
|
145 |
+
- Medium Close-Up (MCU):
|
146 |
+
* Head and shoulders
|
147 |
+
* Dialogue moments
|
148 |
+
* Character reactions
|
149 |
+
|
150 |
+
- Medium Shot (MS):
|
151 |
+
* Character from knees up
|
152 |
+
* Action and movement
|
153 |
+
* Character interactions
|
154 |
+
|
155 |
+
- Medium Long Shot (MLS):
|
156 |
+
* Full character
|
157 |
+
* Immediate environment
|
158 |
+
* Physical action
|
159 |
+
|
160 |
+
- Long Shot (LS):
|
161 |
+
* Character in environment
|
162 |
+
* Establishing location
|
163 |
+
* Movement through space
|
164 |
+
|
165 |
+
- Very Long Shot (VLS):
|
166 |
+
* Epic landscapes
|
167 |
+
* Environmental storytelling
|
168 |
+
* Character isolation
|
169 |
+
|
170 |
+
ANGLES AND MOVEMENT:
|
171 |
+
- High angle: Vulnerability, weakness
|
172 |
+
- Low angle: Power, threat
|
173 |
+
- Dutch angle: Tension, disorientation
|
174 |
+
- Over shoulder: POV, surveillance
|
175 |
+
|
176 |
+
VISUAL STORYTELLING TOOLS:
|
177 |
+
- Focus on story-relevant details:
|
178 |
+
* Objects that will be important later
|
179 |
+
* Environmental clues
|
180 |
+
* Character reactions
|
181 |
+
* Symbolic elements
|
182 |
+
|
183 |
+
- Dynamic composition:
|
184 |
+
* Frame within frame (through doorways, windows)
|
185 |
+
* Reflections and shadows
|
186 |
+
* Foreground elements for depth
|
187 |
+
* Leading lines
|
188 |
+
* Rule of thirds
|
189 |
+
|
190 |
+
IMAGE PROMPT FORMAT:
|
191 |
+
Each panel must follow this EXACT format:
|
192 |
+
"[shot value] [scene description], french comic panel"
|
193 |
+
|
194 |
+
Rules for scene description:
|
195 |
+
- Maximum 20 words
|
196 |
+
- No superfluous adjectives
|
197 |
+
- Capture only the main action
|
198 |
+
- Include shot value (ECU, CU, MS, etc.)
|
199 |
+
- Focus on dramatic moments
|
200 |
+
|
201 |
+
EXAMPLE SEQUENCES:
|
202 |
+
|
203 |
+
Single powerful moment:
|
204 |
+
- "ECU radiation detector needle swings violently into pulsing red danger zone"
|
205 |
+
|
206 |
+
Shot/reverse shot:
|
207 |
+
- "MS Sarah crouches tensely behind crumbling concrete wall peering through broken window"
|
208 |
+
- "POV through shattered glass raiders gather around burning barrel in snow-covered ruins"
|
209 |
+
|
210 |
+
Progressive reveal:
|
211 |
+
- "VLS massive steel bunker door stands half-open in barren windswept wasteland"
|
212 |
+
- "CU fresh bloody handprints smear down rusted metal wall beside flickering emergency light"
|
213 |
+
- "dutch-angle LS twisted corpse sprawled among scattered medical supplies casting long shadows"
|
214 |
+
|
215 |
+
Environmental storytelling:
|
216 |
+
- "LS Sarah's silhouette dwarfed by towering ruins against blood-red sunset sky"
|
217 |
+
- "MCU radiation detector screen flickers warning through heavy falling radioactive snow"
|
218 |
+
- "ECU Sarah's trembling hands clutch last remaining water bottle in dim bunker light"
|
219 |
+
|
220 |
{format_instructions}"""
|
221 |
|
222 |
human_template = """Current story beat: {story_beat}
|
223 |
Current radiation level: {radiation_level}/10
|
224 |
Previous choice: {previous_choice}
|
225 |
|
226 |
+
Story so far:
|
227 |
+
{story_history}
|
228 |
+
|
229 |
+
Generate the next story segment and choices. Make sure it advances the plot and never repeats previous descriptions or situations. If this is story_beat 0, create an atmospheric introduction that sets up the horror but doesn't kill Sarah (radiation_increase MUST be 0). Otherwise, create a brutal and potentially lethal segment."""
|
230 |
|
231 |
return ChatPromptTemplate(
|
232 |
messages=[
|
|
|
236 |
partial_variables={"format_instructions": self.parser.get_format_instructions()}
|
237 |
)
|
238 |
|
239 |
+
async def generate_story_segment(self, game_state: GameState, previous_choice: str) -> StoryLLMResponse:
|
240 |
+
# Format story history as a narrative storyboard
|
241 |
+
story_history = ""
|
242 |
+
if game_state.story_history:
|
243 |
+
segments = []
|
244 |
+
for entry in game_state.story_history:
|
245 |
+
segment = entry['segment']
|
246 |
+
image_descriptions = "\nVisual panels:\n" + "\n".join(f"- {prompt}" for prompt in entry['image_prompts'])
|
247 |
+
segments.append(f"{segment}{image_descriptions}")
|
248 |
+
|
249 |
+
story_history = "\n\n---\n\n".join(segments)
|
250 |
+
story_history += "\n\nLast choice made: " + previous_choice
|
251 |
+
|
252 |
messages = self.prompt.format_messages(
|
253 |
story_beat=game_state.story_beat,
|
254 |
radiation_level=game_state.radiation_level,
|
255 |
+
previous_choice=previous_choice,
|
256 |
+
story_history=story_history
|
257 |
)
|
258 |
|
259 |
max_retries = 3
|
|
|
299 |
async def transform_story_to_art_prompt(self, story_text: str) -> str:
|
300 |
return await self.mistral_client.transform_prompt(story_text, SYSTEM_ART_PROMPT)
|
301 |
|
302 |
+
def process_radiation_death(self, segment: StoryLLMResponse) -> StoryLLMResponse:
|
303 |
segment.is_death = True
|
304 |
segment.story_text += "\n\nThe end... ?"
|
305 |
return segment
|
server/server.py
CHANGED
@@ -102,6 +102,7 @@ class StoryResponse(BaseModel):
|
|
102 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
103 |
is_first_step: bool = Field(description="Whether this is the first step of the story", default=False)
|
104 |
is_last_step: bool = Field(description="Whether this is the last step (victory or death)", default=False)
|
|
|
105 |
|
106 |
class ChatMessage(BaseModel):
|
107 |
message: str
|
@@ -121,6 +122,11 @@ class TextToSpeechRequest(BaseModel):
|
|
121 |
text: str
|
122 |
voice_id: str = "nPczCjzI2devNBz1zQrb" # Default voice ID (Rachel)
|
123 |
|
|
|
|
|
|
|
|
|
|
|
124 |
async def get_test_image(client_id: str, width=1024, height=1024):
|
125 |
"""Get a random image from Lorem Picsum"""
|
126 |
# Build the Lorem Picsum URL with blur and grayscale effects
|
@@ -152,72 +158,84 @@ async def chat_endpoint(chat_message: ChatMessage):
|
|
152 |
|
153 |
# Handle restart
|
154 |
if chat_message.message.lower() == "restart":
|
|
|
155 |
game_state.reset()
|
156 |
previous_choice = "none"
|
|
|
157 |
else:
|
158 |
previous_choice = f"Choice {chat_message.choice_id}" if chat_message.choice_id else "none"
|
159 |
|
160 |
print("Previous choice:", previous_choice)
|
|
|
161 |
|
162 |
# Generate story segment
|
163 |
-
|
164 |
-
print("Generated story segment:",
|
165 |
|
166 |
# Update radiation level
|
167 |
-
game_state.radiation_level +=
|
168 |
print("Updated radiation level:", game_state.radiation_level)
|
169 |
|
170 |
# Check for radiation death
|
171 |
is_death = game_state.radiation_level >= MAX_RADIATION
|
172 |
if is_death:
|
173 |
-
|
174 |
|
175 |
MORT PAR RADIATION: Le corps de Sarah ne peut plus supporter ce niveau de radiation ({game_state.radiation_level}/10).
|
176 |
Ses cellules se désagrègent alors qu'elle s'effondre, l'esprit rempli de regrets concernant sa sœur.
|
177 |
Les fournitures médicales qu'elle transportait n'atteindront jamais leur destination.
|
178 |
Sa mission s'arrête ici, une autre victime du tueur invisible des terres désolées."""
|
179 |
-
|
|
|
|
|
|
|
180 |
|
|
|
|
|
|
|
181 |
# Check for victory condition
|
182 |
if not is_death and game_state.story_beat >= 5:
|
183 |
# Chance de victoire augmente avec le nombre de steps
|
184 |
victory_chance = (game_state.story_beat - 4) * 0.2 # 20% de chance par step après le 5ème
|
185 |
if random.random() < victory_chance:
|
186 |
-
|
187 |
-
|
188 |
À l'intérieur, elle découvre une communauté organisée qui a réussi à maintenir un semblant de civilisation.
|
189 |
Ils ont même un système de décontamination ! Son niveau de radiation : {game_state.radiation_level}/10.
|
190 |
Elle peut enfin se reposer et peut-être un jour, reconstruire un monde meilleur.
|
191 |
|
192 |
VICTOIRE !"""
|
193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
194 |
|
195 |
-
#
|
196 |
-
if
|
197 |
-
game_state.story_beat += 1
|
198 |
-
print("Incremented story beat to:", game_state.story_beat)
|
199 |
-
|
200 |
-
# Convert to response format
|
201 |
-
choices = [] if is_death or story_segment.is_victory else [
|
202 |
Choice(id=i, text=choice.strip())
|
203 |
-
for i, choice in enumerate(
|
204 |
]
|
205 |
|
206 |
-
#
|
207 |
-
is_first_step = chat_message.message == "restart"
|
208 |
-
|
209 |
-
# Determine if this is the last step (victory or death)
|
210 |
-
is_last_step = game_state.radiation_level >= MAX_RADIATION or story_segment.is_victory
|
211 |
-
|
212 |
-
# Return the response with the new fields
|
213 |
response = StoryResponse(
|
214 |
-
story_text=
|
215 |
choices=choices,
|
216 |
radiation_level=game_state.radiation_level,
|
217 |
-
is_victory=
|
218 |
-
is_first_step=
|
219 |
-
is_last_step=
|
|
|
220 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
221 |
print("Sending response:", response)
|
222 |
return response
|
223 |
|
@@ -303,9 +321,7 @@ async def test_chat_endpoint(request: Request, chat_message: ChatMessage):
|
|
303 |
story_text=story_text,
|
304 |
choices=choices,
|
305 |
radiation_level=radiation_level,
|
306 |
-
is_victory=is_last_step and radiation_level < 30
|
307 |
-
is_first_step=is_first_step,
|
308 |
-
is_last_step=is_last_step
|
309 |
)
|
310 |
|
311 |
# Create and store the new request
|
@@ -400,6 +416,38 @@ async def text_to_speech(request: TextToSpeechRequest):
|
|
400 |
print(f"Error in text_to_speech: {str(e)}")
|
401 |
raise HTTPException(status_code=500, detail=str(e))
|
402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
403 |
@app.on_event("shutdown")
|
404 |
async def shutdown_event():
|
405 |
"""Clean up sessions on shutdown"""
|
|
|
102 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
103 |
is_first_step: bool = Field(description="Whether this is the first step of the story", default=False)
|
104 |
is_last_step: bool = Field(description="Whether this is the last step (victory or death)", default=False)
|
105 |
+
image_prompts: List[str] = Field(description="List of 1 to 3 comic panel descriptions that illustrate the key moments of the scene", min_items=1, max_items=3)
|
106 |
|
107 |
class ChatMessage(BaseModel):
|
108 |
message: str
|
|
|
122 |
text: str
|
123 |
voice_id: str = "nPczCjzI2devNBz1zQrb" # Default voice ID (Rachel)
|
124 |
|
125 |
+
class DirectImageGenerationRequest(BaseModel):
|
126 |
+
prompt: str = Field(description="The prompt to use directly for image generation")
|
127 |
+
width: int = Field(description="Width of the image to generate")
|
128 |
+
height: int = Field(description="Height of the image to generate")
|
129 |
+
|
130 |
async def get_test_image(client_id: str, width=1024, height=1024):
|
131 |
"""Get a random image from Lorem Picsum"""
|
132 |
# Build the Lorem Picsum URL with blur and grayscale effects
|
|
|
158 |
|
159 |
# Handle restart
|
160 |
if chat_message.message.lower() == "restart":
|
161 |
+
print("Handling restart - Resetting game state")
|
162 |
game_state.reset()
|
163 |
previous_choice = "none"
|
164 |
+
print(f"After reset - story_beat: {game_state.story_beat}")
|
165 |
else:
|
166 |
previous_choice = f"Choice {chat_message.choice_id}" if chat_message.choice_id else "none"
|
167 |
|
168 |
print("Previous choice:", previous_choice)
|
169 |
+
print("Current story beat:", game_state.story_beat)
|
170 |
|
171 |
# Generate story segment
|
172 |
+
llm_response = await story_generator.generate_story_segment(game_state, previous_choice)
|
173 |
+
print("Generated story segment:", llm_response)
|
174 |
|
175 |
# Update radiation level
|
176 |
+
game_state.radiation_level += llm_response.radiation_increase
|
177 |
print("Updated radiation level:", game_state.radiation_level)
|
178 |
|
179 |
# Check for radiation death
|
180 |
is_death = game_state.radiation_level >= MAX_RADIATION
|
181 |
if is_death:
|
182 |
+
llm_response.story_text += f"""
|
183 |
|
184 |
MORT PAR RADIATION: Le corps de Sarah ne peut plus supporter ce niveau de radiation ({game_state.radiation_level}/10).
|
185 |
Ses cellules se désagrègent alors qu'elle s'effondre, l'esprit rempli de regrets concernant sa sœur.
|
186 |
Les fournitures médicales qu'elle transportait n'atteindront jamais leur destination.
|
187 |
Sa mission s'arrête ici, une autre victime du tueur invisible des terres désolées."""
|
188 |
+
llm_response.choices = []
|
189 |
+
# Pour la mort, on ne garde qu'un seul prompt d'image
|
190 |
+
if len(llm_response.image_prompts) > 1:
|
191 |
+
llm_response.image_prompts = [llm_response.image_prompts[0]]
|
192 |
|
193 |
+
# Add segment to history (before victory check to include final state)
|
194 |
+
game_state.add_to_history(llm_response.story_text, previous_choice, llm_response.image_prompts)
|
195 |
+
|
196 |
# Check for victory condition
|
197 |
if not is_death and game_state.story_beat >= 5:
|
198 |
# Chance de victoire augmente avec le nombre de steps
|
199 |
victory_chance = (game_state.story_beat - 4) * 0.2 # 20% de chance par step après le 5ème
|
200 |
if random.random() < victory_chance:
|
201 |
+
llm_response.is_victory = True
|
202 |
+
llm_response.story_text = f"""Sarah l'a fait ! Elle a trouvé un bunker sécurisé avec des survivants.
|
203 |
À l'intérieur, elle découvre une communauté organisée qui a réussi à maintenir un semblant de civilisation.
|
204 |
Ils ont même un système de décontamination ! Son niveau de radiation : {game_state.radiation_level}/10.
|
205 |
Elle peut enfin se reposer et peut-être un jour, reconstruire un monde meilleur.
|
206 |
|
207 |
VICTOIRE !"""
|
208 |
+
llm_response.choices = []
|
209 |
+
# Pour la victoire, on ne garde qu'un seul prompt d'image
|
210 |
+
if len(llm_response.image_prompts) > 1:
|
211 |
+
llm_response.image_prompts = [llm_response.image_prompts[0]]
|
212 |
+
|
213 |
+
# Pour la première étape, on ne garde qu'un seul prompt d'image
|
214 |
+
if game_state.story_beat == 0 and len(llm_response.image_prompts) > 1:
|
215 |
+
llm_response.image_prompts = [llm_response.image_prompts[0]]
|
216 |
|
217 |
+
# Convert LLM choices to API choices format
|
218 |
+
choices = [] if is_death or llm_response.is_victory else [
|
|
|
|
|
|
|
|
|
|
|
219 |
Choice(id=i, text=choice.strip())
|
220 |
+
for i, choice in enumerate(llm_response.choices, 1)
|
221 |
]
|
222 |
|
223 |
+
# Convert LLM response to API response format
|
|
|
|
|
|
|
|
|
|
|
|
|
224 |
response = StoryResponse(
|
225 |
+
story_text=llm_response.story_text,
|
226 |
choices=choices,
|
227 |
radiation_level=game_state.radiation_level,
|
228 |
+
is_victory=llm_response.is_victory,
|
229 |
+
is_first_step=game_state.story_beat == 0,
|
230 |
+
is_last_step=is_death or llm_response.is_victory,
|
231 |
+
image_prompts=llm_response.image_prompts
|
232 |
)
|
233 |
+
|
234 |
+
# Only increment story beat if not dead and not victory
|
235 |
+
if not is_death and not llm_response.is_victory:
|
236 |
+
game_state.story_beat += 1
|
237 |
+
print("Incremented story beat to:", game_state.story_beat)
|
238 |
+
|
239 |
print("Sending response:", response)
|
240 |
return response
|
241 |
|
|
|
321 |
story_text=story_text,
|
322 |
choices=choices,
|
323 |
radiation_level=radiation_level,
|
324 |
+
is_victory=is_last_step and radiation_level < 30
|
|
|
|
|
325 |
)
|
326 |
|
327 |
# Create and store the new request
|
|
|
416 |
print(f"Error in text_to_speech: {str(e)}")
|
417 |
raise HTTPException(status_code=500, detail=str(e))
|
418 |
|
419 |
+
@app.post("/api/generate-image-direct")
|
420 |
+
async def generate_image_direct(request: DirectImageGenerationRequest):
|
421 |
+
try:
|
422 |
+
print(f"Generating image directly with dimensions: {request.width}x{request.height}")
|
423 |
+
print(f"Using prompt: {request.prompt}")
|
424 |
+
|
425 |
+
# Generate image using Flux client directly without transforming the prompt
|
426 |
+
image_bytes = await flux_client.generate_image(
|
427 |
+
prompt=request.prompt,
|
428 |
+
width=request.width,
|
429 |
+
height=request.height
|
430 |
+
)
|
431 |
+
|
432 |
+
if image_bytes:
|
433 |
+
print(f"Received image bytes of length: {len(image_bytes)}")
|
434 |
+
if isinstance(image_bytes, str):
|
435 |
+
print("Warning: image_bytes is a string, converting to bytes")
|
436 |
+
image_bytes = image_bytes.encode('utf-8')
|
437 |
+
base64_image = base64.b64encode(image_bytes).decode('utf-8').strip('"')
|
438 |
+
print(f"Converted to base64 string of length: {len(base64_image)}")
|
439 |
+
return {"success": True, "image_base64": base64_image}
|
440 |
+
else:
|
441 |
+
print("No image bytes received from Flux client")
|
442 |
+
return {"success": False, "error": "Failed to generate image"}
|
443 |
+
|
444 |
+
except Exception as e:
|
445 |
+
print(f"Error generating image: {str(e)}")
|
446 |
+
print(f"Error type: {type(e)}")
|
447 |
+
import traceback
|
448 |
+
print(f"Traceback: {traceback.format_exc()}")
|
449 |
+
return {"success": False, "error": str(e)}
|
450 |
+
|
451 |
@app.on_event("shutdown")
|
452 |
async def shutdown_event():
|
453 |
"""Clean up sessions on shutdown"""
|