tfrere commited on
Commit
39c89a5
·
1 Parent(s): 814c197
.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 { getNextPanelDimensions } from "./layouts/utils";
 
 
 
 
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
- const generateImageForStory = async (storyText, segmentIndex) => {
 
 
 
 
 
 
 
 
 
52
  try {
53
- // Cancel previous request if it exists
54
- if (currentImageRequestRef.current) {
55
- currentImageRequestRef.current.abort();
56
- }
57
-
58
- // Add this segment to pending requests
59
- pendingImageRequests.current.add(segmentIndex);
60
-
61
- console.log("Generating image for story:", storyText);
62
- const dimensions = getNextPanelDimensions(storySegments);
63
- console.log("[DEBUG] Story segments:", storySegments);
64
- console.log("[DEBUG] Dimensions object:", dimensions);
65
- console.log(
66
- "[DEBUG] Width:",
67
- dimensions?.width,
68
- "Height:",
69
- dimensions?.height
70
- );
71
-
72
- if (!dimensions || !dimensions.width || !dimensions.height) {
73
- console.error("[ERROR] Invalid dimensions:", dimensions);
74
- pendingImageRequests.current.delete(segmentIndex);
75
- return null;
76
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
- // Create new AbortController for this request
79
- const abortController = new AbortController();
80
- currentImageRequestRef.current = abortController;
 
 
 
 
81
 
82
- const response = await api.post(
83
- `${API_URL}/api/${isDebugMode ? "test/" : ""}generate-image`,
84
- {
85
- prompt: `Comic book style scene: ${storyText}`,
86
- width: dimensions.width,
87
- height: dimensions.height,
88
- },
89
- {
90
- signal: abortController.signal,
 
 
 
 
 
 
 
 
 
 
 
 
91
  }
92
- );
93
 
94
- // Remove from pending requests
95
- pendingImageRequests.current.delete(segmentIndex);
 
 
 
 
 
 
96
 
97
- if (response.data.success) {
98
- return response.data.image_base64;
99
- }
100
- return null;
101
- } catch (error) {
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
- return updatedSegments;
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
- // Créer et jouer le nouvel audio
138
- const audioBlob = await fetch(
139
- `data:audio/mpeg;base64,${response.data.audio_base64}`
140
- ).then((r) => r.blob());
141
- console.log("Audio blob created:", audioBlob.size, "bytes");
 
 
 
 
142
 
143
- const audioUrl = URL.createObjectURL(audioBlob);
144
- audioRef.current.src = audioUrl;
145
- audioRef.current.volume = 1.0; // S'assurer que le volume est au maximum
 
 
146
 
147
- try {
148
- console.log("Attempting to play audio...");
149
- await audioRef.current.play();
150
- console.log("Audio playing successfully");
151
- } catch (playError) {
152
- console.error("Error playing audio:", playError);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 in playAudio:", error);
 
163
  }
164
  };
165
 
@@ -175,7 +227,7 @@ function App() {
175
  }
176
  );
177
 
178
- // 2. Créer le nouveau segment sans image
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
- image_base64: null,
 
 
188
  };
189
 
 
190
  let segmentIndex;
191
- // 3. Mettre à jour l'état avec le nouveau segment
 
192
  if (action === "restart") {
193
- setStorySegments([newSegment]);
194
  segmentIndex = 0;
 
195
  } else {
196
- setStorySegments((prev) => {
197
- segmentIndex = prev.length;
198
- return [...prev, newSegment];
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. Lancer la synthèse vocale pour le nouveau segment
209
- await playAudio(response.data.story_text);
210
-
211
- // 7. Tenter de générer l'image en arrière-plan
212
- try {
213
- const image_base64 = await generateImageForStory(
214
- response.data.story_text,
215
- segmentIndex
216
- );
217
- if (image_base64) {
218
- setStorySegments((prev) => {
219
- const updatedSegments = [...prev];
220
- if (updatedSegments[segmentIndex]) {
221
- updatedSegments[segmentIndex] = {
222
- ...updatedSegments[segmentIndex],
223
- image_base64: image_base64,
224
- };
225
- }
226
- return updatedSegments;
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
- image_base64: null,
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, CircularProgress, Typography } from "@mui/material";
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.map((panel, panelIndex) => (
104
- <Panel
105
- key={panelIndex}
106
- panel={panel}
107
- segment={layout.segments[panelIndex]}
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: 1024, height: 512, gridColumn: "1", gridRow: "1" }, // Format pleine page (1:1 ratio)
46
  ],
47
  },
48
  LAYOUT_1: {
49
  gridCols: 2,
50
  gridRows: 2,
51
  panels: [
52
- { width: 1024, height: 768, gridColumn: "1", gridRow: "1" }, // 1. Landscape top left
53
- { width: 768, height: 1024, gridColumn: "2", gridRow: "1" }, // 2. Portrait top right
54
- { width: 1024, height: 768, gridColumn: "1", gridRow: "2" }, // 3. Landscape middle left
55
- { width: 768, height: 1024, gridColumn: "2", gridRow: "2" }, // 4. Portrait right, spans bottom rows
56
  ],
57
  },
58
  LAYOUT_2: {
59
  gridCols: 3,
60
  gridRows: 2,
61
  panels: [
62
- { width: 1024, height: 1024, gridColumn: "1 / span 2", gridRow: "1" }, // 1. Large square top left
63
- { width: 512, height: 1024, gridColumn: "3", gridRow: "1" }, // 2. Portrait top right
64
- { width: 1024, height: 768, gridColumn: "1 / span 3", gridRow: "2" }, // 3. Landscape bottom, spans full width
65
  ],
66
  },
67
  LAYOUT_3: {
68
  gridCols: 3,
69
  gridRows: 2,
70
  panels: [
71
- { width: 1024, height: 768, gridColumn: "1 / span 2", gridRow: "1" }, // 1. Landscape top left, spans 2 columns
72
- { width: 512, height: 1024, gridColumn: "3", gridRow: "1" }, // 2. Portrait top right
73
- { width: 512, height: 1024, gridColumn: "1", gridRow: "2" }, // 3. Portrait bottom left
74
- { width: 1024, height: 768, gridColumn: "2 / span 2", gridRow: "2" }, // 4. Landscape bottom right, spans 2 columns
75
  ],
76
  },
77
  LAYOUT_4: {
78
- gridCols: 8,
79
- gridRows: 8,
80
  panels: [
 
 
 
 
 
 
 
 
 
 
 
 
81
  {
82
- width: 768,
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: "5 / span 3",
97
- gridRow: "1 / span 5",
98
- }, // 3. Long portrait top right
99
- {
100
- width: 768,
101
- height: 768,
102
- gridColumn: "5 / span 3",
103
- gridRow: "6 / span 3",
104
- }, // 4. Square bottom right
 
 
 
 
105
  ],
106
  },
107
  };
108
 
109
  export const defaultLayout = "LAYOUT_1";
110
  export const nonRandomLayouts = Object.keys(LAYOUTS).filter(
111
- (layout) => layout !== "random"
112
  );
113
 
114
  // Helper functions for layout configuration
115
- export const getNextLayoutType = (currentLayoutCount) =>
116
- `LAYOUT_${(currentLayoutCount % 3) + 1}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // Premier segment toujours en COVER s'il est marqué comme first_step
10
- if (segments[0].is_first_step) {
11
- layouts.push({
12
- type: "COVER",
13
- segments: [segments[0]],
14
- });
15
- }
16
-
17
- // Segments du milieu (on exclut le premier s'il était en COVER)
18
- const startIndex = segments[0].is_first_step ? 1 : 0;
19
- const middleSegments = segments.slice(startIndex);
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
- currentIndex++;
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
- // Déterminer le type du prochain layout
78
- const nextLayoutType = getNextLayoutType(layouts.length);
 
 
 
 
 
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
- <React.StrictMode>
19
- <ThemeProvider theme={theme}>
20
- <CssBaseline />
21
- <App />
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 generate_image(self,
 
 
 
 
 
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
- response = requests.post(
 
 
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": "speech bubble, caption, subtitle"
96
  }
97
  }
98
- )
99
-
100
- print(f"Response status code: {response.status_code}")
101
- print(f"Response headers: {response.headers}")
102
- print(f"Response content type: {response.headers.get('content-type', 'unknown')}")
103
-
104
- if response.status_code == 200:
105
- content_length = len(response.content)
106
- print(f"Received successful response with content length: {content_length}")
107
- if isinstance(response.content, bytes):
108
- print("Response content is bytes (correct)")
 
 
 
109
  else:
110
- print(f"Warning: Response content is {type(response.content)}")
111
- return response.content
112
- else:
113
- print(f"Error from Flux API: {response.status_code}")
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 StorySegment(BaseModel):
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=StorySegment)
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: The first story beat (story_beat = 0) MUST be an introduction that sets up the horror atmosphere.
 
 
 
 
 
 
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
- - Describes the horrific environment
 
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
- Examples of good choices:
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
- Generate the next story segment and choices. 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."""
 
 
 
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) -> StorySegment:
 
 
 
 
 
 
 
 
 
 
 
 
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: StorySegment) -> StorySegment:
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
- story_segment = await story_generator.generate_story_segment(game_state, previous_choice)
164
- print("Generated story segment:", story_segment)
165
 
166
  # Update radiation level
167
- game_state.radiation_level += story_segment.radiation_increase
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
- story_segment.story_text += f"""
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
- story_segment.choices = []
 
 
 
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
- story_segment.is_victory = True
187
- story_segment.story_text = f"""Sarah l'a fait ! Elle a trouvé un bunker sécurisé avec des survivants.
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
- story_segment.choices = []
 
 
 
 
 
 
 
194
 
195
- # Only increment story beat if not dead and not victory
196
- if not is_death and not story_segment.is_victory:
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(story_segment.choices, 1)
204
  ]
205
 
206
- # Determine if this is the first step
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=story_segment.story_text,
215
  choices=choices,
216
  radiation_level=game_state.radiation_level,
217
- is_victory=story_segment.is_victory,
218
- is_first_step=is_first_step,
219
- is_last_step=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"""