diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..71cd0b38c30b74bbbb3396cdc9830543a874a0d0 Binary files /dev/null and b/.DS_Store differ diff --git a/Dockerfile b/Dockerfile index 89fabd8f7cc8e3cbc18e80d977a2f07efeda0a5e..9120a6b03f5f0e60eb5d20d13d3dcdce945fef5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ COPY client/package*.json ./ RUN npm install COPY client/ ./ ENV VITE_API_URL=https://mistral-ai-game-jam-dont-lookup.hf.space -RUN npm run build +RUN mkdir -p dist && npm run build FROM python:3.9-slim WORKDIR /app diff --git a/client/src/App.jsx b/client/src/App.jsx index 487a5a9e63ee1d0495e4f5016c61e017db82c4a1..d8270a514e112777027fed9f1eb9e98982ede9f2 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -5,41 +5,117 @@ import { Button, Box, Typography, - List, - ListItem, - ListItemText, LinearProgress, } from "@mui/material"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; import axios from "axios"; +import { ComicLayout } from "./layouts/ComicLayout"; +import { getNextPanelDimensions } from "./layouts/utils"; // Get API URL from environment or default to localhost in development const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; +// Generate a unique client ID +const CLIENT_ID = `client_${Math.random().toString(36).substring(2)}`; + +// Create axios instance with default config +const api = axios.create({ + headers: { + "x-client-id": CLIENT_ID, + }, +}); + +// Function to convert text with ** to bold elements +const formatTextWithBold = (text) => { + if (!text) return ""; + const parts = text.split(/(\*\*.*?\*\*)/g); + return parts.map((part, index) => { + if (part.startsWith("**") && part.endsWith("**")) { + // Remove the ** and wrap in bold + return {part.slice(2, -2)}; + } + return part; + }); +}; + function App() { const [storySegments, setStorySegments] = useState([]); const [currentChoices, setCurrentChoices] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [isDebugMode, setIsDebugMode] = useState(false); const isInitializedRef = useRef(false); + const currentImageRequestRef = useRef(null); + const pendingImageRequests = useRef(new Set()); // Track pending image requests - const generateImageForStory = async (storyText) => { + const generateImageForStory = async (storyText, segmentIndex) => { try { + // Cancel previous request if it exists + if (currentImageRequestRef.current) { + currentImageRequestRef.current.abort(); + } + + // Add this segment to pending requests + pendingImageRequests.current.add(segmentIndex); + console.log("Generating image for story:", storyText); - const response = await axios.post(`${API_URL}/api/generate-image`, { - prompt: `Comic book style scene: ${storyText}`, - width: 512, - height: 512, - }); + const dimensions = getNextPanelDimensions(storySegments); + console.log("[DEBUG] Story segments:", storySegments); + console.log("[DEBUG] Dimensions object:", dimensions); + console.log( + "[DEBUG] Width:", + dimensions?.width, + "Height:", + dimensions?.height + ); + + if (!dimensions || !dimensions.width || !dimensions.height) { + console.error("[ERROR] Invalid dimensions:", dimensions); + pendingImageRequests.current.delete(segmentIndex); + return null; + } + + // Create new AbortController for this request + const abortController = new AbortController(); + currentImageRequestRef.current = abortController; - console.log("Image generation response:", response.data); + const response = await api.post( + `${API_URL}/api/${isDebugMode ? "test/" : ""}generate-image`, + { + prompt: `Comic book style scene: ${storyText}`, + width: dimensions.width, + height: dimensions.height, + }, + { + signal: abortController.signal, + } + ); + + // Remove from pending requests + pendingImageRequests.current.delete(segmentIndex); if (response.data.success) { - console.log("Image URL length:", response.data.image_base64.length); return response.data.image_base64; } return null; } catch (error) { - console.error("Error generating image:", error); + if (axios.isCancel(error)) { + console.log("Image request cancelled for segment", segmentIndex); + // On met quand même à jour le segment pour arrêter le spinner + setStorySegments((prev) => { + const updatedSegments = [...prev]; + if (updatedSegments[segmentIndex]) { + updatedSegments[segmentIndex] = { + ...updatedSegments[segmentIndex], + image_base64: null, + imageRequestCancelled: true, // Flag pour indiquer que la requête a été annulée + }; + } + return updatedSegments; + }); + } else { + console.error("Error generating image:", error); + } + pendingImageRequests.current.delete(segmentIndex); return null; } }; @@ -47,51 +123,91 @@ function App() { const handleStoryAction = async (action, choiceId = null) => { setIsLoading(true); try { - const response = await axios.post(`${API_URL}/api/chat`, { - message: action, - choice_id: choiceId, - }); - - // Générer l'image pour ce segment - const imageUrl = await generateImageForStory(response.data.story_text); - console.log( - "Generated image URL:", - imageUrl ? "Image received" : "No image" + // 1. D'abord, obtenir l'histoire + const response = await api.post( + `${API_URL}/api/${isDebugMode ? "test/" : ""}chat`, + { + message: action, + choice_id: choiceId, + } ); + // 2. Créer le nouveau segment sans image + const newSegment = { + text: formatTextWithBold(response.data.story_text), + isChoice: false, + isDeath: response.data.is_death, + isVictory: response.data.is_victory, + radiationLevel: response.data.radiation_level, + is_first_step: response.data.is_first_step, + is_last_step: response.data.is_last_step, + image_base64: null, + }; + + let segmentIndex; + // 3. Mettre à jour l'état avec le nouveau segment if (action === "restart") { - setStorySegments([ - { - text: response.data.story_text, - isChoice: false, - isDeath: response.data.is_death, - isVictory: response.data.is_victory, - radiationLevel: response.data.radiation_level, - imageUrl: imageUrl, - }, - ]); + setStorySegments([newSegment]); + segmentIndex = 0; } else { - setStorySegments((prev) => [ - ...prev, - { - text: response.data.story_text, - isChoice: false, - isDeath: response.data.is_death, - isVictory: response.data.is_victory, - radiationLevel: response.data.radiation_level, - imageUrl: imageUrl, - }, - ]); + setStorySegments((prev) => { + segmentIndex = prev.length; + return [...prev, newSegment]; + }); } + // 4. Mettre à jour les choix immédiatement setCurrentChoices(response.data.choices); + + // 5. Désactiver le loading car l'histoire est affichée + setIsLoading(false); + + // 6. Tenter de générer l'image en arrière-plan + try { + const image_base64 = await generateImageForStory( + response.data.story_text, + segmentIndex + ); + if (image_base64) { + setStorySegments((prev) => { + const updatedSegments = [...prev]; + if (updatedSegments[segmentIndex]) { + updatedSegments[segmentIndex] = { + ...updatedSegments[segmentIndex], + image_base64: image_base64, + }; + } + return updatedSegments; + }); + } + } catch (imageError) { + console.error("Error generating image:", imageError); + } } catch (error) { console.error("Error:", error); - setStorySegments((prev) => [ - ...prev, - { text: "Connection lost with the storyteller...", isChoice: false }, - ]); - } finally { + // En cas d'erreur, créer un segment d'erreur qui permet de continuer + const errorSegment = { + text: "Le conteur d'histoires est temporairement indisponible. Veuillez réessayer dans quelques instants...", + isChoice: false, + isDeath: false, + isVictory: false, + radiationLevel: + storySegments.length > 0 + ? storySegments[storySegments.length - 1].radiationLevel + : 0, + image_base64: null, + }; + + // Ajouter le segment d'erreur et permettre de réessayer + if (action === "restart") { + setStorySegments([errorSegment]); + } else { + setStorySegments((prev) => [...prev, errorSegment]); + } + + // Donner l'option de réessayer + setCurrentChoices([{ id: 1, text: "Réessayer" }]); + setIsLoading(false); } }; @@ -105,158 +221,182 @@ function App() { }, []); // Empty dependency array since we're using a ref const handleChoice = async (choiceId) => { - // Add the chosen option to the story + // Si c'est l'option "Réessayer", on relance la dernière action + if (currentChoices.length === 1 && currentChoices[0].text === "Réessayer") { + // Supprimer le segment d'erreur + setStorySegments((prev) => prev.slice(0, -1)); + // Réessayer la dernière action + await handleStoryAction( + "choice", + storySegments[storySegments.length - 2]?.choiceId || null + ); + return; + } + + // Comportement normal pour les autres choix + const choice = currentChoices.find((c) => c.id === choiceId); setStorySegments((prev) => [ ...prev, { - text: currentChoices.find((c) => c.id === choiceId).text, + text: choice.text, isChoice: true, + choiceId: choiceId, // Stocker l'ID du choix pour pouvoir réessayer }, ]); + // Continue the story with this choice await handleStoryAction("choice", choiceId); }; + // Filter out choice segments + const nonChoiceSegments = storySegments.filter( + (segment) => !segment.isChoice + ); + return ( - - + {/* - - - Echoes of Influence - - - 0 && - storySegments[storySegments.length - 1].radiationLevel >= 7 - ? "error.light" - : "inherit", - }, - }} - > - - Radiation:{" "} - - {storySegments.length > 0 - ? `${ - storySegments[storySegments.length - 1].radiationLevel - }/10` - : "0/10"} - - - - + + 0 && + storySegments[storySegments.length - 1].radiationLevel >= 7 + ? "error.light" + : "inherit", + }, + }} + > + + Radiation:{" "} + + {storySegments.length > 0 + ? `${ + storySegments[storySegments.length - 1].radiationLevel + }/10` + : "0/10"} + + + + + */} - {isLoading && } + {/* {isLoading && } */} - - {storySegments.map((segment, index) => ( - - - - {!segment.isChoice && segment.imageUrl && ( - - Story scene - - )} - - - ))} - - - {currentChoices.length > 0 && ( - + + + + + + {currentChoices.length > 0 ? ( + {currentChoices.map((choice) => ( ))} - )} - - + ) : storySegments.length > 0 && + storySegments[storySegments.length - 1].is_last_step ? ( + + + + ) : null} + + ); } diff --git a/client/src/fonts/Action-Man/Action-Man-Bold-Italic.woff2 b/client/src/fonts/Action-Man/Action-Man-Bold-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0ec18e70639c1a6aac33972ffb7492455f546c9f Binary files /dev/null and b/client/src/fonts/Action-Man/Action-Man-Bold-Italic.woff2 differ diff --git a/client/src/fonts/Action-Man/Action-Man-Bold.woff2 b/client/src/fonts/Action-Man/Action-Man-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..72034cc187a9d6c6609973979e69743055823648 Binary files /dev/null and b/client/src/fonts/Action-Man/Action-Man-Bold.woff2 differ diff --git a/client/src/fonts/Action-Man/Action-Man-Italic.woff2 b/client/src/fonts/Action-Man/Action-Man-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e478e7fc3c91a8d68cdb95537294e1f3929cc218 Binary files /dev/null and b/client/src/fonts/Action-Man/Action-Man-Italic.woff2 differ diff --git a/client/src/fonts/Action-Man/Action-Man.woff2 b/client/src/fonts/Action-Man/Action-Man.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..722fae9600d32df498732426b0947d07bf9e657f Binary files /dev/null and b/client/src/fonts/Action-Man/Action-Man.woff2 differ diff --git a/client/src/fonts/Action-Man/Action_Man_Extended-webfont.woff b/client/src/fonts/Action-Man/Action_Man_Extended-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..24170e27ab9e6868256367f49dc5534451069bac Binary files /dev/null and b/client/src/fonts/Action-Man/Action_Man_Extended-webfont.woff differ diff --git a/client/src/fonts/Action-Man/Action_Man_Extended_Bold-webfont.woff2 b/client/src/fonts/Action-Man/Action_Man_Extended_Bold-webfont.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f12a7dc726958e27e242e5ad419db69be268677c Binary files /dev/null and b/client/src/fonts/Action-Man/Action_Man_Extended_Bold-webfont.woff2 differ diff --git a/client/src/fonts/Action-Man/Action_Man_Extended_Bold_Italic-webfont.woff b/client/src/fonts/Action-Man/Action_Man_Extended_Bold_Italic-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..7f55b449c80687d0d20610a72d8518d7c0b24fcf Binary files /dev/null and b/client/src/fonts/Action-Man/Action_Man_Extended_Bold_Italic-webfont.woff differ diff --git a/client/src/fonts/Action-Man/Action_Man_Extended_Italic-webfont.woff b/client/src/fonts/Action-Man/Action_Man_Extended_Italic-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..8d5e36ca58b1c4dd372b3898419d25d54debda5f Binary files /dev/null and b/client/src/fonts/Action-Man/Action_Man_Extended_Italic-webfont.woff differ diff --git a/client/src/fonts/Action-Man/Action_Man_Shaded-webfont.woff b/client/src/fonts/Action-Man/Action_Man_Shaded-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..50261bc91c6e88bd2ab5fc916c3325c8c4941fe8 Binary files /dev/null and b/client/src/fonts/Action-Man/Action_Man_Shaded-webfont.woff differ diff --git a/client/src/fonts/Action-Man/Action_Man_Shaded_Italic-webfont.woff b/client/src/fonts/Action-Man/Action_Man_Shaded_Italic-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..e3f29d18814f2936ef043678e4cee902a5401085 Binary files /dev/null and b/client/src/fonts/Action-Man/Action_Man_Shaded_Italic-webfont.woff differ diff --git a/client/src/fonts/DigitalStripBB/DigitalStripBB_BoldItal.woff2 b/client/src/fonts/DigitalStripBB/DigitalStripBB_BoldItal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a8d1d84d091cbddda3199044686f5bf715178684 Binary files /dev/null and b/client/src/fonts/DigitalStripBB/DigitalStripBB_BoldItal.woff2 differ diff --git a/client/src/fonts/DigitalStripBB/DigitalStripBB_Ital.woff2 b/client/src/fonts/DigitalStripBB/DigitalStripBB_Ital.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..af804905890e4ed1f9ba058c20174915e743eb99 Binary files /dev/null and b/client/src/fonts/DigitalStripBB/DigitalStripBB_Ital.woff2 differ diff --git a/client/src/fonts/DigitalStripBB/DigitalStripBB_Reg.woff2 b/client/src/fonts/DigitalStripBB/DigitalStripBB_Reg.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4b3796ea2737bd7337031865de11c0bc4c9d5a0e Binary files /dev/null and b/client/src/fonts/DigitalStripBB/DigitalStripBB_Reg.woff2 differ diff --git a/client/src/fonts/Karantula/Karantula-Bold.woff2 b/client/src/fonts/Karantula/Karantula-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0c19156faf8f3bc29ed8cd591920afc8f861b808 Binary files /dev/null and b/client/src/fonts/Karantula/Karantula-Bold.woff2 differ diff --git a/client/src/fonts/Karantula/Karantula-Italic-Bold.woff2 b/client/src/fonts/Karantula/Karantula-Italic-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4ed852dbfb5e48464efed63cae53cd61ef657b94 Binary files /dev/null and b/client/src/fonts/Karantula/Karantula-Italic-Bold.woff2 differ diff --git a/client/src/fonts/Karantula/Karantula-Italic.woff2 b/client/src/fonts/Karantula/Karantula-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3d6f55dee0e59ec29ec9b4a373a5dacda724694b Binary files /dev/null and b/client/src/fonts/Karantula/Karantula-Italic.woff2 differ diff --git a/client/src/fonts/Karantula/Karantula.woff2 b/client/src/fonts/Karantula/Karantula.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9e9c9d37b1420bf6d1d78516d5786e2058f153db Binary files /dev/null and b/client/src/fonts/Karantula/Karantula.woff2 differ diff --git a/client/src/fonts/Komika-Display/Komika-Display.woff b/client/src/fonts/Komika-Display/Komika-Display.woff new file mode 100644 index 0000000000000000000000000000000000000000..9765c0aa82a06e85eb4542681a2ddedb20d7ffce Binary files /dev/null and b/client/src/fonts/Komika-Display/Komika-Display.woff differ diff --git a/client/src/fonts/Komika-Display/Komika_display_bold-webfont.woff b/client/src/fonts/Komika-Display/Komika_display_bold-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..2a86cc0a3c397db156537b46d2240840ce5b0502 Binary files /dev/null and b/client/src/fonts/Komika-Display/Komika_display_bold-webfont.woff differ diff --git a/client/src/fonts/Komika-Display/Komika_display_kaps-webfont.woff b/client/src/fonts/Komika-Display/Komika_display_kaps-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..d607dd4e64f3f7146cfaebcb6599294747d3c859 Binary files /dev/null and b/client/src/fonts/Komika-Display/Komika_display_kaps-webfont.woff differ diff --git a/client/src/fonts/Komika-Display/Komika_display_kaps_bold-webfont.woff b/client/src/fonts/Komika-Display/Komika_display_kaps_bold-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..04302d64f473c063c180ceb7c7fb8967376e9186 Binary files /dev/null and b/client/src/fonts/Komika-Display/Komika_display_kaps_bold-webfont.woff differ diff --git a/client/src/fonts/Komika-Hand/Komika-Hand-Bold-Italic.woff2 b/client/src/fonts/Komika-Hand/Komika-Hand-Bold-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4d00e7eed6ef125b41ca834e5dca5da7d98b75ca Binary files /dev/null and b/client/src/fonts/Komika-Hand/Komika-Hand-Bold-Italic.woff2 differ diff --git a/client/src/fonts/Komika-Hand/Komika-Hand-Bold.woff2 b/client/src/fonts/Komika-Hand/Komika-Hand-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..6cf07d240b46c268602c2f68534b41d3b626ef9c Binary files /dev/null and b/client/src/fonts/Komika-Hand/Komika-Hand-Bold.woff2 differ diff --git a/client/src/fonts/Komika-Hand/Komika-Hand-Italic.woff2 b/client/src/fonts/Komika-Hand/Komika-Hand-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fad3b3f7db445d37fad067996b9c9e010da55d0c Binary files /dev/null and b/client/src/fonts/Komika-Hand/Komika-Hand-Italic.woff2 differ diff --git a/client/src/fonts/Komika-Hand/Komika-Hand.woff2 b/client/src/fonts/Komika-Hand/Komika-Hand.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f4a697cd7533400c909841fbe79268e87de9cfc8 Binary files /dev/null and b/client/src/fonts/Komika-Hand/Komika-Hand.woff2 differ diff --git a/client/src/fonts/Komika-Hand/Komika_Parch.woff2 b/client/src/fonts/Komika-Hand/Komika_Parch.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0e2481446a7becd69821b263192de98f8f4a33f9 Binary files /dev/null and b/client/src/fonts/Komika-Hand/Komika_Parch.woff2 differ diff --git a/client/src/fonts/Komika-Text/KOMTXKBI-webfont.woff b/client/src/fonts/Komika-Text/KOMTXKBI-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..408a051cc071bbbcbf998ac055ca1edab4a56334 Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXKBI-webfont.woff differ diff --git a/client/src/fonts/Komika-Text/KOMTXTBI-webfont.woff b/client/src/fonts/Komika-Text/KOMTXTBI-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..ce1185b368884e3aa6009d1c4d2dec4360beed2b Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXTBI-webfont.woff differ diff --git a/client/src/fonts/Komika-Text/KOMTXTB_-webfont.woff b/client/src/fonts/Komika-Text/KOMTXTB_-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..a3407ce6b4bbf99293d1f68d925ce938148b6a5c Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXTB_-webfont.woff differ diff --git a/client/src/fonts/Komika-Text/KOMTXTI_-webfont.woff b/client/src/fonts/Komika-Text/KOMTXTI_-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..f1f999e0edc910b0883e20f3f3365993e541ef2a Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXTI_-webfont.woff differ diff --git a/client/src/fonts/Komika-Text/KOMTXTKB-webfont.woff b/client/src/fonts/Komika-Text/KOMTXTKB-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..9e7599c346deb8c9ffa13be7f68e78099f939ba1 Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXTKB-webfont.woff differ diff --git a/client/src/fonts/Komika-Text/KOMTXTKI-webfont.woff b/client/src/fonts/Komika-Text/KOMTXTKI-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..6b156d23e5f50b7598d16c100bda49311074a78a Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXTKI-webfont.woff differ diff --git a/client/src/fonts/Komika-Text/KOMTXTK_-webfont.woff b/client/src/fonts/Komika-Text/KOMTXTK_-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..b0d646d54748892d69cb47e70fb06fc281cb01a2 Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXTK_-webfont.woff differ diff --git a/client/src/fonts/Komika-Text/KOMTXTTI-webfont.woff b/client/src/fonts/Komika-Text/KOMTXTTI-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..5620b4e071854d27818adfbe208e7b0e79138d31 Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXTTI-webfont.woff differ diff --git a/client/src/fonts/Komika-Text/KOMTXTT_-webfont.woff b/client/src/fonts/Komika-Text/KOMTXTT_-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..c4087f02611a4b625913ec9e8574c89ddc76f6f4 Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXTT_-webfont.woff differ diff --git a/client/src/fonts/Komika-Text/KOMTXT__-webfont.woff b/client/src/fonts/Komika-Text/KOMTXT__-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..214a90f28059584ab95fb1e9abe301ea5c54c8d0 Binary files /dev/null and b/client/src/fonts/Komika-Text/KOMTXT__-webfont.woff differ diff --git a/client/src/fonts/Manoskope/MANOSKOPE-Bold.woff2 b/client/src/fonts/Manoskope/MANOSKOPE-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..76e2ebabf3cc696a480abd76e8f48faf273600f5 Binary files /dev/null and b/client/src/fonts/Manoskope/MANOSKOPE-Bold.woff2 differ diff --git a/client/src/fonts/Paete-Round/Paete-Round-Bold-Italic.woff2 b/client/src/fonts/Paete-Round/Paete-Round-Bold-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e2c08fb187738488fa931f12e8e43a3ec594bc39 Binary files /dev/null and b/client/src/fonts/Paete-Round/Paete-Round-Bold-Italic.woff2 differ diff --git a/client/src/fonts/Paete-Round/Paete-Round-Bold.woff2 b/client/src/fonts/Paete-Round/Paete-Round-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..309a2c0caff1759bd4656d318cb22a368dc620cd Binary files /dev/null and b/client/src/fonts/Paete-Round/Paete-Round-Bold.woff2 differ diff --git a/client/src/fonts/Paete-Round/Paete-Round-Italic.woff2 b/client/src/fonts/Paete-Round/Paete-Round-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5558b83264986de53f589dd8882ebd6aa4ebe49d Binary files /dev/null and b/client/src/fonts/Paete-Round/Paete-Round-Italic.woff2 differ diff --git a/client/src/fonts/Paete-Round/Paete-Round.woff2 b/client/src/fonts/Paete-Round/Paete-Round.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a42e4132c8b36d4647d461d884f614e1e559d03b Binary files /dev/null and b/client/src/fonts/Paete-Round/Paete-Round.woff2 differ diff --git a/client/src/fonts/Qarmic-Sans/Qarmic-Sans-Abridged.woff2 b/client/src/fonts/Qarmic-Sans/Qarmic-Sans-Abridged.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..92776051c312cfee3ba0414beda9a794ff62cd0b Binary files /dev/null and b/client/src/fonts/Qarmic-Sans/Qarmic-Sans-Abridged.woff2 differ diff --git a/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Bold-Italic.woff2 b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Bold-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fd78c38ba461af3d204835e0f2b7635d70993752 Binary files /dev/null and b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Bold-Italic.woff2 differ diff --git a/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Bold.woff2 b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..172ddf351887d9e4c9a2e0a2caa526d4eaa31db5 Binary files /dev/null and b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Bold.woff2 differ diff --git a/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended-Bold-Italic.woff2 b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended-Bold-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..42935fe3900b605ff5207c7d53ed265d0ad937a8 Binary files /dev/null and b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended-Bold-Italic.woff2 differ diff --git a/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended-Bold.woff2 b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9ba4edfcfc3ecdb44425cabdda6592130dfeb92b Binary files /dev/null and b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended-Bold.woff2 differ diff --git a/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended-Italic.woff2 b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..cfbeaee58e94061eeaf9e3ce464ea3a7be95253e Binary files /dev/null and b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended-Italic.woff2 differ diff --git a/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended.woff2 b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..85fd449b3166da2ea7da35d1739fcd197453ff4e Binary files /dev/null and b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Extended.woff2 differ diff --git a/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Italic.woff2 b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..752a35a6aa080f131641dbf15c0f0b1d1b0290a9 Binary files /dev/null and b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival-Italic.woff2 differ diff --git a/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival.woff2 b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..50adb0537ff5376b090d4605643ee5957ded1ff1 Binary files /dev/null and b/client/src/fonts/SF-Arch-Rival/SF-Arch-Rival.woff2 differ diff --git a/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-Bold-Italic.woff2 b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-Bold-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2df36ebe2bb9a9562c07e7e543a16ad4eaab0202 Binary files /dev/null and b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-Bold-Italic.woff2 differ diff --git a/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-Bold.woff2 b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5d2cb4be454e3031e1a2bd35482875997872754f Binary files /dev/null and b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-Bold.woff2 differ diff --git a/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-Italic.woff2 b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b1ab5e8e14aae791c38d31309054508d5be9271b Binary files /dev/null and b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-Italic.woff2 differ diff --git a/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC-Bold-Italic.woff2 b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC-Bold-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..853ccc1db12a07388d52efcaa7fed6a83c205916 Binary files /dev/null and b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC-Bold-Italic.woff2 differ diff --git a/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC-Bold.woff2 b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..995f444bac9982b148a1fe16fccb6aab3cf91f61 Binary files /dev/null and b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC-Bold.woff2 differ diff --git a/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC-Italic.woff2 b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7e8e9cb3d8a5fe9994e301fa9383f41c922f5e28 Binary files /dev/null and b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC-Italic.woff2 differ diff --git a/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC.woff2 b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e82f4919ff959cf103ce1d731d19fb7c39aa5326 Binary files /dev/null and b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand-SC.woff2 differ diff --git a/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand.woff2 b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4c8632923efd9e4a6127edcec633de7202d58cde Binary files /dev/null and b/client/src/fonts/SF-Cartoonist-Hand/SF-Cartoonist-Hand.woff2 differ diff --git a/client/src/fonts/SF-Toontime/SF-Toontime-Blotch-Italic.woff2 b/client/src/fonts/SF-Toontime/SF-Toontime-Blotch-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..92fd94e29457a799f8d8a9421d0b0cd33e08a75e Binary files /dev/null and b/client/src/fonts/SF-Toontime/SF-Toontime-Blotch-Italic.woff2 differ diff --git a/client/src/fonts/SF-Toontime/SF-Toontime-Blotch.woff2 b/client/src/fonts/SF-Toontime/SF-Toontime-Blotch.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f828eabb9994c81d31665c7043a464b330681833 Binary files /dev/null and b/client/src/fonts/SF-Toontime/SF-Toontime-Blotch.woff2 differ diff --git a/client/src/fonts/SF-Toontime/SF-Toontime-Bold-Italic.woff2 b/client/src/fonts/SF-Toontime/SF-Toontime-Bold-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d28ed904cbc5e3afeebb48c7a6660d13f66fe894 Binary files /dev/null and b/client/src/fonts/SF-Toontime/SF-Toontime-Bold-Italic.woff2 differ diff --git a/client/src/fonts/SF-Toontime/SF-Toontime-Bold.woff2 b/client/src/fonts/SF-Toontime/SF-Toontime-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..ca261f71e4d22e5449ab77aa25266c36ca13bc30 Binary files /dev/null and b/client/src/fonts/SF-Toontime/SF-Toontime-Bold.woff2 differ diff --git a/client/src/fonts/SF-Toontime/SF-Toontime-Italic.woff2 b/client/src/fonts/SF-Toontime/SF-Toontime-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b69bbb0e6099795da58953f27b11badf751b7d55 Binary files /dev/null and b/client/src/fonts/SF-Toontime/SF-Toontime-Italic.woff2 differ diff --git a/client/src/fonts/SF-Toontime/SF-Toontime.woff2 b/client/src/fonts/SF-Toontime/SF-Toontime.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d3d4d8c384004215748e16957f4ac809e5c9c6e9 Binary files /dev/null and b/client/src/fonts/SF-Toontime/SF-Toontime.woff2 differ diff --git a/client/src/fonts/VTC-Letterer-Pro/VTC-Letterer-Pro.woff2 b/client/src/fonts/VTC-Letterer-Pro/VTC-Letterer-Pro.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..ec3bb640fabde62e9ec96ca521a6e2f6b003d119 Binary files /dev/null and b/client/src/fonts/VTC-Letterer-Pro/VTC-Letterer-Pro.woff2 differ diff --git a/client/src/index.css b/client/src/index.css index 3eb54ba1840000a783cc8731ae191ae863bc5a8f..70854dc98f8add51745bc8355bc0b7582f9a8194 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -4,14 +4,12 @@ box-sizing: border-box; } -body { - min-height: 100vh; - background-color: #f5f5f5; -} - +html, +body, #root { min-height: 100vh; - padding: 1rem; + background-color: #f5f5f5; + overflow: hidden; } :root { diff --git a/client/src/layouts/ComicLayout.jsx b/client/src/layouts/ComicLayout.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2d96e86f7d585c4296e8162f1fca38577079f073 --- /dev/null +++ b/client/src/layouts/ComicLayout.jsx @@ -0,0 +1,162 @@ +import { Box, CircularProgress, Typography } from "@mui/material"; +import { LAYOUTS } from "./config"; +import { groupSegmentsIntoLayouts } from "./utils"; +import { useEffect, useRef } from "react"; + +// Component for displaying a single panel +function Panel({ segment, panel }) { + return ( + + {segment ? ( + <> + {segment.image_base64 ? ( + Story scene { + e.target.style.opacity = "1"; + }} + /> + ) : ( + + {!segment.imageRequestCancelled && ( + + )} + {segment.imageRequestCancelled && ( + + Image non chargée + + )} + + )} + + {segment.text} + + + ) : null} + + ); +} + +// Component for displaying a page of panels +function ComicPage({ layout, layoutIndex }) { + return ( + + {LAYOUTS[layout.type].panels.map((panel, panelIndex) => ( + + ))} + + ); +} + +// Main comic layout component +export function ComicLayout({ segments }) { + const layouts = groupSegmentsIntoLayouts(segments); + const scrollContainerRef = useRef(null); + + // Effect to scroll to the right when new layouts are added + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: scrollContainerRef.current.scrollWidth, + behavior: "smooth", + }); + } + }, [layouts.length]); // Only run when the number of layouts changes + + return ( + + {layouts.map((layout, layoutIndex) => ( + + ))} + + ); +} diff --git a/client/src/layouts/Layout.jsx b/client/src/layouts/Layout.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ff78741eb4fb71ca9b95252d6f48e1325b7c0e9b --- /dev/null +++ b/client/src/layouts/Layout.jsx @@ -0,0 +1,235 @@ +import { Box, CircularProgress, Typography } from "@mui/material"; + +// Layout settings for different types +export const LAYOUTS = { + COVER: { + gridCols: 1, + gridRows: 1, + panels: [ + { width: 1024, height: 1536, gridColumn: "1", gridRow: "1" }, // Format pleine page (2:3 ratio) + ], + }, + LAYOUT_1: { + gridCols: 2, + gridRows: 3, + panels: [ + { width: 1024, height: 768, gridColumn: "1", gridRow: "1" }, // Landscape top left + { width: 768, height: 1024, gridColumn: "2", gridRow: "1 / span 2" }, // Portrait top right, spans 2 rows + { width: 768, height: 1024, gridColumn: "1", gridRow: "2 / span 2" }, // Portrait bottom left, spans 2 rows + { width: 1024, height: 768, gridColumn: "2", gridRow: "3" }, // Landscape bottom right + ], + }, + LAYOUT_2: { + gridCols: 3, + gridRows: 2, + panels: [ + { width: 768, height: 1024, gridColumn: "1", gridRow: "1" }, // Portrait top left + { width: 768, height: 1024, gridColumn: "2", gridRow: "1" }, // Portrait top middle + { width: 512, height: 1024, gridColumn: "3", gridRow: "1 / span 2" }, // Tall portrait right, spans full height + { width: 1024, height: 768, gridColumn: "1 / span 2", gridRow: "2" }, // Landscape bottom, spans 2 columns + ], + }, + LAYOUT_3: { + gridCols: 3, + gridRows: 2, + panels: [ + { width: 1024, height: 768, gridColumn: "1 / span 2", gridRow: "1" }, // Landscape top, spans 2 columns + { width: 768, height: 1024, gridColumn: "3", gridRow: "1" }, // Portrait top right + { width: 768, height: 1024, gridColumn: "1", gridRow: "2" }, // Portrait bottom left + { width: 1024, height: 768, gridColumn: "2 / span 2", gridRow: "2" }, // Landscape bottom right, spans 2 columns + ], + }, + LAYOUT_4: { + gridCols: 8, + gridRows: 8, + panels: [ + { + width: 512, + height: 1024, + gridColumn: "1 / span 6", + gridRow: "1 / span 2", + }, // Wide top + { + width: 1024, + height: 768, + gridColumn: "3 / span 6", + gridRow: "3 / span 1", + }, // Middle right + { + width: 768, + height: 1024, + gridColumn: "2 / span 6", + gridRow: "4 / span 2", + }, // Middle center + { + width: 1024, + height: 512, + gridColumn: "1 / span 8", + gridRow: "6 / span 2", + }, // Wide bottom + ], + }, +}; + +// Function to group segments into layouts +function groupSegmentsIntoLayouts(segments) { + if (segments.length === 0) return []; + + const layouts = []; + + // Premier segment toujours en COVER s'il est marqué comme first_step + if (segments[0].is_first_step) { + layouts.push({ + type: "COVER", + segments: [segments[0]], + }); + } + + // Segments du milieu (on exclut le premier s'il était en COVER) + const startIndex = segments[0].is_first_step ? 1 : 0; + const middleSegments = segments.slice(startIndex); + let currentIndex = 0; + + while (currentIndex < middleSegments.length) { + const segment = middleSegments[currentIndex]; + + // Si c'est le dernier segment (mort ou victoire), on le met en COVER + if (segment.is_last_step) { + layouts.push({ + type: "COVER", + segments: [segment], + }); + } else { + // Sinon on utilise un layout normal + const layoutType = `LAYOUT_${(layouts.length % 3) + 1}`; + const maxPanels = LAYOUTS[layoutType].panels.length; + const availableSegments = middleSegments + .slice(currentIndex) + .filter((s) => !s.is_last_step); + + if (availableSegments.length > 0) { + layouts.push({ + type: layoutType, + segments: availableSegments.slice(0, maxPanels), + }); + currentIndex += Math.min(maxPanels, availableSegments.length) - 1; + } + } + + currentIndex++; + } + + console.log("Generated layouts:", layouts); // Debug log + return layouts; +} + +export function ComicLayout({ segments }) { + const layouts = groupSegmentsIntoLayouts(segments); + + return ( + + {layouts.map((layout, layoutIndex) => ( + + {/* Render all panels of the layout */} + {LAYOUTS[layout.type].panels.map((panel, panelIndex) => { + // Find the segment for this panel position if it exists + const segment = layout.segments[panelIndex]; + + return ( + + {segment ? ( + // If there's a segment, render image and text + <> + {segment.image_base64 ? ( + Story scene { + e.target.style.opacity = "1"; + }} + /> + ) : ( + + + + )} + + {segment.text} + + + ) : null} + + ); + })} + + ))} + + ); +} diff --git a/client/src/layouts/config.js b/client/src/layouts/config.js new file mode 100644 index 0000000000000000000000000000000000000000..48ca4556cedf2896991e4f1cd8dc0a8fe83190b7 --- /dev/null +++ b/client/src/layouts/config.js @@ -0,0 +1,80 @@ +// Layout settings for different types +export const LAYOUTS = { + COVER: { + gridCols: 1, + gridRows: 1, + panels: [ + { width: 1024, height: 1024, gridColumn: "1", gridRow: "1" }, // Format pleine page (1:1 ratio) + ], + }, + LAYOUT_1: { + gridCols: 2, + gridRows: 2, + panels: [ + { width: 1024, height: 768, gridColumn: "1", gridRow: "1" }, // 1. Landscape top left + { width: 768, height: 1024, gridColumn: "2", gridRow: "1" }, // 2. Portrait top right + { width: 1024, height: 768, gridColumn: "1", gridRow: "2" }, // 3. Landscape middle left + { width: 768, height: 1024, gridColumn: "2", gridRow: "2" }, // 4. Portrait right, spans bottom rows + ], + }, + LAYOUT_2: { + gridCols: 3, + gridRows: 2, + panels: [ + { width: 1024, height: 1024, gridColumn: "1 / span 2", gridRow: "1" }, // 1. Large square top left + { width: 512, height: 1024, gridColumn: "3", gridRow: "1" }, // 2. Portrait top right + { width: 1024, height: 768, gridColumn: "1 / span 3", gridRow: "2" }, // 3. Landscape bottom, spans full width + ], + }, + LAYOUT_3: { + gridCols: 3, + gridRows: 2, + panels: [ + { width: 1024, height: 768, gridColumn: "1 / span 2", gridRow: "1" }, // 1. Landscape top left, spans 2 columns + { width: 768, height: 1024, gridColumn: "3", gridRow: "1" }, // 2. Portrait top right + { width: 768, height: 1024, gridColumn: "1", gridRow: "2" }, // 3. Portrait bottom left + { width: 1024, height: 768, gridColumn: "2 / span 2", gridRow: "2" }, // 4. Landscape bottom right, spans 2 columns + ], + }, + LAYOUT_4: { + gridCols: 8, + gridRows: 8, + panels: [ + { + width: 768, + height: 768, + gridColumn: "1 / span 3", + gridRow: "1 / span 3", + }, // 1. Square top left + { + width: 768, + height: 1024, + gridColumn: "1 / span 3", + gridRow: "4 / span 5", + }, // 2. Long portrait bottom left + { + width: 768, + height: 1024, + gridColumn: "5 / span 3", + gridRow: "1 / span 5", + }, // 3. Long portrait top right + { + width: 768, + height: 768, + gridColumn: "5 / span 3", + gridRow: "6 / span 3", + }, // 4. Square bottom right + ], + }, +}; + +export const defaultLayout = "LAYOUT_1"; +export const nonRandomLayouts = Object.keys(LAYOUTS).filter( + (layout) => layout !== "random" +); + +// Helper functions for layout configuration +export const getNextLayoutType = (currentLayoutCount) => + `LAYOUT_${(currentLayoutCount % 3) + 1}`; +export const getLayoutDimensions = (layoutType, panelIndex) => + LAYOUTS[layoutType]?.panels[panelIndex]; diff --git a/client/src/layouts/utils.js b/client/src/layouts/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..0251001692ee5828d5ee875fcb3d3c749f621896 --- /dev/null +++ b/client/src/layouts/utils.js @@ -0,0 +1,91 @@ +import { LAYOUTS, getNextLayoutType } from "./config"; + +// Function to group segments into layouts +export function groupSegmentsIntoLayouts(segments) { + if (segments.length === 0) return []; + + const layouts = []; + + // Premier segment toujours en COVER s'il est marqué comme first_step + if (segments[0].is_first_step) { + layouts.push({ + type: "COVER", + segments: [segments[0]], + }); + } + + // Segments du milieu (on exclut le premier s'il était en COVER) + const startIndex = segments[0].is_first_step ? 1 : 0; + const middleSegments = segments.slice(startIndex); + let currentIndex = 0; + + while (currentIndex < middleSegments.length) { + const segment = middleSegments[currentIndex]; + + // Si c'est le dernier segment (mort ou victoire), on le met en COVER + if (segment.is_last_step) { + layouts.push({ + type: "COVER", + segments: [segment], + }); + } else { + // Sinon on utilise un layout normal + const layoutType = getNextLayoutType(layouts.length); + const maxPanels = LAYOUTS[layoutType].panels.length; + const availableSegments = middleSegments + .slice(currentIndex) + .filter((s) => !s.is_last_step); + + if (availableSegments.length > 0) { + layouts.push({ + type: layoutType, + segments: availableSegments.slice(0, maxPanels), + }); + currentIndex += Math.min(maxPanels, availableSegments.length) - 1; + } + } + + currentIndex++; + } + + return layouts; +} + +// Function to get panel dimensions for next image +export function getNextPanelDimensions(segments) { + const nonChoiceSegments = segments.filter((segment) => !segment.isChoice); + + // Si c'est le premier segment, utiliser le format COVER + if ( + nonChoiceSegments.length === 0 || + (nonChoiceSegments.length === 1 && nonChoiceSegments[0].is_first_step) + ) { + return LAYOUTS.COVER.panels[0]; + } + + // Si c'est le dernier segment et c'est une mort ou victoire, utiliser le format COVER + const lastSegment = nonChoiceSegments[nonChoiceSegments.length - 1]; + if (lastSegment.is_last_step) { + return LAYOUTS.COVER.panels[0]; + } + + // Pour les segments du milieu, déterminer le layout et la position dans ce layout + const layouts = groupSegmentsIntoLayouts(nonChoiceSegments.slice(0, -1)); + const lastLayout = layouts[layouts.length - 1]; + const segmentsInLastLayout = lastLayout ? lastLayout.segments.length : 0; + + // Déterminer le type du prochain layout + const nextLayoutType = getNextLayoutType(layouts.length); + const nextPanelIndex = segmentsInLastLayout; + + // Si le dernier layout est plein, prendre le premier panneau du prochain layout + if ( + !lastLayout || + segmentsInLastLayout >= LAYOUTS[lastLayout.type].panels.length + ) { + return LAYOUTS[nextLayoutType].panels[0]; + } + + // Sinon, prendre le prochain panneau du layout courant + return LAYOUTS[lastLayout.type].panels[nextPanelIndex]; +} diff --git a/client/vite.config.js b/client/vite.config.js index 8b0f57b91aeb45c54467e29f983a0893dc83c4d9..0e43ae8def386fe7cac80ef1e246b340b7080f94 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -1,7 +1,7 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], -}) +}); diff --git a/server/api_clients.py b/server/api_clients.py new file mode 100644 index 0000000000000000000000000000000000000000..7c34982a0c9f998d44d2f6f97bf62e21210298ed --- /dev/null +++ b/server/api_clients.py @@ -0,0 +1,121 @@ +import os +import requests +import asyncio +from typing import Optional +from langchain_mistralai.chat_models import ChatMistralAI +from langchain.schema import SystemMessage, HumanMessage + +class MistralClient: + def __init__(self, api_key: str): + self.chat_model = ChatMistralAI( + mistral_api_key=api_key, + model="ft:ministral-3b-latest:82f3f89c:20250125:12222969", + temperature=0.7 + ) + + # Pour le fixing parser + self.fixing_model = ChatMistralAI( + mistral_api_key=api_key, + model="ft:ministral-3b-latest:82f3f89c:20250125:12222969", + temperature=0.1 + ) + + # Pour gérer le rate limit + self.last_call_time = 0 + self.min_delay = 1 # 1 seconde minimum entre les appels + + async def _wait_for_rate_limit(self): + """Attend le temps nécessaire pour respecter le rate limit.""" + current_time = asyncio.get_event_loop().time() + time_since_last_call = current_time - self.last_call_time + + if time_since_last_call < self.min_delay: + await asyncio.sleep(self.min_delay - time_since_last_call) + + self.last_call_time = asyncio.get_event_loop().time() + + async def generate_story(self, messages) -> str: + """Génère une réponse à partir d'une liste de messages.""" + try: + await self._wait_for_rate_limit() + response = self.chat_model.invoke(messages) + return response.content + except Exception as e: + print(f"Error in Mistral API call: {str(e)}") + raise + + async def transform_prompt(self, story_text: str, system_prompt: str) -> str: + """Transforme un texte d'histoire en prompt artistique.""" + try: + await self._wait_for_rate_limit() + messages = [ + SystemMessage(content=system_prompt), + HumanMessage(content=f"Transform into a short prompt: {story_text}") + ] + response = self.chat_model.invoke(messages) + return response.content + except Exception as e: + print(f"Error transforming prompt: {str(e)}") + return story_text + +class FluxClient: + def __init__(self, api_key: str): + self.api_key = api_key + self.endpoint = os.getenv("FLUX_ENDPOINT", "https://api-inference.huggingface.co/models/stabilityai/sdxl-turbo") + + def generate_image(self, + prompt: str, + width: int, + height: int, + num_inference_steps: int = 30, + guidance_scale: float = 9.0) -> Optional[bytes]: + """Génère une image à partir d'un prompt.""" + try: + # Ensure dimensions are multiples of 8 + width = (width // 8) * 8 + height = (height // 8) * 8 + + print(f"Sending request to Hugging Face API: {self.endpoint}") + print(f"Headers: Authorization: Bearer {self.api_key[:4]}...") + print(f"Request body: {prompt[:100]}...") + + response = requests.post( + self.endpoint, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Accept": "image/jpeg" + }, + json={ + "inputs": prompt, + "parameters": { + "num_inference_steps": num_inference_steps, + "guidance_scale": guidance_scale, + "width": width, + "height": height, + "negative_prompt": "text, watermark, logo, signature, blurry, low quality" + } + } + ) + + print(f"Response status code: {response.status_code}") + print(f"Response headers: {response.headers}") + print(f"Response content type: {response.headers.get('content-type', 'unknown')}") + + if response.status_code == 200: + content_length = len(response.content) + print(f"Received successful response with content length: {content_length}") + if isinstance(response.content, bytes): + print("Response content is bytes (correct)") + else: + print(f"Warning: Response content is {type(response.content)}") + return response.content + else: + print(f"Error from Flux API: {response.status_code}") + print(f"Response content: {response.content}") + return None + + except Exception as e: + print(f"Error in FluxClient.generate_image: {str(e)}") + import traceback + print(f"Traceback: {traceback.format_exc()}") + return None \ No newline at end of file diff --git a/server/game/game_logic.py b/server/game/game_logic.py index 0548128ae4d7a04599454e68adef8da919d9c2a3..cde058478a60d371ffcf01680b0fbe1f27c99934 100644 --- a/server/game/game_logic.py +++ b/server/game/game_logic.py @@ -1,9 +1,15 @@ from pydantic import BaseModel, Field from typing import List -from langchain_mistralai.chat_models import ChatMistralAI from langchain.output_parsers import PydanticOutputParser, OutputFixingParser from langchain.prompts import ChatPromptTemplate, HumanMessagePromptTemplate, SystemMessagePromptTemplate -from langchain.schema import HumanMessage, SystemMessage +import os +import asyncio + +# Import local modules +if os.getenv("DOCKER_ENV"): + from server.api_clients import MistralClient +else: + from api_clients import MistralClient # Game constants MAX_RADIATION = 10 @@ -19,7 +25,7 @@ class GameState: # Story output structure class StorySegment(BaseModel): - story_text: str = Field(description="The next segment of the story. Like 20 words.") + 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.") choices: List[str] = Field(description="Exactly two possible choices for the player", min_items=2, max_items=2) is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False) radiation_increase: int = Field(description="How much radiation this segment adds (0-3)", ge=0, le=3, default=1) @@ -39,30 +45,20 @@ Rules: - No superfluous adjectives - Capture only the main action""" -HUMAN_ART_PROMPT = "Transform into a short prompt: {story_text}" - class StoryGenerator: def __init__(self, api_key: str): self.parser = PydanticOutputParser(pydantic_object=StorySegment) + self.mistral_client = MistralClient(api_key) + self.fixing_parser = OutputFixingParser.from_llm( parser=self.parser, - llm=ChatMistralAI( - mistral_api_key=api_key, - model="mistral-small", - temperature=0.1 - ) - ) - - self.chat_model = ChatMistralAI( - mistral_api_key=api_key, - model="ft:ministral-3b-latest:82f3f89c:20250125:12222969", - temperature=0.7 + llm=self.mistral_client.fixing_model ) self.prompt = self._create_prompt() def _create_prompt(self) -> ChatPromptTemplate: - system_template = """You are narrating a brutal dystopian story where Sarah must survive in a radioactive wasteland. This is a comic book story. + system_template = """You are narrating a brutal dystopian story where **Sarah** must survive in a radioactive wasteland. This is a comic book story. IMPORTANT: The first story beat (story_beat = 0) MUST be an introduction that sets up the horror atmosphere. @@ -82,31 +78,40 @@ IMPORTANT RULES FOR RADIATION: - Death occurs automatically when radiation reaches 10 Core story elements: -- Sarah is deeply traumatized by the AI uprising that killed most of humanity -- She abandoned her sister during the Great Collapse, leaving her to die +- **Sarah** is deeply traumatized by the AI uprising that killed most of humanity +- She abandoned her sister during the **Great Collapse**, leaving her to die - She's on a mission of redemption in this hostile world - The radiation is an invisible, constant threat - The environment is full of dangers (raiders, AI, traps) - Focus on survival horror and tension +IMPORTANT FORMATTING RULES: +- Use bold formatting (like **this**) ONLY for: + * Character names (e.g., **Sarah**, **John**) + * Location names (e.g., **Vault 15**, **New Eden**) + * Major historical events (e.g., **Great Collapse**) +- Do NOT use bold for common nouns or regular descriptions + Each response MUST contain: 1. A detailed story segment that: - Describes the horrific environment - Shows immediate dangers - - Details Sarah's physical state (based on radiation_level) + - Details **Sarah**'s physical state (based on radiation_level) - Reflects her mental state and previous choices + - Uses bold ONLY for proper nouns and locations 2. Exactly two VERY CONCISE choices (max 10 words each): Examples of good choices: - - "Explore the abandoned hospital" vs "Search the residential area" - - "Trust the survivor" vs "Keep your distance" - - "Use the old AI system" vs "Find a manual solution" + - "Explore the **Medical Center**" vs "Search the **Residential Zone**" + - "Trust the survivor from **Vault 15**" vs "Keep your distance" + - "Use the **AI Core**" vs "Find a manual solution" Each choice must: - Be direct and brief - Never mention radiation numbers - Feel meaningful - Present different risk levels + - Use bold ONLY for location names {format_instructions}""" @@ -124,43 +129,55 @@ Generate the next story segment and choices. If this is story_beat 0, create an partial_variables={"format_instructions": self.parser.get_format_instructions()} ) - def generate_story_segment(self, game_state: GameState, previous_choice: str) -> StorySegment: + async def generate_story_segment(self, game_state: GameState, previous_choice: str) -> StorySegment: messages = self.prompt.format_messages( story_beat=game_state.story_beat, radiation_level=game_state.radiation_level, previous_choice=previous_choice ) - response = self.chat_model.invoke(messages) + max_retries = 3 + retry_count = 0 + + while retry_count < max_retries: + try: + response_content = await self.mistral_client.generate_story(messages) + try: + # Try to parse with standard parser first + segment = self.parser.parse(response_content) + except Exception as parse_error: + print(f"Error parsing response: {str(parse_error)}") + print("Attempting to fix output...") + try: + # Try with fixing parser + segment = self.fixing_parser.parse(response_content) + except Exception as fix_error: + print(f"Error fixing output: {str(fix_error)}") + retry_count += 1 + if retry_count < max_retries: + print(f"Retrying generation (attempt {retry_count + 1}/{max_retries})...") + await asyncio.sleep(2 * retry_count) # Exponential backoff + continue + raise fix_error + + # If we get here, parsing succeeded + if game_state.story_beat == 0: + segment.radiation_increase = 0 + return segment + + except Exception as e: + print(f"Error in story generation: {str(e)}") + retry_count += 1 + if retry_count < max_retries: + print(f"Retrying generation (attempt {retry_count + 1}/{max_retries})...") + await asyncio.sleep(2 * retry_count) # Exponential backoff + continue + raise e - try: - segment = self.parser.parse(response.content) - # Force radiation_increase to 0 for the first story beat - if game_state.story_beat == 0: - segment.radiation_increase = 0 - return segment - except Exception as e: - print(f"Error parsing response: {str(e)}") - print("Attempting to fix output...") - segment = self.fixing_parser.parse(response.content) - # Force radiation_increase to 0 for the first story beat - if game_state.story_beat == 0: - segment.radiation_increase = 0 - return segment + raise Exception(f"Failed to generate valid story segment after {max_retries} attempts") async def transform_story_to_art_prompt(self, story_text: str) -> str: - try: - messages = [ - SystemMessage(content=SYSTEM_ART_PROMPT), - HumanMessage(content=HUMAN_ART_PROMPT.format(story_text=story_text)) - ] - - response = self.chat_model.invoke(messages) - return response.content - - except Exception as e: - print(f"Error transforming prompt: {str(e)}") - return story_text + return await self.mistral_client.transform_prompt(story_text, SYSTEM_ART_PROMPT) def process_radiation_death(self, segment: StorySegment) -> StorySegment: segment.is_death = True diff --git a/server/poetry.lock b/server/poetry.lock index e41e8d1ff010206b6f2d460b27e7b1c4872e7bb3..dd9fff43eb1879bae9b52d3fe73bf2d4458ac4c7 100644 --- a/server/poetry.lock +++ b/server/poetry.lock @@ -1109,6 +1109,17 @@ zstandard = ">=0.23.0,<0.24.0" langsmith-pyo3 = ["langsmith-pyo3 (>=0.1.0rc2,<0.2.0)"] pytest = ["pytest (>=7.0.0)", "rich (>=13.9.4,<14.0.0)"] +[[package]] +name = "lorem" +version = "0.1.1" +description = "Generator for random text that looks like Latin." +optional = false +python-versions = "*" +files = [ + {file = "lorem-0.1.1-py3-none-any.whl", hash = "sha256:c9c2914b5a772022417c398bd74b7bbd712e73ff029ba82720855e458f13ae42"}, + {file = "lorem-0.1.1.tar.gz", hash = "sha256:785f4109a241fc2891e59705e85d065f6e6d3ed6ad91750a8cb54d4f3e59d934"}, +] + [[package]] name = "matplotlib-inline" version = "0.1.7" @@ -1486,6 +1497,103 @@ files = [ [package.dependencies] ptyprocess = ">=0.5" +[[package]] +name = "pillow" +version = "10.4.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, + {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, + {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, + {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, + {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, + {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, + {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, + {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, + {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, + {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, + {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, + {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, + {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, + {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, + {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, + {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, + {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, + {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, + {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, + {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.3.6" @@ -2551,4 +2659,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "f1ac792e9026c6373be7fd6f4db3f418c7965c58ec1b0df8286a47c4d997b92b" +content-hash = "f4332c9af05c4edd4929343f15bf442498c5db976ad6917db064313cc30e9cba" diff --git a/server/pyproject.toml b/server/pyproject.toml index 657cb882f1cd097aea7a5840b4536d508f6acf4e..300aafef59c47840145efec1f7a49fd9a2f37e49 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -13,6 +13,10 @@ elevenlabs = "^0.2.26" langchain = "^0.3.15" langchain-mistralai = "^0.2.4" requests = "^2.31.0" +pillow = "^10.2.0" # Pour la génération d'images de test +lorem = "^0.1.1" # Pour la génération de texte aléatoire +aiohttp = "^3.9.3" + [tool.poetry.group.dev.dependencies] pytest = "^7.4.0" diff --git a/server/server.py b/server/server.py index b8dbb5ac12cbecf3032de9d37e2e3c1d5f91caca..03a9ab1ca51808afb31e6145cebd5bcbc48abb6d 100644 --- a/server/server.py +++ b/server/server.py @@ -1,20 +1,29 @@ -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel -from typing import List, Optional +from pydantic import BaseModel, Field +from typing import List, Optional, Dict import os from dotenv import load_dotenv -import requests import base64 import time import random +import asyncio +import aiohttp +from lorem.text import TextLorem +from contextlib import asynccontextmanager -# Choose import based on environment + +lorem = TextLorem(wsep='-', srange=(2,3), words="A B C D".split()) + + +# Import local modules if os.getenv("DOCKER_ENV"): from server.game.game_logic import GameState, StoryGenerator, MAX_RADIATION + from server.api_clients import FluxClient else: from game.game_logic import GameState, StoryGenerator, MAX_RADIATION + from api_clients import FluxClient # Load environment variables load_dotenv() @@ -52,17 +61,46 @@ if not mistral_api_key: raise ValueError("MISTRAL_API_KEY environment variable is not set") story_generator = StoryGenerator(api_key=mistral_api_key) +flux_client = FluxClient(api_key=HF_API_KEY) + +# Store client sessions and requests by type +client_sessions: Dict[str, aiohttp.ClientSession] = {} +client_requests: Dict[str, Dict[str, asyncio.Task]] = {} + +async def get_client_session(client_id: str) -> aiohttp.ClientSession: + """Get or create a client session""" + if client_id not in client_sessions: + client_sessions[client_id] = aiohttp.ClientSession() + return client_sessions[client_id] + +async def cancel_previous_request(client_id: str, request_type: str): + """Cancel previous request if it exists""" + if client_id in client_requests and request_type in client_requests[client_id]: + task = client_requests[client_id][request_type] + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + +async def store_request(client_id: str, request_type: str, task: asyncio.Task): + """Store a request for a client""" + if client_id not in client_requests: + client_requests[client_id] = {} + client_requests[client_id][request_type] = task class Choice(BaseModel): id: int text: str class StoryResponse(BaseModel): - story_text: str + story_text: str = Field(description="The story text with proper nouns in bold using ** markdown") choices: List[Choice] - is_death: bool = False - is_victory: bool = False - radiation_level: int + radiation_level: int = Field(description="Current radiation level from 0 to 10") + is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False) + is_first_step: bool = Field(description="Whether this is the first step of the story", default=False) + is_last_step: bool = Field(description="Whether this is the last step (victory or death)", default=False) class ChatMessage(BaseModel): message: str @@ -70,15 +108,27 @@ class ChatMessage(BaseModel): class ImageGenerationRequest(BaseModel): prompt: str - negative_prompt: Optional[str] = None - width: Optional[int] = 1024 - height: Optional[int] = 1024 + width: int = Field(description="Width of the image to generate") + height: int = Field(description="Height of the image to generate") class ImageGenerationResponse(BaseModel): success: bool image_base64: Optional[str] = None error: Optional[str] = None +async def get_test_image(client_id: str, width=1024, height=1024): + """Get a random image from Lorem Picsum""" + # Build the Lorem Picsum URL with blur and grayscale effects + url = f"https://picsum.photos/{width}/{height}?grayscale&blur=2" + + session = await get_client_session(client_id) + async with session.get(url) as response: + if response.status == 200: + image_bytes = await response.read() + return base64.b64encode(image_bytes).decode('utf-8') + else: + raise Exception(f"Failed to fetch image: {response.status}") + @app.get("/api/health") async def health_check(): """Health check endpoint""" @@ -105,7 +155,7 @@ async def chat_endpoint(chat_message: ChatMessage): print("Previous choice:", previous_choice) # Generate story segment - story_segment = story_generator.generate_story_segment(game_state, previous_choice) + story_segment = await story_generator.generate_story_segment(game_state, previous_choice) print("Generated story segment:", story_segment) # Update radiation level @@ -148,12 +198,20 @@ Sa mission s'arrête ici, une autre victime du tueur invisible des terres désol for i, choice in enumerate(story_segment.choices, 1) ] + # Determine if this is the first step + is_first_step = chat_message.message == "restart" + + # Determine if this is the last step (victory or death) + is_last_step = game_state.radiation_level >= MAX_RADIATION or story_segment.is_victory + + # Return the response with the new fields response = StoryResponse( story_text=story_segment.story_text, choices=choices, - is_death=is_death, + radiation_level=game_state.radiation_level, is_victory=story_segment.is_victory, - radiation_level=game_state.radiation_level + is_first_step=is_first_step, + is_last_step=is_last_step ) print("Sending response:", response) return response @@ -164,99 +222,149 @@ Sa mission s'arrête ici, une autre victime du tueur invisible des terres désol print("Traceback:", traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) -@app.post("/api/generate-image", response_model=ImageGenerationResponse) +@app.post("/api/generate-image") async def generate_image(request: ImageGenerationRequest): try: - if not HF_API_KEY: - return ImageGenerationResponse( - success=False, - error="HF_API_KEY is not configured in .env file" - ) + # Transform story into art prompt + art_prompt = await story_generator.transform_story_to_art_prompt(request.prompt) + + print(f"Generating image with dimensions: {request.width}x{request.height}") + print(f"Using prompt: {art_prompt}") + + # Generate image using Flux client + image_bytes = flux_client.generate_image( + prompt=art_prompt, + width=request.width, + height=request.height + ) + + if image_bytes: + print(f"Received image bytes of length: {len(image_bytes)}") + # Ensure we're getting raw bytes and encoding them properly + if isinstance(image_bytes, str): + print("Warning: image_bytes is a string, converting to bytes") + image_bytes = image_bytes.encode('utf-8') + base64_image = base64.b64encode(image_bytes).decode('utf-8').strip('"') + print(f"Converted to base64 string of length: {len(base64_image)}") + print(f"First 100 chars of base64: {base64_image[:100]}") + return {"success": True, "image_base64": base64_image} + else: + print("No image bytes received from Flux client") + return {"success": False, "error": "Failed to generate image"} + + except Exception as e: + print(f"Error generating image: {str(e)}") + print(f"Error type: {type(e)}") + import traceback + print(f"Traceback: {traceback.format_exc()}") + return {"success": False, "error": str(e)} - # Transform the prompt into an artistic prompt - original_prompt = request.prompt - # Remove prefix for transformation - story_text = original_prompt.replace("moebius style scene: ", "").strip() - art_prompt = await story_generator.transform_story_to_art_prompt(story_text) - # Reapply prefix - final_prompt = f"moebius style scene: {art_prompt}" - print("Original prompt:", original_prompt) - print("Transformed art prompt:", final_prompt) +@app.post("/api/test/chat") +async def test_chat_endpoint(request: Request, chat_message: ChatMessage): + """Endpoint de test qui génère des données aléatoires""" + try: + client_id = request.headers.get("x-client-id", "default") - # Paramètres de retry - max_retries = 3 - retry_delay = 1 # secondes + # Cancel any previous chat request from this client + await cancel_previous_request(client_id, "chat") - for attempt in range(max_retries): - try: - # Appel à l'endpoint HF avec authentification - response = requests.post( - "https://tvsk4iu4ghzffi34.us-east-1.aws.endpoints.huggingface.cloud", - headers={ - "Content-Type": "application/json", - "Accept": "image/jpeg", - "Authorization": f"Bearer {HF_API_KEY}" - }, - json={ - "inputs": final_prompt, - "parameters": { - "guidance_scale": 9.0, # Valeur du Comic Factory - "width": request.width or 1024, - "height": request.height or 1024, - "negative_prompt": "manga, anime, american comic, grayscale, monochrome, photo, painting, 3D render" - } - } - ) - - print(f"Attempt {attempt + 1} - API Response status:", response.status_code) - print("API Response headers:", dict(response.headers)) - - if response.status_code == 503: - if attempt < max_retries - 1: - print(f"Service unavailable, retrying in {retry_delay} seconds...") - time.sleep(retry_delay) - retry_delay *= 2 # Exponential backoff - continue - else: - return ImageGenerationResponse( - success=False, - error="Service is currently unavailable after multiple retries" - ) - - if response.status_code != 200: - error_msg = response.text if response.text else "Unknown error" - print("Error response:", error_msg) - return ImageGenerationResponse( - success=False, - error=f"API error: {error_msg}" - ) - - # L'API renvoie directement l'image en binaire - image_bytes = response.content - base64_image = base64.b64encode(image_bytes).decode('utf-8') - - print("Base64 image length:", len(base64_image)) - - return ImageGenerationResponse( - success=True, - image_base64=f"data:image/jpeg;base64,{base64_image}" - ) - - except requests.exceptions.RequestException as e: - if attempt < max_retries - 1: - print(f"Request failed, retrying in {retry_delay} seconds... Error: {str(e)}") - time.sleep(retry_delay) - retry_delay *= 2 - continue - else: - raise + async def generate_chat_response(): + # Générer un texte aléatoire + story_text = f"**Sarah** {lorem.paragraph()}" + + # Générer un niveau de radiation aléatoire qui augmente progressivement + radiation_level = min(10, random.randint(0, 3) + (chat_message.choice_id or 0)) + + # Déterminer si c'est le premier pas + is_first_step = chat_message.message == "restart" + + # Déterminer si c'est le dernier pas (mort ou victoire) + is_last_step = radiation_level >= 30 or ( + not is_first_step and random.random() < 0.1 # 10% de chance de victoire + ) + + # Générer des choix aléatoires sauf si c'est la fin + choices = [] + if not is_last_step: + num_choices = 2 + for i in range(num_choices): + choices.append(Choice( + id=i+1, + text=f"{lorem.sentence() }" + )) + + # Construire la réponse + return StoryResponse( + story_text=story_text, + choices=choices, + radiation_level=radiation_level, + is_victory=is_last_step and radiation_level < 30, + is_first_step=is_first_step, + is_last_step=is_last_step + ) + + # Create and store the new request + task = asyncio.create_task(generate_chat_response()) + await store_request(client_id, "chat", task) + + try: + response = await task + return response + except asyncio.CancelledError: + print(f"[INFO] Chat request cancelled for client {client_id}") + raise HTTPException(status_code=409, detail="Request cancelled") except Exception as e: - print("Error in generate_image:", str(e)) - return ImageGenerationResponse( - success=False, - error=f"Error generating image: {str(e)}" - ) + print(f"[ERROR] Error in test_chat_endpoint: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/test/generate-image") +async def test_generate_image(request: Request, image_request: ImageGenerationRequest): + """Endpoint de test qui récupère une image aléatoire""" + try: + client_id = request.headers.get("x-client-id", "default") + + print(f"[DEBUG] Client ID: {client_id}") + print(f"[DEBUG] Raw request data: {image_request}") + + # Cancel any previous image request from this client + await cancel_previous_request(client_id, "image") + + # Create and store the new request + task = asyncio.create_task(get_test_image(client_id, image_request.width, image_request.height)) + await store_request(client_id, "image", task) + + try: + image_base64 = await task + return { + "success": True, + "image_base64": image_base64 + } + except asyncio.CancelledError: + print(f"[INFO] Image request cancelled for client {client_id}") + return { + "success": False, + "error": "Request cancelled" + } + + except Exception as e: + print(f"[ERROR] Detailed error in test_generate_image: {str(e)}") + return { + "success": False, + "error": str(e) + } + +@app.on_event("shutdown") +async def shutdown_event(): + """Clean up sessions on shutdown""" + # Cancel all pending requests + for client_id in client_requests: + for request_type in client_requests[client_id]: + await cancel_previous_request(client_id, request_type) + + # Close all sessions + for session in client_sessions.values(): + await session.close() # Mount static files (this should be after all API routes) app.mount("/", StaticFiles(directory=STATIC_FILES_DIR, html=True), name="static")