using UnityEngine; using UnityEngine.UI; using System.Collections; using System; public class OpenAIImageEnhancer : MonoBehaviour { [Header("ComfyUI — Connexion")] [SerializeField] private string _comfyUrl = "http://127.0.0.1:8000"; [SerializeField] private float _hdCaptureDelay = 1.5f; // ── Prompts ComfyUI (Flux Kontext) — instruction-based ─────────────── // Flux Kontext comprend les instructions directes ("Convert", "Do not add...") private const string _comfyPrompt = "Convert this 3D render into a professional architectural interior photograph " + "with realistic directional lighting and natural shadow contrast. " + "Daylight-balanced ambient lighting with subtle gradient and light falloff, " + "soft but defined shadows that reveal surface texture and dimensional depth, " + "ambient occlusion in corners and under structural elements, " + "photorealistic materials with depth and visual weight, " + "magazine quality DSLR architectural photography with natural balanced exposure. " + "Strictly preserve the exact room geometry, walls, ceiling, floor, and all openings. " + "Do not add, remove, or modify any windows, doors, openings, or architectural elements. " + "Cool neutral white balance, daylight color temperature 6500K. " + "No warm color cast, no yellow tint. " + "Balanced exposure — not flat, not dim — with natural light falloff and depth."; private const string _comfyNegativePrompt = "different textures, changed colors, blurry, distorted materials, " + "different cabinets, new appliances, changed layout"; // ── Prompts Stability AI (control/structure) — description-based ───── // Le endpoint structure préserve les lignes structurelles de l'image source. // Prompt = description de l'OUTPUT souhaité (pas d'instructions). // Negative prompt très explicite pour empêcher l'ajout de fenêtres et changements de texture. private const string _stabilityPrompt = "Professional architectural interior photograph, " + "photorealistic DSLR photography, high resolution, sharp focus, " + "natural daylight entering only through the existing windows already present in the scene, " + "realistic soft directional lighting with gentle shadows, " + "subtle ambient occlusion in corners and under elements, " + "exact same cabinet finishes and door styles as the input image, " + "exact same flooring material and color as the input image, " + "exact same wall surfaces and colors as the input image, " + "exact same countertop materials as the input image, " + "photorealistic surface textures matching the input, " + "cool neutral white balance 6500K, no warm color cast, " + "magazine quality architectural photography, natural balanced exposure."; private const string _stabilityNegativePrompt = "new windows, added windows, extra windows, modified windows, window changes, " + "new openings, added doors, structural modifications, changed layout, " + "different cabinet style, different flooring, different wall color, " + "new textures, changed textures, different countertop, different materials, " + "new furniture, added objects, removed elements, " + "warm tones, yellow cast, orange tint, oversaturated, " + "blurry, distorted, deformed, low quality, cartoon, painting, illustration"; // ── Prompt MyArchitectAI ───────────────────────────────────────────── // Règle absolue : ne jamais modifier la géométrie (fenêtres, murs, ouvertures) private const string _myArchPrompt = "Photorealistic architectural interior rendering. " + "Strictly preserve the exact room geometry shown in this image: " + "same walls, same ceiling, same floor, same layout. " + "No windows. No exterior light. No natural light. No daylight. " + "No ceiling spots. No recessed lights. No added light fixtures. " + "Do not add any windows, doors, skylights, ceiling spots, or openings that are not already visible in the image. " + "If the room has no windows, keep it without windows — no window must appear in the result. " + "Use only subtle ambient room lighting — do not add any light fixtures or light sources. " + "Do not add, remove, or modify any architectural elements. " + "Only enhance the lighting quality, material realism, and surface textures of what already exists. " + "Same flooring material and color, same wall color and texture, same ceiling finish. " + "Soft interior lighting, soft shadows, ambient occlusion in corners. " + "Cool neutral white balance, professional interior photography quality, sharp and detailed."; [Header("ComfyUI — Qualité")] [SerializeField, Range(0.05f, 1f)] private float _comfyDenoise = 1f; [SerializeField, Range(1, 60)] private int _pollTimeoutMinutes = 20; // Flux GGUF peut être lent [Header("ComfyUI — Flux Kontext (GGUF — recommandé)")] [SerializeField] private bool _useFlux = true; [SerializeField] private bool _useGGUF = true; // true = UnetLoaderGGUF, false = UNETLoader fp8 [SerializeField] private string _comfyFluxGGUF = "flux1-kontext-dev-Q4_K_M.gguf"; [SerializeField] private string _comfyFluxUnet = "flux1-dev-kontext_fp8_scaled.safetensors"; [SerializeField] private string _comfyClipL = "clip_l.safetensors"; [SerializeField] private string _comfyT5 = "t5xxl_fp8_e4m3fn_scaled.safetensors"; [SerializeField] private string _comfyVae = "ae.safetensors"; [Header("ComfyUI — Modèle Standard (SD1.5 / SDXL — si Flux décoché)")] [SerializeField] private string _comfyModel = "dreamshaper_8.safetensors"; // ───────────────────────────────────────────────────────────────── [Header("Service IA")] [SerializeField] private AIService _service = AIService.ComfyUI; public enum AIService { ComfyUI, StabilityAI, MyArchitectAI } [Header("Stability AI")] [SerializeField] private string _stabilityApiKey = ""; // Structure : préserve la géométrie (recommandé pour archi) // Style : applique un style artistique libre [SerializeField] private bool _stabilityUseStructure = true; [SerializeField, Range(0f, 1f)] private float _stabilityStrength = 0.9f; [Header("MyArchitectAI")] [SerializeField] private string _myArchApiKey = ""; // RenderInterior : sketch/CAD → photo (recommandé si l'image source est un dessin/plan) // StyleTransfer : render existant → photo (recommandé pour les renders Unity 3D) [SerializeField] private MyArchEndpoint _myArchEndpoint = MyArchEndpoint.RenderInterior; public enum MyArchEndpoint { RenderInterior, StyleTransfer } // MyArchitectAI exige une URL publique → imgbb héberge l'image 10 min gratuitement // Clé gratuite sur : https://api.imgbb.com/ [SerializeField] private string _imgbbApiKey = ""; [Header("UI")] [SerializeField] private GameObject _mainPanel; [SerializeField] private GameObject _resultWindow; [SerializeField] private RawImage _resultImage; [SerializeField] private GameObject _loadingPanel; [SerializeField] private Text _statusText; [SerializeField] private SettingImageEnhance _imageEnhance; // réglages brightness/contrast private Texture2D _enhancedTexture; private byte[] _capturedBytes; // image stockée entre capture et envoi private bool _headless; // true = appel automatisé (mode AI) : aucune fenêtre n'est ouverte // ───────────────────────────────────────────── // API PUBLIQUE — améliore une image sans UI (utilisé par PDFCreate en mode AI) // onDone reçoit null si l'amélioration échoue → l'appelant garde son image d'origine. // // Service forcé sur MyArchitectAI (~13-30 s), volontairement indépendant de _service : // ComfyUI/Flux peut prendre jusqu'à 20 min par image, ce qui est incompatible avec un // worker de rendu automatisé (2 vues par job). // ───────────────────────────────────────────── public IEnumerator EnhanceImage(byte[] pngBytes, Action onDone) { if (pngBytes == null || pngBytes.Length == 0) { onDone?.Invoke(null); yield break; } _headless = true; if (_enhancedTexture != null) { Destroy(_enhancedTexture); _enhancedTexture = null; } yield return SendToMyArchitectAI(pngBytes); _headless = false; byte[] result = null; if (_enhancedTexture != null) { try { result = _enhancedTexture.EncodeToPNG(); } catch (Exception e) { Debug.LogWarning("[AI] Texture non lisible : " + e.Message); } } onDone?.Invoke(result); } // ── Bouton 1 (extérieur) : capture + ouvre le panneau ───────────── public void OpenPanel() { Debug.Log("[AI] OpenPanel() appelé → CaptureAndShowPreview"); StartCoroutine(CaptureAndShowPreview()); } // ── Bouton 2 (dans le panneau) : envoie à ComfyUI ───────────────── // Si _capturedBytes est null (panneau ouvert autrement), capture d'abord puis envoie [ContextMenu("Enhance Image")] public void OnClickEnhance() { Debug.Log($"[AI] OnClickEnhance — bytes: {(_capturedBytes == null ? "NULL" : _capturedBytes.Length + "b")}"); StartCoroutine(CaptureAndSend()); } private IEnumerator CaptureAndSend() { // Recapturer seulement si pas encore capturé (ou après fermeture du panneau) // → permet d'ajuster les sliders sur la preview avant d'envoyer if (_capturedBytes == null || _capturedBytes.Length == 0) { Debug.Log("[AI] Pas de capture — capture automatique avant envoi..."); yield return StartCoroutine(CaptureAndShowPreview()); } if (_capturedBytes == null || _capturedBytes.Length == 0) { ShowError("Capture échouée — impossible d'envoyer."); yield break; } // Utilise l'image ajustée (sliders) si disponible, sinon la capture brute byte[] bytesToSend = (_imageEnhance != null) ? (_imageEnhance.GetEditedBytes() ?? _capturedBytes) : _capturedBytes; if (_loadingPanel != null) _loadingPanel.SetActive(true); switch (_service) { case AIService.StabilityAI: UpdateStatus("Envoi à Stability AI..."); Debug.Log("[AI] → StabilityAI"); yield return StartCoroutine(SendToStabilityAI(bytesToSend)); break; case AIService.MyArchitectAI: UpdateStatus("Envoi à MyArchitectAI..."); Debug.Log("[AI] → MyArchitectAI"); yield return StartCoroutine(SendToMyArchitectAI(bytesToSend)); break; default: UpdateStatus("Envoi à ComfyUI..."); Debug.Log("[AI] → ComfyUI"); yield return StartCoroutine(SendToComfyUI(bytesToSend)); break; } } // ───────────────────────────────────────────── // ÉTAPE 1 — CAPTURE HD + APERÇU DANS LE PANNEAU // Même approche que PDFCreation : cam.Render() dans RenderTexture // → force un rendu HD immédiat, Canvas Overlay non capturé (pas besoin de cacher l'UI) // ───────────────────────────────────────────── private IEnumerator CaptureAndShowPreview() { // 1. Active le HD (même appel que PDFCreation.TakeScreenshotScene) bool wasHD = _G.HD; if (!wasHD) DASH.HDsetBTNcolor(true); // obsolete warning ignoré — même usage que PDFCreation // 2. Compte à rebours — laisse le temps au rendu HD de s'initialiser WaitCircle.Setting(true, "Capture HD..."); int totalSeconds = Mathf.Max(1, Mathf.RoundToInt(_hdCaptureDelay)); var wait1s = new WaitForSeconds(1f); for (int s = totalSeconds; s > 0; s--) { WaitCircle.Setting(true, $"Capture HD dans {s}s..."); yield return wait1s; } WaitCircle.Setting(false, ""); // 3. Capture par cam.Render() Debug.Log("[AI] Début capture..."); Texture2D tex = null; try { Camera cam = Camera.main; if (cam == null) { ShowError("Camera.main introuvable."); yield break; } int w = 1024; int h = 600; Debug.Log($"[AI] Résolution capture: {w}x{h}"); var rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32); var savedRT = cam.targetTexture; cam.targetTexture = rt; cam.Render(); cam.targetTexture = savedRT; RenderTexture.active = rt; tex = new Texture2D(w, h, TextureFormat.RGBA32, false); tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); tex.Apply(); RenderTexture.active = null; rt.Release(); Destroy(rt); Debug.Log("[AI] Capture OK."); } catch (Exception e) { ShowError("Erreur capture: " + e.Message); Debug.LogException(e); yield break; } // 4. Restaure l'état HD si nécessaire if (!wasHD) DASH.HDsetBTNcolor(false); // obsolete warning ignoré — même usage que PDFCreation // 5. Stocke la capture pour l'envoi ultérieur if (_enhancedTexture != null) { Destroy(_enhancedTexture); _enhancedTexture = null; } _enhancedTexture = tex; _capturedBytes = tex.EncodeToPNG(); Debug.Log($"[AI] _capturedBytes: {_capturedBytes.Length} bytes"); // 6. Ouvre le panneau et affiche l'aperçu if (_resultImage != null) _resultImage.texture = _enhancedTexture; // Active les sliders sur la capture — l'utilisateur peut ajuster avant d'envoyer if (_imageEnhance != null) _imageEnhance.SetSourceTexture(_enhancedTexture); if (_mainPanel != null) _mainPanel.SetActive(true); if (_resultWindow != null) _resultWindow.SetActive(true); if (_loadingPanel != null) _loadingPanel.SetActive(false); string svcName = _service == AIService.StabilityAI ? "Stability AI" : _service == AIService.MyArchitectAI ? "MyArchitectAI" : "ComfyUI"; UpdateStatus($"Prêt — cliquez Envoyer pour améliorer avec {svcName}."); Set.GlobalColorAndTranslate(); } // ───────────────────────────────────────────── // STABILITY AI — img2img via API REST (v2beta) // Endpoint structure : préserve la géométrie architecturale // Endpoint style : applique un style libre // ───────────────────────────────────────────── private IEnumerator SendToStabilityAI(byte[] pngBytes) { string apiKey = (_stabilityApiKey ?? "").Trim(); if (string.IsNullOrEmpty(apiKey)) { ShowError("Stability AI: clé API vide.\nRenseignez _stabilityApiKey dans l'Inspector."); yield break; } string endpoint = _stabilityUseStructure ? "https://api.stability.ai/v2beta/stable-image/control/structure" : "https://api.stability.ai/v2beta/stable-image/control/style"; UpdateStatus("Stability AI — envoi..."); Debug.Log("[Stability] Endpoint: " + endpoint); string strength = _stabilityStrength .ToString("F2", System.Globalization.CultureInfo.InvariantCulture); var form = new System.Collections.Generic.List { new UnityEngine.Networking.MultipartFormFileSection( "image", pngBytes, "capture.png", "image/png"), new UnityEngine.Networking.MultipartFormDataSection("prompt", _stabilityPrompt), new UnityEngine.Networking.MultipartFormDataSection("negative_prompt", _stabilityNegativePrompt), new UnityEngine.Networking.MultipartFormDataSection("control_strength", strength), new UnityEngine.Networking.MultipartFormDataSection("output_format", "png"), new UnityEngine.Networking.MultipartFormDataSection("seed", "0"), // 0 = aléatoire }; var req = UnityEngine.Networking.UnityWebRequest.Post(endpoint, form); req.SetRequestHeader("Authorization", "Bearer " + apiKey); req.SetRequestHeader("Accept", "image/*"); req.downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer(); UpdateStatus("Stability AI — génération..."); yield return req.SendWebRequest(); if (req.result != UnityEngine.Networking.UnityWebRequest.Result.Success) { string body = req.downloadHandler.text; Debug.LogError("[Stability] Erreur: " + req.responseCode + "\n" + body); // Extraire le message d'erreur JSON si présent string errMsg = ExtractJsonString(body, "message") ?? ExtractJsonString(body, "name") ?? body; ShowError($"Stability AI {req.responseCode}:\n{errMsg[..Mathf.Min(200, errMsg.Length)]}"); yield break; } // La réponse est l'image brute (Accept: image/*) byte[] imgBytes = req.downloadHandler.data; Debug.Log($"[Stability] Image reçue: {imgBytes.Length} bytes"); var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false); if (!tex.LoadImage(imgBytes)) { ShowError("Stability AI: impossible de décoder l'image reçue."); yield break; } if (_enhancedTexture != null) { Destroy(_enhancedTexture); _enhancedTexture = null; } _enhancedTexture = tex; ShowResultWindow(); } // ───────────────────────────────────────────── // MYARCHITECTAI — Cloud API (rapide, ~13s, $0.03/image) // ───────────────────────────────────────────── private IEnumerator SendToMyArchitectAI(byte[] pngBytes) { string apiKey = (_myArchApiKey ?? "").Trim(); if (string.IsNullOrEmpty(apiKey)) { ShowError("MyArchitectAI: clé API vide.\nRenseignez _myArchApiKey dans l'Inspector."); yield break; } // Étape A : Upload de l'image vers imgbb pour obtenir une URL publique UpdateStatus("MyArchitectAI — upload image..."); Debug.Log("[MyArchitect] Upload image temporaire..."); string imageUrl = null; // yield return direct (sans StartCoroutine) : fonctionne même si ce GameObject est inactif, // la coroutine étant portée par l'appelant (PDFCreate en mode AI). yield return UploadToImgbb(pngBytes, url => imageUrl = url); if (string.IsNullOrEmpty(imageUrl)) { ShowError("Échec upload image vers imgbb. Vérifiez la clé imgbb."); yield break; } Debug.Log("[MyArchitect] Image hébergée : " + imageUrl); // Étape B : Appel API MyArchitectAI UpdateStatus("MyArchitectAI — génération (~13s)..."); Debug.Log("[MyArchitect] Envoi vers API..."); // ✅ FIX : Échappe correctement TOUS les caractères JSON spéciaux (newlines incluses) string prompt = EscapeJsonString(_myArchPrompt); string urlEscaped = EscapeJsonString(imageUrl); string body = "{" + "\"image\":\"" + urlEscaped + "\"," + "\"outputFormat\":\"jpg\"," + "\"prompt\":\"" + prompt + "\"" + "}"; // 🔍 Log du body envoyé pour debug Debug.Log("[MyArchitect] Body envoyé:\n" + body); byte[] bodyBytes = System.Text.Encoding.UTF8.GetBytes(body); var request = new UnityEngine.Networking.UnityWebRequest( "https://api.myarchitectai.com/v1/render/interior", "POST") { uploadHandler = new UnityEngine.Networking.UploadHandlerRaw(bodyBytes), downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer() }; request.SetRequestHeader("x-api-key", apiKey); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("Accept", "application/json"); yield return request.SendWebRequest(); string responseJson = request.downloadHandler.text; Debug.Log("[MyArchitect] Réponse complète: " + responseJson); if (request.result != UnityEngine.Networking.UnityWebRequest.Result.Success) { string errMsg = ExtractJsonString(responseJson, "error"); ShowError("MyArchitectAI: " + (string.IsNullOrEmpty(errMsg) ? request.error : errMsg) + "\n\n" + responseJson[..Mathf.Min(400, responseJson.Length)]); yield break; } // ✅ FIX : La doc API confirme que le résultat est dans le champ "output" string resultUrl = ExtractJsonString(responseJson, "output"); // Fallback sur d'autres champs au cas où if (string.IsNullOrEmpty(resultUrl)) resultUrl = ExtractJsonString(responseJson, "url"); if (string.IsNullOrEmpty(resultUrl)) resultUrl = ExtractJsonString(responseJson, "outputUrl"); if (string.IsNullOrEmpty(resultUrl) || !resultUrl.StartsWith("http")) { ShowError("MyArchitectAI: URL de sortie introuvable dans la réponse.\n\n" + responseJson[..Mathf.Min(400, responseJson.Length)]); yield break; } Debug.Log("[MyArchitect] URL résultat: " + resultUrl); // ImageKit CDN supporte les transformations via URL : // ?tr=w-1024,h-600,cm-pad_resize,q-85 → redimensionne à 1024×600 sans déformer, qualité 85% // cm-pad_resize : conserve le ratio et complète avec du blanc si nécessaire string downloadUrl = resultUrl.Contains("ik.imagekit.io") ? resultUrl + "?tr=w-1024,h-600,cm-pad_resize,q-85" : resultUrl; Debug.Log("[MyArchitect] URL téléchargement: " + downloadUrl); // Téléchargement de l'image résultat UpdateStatus("Téléchargement résultat..."); var imgRequest = UnityEngine.Networking.UnityWebRequestTexture.GetTexture(downloadUrl); yield return imgRequest.SendWebRequest(); if (imgRequest.result != UnityEngine.Networking.UnityWebRequest.Result.Success) { ShowError("Téléchargement résultat échoué: " + imgRequest.error); yield break; } if (_enhancedTexture != null) { Destroy(_enhancedTexture); _enhancedTexture = null; } _enhancedTexture = UnityEngine.Networking.DownloadHandlerTexture.GetContent(imgRequest); ShowResultWindow(); } // Helper — upload image vers imgbb, retourne l'URL via callback private IEnumerator UploadToImgbb(byte[] imgBytes, Action callback) { string imgbbKey = (_imgbbApiKey ?? "").Trim(); if (string.IsNullOrEmpty(imgbbKey)) { ShowError("MyArchitectAI: clé imgbb vide.\nObtenez une clé gratuite sur api.imgbb.com\net renseignez _imgbbApiKey."); callback(null); yield break; } string b64 = Convert.ToBase64String(imgBytes); var form = new System.Collections.Generic.List { new UnityEngine.Networking.MultipartFormDataSection("key", imgbbKey), new UnityEngine.Networking.MultipartFormDataSection("image", b64), new UnityEngine.Networking.MultipartFormDataSection("expiration", "600"), // 10 min }; var req = UnityEngine.Networking.UnityWebRequest.Post("https://api.imgbb.com/1/upload", form); req.downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer(); yield return req.SendWebRequest(); if (req.result != UnityEngine.Networking.UnityWebRequest.Result.Success) { ShowError("imgbb upload: " + req.error + "\n" + req.downloadHandler.text); callback(null); yield break; } string resp = req.downloadHandler.text; Debug.Log("[imgbb] Réponse: " + resp[..Mathf.Min(300, resp.Length)]); // Réponse : {"data":{"url":"https://i.ibb.co/...",...},"success":true} string url = ExtractJsonString(resp, "url"); if (string.IsNullOrEmpty(url)) { ShowError("imgbb: URL introuvable dans la réponse.\n" + resp); callback(null); yield break; } Debug.Log("[imgbb] URL: " + url); callback(url); } // ───────────────────────────────────────────── // COMFYUI — img2img via API REST // ───────────────────────────────────────────── private IEnumerator SendToComfyUI(byte[] pngBytes) { string comfyBase = _comfyUrl.TrimEnd('/'); // 1. Upload image UpdateStatus("ComfyUI — upload..."); Debug.Log("[ComfyUI] Upload vers " + comfyBase + "..."); // overwrite=true : évite kitchen(1).png, kitchen(2).png… → toujours le même nom var uploadForm = new System.Collections.Generic.List { new UnityEngine.Networking.MultipartFormFileSection("image", pngBytes, "unity_capture.png", "image/png"), new UnityEngine.Networking.MultipartFormDataSection("type", "input"), new UnityEngine.Networking.MultipartFormDataSection("overwrite", "true"), }; var uploadReq = UnityEngine.Networking.UnityWebRequest.Post(comfyBase + "/upload/image", uploadForm); uploadReq.downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer(); yield return uploadReq.SendWebRequest(); if (uploadReq.result != UnityEngine.Networking.UnityWebRequest.Result.Success) { ShowError("ComfyUI upload: " + uploadReq.error + "\nVérifiez que ComfyUI est lancé sur " + comfyBase); yield break; } string uploadResponse = uploadReq.downloadHandler.text; Debug.Log("[ComfyUI] Upload response: " + uploadResponse); string uploadedName = ExtractJsonString(uploadResponse, "name"); string uploadedSubfolder = ExtractJsonString(uploadResponse, "subfolder") ?? ""; if (string.IsNullOrEmpty(uploadedName)) { ShowError("ComfyUI: upload invalide — réponse: " + uploadResponse); yield break; } // Chemin complet pour le node LoadImage (inclut le subfolder si présent) string imgPath = string.IsNullOrEmpty(uploadedSubfolder) ? uploadedName : uploadedSubfolder + "/" + uploadedName; Debug.Log($"[ComfyUI] Uploadé → name:'{uploadedName}' subfolder:'{uploadedSubfolder}' → LoadImage path:'{imgPath}'"); // 2. Construire et soumettre le workflow UpdateStatus("ComfyUI — génération..."); string pos = _comfyPrompt.Replace("\\","\\\\").Replace("\"","\\\""); string neg = _comfyNegativePrompt.Replace("\\","\\\\").Replace("\"","\\\""); string den = _comfyDenoise.ToString("F2", System.Globalization.CultureInfo.InvariantCulture); string workflow = _useFlux ? BuildFluxWorkflow(imgPath, pos, den) : BuildStandardWorkflow(imgPath, pos, neg, den); string fullBody = "{\"prompt\":" + workflow + "}"; Debug.Log("[ComfyUI] Workflow " + (_useFlux ? "FLUX" : "Standard") + ":\n" + fullBody[..Mathf.Min(600, fullBody.Length)]); byte[] bodyBytes = System.Text.Encoding.UTF8.GetBytes(fullBody); var promptReq = new UnityEngine.Networking.UnityWebRequest(comfyBase + "/prompt", "POST") { uploadHandler = new UnityEngine.Networking.UploadHandlerRaw(bodyBytes), downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer() }; promptReq.SetRequestHeader("Content-Type", "application/json"); yield return promptReq.SendWebRequest(); if (promptReq.result != UnityEngine.Networking.UnityWebRequest.Result.Success) { string errBody = promptReq.downloadHandler.text; Debug.LogError("[ComfyUI] Erreur workflow:\n" + errBody); string errMsg = ExtractJsonString(errBody, "message"); string errType = ExtractJsonString(errBody, "type"); string detail = (!string.IsNullOrEmpty(errMsg) ? errMsg : errBody) [..Mathf.Min(300, (!string.IsNullOrEmpty(errMsg) ? errMsg : errBody).Length)]; ShowError("ComfyUI 400 — " + errType + "\n" + detail); yield break; } string promptId = ExtractJsonString(promptReq.downloadHandler.text, "prompt_id"); Debug.Log("[ComfyUI] Prompt ID: " + promptId); // 3. Polling jusqu'à la fin // /history/{id} renvoie {} tant que le job tourne → normal, ne pas afficher "en attente" // Quand le job finit, history renvoie les outputs avec "filename" string outputFilename = null; string outputSubfolder = ""; bool loggedFirst = false; int maxPolls = _pollTimeoutMinutes * 30; // toutes les 2 s → 30 polls/min var pollWait = new WaitForSeconds(2f); for (int poll = 0; poll < maxPolls; poll++) { yield return pollWait; var histReq = UnityEngine.Networking.UnityWebRequest.Get(comfyBase + "/history/" + promptId); histReq.downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer(); yield return histReq.SendWebRequest(); if (histReq.result != UnityEngine.Networking.UnityWebRequest.Result.Success) { Debug.LogWarning("[ComfyUI] Poll " + poll + " erreur: " + histReq.error); continue; } string hist = histReq.downloadHandler.text; int elapsed = poll * 2; // {} → ComfyUI génère encore (normal pendant toute la durée du job) if (hist.Length < 10 || hist == "{}") { UpdateStatus($"ComfyUI — génération en cours... {elapsed}s"); if (poll % 15 == 0) // log toutes les 30 s Debug.Log($"[ComfyUI] Génération en cours... {elapsed}s (ComfyUI Running)"); continue; } // Log du premier retour non-vide — COMPLET pour diagnostic if (!loggedFirst) { loggedFirst = true; Debug.Log("[ComfyUI] History (complet):\n" + hist); } bool hasFilename = hist.Contains("\"filename\""); bool isSuccess = hist.Contains("\"status_text\":\"success\"") || hist.Contains("\"status_text\": \"success\""); bool isError = hist.Contains("\"status_text\":\"error\"") || hist.Contains("\"status_text\": \"error\""); // ── Erreur explicite ComfyUI → interrompre immédiatement ───── if (isError) { string errMsg = ExtractJsonString(hist, "exception_message") ?? ExtractJsonString(hist, "exception_type") ?? ExtractJsonString(hist, "message") ?? "vérifiez la Console ComfyUI"; Debug.LogError("[ComfyUI] Job échoué (status_text:error):\n" + hist); ShowError("ComfyUI — erreur:\n" + errMsg); yield break; } // ── Job terminé avec succès ────────────────────────────────── if (hasFilename && isSuccess) { outputFilename = ExtractJsonString(hist, "filename"); outputSubfolder = ExtractJsonString(hist, "subfolder") ?? ""; if (!string.IsNullOrEmpty(outputFilename)) { Debug.Log($"[ComfyUI] Terminé en {elapsed}s — '{outputFilename}'"); break; } } // ── "filename" présent mais pas encore success (ComfyUI Desktop ajoute // l'entrée history avant la fin) → continuer de poller ───── if (hasFilename && !isSuccess) { outputFilename = ExtractJsonString(hist, "filename"); outputSubfolder = ExtractJsonString(hist, "subfolder") ?? ""; if (!string.IsNullOrEmpty(outputFilename)) { Debug.Log($"[ComfyUI] Outputs reçus en {elapsed}s — '{outputFilename}'"); break; } } // ── Dans l'historique mais outputs pas encore écrits → continuer ─ UpdateStatus($"ComfyUI — génération en cours... {elapsed}s"); } if (string.IsNullOrEmpty(outputFilename)) { ShowError($"ComfyUI: timeout ({_pollTimeoutMinutes} min) — vérifiez la Console ComfyUI."); yield break; } // 4. Télécharger l'image résultante UpdateStatus("ComfyUI — téléchargement..."); string viewUrl = comfyBase + "/view?filename=" + UnityEngine.Networking.UnityWebRequest.EscapeURL(outputFilename) + "&subfolder=" + UnityEngine.Networking.UnityWebRequest.EscapeURL(outputSubfolder) + "&type=output"; var viewReq = UnityEngine.Networking.UnityWebRequestTexture.GetTexture(viewUrl); yield return viewReq.SendWebRequest(); if (viewReq.result != UnityEngine.Networking.UnityWebRequest.Result.Success) { ShowError("ComfyUI download: " + viewReq.error); yield break; } if (_enhancedTexture != null) { Destroy(_enhancedTexture); _enhancedTexture = null; } _enhancedTexture = UnityEngine.Networking.DownloadHandlerTexture.GetContent(viewReq); ShowResultWindow(); } // ───────────────────────────────────────────── // BUILDERS DE WORKFLOW // ───────────────────────────────────────────── // Flux Kontext img2img — workflow identique au ComfyUI Desktop // 1=Loader 2=DualCLIPLoader 3=VAELoader // 4=LoadImage 5=FluxKontextImageScale 6=VAEEncode // 7=CLIPTextEncode 8=FluxGuidance 9=ConditioningZeroOut // 10=ReferenceLatent 11=KSampler 12=VAEDecode 13=SaveImage private string BuildFluxWorkflow(string imgName, string pos, string den) { string clipL = _comfyClipL.Replace("\"","\\\""); // Auto-corrige la variante sans _scaled (valeur sérialisée ancienne) string t5raw = _comfyT5 == "t5xxl_fp8_e4m3fn.safetensors" ? "t5xxl_fp8_e4m3fn_scaled.safetensors" : _comfyT5; string t5 = t5raw.Replace("\"","\\\""); string vae = _comfyVae.Replace("\"","\\\""); // Seed aléatoire pour avoir des variations entre générations int randomSeed = UnityEngine.Random.Range(0, int.MaxValue); string node1 = _useGGUF ? "\"1\":{\"class_type\":\"UnetLoaderGGUF\",\"inputs\":{\"unet_name\":\"" + _comfyFluxGGUF.Replace("\"","\\\"") + "\"}}" : "\"1\":{\"class_type\":\"UNETLoader\",\"inputs\":{\"unet_name\":\"" + _comfyFluxUnet.Replace("\"","\\\"") + "\",\"weight_dtype\":\"fp8_e4m3fn\"}}"; return "{" + node1 + "," + // CLIP + VAE loaders "\"2\":{\"class_type\":\"DualCLIPLoader\",\"inputs\":{\"clip_name1\":\"" + clipL + "\",\"clip_name2\":\"" + t5 + "\",\"type\":\"flux\"}}," + "\"3\":{\"class_type\":\"VAELoader\",\"inputs\":{\"vae_name\":\"" + vae + "\"}}," + // Image → scale → encode "\"4\":{\"class_type\":\"LoadImage\",\"inputs\":{\"image\":\"" + imgName + "\"}}," + "\"5\":{\"class_type\":\"FluxKontextImageScale\",\"inputs\":{\"image\":[\"4\",0]}}," + "\"6\":{\"class_type\":\"VAEEncode\",\"inputs\":{\"pixels\":[\"5\",0],\"vae\":[\"3\",0]}}," + // Texte → conditioning "\"7\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"" + pos + "\",\"clip\":[\"2\",0]}}," + "\"8\":{\"class_type\":\"FluxGuidance\",\"inputs\":{\"conditioning\":[\"7\",0],\"guidance\":2.5}}," + "\"9\":{\"class_type\":\"ConditioningZeroOut\",\"inputs\":{\"conditioning\":[\"7\",0]}}," + // ReferenceLatent : enrichit le CONDITIONING avec l'image encodée → sort CONDITIONING "\"10\":{\"class_type\":\"ReferenceLatent\",\"inputs\":{\"conditioning\":[\"8\",0],\"latent\":[\"6\",0]}}," + // KSampler optimisé : // - sampler dpmpp_2m + scheduler beta : ~30-40% plus rapide que euler/simple // - steps 12 : optimal pour Flux Kontext (au-delà gain négligeable) // - seed aléatoire : variation entre images "\"11\":{\"class_type\":\"KSampler\",\"inputs\":{" + "\"model\":[\"1\",0],\"positive\":[\"10\",0],\"negative\":[\"9\",0]," + "\"latent_image\":[\"6\",0]," + "\"seed\":" + randomSeed + "," + "\"steps\":12,\"cfg\":1.0," + "\"sampler_name\":\"dpmpp_2m\",\"scheduler\":\"beta\",\"denoise\":" + den + "}}," + "\"12\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"11\",0],\"vae\":[\"3\",0]}}," + "\"13\":{\"class_type\":\"SaveImage\",\"inputs\":{\"images\":[\"12\",0],\"filename_prefix\":\"enhanced\"}}" + "}"; } // Modèle Standard SD1.5 / SDXL img2img private string BuildStandardWorkflow(string imgName, string pos, string neg, string den) { string ckpt = _comfyModel.Replace("\"","\\\""); return "{" + "\"1\":{\"class_type\":\"CheckpointLoaderSimple\",\"inputs\":{\"ckpt_name\":\"" + ckpt + "\"}}," + "\"2\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"" + pos + "\",\"clip\":[\"1\",1]}}," + "\"3\":{\"class_type\":\"CLIPTextEncode\",\"inputs\":{\"text\":\"" + neg + "\",\"clip\":[\"1\",1]}}," + "\"4\":{\"class_type\":\"LoadImage\",\"inputs\":{\"image\":\"" + imgName + "\"}}," + "\"5\":{\"class_type\":\"VAEEncode\",\"inputs\":{\"pixels\":[\"4\",0],\"vae\":[\"1\",2]}}," + "\"6\":{\"class_type\":\"KSampler\",\"inputs\":{" + "\"model\":[\"1\",0],\"positive\":[\"2\",0],\"negative\":[\"3\",0]," + "\"latent_image\":[\"5\",0],\"seed\":42,\"steps\":20,\"cfg\":6.0," + "\"sampler_name\":\"dpmpp_2m\",\"scheduler\":\"karras\",\"denoise\":" + den + "}}," + "\"7\":{\"class_type\":\"VAEDecode\",\"inputs\":{\"samples\":[\"6\",0],\"vae\":[\"1\",2]}}," + "\"8\":{\"class_type\":\"SaveImage\",\"inputs\":{\"images\":[\"7\",0],\"filename_prefix\":\"enhanced\"}}" + "}"; } // ───────────────────────────────────────────── // PARSE JSON — extrait la valeur d'une clé string // ───────────────────────────────────────────── private static string ExtractJsonString(string json, string key) { string marker = "\"" + key + "\":"; int idx = json.IndexOf(marker); if (idx < 0) return null; idx += marker.Length; while (idx < json.Length && json[idx] != '"') idx++; if (idx >= json.Length) return null; idx++; var sb = new System.Text.StringBuilder(); while (idx < json.Length) { char c = json[idx]; if (c == '\\' && idx + 1 < json.Length) { // Décodage des séquences d'échappement JSON char esc = json[idx + 1]; switch (esc) { case '"': sb.Append('"'); break; case '\\': sb.Append('\\'); break; case '/': sb.Append('/'); break; // ← bug corrigé : \/ → / (imgbb échappe les slashes) case 'n': sb.Append('\n'); break; case 'r': sb.Append('\r'); break; case 't': sb.Append('\t'); break; default: sb.Append(esc); break; } idx += 2; continue; } if (c == '"') break; sb.Append(c); idx++; } return sb.ToString(); } // ───────────────────────────────────────────── // ESCAPE JSON — encode tous les caractères spéciaux pour un string JSON valide // ───────────────────────────────────────────── private static string EscapeJsonString(string s) { if (string.IsNullOrEmpty(s)) return s; var sb = new System.Text.StringBuilder(s.Length + 16); foreach (char c in s) { switch (c) { case '\\': sb.Append("\\\\"); break; case '"': sb.Append("\\\""); break; case '\n': sb.Append("\\n"); break; case '\r': sb.Append("\\r"); break; case '\t': sb.Append("\\t"); break; case '\b': sb.Append("\\b"); break; case '\f': sb.Append("\\f"); break; default: if (c < 0x20) sb.AppendFormat("\\u{0:X4}", (int)c); else sb.Append(c); break; } } return sb.ToString(); } // ───────────────────────────────────────────── // UI // ───────────────────────────────────────────── private void ShowResultWindow() { // Mode automatisé (EnhanceImage) : on garde _enhancedTexture mais on n'ouvre aucune fenêtre. if (_headless) { _capturedBytes = null; return; } if (_loadingPanel != null) _loadingPanel.SetActive(false); if (_resultWindow != null) _resultWindow.SetActive(true); if (_resultImage != null) _resultImage.texture = _enhancedTexture; // Transmet la texture IA à SettingImageEnhance pour les réglages post-traitement if (_imageEnhance != null) _imageEnhance.SetSourceTexture(_enhancedTexture); // Force une nouvelle capture au prochain envoi (la scène a peut-être changé) _capturedBytes = null; } private void ShowError(string msg) { if (_loadingPanel != null) _loadingPanel.SetActive(false); UpdateStatus("❌ " + msg); Debug.LogError(msg); } private void UpdateStatus(string msg) { if (_statusText != null) _statusText.text = msg; } public void CloseResultWindow() { if (_resultWindow != null) _resultWindow.SetActive(false); if (_mainPanel != null) _mainPanel.SetActive(false); if (_enhancedTexture != null) { Destroy(_enhancedTexture); _enhancedTexture = null; } _capturedBytes = null; } // ───────────────────────────────────────────── // DOWNLOAD — sauvegarde locale // ───────────────────────────────────────────── public void DownloadImage() { if (_enhancedTexture == null) return; byte[] bytes = _enhancedTexture.EncodeToPNG(); #if UNITY_EDITOR string path = System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "KitchenRender_Enhanced_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".png"); System.IO.File.WriteAllBytes(path, bytes); Debug.Log("✅ Sauvegardé: " + path); #elif UNITY_WEBGL string b64 = Convert.ToBase64String(bytes); DownloadFileWebGL("KitchenRender_Enhanced.png", "data:image/png;base64," + b64); #endif } #if UNITY_WEBGL && !UNITY_EDITOR [System.Runtime.InteropServices.DllImport("__Internal")] private static extern void DownloadFileWebGL(string filename, string dataUrl); #endif }