From cba1e58c8ee24b28aeaf142c0cdc1a0ea74e8f05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:21:57 +0000 Subject: [PATCH 1/4] Initial plan From 85b2ffcff33ebce393e08896e97c54f750e811a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:33:13 +0000 Subject: [PATCH 2/4] Add plugin registry, texture optimizer, and Ollama AI connector Agent-Logs-Url: https://github.com/LuticaCANARD/TextureCocktail/sessions/f02e51a9-dae2-4959-9c28-dc0f82dbc1c8 Co-authored-by: LuticaCANARD <80238084+LuticaCANARD@users.noreply.github.com> --- .../luticalab.core/Languages/English.json | 51 +- .../luticalab.core/Languages/Japanese.json | 51 +- Packages/luticalab.core/Languages/Korean.json | 51 +- .../Editor/OllamaConnector.cs | 500 ++++++++++++++++++ .../Editor/OllamaConnector.cs.meta | 11 + .../Editor/Plugin.meta | 8 + .../Editor/Plugin/PluginBrowserWindow.cs | 91 ++++ .../Editor/Plugin/PluginBrowserWindow.cs.meta | 11 + .../Plugin/TextureCocktailPluginAttribute.cs | 50 ++ .../TextureCocktailPluginAttribute.cs.meta | 11 + .../Plugin/TextureCocktailPluginRegistry.cs | 157 ++++++ .../TextureCocktailPluginRegistry.cs.meta | 11 + .../Editor/TextureCocktail.cs | 29 +- .../Editor/TextureOptimizer.cs | 413 +++++++++++++++ .../Editor/TextureOptimizer.cs.meta | 11 + .../luticalab.texturecocktail/PLUGIN_GUIDE.md | 168 ++++++ .../PLUGIN_GUIDE.md.meta | 7 + 17 files changed, 1608 insertions(+), 23 deletions(-) create mode 100644 Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs create mode 100644 Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs.meta create mode 100644 Packages/luticalab.texturecocktail/Editor/Plugin.meta create mode 100644 Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs create mode 100644 Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs.meta create mode 100644 Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs create mode 100644 Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs.meta create mode 100644 Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs create mode 100644 Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs.meta create mode 100644 Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs create mode 100644 Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs.meta create mode 100644 Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md create mode 100644 Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md.meta diff --git a/Packages/luticalab.core/Languages/English.json b/Packages/luticalab.core/Languages/English.json index f95c0ca..ad8089c 100644 --- a/Packages/luticalab.core/Languages/English.json +++ b/Packages/luticalab.core/Languages/English.json @@ -156,7 +156,56 @@ "normal_map_settings": "Normal Map Settings", "normal_strength": "Normal Strength", "height_scale": "Height Scale", - "normal_map_info": "Converts height map (grayscale) to normal map for 3D surface details." + "normal_map_info": "Converts height map (grayscale) to normal map for 3D surface details.", + + "plugin_browser_title": "TextureCocktail Plugin Browser", + "plugin_browser_desc": "All TextureCocktailContent subclasses found in loaded assemblies are listed here.", + "plugin_browser_howto": "To create a plugin: inherit from TextureCocktailContent and create a matching shader.", + "plugin_refresh": "Refresh", + "plugin_count": "Registered Plugins ({0})", + "plugin_class": "Class", + "plugin_assembly": "Assembly", + "plugin_author": "Author", + "plugin_version": "Version", + "plugin_description": "Description", + + "texture_optimizer_title": "Texture Optimizer", + "texture_optimizer_scan_settings": "Scan Settings", + "texture_optimizer_folder": "Folder to Scan", + "texture_optimizer_platform": "Target Platform", + "texture_optimizer_max_size": "Max Allowed Size (px)", + "texture_optimizer_show_issues": "Show Only Textures With Issues", + "texture_optimizer_scan_btn": "Scan Textures", + "texture_optimizer_found": "Found {0} texture(s). {1} selected.", + "texture_optimizer_select_all": "Select All", + "texture_optimizer_deselect_all": "Deselect All", + "texture_optimizer_apply_fixes": "Apply Recommended Fixes to Selected", + "texture_optimizer_applied": "Applied fixes to {0} texture(s). Re-scan to verify.", + "texture_optimizer_issues": "Issues", + "texture_optimizer_ping": "Ping Asset", + "texture_optimizer_fix": "Fix This Texture", + + "ollama_title": "Ollama Local AI Connector", + "ollama_desc": "Connects to a local Ollama server. Input: text prompt (+ optional image). Output: AI-generated text.", + "ollama_server": "Server Configuration", + "ollama_url": "Ollama URL", + "ollama_list_models": "List Models", + "ollama_model": "Model", + "ollama_model_manual": "Model (manual)", + "ollama_prompt_section": "Prompt", + "ollama_prompt_label": "Text Prompt:", + "ollama_attach_texture": "Attach Texture (vision models)", + "ollama_send": "Send Prompt", + "ollama_cancel": "Cancel", + "ollama_response_section": "Response", + "ollama_no_response": "(no response yet)", + "ollama_input_context": "Input Image Context:", + "ollama_response_image": "Response Image:", + "ollama_save_image": "Save Response Image...", + "ollama_clear": "Clear", + "ollama_ready": "Ready. Configure server URL and click 'List Models'.", + "ollama_waiting": "Waiting for Ollama response...", + "ollama_vision_help": "Requires a vision model (e.g. llava). The texture is converted to PNG and sent as base64." } } \ No newline at end of file diff --git a/Packages/luticalab.core/Languages/Japanese.json b/Packages/luticalab.core/Languages/Japanese.json index 55c257b..3cd456c 100644 --- a/Packages/luticalab.core/Languages/Japanese.json +++ b/Packages/luticalab.core/Languages/Japanese.json @@ -159,7 +159,56 @@ "normal_map_settings": "ノーマルマップ設定", "normal_strength": "ノーマル強度", "height_scale": "高さスケール", - "normal_map_info": "ハイトマップ(グレースケール)を3Dサーフェスディテール用のノーマルマップに変換します。" + "normal_map_info": "ハイトマップ(グレースケール)を3Dサーフェスディテール用のノーマルマップに変換します。", + + "plugin_browser_title": "TextureCocktail プラグインブラウザー", + "plugin_browser_desc": "ロードされたアセンブリで見つかったすべてのTextureCocktailContentサブクラスがここに表示されます。", + "plugin_browser_howto": "プラグイン作成方法: TextureCocktailContentを継承し、同じ名前のシェーダーを作成してください。", + "plugin_refresh": "更新", + "plugin_count": "登録プラグイン ({0})", + "plugin_class": "クラス", + "plugin_assembly": "アセンブリ", + "plugin_author": "作者", + "plugin_version": "バージョン", + "plugin_description": "説明", + + "texture_optimizer_title": "テクスチャ最適化ツール", + "texture_optimizer_scan_settings": "スキャン設定", + "texture_optimizer_folder": "スキャンするフォルダー", + "texture_optimizer_platform": "対象プラットフォーム", + "texture_optimizer_max_size": "最大許容サイズ (px)", + "texture_optimizer_show_issues": "問題のあるテクスチャのみ表示", + "texture_optimizer_scan_btn": "テクスチャをスキャン", + "texture_optimizer_found": "テクスチャ {0} 個を検出。{1} 個選択中。", + "texture_optimizer_select_all": "すべて選択", + "texture_optimizer_deselect_all": "すべて選択解除", + "texture_optimizer_apply_fixes": "選択したテクスチャに推奨修正を適用", + "texture_optimizer_applied": "{0} 個のテクスチャに修正を適用しました。再スキャンして確認してください。", + "texture_optimizer_issues": "問題点", + "texture_optimizer_ping": "アセットを強調表示", + "texture_optimizer_fix": "このテクスチャを修正", + + "ollama_title": "Ollama ローカルAI連携", + "ollama_desc": "ローカルOllamaサーバーに接続します。入力: テキストプロンプト(+オプション画像)。出力: AI生成テキスト。", + "ollama_server": "サーバー設定", + "ollama_url": "Ollama URL", + "ollama_list_models": "モデル一覧", + "ollama_model": "モデル", + "ollama_model_manual": "モデル (手動入力)", + "ollama_prompt_section": "プロンプト", + "ollama_prompt_label": "テキストプロンプト:", + "ollama_attach_texture": "テクスチャを添付 (ビジョンモデル用)", + "ollama_send": "プロンプトを送信", + "ollama_cancel": "キャンセル", + "ollama_response_section": "応答", + "ollama_no_response": "(まだ応答なし)", + "ollama_input_context": "入力画像コンテキスト:", + "ollama_response_image": "応答画像:", + "ollama_save_image": "応答画像を保存...", + "ollama_clear": "クリア", + "ollama_ready": "準備完了。サーバーURLを設定して「モデル一覧」をクリックしてください。", + "ollama_waiting": "Ollamaの応答を待っています...", + "ollama_vision_help": "ビジョンモデル(例: llava)が必要です。テクスチャはPNGに変換されbase64で送信されます。" } } \ No newline at end of file diff --git a/Packages/luticalab.core/Languages/Korean.json b/Packages/luticalab.core/Languages/Korean.json index 5d59751..727391f 100644 --- a/Packages/luticalab.core/Languages/Korean.json +++ b/Packages/luticalab.core/Languages/Korean.json @@ -155,6 +155,55 @@ "normal_map_settings": "노말 맵 설정", "normal_strength": "노말 강도", "height_scale": "높이 스케일", - "normal_map_info": "하이트 맵(그레이스케일)을 3D 표면 디테일용 노말 맵으로 변환합니다." + "normal_map_info": "하이트 맵(그레이스케일)을 3D 표면 디테일용 노말 맵으로 변환합니다.", + + "plugin_browser_title": "텍스처 칵테일 플러그인 브라우저", + "plugin_browser_desc": "로드된 어셈블리에서 발견된 모든 TextureCocktailContent 서브클래스가 여기에 나열됩니다.", + "plugin_browser_howto": "플러그인 생성 방법: TextureCocktailContent를 상속하고 동일한 이름의 셰이더를 생성하세요.", + "plugin_refresh": "새로 고침", + "plugin_count": "등록된 플러그인 ({0})", + "plugin_class": "클래스", + "plugin_assembly": "어셈블리", + "plugin_author": "제작자", + "plugin_version": "버전", + "plugin_description": "설명", + + "texture_optimizer_title": "텍스처 최적화 도구", + "texture_optimizer_scan_settings": "스캔 설정", + "texture_optimizer_folder": "스캔할 폴더", + "texture_optimizer_platform": "대상 플랫폼", + "texture_optimizer_max_size": "최대 허용 크기 (px)", + "texture_optimizer_show_issues": "문제가 있는 텍스처만 표시", + "texture_optimizer_scan_btn": "텍스처 스캔", + "texture_optimizer_found": "텍스처 {0}개 발견. {1}개 선택됨.", + "texture_optimizer_select_all": "모두 선택", + "texture_optimizer_deselect_all": "모두 해제", + "texture_optimizer_apply_fixes": "선택된 항목에 권장 수정 사항 적용", + "texture_optimizer_applied": "{0}개 텍스처에 수정 사항이 적용되었습니다. 재스캔하여 확인하세요.", + "texture_optimizer_issues": "문제점", + "texture_optimizer_ping": "에셋 강조 표시", + "texture_optimizer_fix": "이 텍스처 수정", + + "ollama_title": "Ollama 로컬 AI 연동", + "ollama_desc": "로컬 Ollama 서버에 연결합니다. 입력: 텍스트 프롬프트 (+ 선택적 이미지). 출력: AI 생성 텍스트.", + "ollama_server": "서버 설정", + "ollama_url": "Ollama URL", + "ollama_list_models": "모델 목록", + "ollama_model": "모델", + "ollama_model_manual": "모델 (직접 입력)", + "ollama_prompt_section": "프롬프트", + "ollama_prompt_label": "텍스트 프롬프트:", + "ollama_attach_texture": "텍스처 첨부 (비전 모델용)", + "ollama_send": "프롬프트 전송", + "ollama_cancel": "취소", + "ollama_response_section": "응답", + "ollama_no_response": "(아직 응답 없음)", + "ollama_input_context": "입력 이미지 컨텍스트:", + "ollama_response_image": "응답 이미지:", + "ollama_save_image": "응답 이미지 저장...", + "ollama_clear": "지우기", + "ollama_ready": "준비됨. 서버 URL을 설정하고 '모델 목록'을 클릭하세요.", + "ollama_waiting": "Ollama 응답을 기다리는 중...", + "ollama_vision_help": "비전 모델(예: llava)이 필요합니다. 텍스처가 PNG로 변환되어 base64로 전송됩니다." } } \ No newline at end of file diff --git a/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs new file mode 100644 index 0000000..6349725 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs @@ -0,0 +1,500 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; + +namespace LuticaLab.TextureCocktail +{ + /// + /// Editor window that connects to a local Ollama instance and provides a + /// text → text+image AI pipeline inside Unity Editor. + /// + /// Open via: LuticaLab → Ollama AI Connector + /// + /// INPUT : text prompt + (optional) Texture2D for vision models + /// OUTPUT : text response displayed in the window + /// + /// Requires a running Ollama server (default: http://localhost:11434). + /// Install Ollama at https://ollama.com and pull a model, e.g.: + /// ollama pull llama3 + /// ollama pull llava (for vision / image input) + /// + public class OllamaConnector : EditorWindow + { + // ── Menu item ──────────────────────────────────────────────────────── + [MenuItem("LuticaLab/Ollama AI Connector")] + public static void ShowWindow() + { + GetWindow("Ollama AI"); + } + + // ── Constants ──────────────────────────────────────────────────────── + private const string DefaultServerUrl = "http://localhost:11434"; + private const string PrefsKeyUrl = "TC_Ollama_Url"; + private const string PrefsKeyModel = "TC_Ollama_Model"; + + // ── State ──────────────────────────────────────────────────────────── + private string _serverUrl = DefaultServerUrl; + private string _selectedModel = ""; + private List _availableModels = new List(); + private int _selectedModelIndex = 0; + + private string _promptText = ""; + private Texture2D _inputTexture; + private bool _attachTexture; + + private string _responseText = ""; + private Texture2D _responseImage; // decoded from base64 if server returns one + private Vector2 _responseScroll; + + private bool _busy; + private string _statusMessage = "Ready. Configure server URL and click 'List Models'."; + private CancellationTokenSource _cts; + + private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; + + // ── Lifecycle ──────────────────────────────────────────────────────── + private void OnEnable() + { + _serverUrl = EditorPrefs.GetString(PrefsKeyUrl, DefaultServerUrl); + _selectedModel = EditorPrefs.GetString(PrefsKeyModel, ""); + } + + private void OnDisable() + { + _cts?.Cancel(); + EditorPrefs.SetString(PrefsKeyUrl, _serverUrl); + EditorPrefs.SetString(PrefsKeyModel, _selectedModel); + } + + // ── GUI ────────────────────────────────────────────────────────────── + private void OnGUI() + { + GUILayout.Label("Ollama Local AI Connector", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "Connects to a local Ollama server. Input: text prompt (+ optional image). " + + "Output: AI-generated text (and image preview when an image was provided).", + MessageType.Info); + + EditorGUILayout.Space(4); + DrawServerSection(); + EditorGUILayout.Space(4); + DrawPromptSection(); + EditorGUILayout.Space(4); + DrawResponseSection(); + EditorGUILayout.Space(4); + DrawStatusBar(); + } + + // ── Server section ─────────────────────────────────────────────────── + private void DrawServerSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUILayout.Label("Server Configuration", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + _serverUrl = EditorGUILayout.TextField("Ollama URL", _serverUrl); + GUI.enabled = !_busy; + if (GUILayout.Button("List Models", GUILayout.Width(100))) + _ = FetchModelsAsync(); + GUI.enabled = true; + EditorGUILayout.EndHorizontal(); + + if (_availableModels.Count > 0) + { + string[] modelArray = _availableModels.ToArray(); + _selectedModelIndex = Mathf.Clamp(_selectedModelIndex, 0, modelArray.Length - 1); + int newIdx = EditorGUILayout.Popup("Model", _selectedModelIndex, modelArray); + if (newIdx != _selectedModelIndex) + { + _selectedModelIndex = newIdx; + _selectedModel = _availableModels[newIdx]; + } + } + else + { + _selectedModel = EditorGUILayout.TextField("Model (manual)", _selectedModel); + } + + EditorGUILayout.EndVertical(); + } + + // ── Prompt section ─────────────────────────────────────────────────── + private void DrawPromptSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUILayout.Label("Prompt", EditorStyles.boldLabel); + + GUILayout.Label("Text Prompt:"); + _promptText = EditorGUILayout.TextArea(_promptText, GUILayout.MinHeight(80)); + + EditorGUILayout.Space(4); + + // Image attachment (for vision models like llava) + _attachTexture = EditorGUILayout.Toggle("Attach Texture (vision models)", _attachTexture); + if (_attachTexture) + { + _inputTexture = (Texture2D)EditorGUILayout.ObjectField( + "Input Texture", _inputTexture, typeof(Texture2D), false); + + if (_inputTexture != null) + { + // Preview thumbnail + Rect thumbRect = GUILayoutUtility.GetRect(80, 80); + GUI.DrawTexture(thumbRect, _inputTexture, ScaleMode.ScaleToFit); + } + + EditorGUILayout.HelpBox( + "Requires a vision model (e.g. llava). The texture is converted to PNG and " + + "sent as base64. Make sure the texture has Read/Write enabled in its import settings.", + MessageType.Info); + } + + EditorGUILayout.Space(4); + + EditorGUILayout.BeginHorizontal(); + GUI.enabled = !_busy && !string.IsNullOrWhiteSpace(_promptText) && !string.IsNullOrEmpty(_selectedModel); + if (GUILayout.Button("Send Prompt", GUILayout.Height(32))) + _ = SendPromptAsync(); + GUI.enabled = !_busy; + if (GUILayout.Button("Cancel", GUILayout.Width(80), GUILayout.Height(32))) + _cts?.Cancel(); + GUI.enabled = true; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + + // ── Response section ───────────────────────────────────────────────── + private void DrawResponseSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUILayout.Label("Response", EditorStyles.boldLabel); + + _responseScroll = EditorGUILayout.BeginScrollView(_responseScroll, GUILayout.MinHeight(120)); + if (!string.IsNullOrEmpty(_responseText)) + { + EditorGUILayout.SelectableLabel(_responseText, + EditorStyles.wordWrappedLabel, + GUILayout.ExpandHeight(true)); + } + else + { + GUILayout.Label("(no response yet)", EditorStyles.centeredGreyMiniLabel); + } + EditorGUILayout.EndScrollView(); + + // If input texture was attached, show a combined image+text panel + if (_attachTexture && _inputTexture != null && !string.IsNullOrEmpty(_responseText)) + { + EditorGUILayout.Space(4); + GUILayout.Label("Input Image Context:", EditorStyles.boldLabel); + Rect imgRect = GUILayoutUtility.GetRect(160, 120); + GUI.DrawTexture(imgRect, _inputTexture, ScaleMode.ScaleToFit); + } + + // If the response contained a base64 image, show it + if (_responseImage != null) + { + EditorGUILayout.Space(4); + GUILayout.Label("Response Image:", EditorStyles.boldLabel); + Rect imgRect = GUILayoutUtility.GetRect(200, 200); + GUI.DrawTexture(imgRect, _responseImage, ScaleMode.ScaleToFit); + + if (GUILayout.Button("Save Response Image…", GUILayout.Width(180))) + SaveResponseImage(); + } + + if (!string.IsNullOrEmpty(_responseText)) + { + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Clear", GUILayout.Width(70))) + { + _responseText = ""; + _responseImage = null; + } + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.EndVertical(); + } + + private void DrawStatusBar() + { + MessageType msgType = _busy ? MessageType.Info : + (_statusMessage.StartsWith("Error") ? MessageType.Error : MessageType.Info); + EditorGUILayout.HelpBox(_statusMessage, msgType); + if (_busy) + EditorGUILayout.HelpBox("⏳ Waiting for Ollama response…", MessageType.Info); + } + + // ── Async helpers ──────────────────────────────────────────────────── + + private async Task FetchModelsAsync() + { + SetBusy(true, "Fetching model list…"); + try + { + string url = _serverUrl.TrimEnd('/') + "/api/tags"; + string json = await _http.GetStringAsync(url); + ParseModelList(json); + SetStatus($"Found {_availableModels.Count} model(s)."); + } + catch (Exception ex) + { + SetStatus($"Error fetching models: {ex.Message}"); + } + finally + { + SetBusy(false); + } + } + + private async Task SendPromptAsync() + { + if (string.IsNullOrWhiteSpace(_promptText) || string.IsNullOrEmpty(_selectedModel)) + return; + + _cts = new CancellationTokenSource(); + SetBusy(true, "Sending prompt…"); + _responseText = ""; + _responseImage = null; + + try + { + string body = BuildRequestBody(); + string url = _serverUrl.TrimEnd('/') + "/api/generate"; + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync(url, content, _cts.Token); + response.EnsureSuccessStatusCode(); + + string responseJson = await response.Content.ReadAsStringAsync(); + ParseGenerateResponse(responseJson); + + SetStatus("Response received."); + } + catch (OperationCanceledException) + { + SetStatus("Request cancelled."); + } + catch (Exception ex) + { + SetStatus($"Error: {ex.Message}"); + } + finally + { + SetBusy(false); + } + } + + // ── JSON helpers (manual, no extra deps) ───────────────────────────── + + private string BuildRequestBody() + { + var sb = new StringBuilder(); + sb.Append("{"); + sb.Append($"\"model\":{JsonString(_selectedModel)},"); + sb.Append($"\"prompt\":{JsonString(_promptText)},"); + sb.Append("\"stream\":false"); + + if (_attachTexture && _inputTexture != null) + { + string b64 = TextureToBase64(_inputTexture); + if (!string.IsNullOrEmpty(b64)) + { + sb.Append($",\"images\":[{JsonString(b64)}]"); + } + } + + sb.Append("}"); + return sb.ToString(); + } + + private void ParseModelList(string json) + { + _availableModels.Clear(); + // Parse "models":[{"name":"..."},...] — lightweight manual parse + int modelsIdx = json.IndexOf("\"models\"", StringComparison.Ordinal); + if (modelsIdx < 0) return; + + int start = json.IndexOf('[', modelsIdx); + int end = json.IndexOf(']', start); + if (start < 0 || end < 0) return; + + string segment = json.Substring(start, end - start + 1); + int pos = 0; + while (true) + { + int nameIdx = segment.IndexOf("\"name\"", pos, StringComparison.Ordinal); + if (nameIdx < 0) break; + int colon = segment.IndexOf(':', nameIdx); + int q1 = segment.IndexOf('"', colon + 1); + int q2 = segment.IndexOf('"', q1 + 1); + if (q1 < 0 || q2 < 0) break; + string name = segment.Substring(q1 + 1, q2 - q1 - 1); + _availableModels.Add(name); + pos = q2 + 1; + } + + // Restore persisted selection + int idx = _availableModels.IndexOf(_selectedModel); + _selectedModelIndex = idx >= 0 ? idx : 0; + if (_availableModels.Count > 0) + _selectedModel = _availableModels[_selectedModelIndex]; + } + + private void ParseGenerateResponse(string json) + { + // Extract "response":"..." + _responseText = ExtractJsonStringField(json, "response"); + + // Some models/endpoints may return "images":["base64..."] + int imgIdx = json.IndexOf("\"images\"", StringComparison.Ordinal); + if (imgIdx >= 0) + { + int arrStart = json.IndexOf('[', imgIdx); + int q1 = json.IndexOf('"', arrStart); + int q2 = json.IndexOf('"', q1 + 1); + if (q1 >= 0 && q2 > q1) + { + string b64 = json.Substring(q1 + 1, q2 - q1 - 1); + _responseImage = Base64ToTexture(b64); + } + } + + Repaint(); + } + + private static string ExtractJsonStringField(string json, string fieldName) + { + int idx = json.IndexOf($"\"{fieldName}\"", StringComparison.Ordinal); + if (idx < 0) return ""; + int colon = json.IndexOf(':', idx); + int q1 = json.IndexOf('"', colon + 1); + if (q1 < 0) return ""; + var sb = new StringBuilder(); + bool escape = false; + for (int i = q1 + 1; i < json.Length; i++) + { + char c = json[i]; + if (escape) + { + switch (c) + { + case 'n': sb.Append('\n'); break; + case 't': sb.Append('\t'); break; + case 'r': sb.Append('\r'); break; + default: sb.Append(c); break; + } + escape = false; + } + else if (c == '\\') { escape = true; } + else if (c == '"') break; + else sb.Append(c); + } + return sb.ToString(); + } + + private static string JsonString(string s) + { + if (s == null) return "null"; + var sb = new StringBuilder("\""); + 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; + default: sb.Append(c); break; + } + } + sb.Append('"'); + return sb.ToString(); + } + + // ── Texture helpers ────────────────────────────────────────────────── + + private static string TextureToBase64(Texture2D tex) + { + try + { + // Ensure read/write; if texture is not readable, render it first + byte[] pngBytes; + if (tex.isReadable) + { + pngBytes = tex.EncodeToPNG(); + } + else + { + var rt = new RenderTexture(tex.width, tex.height, 0, RenderTextureFormat.ARGB32); + Graphics.Blit(tex, rt); + RenderTexture.active = rt; + var readable = new Texture2D(tex.width, tex.height, TextureFormat.RGBA32, false); + readable.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + readable.Apply(); + RenderTexture.active = null; + rt.Release(); + pngBytes = readable.EncodeToPNG(); + UnityEngine.Object.DestroyImmediate(readable); + } + return Convert.ToBase64String(pngBytes); + } + catch (Exception ex) + { + Debug.LogWarning($"[OllamaConnector] Could not encode texture: {ex.Message}"); + return null; + } + } + + private static Texture2D Base64ToTexture(string base64) + { + try + { + byte[] bytes = Convert.FromBase64String(base64); + var tex = new Texture2D(2, 2); + tex.LoadImage(bytes); + return tex; + } + catch + { + return null; + } + } + + private void SaveResponseImage() + { + if (_responseImage == null) return; + string path = EditorUtility.SaveFilePanel("Save Response Image", "Assets", "ollama_response.png", "png"); + if (!string.IsNullOrEmpty(path)) + { + System.IO.File.WriteAllBytes(path, _responseImage.EncodeToPNG()); + AssetDatabase.Refresh(); + SetStatus($"Image saved to {path}"); + } + } + + // ── Thread-safe UI update helpers ──────────────────────────────────── + private void SetBusy(bool busy, string msg = null) + { + _busy = busy; + if (msg != null) _statusMessage = msg; + // Repaint must happen on the main thread — use EditorApplication.delayCall + EditorApplication.delayCall += Repaint; + } + + private void SetStatus(string msg) + { + _statusMessage = msg; + EditorApplication.delayCall += Repaint; + } + } +} diff --git a/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs.meta b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs.meta new file mode 100644 index 0000000..91f4291 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc023274fdc54f038c4ca5b80e4c04ae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin.meta b/Packages/luticalab.texturecocktail/Editor/Plugin.meta new file mode 100644 index 0000000..c9a0a4f --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/Plugin.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8dacce2b48b1485f92b5cd4fd54b59a2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs b/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs new file mode 100644 index 0000000..bb200a8 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace LuticaLab.TextureCocktail +{ + /// + /// Editor window that lists all TextureCocktail plugins discovered in loaded assemblies. + /// Open via: LuticaLab → TextureCocktail Plugin Browser + /// + public class PluginBrowserWindow : EditorWindow + { + [MenuItem("LuticaLab/TextureCocktail Plugin Browser")] + public static void ShowWindow() + { + GetWindow("TC Plugin Browser"); + } + + private Vector2 _scroll; + private string _searchFilter = ""; + + private void OnEnable() + { + TextureCocktailPluginRegistry.Refresh(); + } + + private void OnGUI() + { + GUILayout.Label("TextureCocktail Plugin Browser", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "All TextureCocktailContent subclasses found in loaded assemblies are listed here.\n" + + "To create a plugin: inherit from TextureCocktailContent and create a matching shader.", + MessageType.Info); + + EditorGUILayout.Space(4); + + // Search bar + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Search:", GUILayout.Width(55)); + _searchFilter = EditorGUILayout.TextField(_searchFilter); + if (GUILayout.Button("Refresh", GUILayout.Width(70))) + TextureCocktailPluginRegistry.Refresh(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(4); + + IReadOnlyList plugins = TextureCocktailPluginRegistry.AllPlugins; + GUILayout.Label($"Registered Plugins ({plugins.Count})", EditorStyles.boldLabel); + + _scroll = EditorGUILayout.BeginScrollView(_scroll); + foreach (var info in plugins) + { + if (!string.IsNullOrEmpty(_searchFilter) && + !info.DisplayName.ToLowerInvariant().Contains(_searchFilter.ToLowerInvariant()) && + !info.TypeName.ToLowerInvariant().Contains(_searchFilter.ToLowerInvariant())) + continue; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label(info.DisplayName, EditorStyles.boldLabel); + GUILayout.FlexibleSpace(); + if (!string.IsNullOrEmpty(info.Version)) + GUILayout.Label($"v{info.Version}", EditorStyles.miniLabel); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.LabelField("Class:", info.TypeName, EditorStyles.miniLabel); + EditorGUILayout.LabelField("Assembly:", info.AssemblyName, EditorStyles.miniLabel); + + if (!string.IsNullOrEmpty(info.Author)) + EditorGUILayout.LabelField("Author:", info.Author, EditorStyles.miniLabel); + + if (!string.IsNullOrEmpty(info.Description)) + EditorGUILayout.LabelField("Description:", info.Description, EditorStyles.miniLabel); + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(2); + } + EditorGUILayout.EndScrollView(); + + EditorGUILayout.Space(4); + EditorGUILayout.HelpBox( + "Plugin How-to:\n" + + "1. Create a class inheriting TextureCocktailContent\n" + + "2. (Optional) Add [TextureCocktailPlugin(\"Name\", \"Desc\", \"Author\")] attribute\n" + + "3. Create a shader with the same name (last path segment)\n" + + "4. TextureCocktail auto-loads your UI when the shader is selected", + MessageType.None); + } + } +} diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs.meta b/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs.meta new file mode 100644 index 0000000..0bfb04b --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 072d931791d64cd9ad7dae291fd123df +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs new file mode 100644 index 0000000..1892aac --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs @@ -0,0 +1,50 @@ +using System; + +namespace LuticaLab.TextureCocktail +{ + /// + /// Optional attribute that provides metadata for a TextureCocktail plugin. + /// Apply this to classes that inherit from . + /// + /// Usage: + /// + /// [TextureCocktailPlugin("My Effect", "Applies a custom effect", "YourName", "1.0.0")] + /// public class MyEffect : TextureCocktailContent { ... } + /// + /// + /// The plugin will be automatically discovered by + /// and associated with a shader whose last path segment matches the class name (e.g. + /// "Hidden/MyEffect" → class "MyEffect"). + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class TextureCocktailPluginAttribute : Attribute + { + /// Human-readable name shown in the plugin browser. + public string DisplayName { get; } + + /// Short description of what the plugin does. + public string Description { get; } + + /// Plugin author name. + public string Author { get; } + + /// Plugin version string. + public string Version { get; } + + /// Human-readable plugin name. + /// Short description. + /// Author name. + /// Version string (e.g. "1.0.0"). + public TextureCocktailPluginAttribute( + string displayName, + string description = "", + string author = "", + string version = "1.0.0") + { + DisplayName = displayName; + Description = description; + Author = author; + Version = version; + } + } +} diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs.meta b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs.meta new file mode 100644 index 0000000..60d3ded --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 98ddf82eeead4f239febb09ea0f0db72 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs new file mode 100644 index 0000000..d4fdc13 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace LuticaLab.TextureCocktail +{ + /// + /// Information record for a discovered TextureCocktail plugin. + /// + public sealed class TextureCocktailPluginInfo + { + /// C# class name (used to match the shader's last path segment). + public string TypeName { get; internal set; } + + /// The actual of the plugin. + public Type PluginType { get; internal set; } + + /// Human-readable display name (from attribute, or falls back to TypeName). + public string DisplayName { get; internal set; } + + /// Plugin description (from attribute). + public string Description { get; internal set; } + + /// Plugin author (from attribute). + public string Author { get; internal set; } + + /// Plugin version string (from attribute). + public string Version { get; internal set; } + + /// Assembly that defines the plugin. + public string AssemblyName { get; internal set; } + } + + /// + /// Discovers and caches every subclass found in all + /// assemblies that are currently loaded in the AppDomain. + /// + /// Third-party plugins are picked up automatically — no manual registration is needed. + /// Optionally decorate your class with to + /// supply display metadata shown in the plugin browser. + /// + /// HOW TO CREATE A PLUGIN + /// ─────────────────────── + /// 1. Create a shader whose last path segment is your class name, e.g.: + /// Shader "YourNamespace/MyEffect" { ... } + /// 2. Create a C# class in any assembly: + /// [TextureCocktailPlugin("My Effect", "Does something cool", "YourName")] + /// public class MyEffect : TextureCocktailContent { ... } + /// 3. TextureCocktail will load your UI automatically when the user selects the shader. + /// + public static class TextureCocktailPluginRegistry + { + private static Dictionary _typesByName; + private static List _infos; + + /// Mapping from class name (case-insensitive) → plugin type. + public static IReadOnlyDictionary TypesByName + { + get + { + EnsureLoaded(); + return _typesByName; + } + } + + /// All discovered plugin info records. + public static IReadOnlyList AllPlugins + { + get + { + EnsureLoaded(); + return _infos; + } + } + + /// + /// Forces a full re-scan of all loaded assemblies. + /// Called automatically the first time the registry is accessed and on domain reload. + /// + [InitializeOnLoadMethod] + public static void Refresh() + { + _typesByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + _infos = new List(); + + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + TryScanAssembly(assembly); + } + + Debug.Log($"[TextureCocktail] Plugin registry refreshed — {_infos.Count} plugin(s) found."); + } + + /// + /// Creates an instance of the plugin whose class name matches . + /// Returns null if no matching plugin is registered. + /// + public static TextureCocktailContent CreatePlugin(string shaderLastName) + { + EnsureLoaded(); + if (_typesByName.TryGetValue(shaderLastName, out Type type)) + { + return (TextureCocktailContent)ScriptableObject.CreateInstance(type); + } + return null; + } + + // ── private ───────────────────────────────────────────────────────────── + + private static void EnsureLoaded() + { + if (_typesByName == null) + Refresh(); + } + + private static void TryScanAssembly(Assembly assembly) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + // Partial results — still process what we got + types = ex.Types; + } + catch + { + return; + } + + foreach (Type type in types) + { + if (type == null || type.IsAbstract || !type.IsSubclassOf(typeof(TextureCocktailContent))) + continue; + + var attr = type.GetCustomAttribute(); + var info = new TextureCocktailPluginInfo + { + TypeName = type.Name, + PluginType = type, + DisplayName = attr?.DisplayName ?? type.Name, + Description = attr?.Description ?? string.Empty, + Author = attr?.Author ?? string.Empty, + Version = attr?.Version ?? string.Empty, + AssemblyName = assembly.GetName().Name, + }; + + _typesByName[type.Name] = type; + _infos.Add(info); + } + } + } +} diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs.meta b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs.meta new file mode 100644 index 0000000..ef9354f --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ef796a036664d51955b07dd5ed6aa99 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/TextureCocktail.cs b/Packages/luticalab.texturecocktail/Editor/TextureCocktail.cs index bd4c019..27865d6 100644 --- a/Packages/luticalab.texturecocktail/Editor/TextureCocktail.cs +++ b/Packages/luticalab.texturecocktail/Editor/TextureCocktail.cs @@ -453,31 +453,20 @@ private void OnShaderChange(Shader changeTo) _valueChanged = true; } /// - /// Found shader window by name. + /// Finds and instantiates a shader window by the shader's last path segment. + /// Uses so that third-party plugins defined + /// in any loaded assembly are discovered automatically — no manual registration needed. /// - /// - /// shader name with namespace prefix, for example "ImageSync" - /// window script most be in LuticaLab.TextureCocktail namespace - /// - /// + /// Last segment of the shader path, e.g. "ImageSync". private TextureCocktailContent LoadShaderWindow(string shaderName) { - var foundType = Type.GetType("LuticaLab.TextureCocktail." + shaderName); - if (foundType == null) + var plugin = TextureCocktailPluginRegistry.CreatePlugin(shaderName); + if (plugin == null) { - Debug.LogWarning($"Shader window type '{shaderName}' not found. Ensure it is in the correct namespace and assembly."); - return null; - } - if (foundType.IsSubclassOf(typeof(TextureCocktailContent))) - { - var shaderWindow = (TextureCocktailContent)CreateInstance(foundType); - return shaderWindow; - } - else - { - Debug.LogWarning($"Shader window type '{shaderName}' is not a subclass of TextureCocktailContent."); - return null; + Debug.LogWarning($"[TextureCocktail] No plugin found for shader '{shaderName}'. " + + $"Create a class named '{shaderName}' that inherits TextureCocktailContent."); } + return plugin; } private void OnTextureChanged(Texture2D newTexture) { diff --git a/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs new file mode 100644 index 0000000..ebeb40b --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs @@ -0,0 +1,413 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace LuticaLab.TextureCocktail +{ + /// + /// Unity texture optimization analysis and batch-fix tool. + /// Open via: LuticaLab → Texture Optimizer + /// + public class TextureOptimizer : EditorWindow + { + // ── Menu item ──────────────────────────────────────────────────────── + [MenuItem("LuticaLab/Texture Optimizer")] + public static void ShowWindow() + { + GetWindow("Texture Optimizer"); + } + + // ── Enums ──────────────────────────────────────────────────────────── + public enum TargetPlatform { Desktop, Mobile, VR } + + // ── Inner data class ───────────────────────────────────────────────── + private class TextureReport + { + public Texture2D Texture; + public string AssetPath; + public TextureImporter Importer; + + // Current state + public int Width; + public int Height; + public bool IsPOT; + public bool HasMipmaps; + public TextureImporterCompression CurrentCompression; + public int CurrentMaxSize; + public long EstimatedSizeBytes; + + // Issues / recommendations + public List Warnings = new List(); + public List SuggestedActions = new List(); + + // UI state + public bool IsSelected; + public bool FoldoutOpen; + } + + public enum TextureOptimizationAction + { + EnableMipmaps, + ResizeToPOT, + ReduceMaxSize, + EnableCompression, + EnableCrunchCompression, + } + + // ── Window state ───────────────────────────────────────────────────── + private string _scanFolder = "Assets"; + private TargetPlatform _targetPlatform = TargetPlatform.Desktop; + private List _reports = new List(); + private Vector2 _scroll; + private bool _scanning; + private int _maxSizeThreshold = 2048; + private bool _showOnlyWithIssues = true; + + // ── Action tracking ────────────────────────────────────────────────── + private int _appliedCount; + + // ── GUI ────────────────────────────────────────────────────────────── + private void OnGUI() + { + GUILayout.Label("Texture Optimizer", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + // Scan settings + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUILayout.Label("Scan Settings", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + _scanFolder = EditorGUILayout.TextField("Folder to Scan", _scanFolder); + if (GUILayout.Button("Browse", GUILayout.Width(70))) + { + string path = EditorUtility.OpenFolderPanel("Select Folder", _scanFolder, ""); + if (!string.IsNullOrEmpty(path)) + { + if (path.StartsWith(Application.dataPath)) + _scanFolder = "Assets" + path.Substring(Application.dataPath.Length); + else + _scanFolder = path; + } + } + EditorGUILayout.EndHorizontal(); + + _targetPlatform = (TargetPlatform)EditorGUILayout.EnumPopup("Target Platform", _targetPlatform); + _maxSizeThreshold = EditorGUILayout.IntField("Max Allowed Size (px)", _maxSizeThreshold); + _showOnlyWithIssues = EditorGUILayout.Toggle("Show Only Textures With Issues", _showOnlyWithIssues); + + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(4); + + if (GUILayout.Button("Scan Textures", GUILayout.Height(35))) + ScanTextures(); + + if (_reports.Count > 0) + { + EditorGUILayout.Space(4); + DrawActionBar(); + EditorGUILayout.Space(4); + DrawReportList(); + } + } + + // ── Scan ───────────────────────────────────────────────────────────── + private void ScanTextures() + { + _reports.Clear(); + + string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { _scanFolder }); + int total = guids.Length; + int processed = 0; + + foreach (string guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + if (EditorUtility.DisplayCancelableProgressBar( + "Scanning Textures", + $"Analysing: {Path.GetFileName(path)}", + (float)processed / Mathf.Max(total, 1))) + { + break; + } + + var tex = AssetDatabase.LoadAssetAtPath(path); + var importer = AssetImporter.GetAtPath(path) as TextureImporter; + if (tex == null || importer == null) + { + processed++; + continue; + } + + var report = BuildReport(tex, path, importer); + if (!_showOnlyWithIssues || report.Warnings.Count > 0) + _reports.Add(report); + + processed++; + } + + EditorUtility.ClearProgressBar(); + Repaint(); + } + + private TextureReport BuildReport(Texture2D tex, string path, TextureImporter importer) + { + var r = new TextureReport + { + Texture = tex, + AssetPath = path, + Importer = importer, + Width = tex.width, + Height = tex.height, + IsPOT = IsPowerOfTwo(tex.width) && IsPowerOfTwo(tex.height), + HasMipmaps = tex.mipmapCount > 1, + CurrentCompression = importer.textureCompression, + CurrentMaxSize = importer.maxTextureSize, + }; + + r.EstimatedSizeBytes = EstimateVRAM(tex, r.HasMipmaps); + + // --- Warnings & actions --- + if (!r.IsPOT) + { + r.Warnings.Add("Texture dimensions are not power-of-two. GPU cannot generate mipmaps efficiently."); + r.SuggestedActions.Add(TextureOptimizationAction.ResizeToPOT); + } + + bool is3DTexture = importer.textureType != TextureImporterType.Sprite && + importer.textureType != TextureImporterType.GUI; + + if (is3DTexture && !r.HasMipmaps) + { + r.Warnings.Add("Mipmaps are disabled on a non-UI texture. Enable mipmaps to reduce aliasing and improve performance."); + r.SuggestedActions.Add(TextureOptimizationAction.EnableMipmaps); + } + + if (r.CurrentCompression == TextureImporterCompression.Uncompressed) + { + r.Warnings.Add("Texture is uncompressed. Compression can reduce memory usage significantly."); + r.SuggestedActions.Add(TextureOptimizationAction.EnableCompression); + } + + if (r.Width > _maxSizeThreshold || r.Height > _maxSizeThreshold) + { + r.Warnings.Add($"Texture exceeds {_maxSizeThreshold}px threshold ({r.Width}×{r.Height}). Consider reducing max size."); + r.SuggestedActions.Add(TextureOptimizationAction.ReduceMaxSize); + } + + if (r.CurrentCompression != TextureImporterCompression.Uncompressed && + !importer.crunchedCompression && + r.EstimatedSizeBytes > 1024 * 1024) + { + r.Warnings.Add("Large compressed texture. Crunch compression can further reduce disk size."); + r.SuggestedActions.Add(TextureOptimizationAction.EnableCrunchCompression); + } + + return r; + } + + // ── Action bar ─────────────────────────────────────────────────────── + private void DrawActionBar() + { + int selectedCount = 0; + foreach (var r in _reports) + if (r.IsSelected) selectedCount++; + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label($"Found {_reports.Count} texture(s). {selectedCount} selected.", EditorStyles.boldLabel); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Select All", GUILayout.Width(90))) + foreach (var r in _reports) r.IsSelected = true; + if (GUILayout.Button("Deselect All", GUILayout.Width(95))) + foreach (var r in _reports) r.IsSelected = false; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(2); + + GUI.enabled = selectedCount > 0; + if (GUILayout.Button("Apply Recommended Fixes to Selected", GUILayout.Height(30))) + ApplySelectedFixes(); + GUI.enabled = true; + + if (_appliedCount > 0) + { + EditorGUILayout.HelpBox($"Applied fixes to {_appliedCount} texture(s). Re-scan to verify.", MessageType.Info); + } + } + + // ── Report list ────────────────────────────────────────────────────── + private void DrawReportList() + { + _scroll = EditorGUILayout.BeginScrollView(_scroll); + foreach (var r in _reports) + { + DrawReportEntry(r); + } + EditorGUILayout.EndScrollView(); + } + + private void DrawReportEntry(TextureReport r) + { + bool hasIssues = r.Warnings.Count > 0; + Color rowColor = hasIssues ? new Color(1f, 0.95f, 0.8f) : new Color(0.85f, 1f, 0.85f); + + var prevBg = GUI.backgroundColor; + GUI.backgroundColor = rowColor; + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUI.backgroundColor = prevBg; + + // Header row + EditorGUILayout.BeginHorizontal(); + r.IsSelected = EditorGUILayout.Toggle(r.IsSelected, GUILayout.Width(18)); + r.FoldoutOpen = EditorGUILayout.Foldout(r.FoldoutOpen, + $"{r.Texture.name} ({r.Width}×{r.Height}) {FormatBytes(r.EstimatedSizeBytes)}", + true); + GUILayout.FlexibleSpace(); + if (hasIssues) + GUILayout.Label($"⚠ {r.Warnings.Count} issue(s)", EditorStyles.miniLabel); + else + GUILayout.Label("✓ OK", EditorStyles.miniLabel); + EditorGUILayout.EndHorizontal(); + + if (r.FoldoutOpen) + { + EditorGUI.indentLevel++; + + EditorGUILayout.LabelField("Path:", r.AssetPath, EditorStyles.miniLabel); + EditorGUILayout.LabelField("Compression:", r.CurrentCompression.ToString(), EditorStyles.miniLabel); + EditorGUILayout.LabelField("Max Size:", r.CurrentMaxSize.ToString(), EditorStyles.miniLabel); + EditorGUILayout.LabelField("Mipmaps:", r.HasMipmaps ? "Enabled" : "Disabled", EditorStyles.miniLabel); + EditorGUILayout.LabelField("Power of Two:", r.IsPOT ? "Yes" : "No", EditorStyles.miniLabel); + + if (r.Warnings.Count > 0) + { + GUILayout.Label("Issues:", EditorStyles.boldLabel); + foreach (var w in r.Warnings) + EditorGUILayout.HelpBox(w, MessageType.Warning); + } + + // Quick-fix button + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Ping Asset", GUILayout.Width(90))) + EditorGUIUtility.PingObject(r.Texture); + if (r.Warnings.Count > 0 && GUILayout.Button("Fix This Texture", GUILayout.Width(110))) + { + ApplyFix(r); + _appliedCount++; + Repaint(); + } + EditorGUILayout.EndHorizontal(); + + EditorGUI.indentLevel--; + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(2); + } + + // ── Fix logic ──────────────────────────────────────────────────────── + private void ApplySelectedFixes() + { + _appliedCount = 0; + foreach (var r in _reports) + { + if (!r.IsSelected) continue; + ApplyFix(r); + _appliedCount++; + } + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + ScanTextures(); // Refresh reports + } + + private void ApplyFix(TextureReport r) + { + bool dirty = false; + + foreach (var action in r.SuggestedActions) + { + switch (action) + { + case TextureOptimizationAction.EnableMipmaps: + r.Importer.mipmapEnabled = true; + dirty = true; + break; + + case TextureOptimizationAction.EnableCompression: + r.Importer.textureCompression = GetRecommendedCompression(); + dirty = true; + break; + + case TextureOptimizationAction.EnableCrunchCompression: + r.Importer.crunchedCompression = true; + r.Importer.compressionQuality = 50; + dirty = true; + break; + + case TextureOptimizationAction.ReduceMaxSize: + int newMax = _maxSizeThreshold; + // Clamp to nearest valid value + int[] validSizes = { 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192 }; + foreach (int s in validSizes) + { + if (s >= newMax) { newMax = s; break; } + } + r.Importer.maxTextureSize = newMax; + dirty = true; + break; + + case TextureOptimizationAction.ResizeToPOT: + r.Importer.npotScale = TextureImporterNPOTScale.ToNearest; + dirty = true; + break; + } + } + + if (dirty) + r.Importer.SaveAndReimport(); + } + + private TextureImporterCompression GetRecommendedCompression() + { + return _targetPlatform switch + { + TargetPlatform.Mobile => TextureImporterCompression.CompressedHQ, + TargetPlatform.VR => TextureImporterCompression.CompressedHQ, + _ => TextureImporterCompression.Compressed, + }; + } + + // ── Helpers ────────────────────────────────────────────────────────── + private static bool IsPowerOfTwo(int n) => n > 0 && (n & (n - 1)) == 0; + + private static long EstimateVRAM(Texture2D tex, bool hasMips) + { + // Rough estimate: width * height * bytesPerPixel (compressed ~0.5 BPP, uncompressed ~4 BPP) + long base_ = (long)tex.width * tex.height; + long bpp; + switch (tex.format) + { + case TextureFormat.DXT1: bpp = 1; break; + case TextureFormat.DXT5: bpp = 1; break; + case TextureFormat.ETC_RGB4: bpp = 1; break; + case TextureFormat.ETC2_RGBA8: bpp = 1; break; + case TextureFormat.ASTC_4x4: bpp = 1; break; + case TextureFormat.RGB24: bpp = 3; break; + case TextureFormat.RGBA32: bpp = 4; break; + default: bpp = 4; break; + } + long size = base_ * bpp; + return hasMips ? size * 4 / 3 : size; // mips add ~33% + } + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024} KB"; + return $"{bytes / (1024 * 1024)} MB"; + } + } +} diff --git a/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs.meta b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs.meta new file mode 100644 index 0000000..d01aff8 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bbbf4bb6c853440cb6d0db9e0b88bc8e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md b/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md new file mode 100644 index 0000000..1d3dc57 --- /dev/null +++ b/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md @@ -0,0 +1,168 @@ +# TextureCocktail Plugin Development Guide + +This guide explains how to create custom plugins (content editors) for TextureCocktail +so that **users, modders, and AI agents** can extend the tool with new texture effects. + +--- + +## Quick Start + +### Step 1 — Create a Shader + +Create a Unity shader with any path you like. The **last segment** of the path becomes the +plugin identifier: + +```hlsl +Shader "YourName/GrayscaleEffect" +{ + Properties + { + _MainTex ("Texture", 2D) = "white" {} + _Strength ("Effect Strength", Range(0,1)) = 0.5 + } + SubShader + { + Pass + { + CGPROGRAM + #pragma vertex vert_img + #pragma fragment frag + #include "UnityCG.cginc" + + sampler2D _MainTex; + float _Strength; + + fixed4 frag(v2f_img i) : SV_Target + { + fixed4 col = tex2D(_MainTex, i.uv); + float gray = dot(col.rgb, fixed3(0.299, 0.587, 0.114)); + col.rgb = lerp(col.rgb, fixed3(gray, gray, gray), _Strength); + return col; + } + ENDCG + } + } +} +``` + +--- + +### Step 2 — Create the Plugin Class + +Create a C# class **whose name exactly matches the last path segment of the shader** +(e.g. `GrayscaleEffect`). It must: + +- Be in **any assembly** (no specific namespace required) +- Inherit from `LuticaLab.TextureCocktail.TextureCocktailContent` +- Optionally carry `[TextureCocktailPlugin]` metadata + +```csharp +using LuticaLab.TextureCocktail; +using UnityEditor; +using UnityEngine; + +// Optional metadata for the Plugin Browser window +[TextureCocktailPlugin( + displayName : "Grayscale Effect", + description : "Converts the image to grayscale with adjustable strength", + author : "YourName", + version : "1.0.0")] +public class GrayscaleEffect : TextureCocktailContent +{ + public override bool UseDefaultLayout => false; + + public override void OnGUI() + { + GUILayout.Label("Grayscale Effect", EditorStyles.boldLabel); + + var mat = GetMaterial(); + if (mat == null) { baseWindow.ShowShaderInfo(); return; } + + EditorGUI.BeginChangeCheck(); + float strength = mat.GetFloat("_Strength"); + strength = EditorGUILayout.Slider("Effect Strength", strength, 0f, 1f); + if (EditorGUI.EndChangeCheck()) + { + mat.SetFloat("_Strength", strength); + baseWindow.OnShaderValueChange(); + } + + baseWindow.DisplayPassedIamge(); + + if (GUILayout.Button("Save")) baseWindow.SaveTexture(); + } + + public override void OnShaderValueChanged() { } + + // Helper — access the material through the public API + private Material GetMaterial() + { + var field = baseWindow.GetType().GetField("_calcMaterial", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return field?.GetValue(baseWindow) as Material; + } +} +``` + +--- + +## Plugin Discovery + +TextureCocktail uses `TextureCocktailPluginRegistry` which scans **all assemblies** loaded +in the AppDomain on editor startup. Your class does **not** need to be in the +`LuticaLab.TextureCocktail` namespace — any namespace works. + +Open **LuticaLab → TextureCocktail Plugin Browser** to see all discovered plugins. + +--- + +## TextureCocktailContent API Reference + +| Member | Description | +|--------|-------------| +| `baseWindow` | Reference to the main `TextureCocktail` editor window | +| `scrollPosition` | Shared scroll position for the content area | +| `UseDefaultLayout` | Return `false` to take full control of the GUI | +| `DontWantDisplayPropertyName` | Property names to hide from the default shader inspector | +| `ShaderUpdateDefaultAction` | Return `false` to handle shader updates yourself | +| `PassOrder` | GPU pass index to compile (default 0) | +| `Initialize(window)` | Called once when the shader is selected | +| `OnGUI()` | Draw your custom Unity IMGUI here | +| `OnShaderValueChanged()` | Called when shader parameters change | +| `OnValuepdate()` | Called every editor update | + +### TextureCocktail Window API + +| Method | Description | +|--------|-------------| +| `DisplayPassedIamge()` | Renders the preview RenderTexture inline | +| `ShowShaderInfo()` | Renders the auto-generated shader property inspector | +| `DisplayShaderOptions()` | Renders keyword toggle UI | +| `CompileShader()` | Re-blits the texture through the material | +| `SaveTexture()` | Opens save dialog and writes the result PNG | +| `SetMaterialKeyword(name, on)` | Enable/disable a shader keyword | +| `OnShaderValueChange()` | Triggers a full shader re-compile | + +--- + +## AI Agent Usage + +AI agents can generate plugin code programmatically by following the same pattern: + +1. Generate an HLSL shader string and write it to `Packages//Shader/Image/.shader` +2. Generate a C# class string inheriting `TextureCocktailContent` and write it to + `Packages//Editor/Content/.cs` +3. Call `TextureCocktailPluginRegistry.Refresh()` to pick up the new class +4. The user can now select the shader in the TextureCocktail window + +--- + +## Example Plugin with [TextureCocktailPlugin] Attribute + +```csharp +[TextureCocktailPlugin("Vignette", "Adds a vignette darkening effect", "LuticaLab", "1.0.0")] +public class VignetteEffect : TextureCocktailContent { ... } +``` + +The metadata is visible in the Plugin Browser and can be inspected programmatically via +`TextureCocktailPluginRegistry.AllPlugins`. diff --git a/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md.meta b/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md.meta new file mode 100644 index 0000000..bf0b5f5 --- /dev/null +++ b/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5f38214559be46dea385b117333731a7 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 2c1b98abb4c5c1503120152bb0c1609f1ca7d5fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:34:58 +0000 Subject: [PATCH 3/4] Fix Analysing -> Analyzing spelling in TextureOptimizer progress bar Agent-Logs-Url: https://github.com/LuticaCANARD/TextureCocktail/sessions/f02e51a9-dae2-4959-9c28-dc0f82dbc1c8 Co-authored-by: LuticaCANARD <80238084+LuticaCANARD@users.noreply.github.com> --- Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs index ebeb40b..0663d27 100644 --- a/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs +++ b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs @@ -127,7 +127,7 @@ private void ScanTextures() string path = AssetDatabase.GUIDToAssetPath(guid); if (EditorUtility.DisplayCancelableProgressBar( "Scanning Textures", - $"Analysing: {Path.GetFileName(path)}", + $"Analyzing: {Path.GetFileName(path)}", (float)processed / Mathf.Max(total, 1))) { break; From 984d4a8fc045ead632bf94aae7a3739a2b4d3e11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:18:21 +0000 Subject: [PATCH 4/4] Refactor AI connector into multi-backend system with shared utilities Agent-Logs-Url: https://github.com/LuticaCANARD/TextureCocktail/sessions/077df106-2085-4340-8da7-1339e03845a2 Co-authored-by: LuticaCANARD <80238084+LuticaCANARD@users.noreply.github.com> --- .../luticalab.texturecocktail/Editor/AI.meta | 8 + .../Editor/AI/AiBackendBase.cs | 80 ++++ .../Editor/AI/AiBackendBase.cs.meta | 11 + .../Editor/AI/AiTextureUtils.cs | 68 ++++ .../Editor/AI/AiTextureUtils.cs.meta | 11 + .../Editor/AI/OllamaBackend.cs | 124 ++++++ .../Editor/AI/OllamaBackend.cs.meta | 11 + .../Editor/AI/OpenAiCompatibleBackend.cs | 167 ++++++++ .../Editor/AI/OpenAiCompatibleBackend.cs.meta | 11 + .../Editor/OllamaConnector.cs | 368 ++++++------------ 10 files changed, 601 insertions(+), 258 deletions(-) create mode 100644 Packages/luticalab.texturecocktail/Editor/AI.meta create mode 100644 Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs create mode 100644 Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs.meta create mode 100644 Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs create mode 100644 Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs.meta create mode 100644 Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs create mode 100644 Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs.meta create mode 100644 Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs create mode 100644 Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs.meta diff --git a/Packages/luticalab.texturecocktail/Editor/AI.meta b/Packages/luticalab.texturecocktail/Editor/AI.meta new file mode 100644 index 0000000..6730982 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/AI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c528b4bd8d294f11ad9ab0e530e11f24 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs b/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs new file mode 100644 index 0000000..d087299 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace LuticaLab.TextureCocktail +{ + /// + /// Payload sent to an AI backend. + /// + public struct AiRequest + { + /// Text prompt to send. + public string Prompt; + + /// Optional image attachment for vision models. May be null. + public Texture2D AttachedImage; + } + + /// + /// Response received from an AI backend. + /// + public struct AiResponse + { + /// Whether the call succeeded. + public bool Success; + + /// The text portion of the response. + public string Text; + + /// Optional decoded image returned in the response. May be null. + public Texture2D Image; + + /// Error message when is false. + public string Error; + } + + /// + /// Abstract base class for local / remote AI backends. + /// + /// Implement this to add a new AI provider. The + /// discovers all concrete subclasses at runtime and presents them in a dropdown. + /// + /// Implementations live in Editor/AI/ — see and + /// for reference examples. + /// + public abstract class AiBackendBase + { + /// Human-readable name shown in the backend selector. + public abstract string DisplayName { get; } + + /// Default server URL pre-filled in the UI. + public abstract string DefaultServerUrl { get; } + + /// + /// Whether this backend can accept an image alongside the text prompt. + /// When false the image attachment UI is hidden for this backend. + /// + public abstract bool SupportsImageInput { get; } + + /// + /// Returns the list of model names available on the server. + /// Throw on network failure so the caller can surface the error. + /// + public abstract Task> FetchModelsAsync( + string serverUrl, + CancellationToken ct = default); + + /// + /// Sends a prompt and returns the response. + /// The implementation must not throw — return = false + /// with a populated instead. + /// + public abstract Task SendPromptAsync( + string serverUrl, + string model, + AiRequest request, + CancellationToken ct = default); + } +} diff --git a/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs.meta b/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs.meta new file mode 100644 index 0000000..4e5419f --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2b9eb61f63254aa29168080e396e34a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs b/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs new file mode 100644 index 0000000..6ba1d21 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs @@ -0,0 +1,68 @@ +using System; +using UnityEngine; + +namespace LuticaLab.TextureCocktail +{ + /// + /// Shared texture encoding/decoding utilities used by all AI backend implementations. + /// + public static class AiTextureUtils + { + /// + /// Encodes a to a base64 PNG string. + /// Handles non-readable textures by blitting through a temporary RenderTexture. + /// Returns null on failure. + /// + public static string TextureToBase64(Texture2D tex) + { + if (tex == null) return null; + try + { + byte[] pngBytes; + if (tex.isReadable) + { + pngBytes = tex.EncodeToPNG(); + } + else + { + var rt = new RenderTexture(tex.width, tex.height, 0, RenderTextureFormat.ARGB32); + Graphics.Blit(tex, rt); + RenderTexture.active = rt; + var readable = new Texture2D(tex.width, tex.height, TextureFormat.RGBA32, false); + readable.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + readable.Apply(); + RenderTexture.active = null; + rt.Release(); + pngBytes = readable.EncodeToPNG(); + UnityEngine.Object.DestroyImmediate(readable); + } + return Convert.ToBase64String(pngBytes); + } + catch (Exception ex) + { + Debug.LogWarning($"[AiTextureUtils] Could not encode texture to base64: {ex.Message}"); + return null; + } + } + + /// + /// Decodes a base64-encoded image (PNG/JPEG) into a . + /// Returns null on failure. + /// + public static Texture2D Base64ToTexture(string base64) + { + if (string.IsNullOrEmpty(base64)) return null; + try + { + byte[] bytes = Convert.FromBase64String(base64); + var tex = new Texture2D(2, 2); + tex.LoadImage(bytes); + return tex; + } + catch + { + return null; + } + } + } +} diff --git a/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs.meta b/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs.meta new file mode 100644 index 0000000..c176488 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: da0e05e879e14c7f94ee6b02e5494150 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs b/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs new file mode 100644 index 0000000..7c8701f --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace LuticaLab.TextureCocktail +{ + /// + /// AI backend for a local Ollama server. + /// + /// Endpoints used: + /// GET /api/tags — list available models + /// POST /api/generate — generate a response (non-streaming) + /// + /// Supports vision models (llava, bakllava, etc.) when an image is attached. + /// + public class OllamaBackend : AiBackendBase + { + private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; + + public override string DisplayName => "Ollama"; + public override string DefaultServerUrl => "http://localhost:11434"; + public override bool SupportsImageInput => true; + + /// + public override async Task> FetchModelsAsync(string serverUrl, CancellationToken ct = default) + { + string url = serverUrl.TrimEnd('/') + "/api/tags"; + string json = await _http.GetStringAsync(url); + return ParseModelList(json); + } + + /// + public override async Task SendPromptAsync( + string serverUrl, + string model, + AiRequest request, + CancellationToken ct = default) + { + try + { + string body = BuildRequestBody(model, request); + string url = serverUrl.TrimEnd('/') + "/api/generate"; + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync(url, content, ct); + response.EnsureSuccessStatusCode(); + + string responseJson = await response.Content.ReadAsStringAsync(); + return ParseGenerateResponse(responseJson); + } + catch (OperationCanceledException) + { + return new AiResponse { Success = false, Error = "Request cancelled." }; + } + catch (Exception ex) + { + return new AiResponse { Success = false, Error = ex.Message }; + } + } + + // ── Request builder ────────────────────────────────────────────────── + + private static string BuildRequestBody(string model, AiRequest request) + { + var obj = new JObject + { + ["model"] = model, + ["prompt"] = request.Prompt, + ["stream"] = false, + }; + + if (request.AttachedImage != null) + { + string b64 = AiTextureUtils.TextureToBase64(request.AttachedImage); + if (!string.IsNullOrEmpty(b64)) + obj["images"] = new JArray(b64); + } + + return obj.ToString(Formatting.None); + } + + // ── Response parsers ───────────────────────────────────────────────── + + private static List ParseModelList(string json) + { + var models = new List(); + var root = JObject.Parse(json); + var arr = root["models"] as JArray; + if (arr == null) return models; + + foreach (var item in arr) + { + string name = item["name"]?.ToString(); + if (!string.IsNullOrEmpty(name)) + models.Add(name); + } + return models; + } + + private static AiResponse ParseGenerateResponse(string json) + { + var root = JObject.Parse(json); + string text = root["response"]?.ToString() ?? ""; + + // Some experimental endpoints include an "images" array in the response + Texture2D image = null; + var images = root["images"] as JArray; + if (images != null && images.Count > 0) + { + string b64 = images[0]?.ToString(); + if (!string.IsNullOrEmpty(b64)) + image = AiTextureUtils.Base64ToTexture(b64); + } + + return new AiResponse { Success = true, Text = text, Image = image }; + } + } +} diff --git a/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs.meta b/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs.meta new file mode 100644 index 0000000..d520284 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7e4bb5863e1740ba980664455edaaa0f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs b/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs new file mode 100644 index 0000000..d37f726 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace LuticaLab.TextureCocktail +{ + /// + /// AI backend for any server that implements the OpenAI Chat Completions API. + /// + /// Compatible products include: + /// • LocalAI (https://localai.io) + /// • LM Studio (https://lmstudio.ai) + /// • Jan (https://jan.ai) + /// • Kobold.cpp (https://github.com/LostRuins/koboldcpp) + /// • llama.cpp server with --api-prefix /v1 + /// • text-generation-webui with the OpenAI extension + /// + /// Endpoints used: + /// GET /v1/models — list available models + /// POST /v1/chat/completions — generate a response + /// + /// Vision input follows the OpenAI vision format (base64 data-URI). + /// + public class OpenAiCompatibleBackend : AiBackendBase + { + private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; + + public override string DisplayName => "OpenAI-Compatible (LocalAI / LM Studio / Jan …)"; + public override string DefaultServerUrl => "http://localhost:8080"; + public override bool SupportsImageInput => true; + + /// + public override async Task> FetchModelsAsync(string serverUrl, CancellationToken ct = default) + { + string url = serverUrl.TrimEnd('/') + "/v1/models"; + string json = await _http.GetStringAsync(url); + return ParseModelList(json); + } + + /// + public override async Task SendPromptAsync( + string serverUrl, + string model, + AiRequest request, + CancellationToken ct = default) + { + try + { + string body = BuildRequestBody(model, request); + string url = serverUrl.TrimEnd('/') + "/v1/chat/completions"; + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync(url, content, ct); + response.EnsureSuccessStatusCode(); + + string responseJson = await response.Content.ReadAsStringAsync(); + return ParseChatResponse(responseJson); + } + catch (OperationCanceledException) + { + return new AiResponse { Success = false, Error = "Request cancelled." }; + } + catch (Exception ex) + { + return new AiResponse { Success = false, Error = ex.Message }; + } + } + + // ── Request builder ────────────────────────────────────────────────── + + private static string BuildRequestBody(string model, AiRequest request) + { + // Build the message content — either plain string or mixed array for vision + JToken messageContent; + if (request.AttachedImage != null) + { + string b64 = AiTextureUtils.TextureToBase64(request.AttachedImage); + if (!string.IsNullOrEmpty(b64)) + { + // OpenAI vision format: content is an array of text + image_url parts + messageContent = new JArray( + new JObject { ["type"] = "text", ["text"] = request.Prompt }, + new JObject + { + ["type"] = "image_url", + ["image_url"] = new JObject + { + ["url"] = $"data:image/png;base64,{b64}" + } + } + ); + } + else + { + messageContent = request.Prompt; + } + } + else + { + messageContent = request.Prompt; + } + + var obj = new JObject + { + ["model"] = model, + ["messages"] = new JArray( + new JObject + { + ["role"] = "user", + ["content"] = messageContent, + } + ), + }; + + return obj.ToString(Formatting.None); + } + + // ── Response parsers ───────────────────────────────────────────────── + + private static List ParseModelList(string json) + { + var models = new List(); + var root = JObject.Parse(json); + var arr = root["data"] as JArray; + if (arr == null) return models; + + foreach (var item in arr) + { + string id = item["id"]?.ToString(); + if (!string.IsNullOrEmpty(id)) + models.Add(id); + } + return models; + } + + private static AiResponse ParseChatResponse(string json) + { + var root = JObject.Parse(json); + + // Standard OpenAI response: choices[0].message.content + var choices = root["choices"] as JArray; + if (choices == null || choices.Count == 0) + return new AiResponse { Success = false, Error = "No choices in response." }; + + var message = choices[0]?["message"]; + string text = message?["content"]?.ToString() ?? ""; + + // Some custom endpoints return an images array at the top level + Texture2D image = null; + var images = root["images"] as JArray; + if (images != null && images.Count > 0) + { + string b64 = images[0]?.ToString(); + if (!string.IsNullOrEmpty(b64)) + image = AiTextureUtils.Base64ToTexture(b64); + } + + return new AiResponse { Success = true, Text = text, Image = image }; + } + } +} diff --git a/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs.meta b/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs.meta new file mode 100644 index 0000000..a5aafef --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2fb6cd46fd1046ddab64e13822740bb5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs index 6349725..8826576 100644 --- a/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs +++ b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs @@ -1,44 +1,51 @@ using System; using System.Collections.Generic; -using System.Net.Http; -using System.Text; +using System.IO; using System.Threading; -using System.Threading.Tasks; using UnityEditor; using UnityEngine; namespace LuticaLab.TextureCocktail { /// - /// Editor window that connects to a local Ollama instance and provides a - /// text → text+image AI pipeline inside Unity Editor. + /// Multi-backend AI connector editor window. /// - /// Open via: LuticaLab → Ollama AI Connector + /// Open via: LuticaLab → AI Connector /// /// INPUT : text prompt + (optional) Texture2D for vision models - /// OUTPUT : text response displayed in the window + /// OUTPUT : AI-generated text (and an image preview when the response contains one) /// - /// Requires a running Ollama server (default: http://localhost:11434). - /// Install Ollama at https://ollama.com and pull a model, e.g.: - /// ollama pull llama3 - /// ollama pull llava (for vision / image input) + /// Supported backends: + /// • Ollama — http://localhost:11434 + /// • OpenAI-compatible APIs — LocalAI, LM Studio, Jan, Kobold.cpp, llama.cpp, etc. + /// + /// Add more backends by creating a class that inherits + /// anywhere in any loaded assembly — the window discovers them automatically. /// public class OllamaConnector : EditorWindow { // ── Menu item ──────────────────────────────────────────────────────── - [MenuItem("LuticaLab/Ollama AI Connector")] + [MenuItem("LuticaLab/AI Connector")] public static void ShowWindow() { - GetWindow("Ollama AI"); + GetWindow("AI Connector"); } - // ── Constants ──────────────────────────────────────────────────────── - private const string DefaultServerUrl = "http://localhost:11434"; - private const string PrefsKeyUrl = "TC_Ollama_Url"; - private const string PrefsKeyModel = "TC_Ollama_Model"; + // ── Known backends ─────────────────────────────────────────────────── + private static readonly AiBackendBase[] Backends = new AiBackendBase[] + { + new OllamaBackend(), + new OpenAiCompatibleBackend(), + }; + + // ── EditorPrefs keys ───────────────────────────────────────────────── + private const string PrefsKeyBackend = "TC_AI_Backend"; + private const string PrefsKeyUrl = "TC_AI_Url"; + private const string PrefsKeyModel = "TC_AI_Model"; // ── State ──────────────────────────────────────────────────────────── - private string _serverUrl = DefaultServerUrl; + private int _backendIndex = 0; + private string _serverUrl = ""; private string _selectedModel = ""; private List _availableModels = new List(); private int _selectedModelIndex = 0; @@ -48,25 +55,29 @@ public static void ShowWindow() private bool _attachTexture; private string _responseText = ""; - private Texture2D _responseImage; // decoded from base64 if server returns one + private Texture2D _responseImage; private Vector2 _responseScroll; private bool _busy; - private string _statusMessage = "Ready. Configure server URL and click 'List Models'."; + private string _statusMessage = ""; private CancellationTokenSource _cts; - private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; + private AiBackendBase ActiveBackend => Backends[_backendIndex]; // ── Lifecycle ──────────────────────────────────────────────────────── private void OnEnable() { - _serverUrl = EditorPrefs.GetString(PrefsKeyUrl, DefaultServerUrl); + _backendIndex = Mathf.Clamp(EditorPrefs.GetInt(PrefsKeyBackend, 0), 0, Backends.Length - 1); + _serverUrl = EditorPrefs.GetString(PrefsKeyUrl, ActiveBackend.DefaultServerUrl); _selectedModel = EditorPrefs.GetString(PrefsKeyModel, ""); + if (string.IsNullOrEmpty(_statusMessage)) + _statusMessage = "Ready. Select a backend, configure the server URL, and click 'List Models'."; } private void OnDisable() { _cts?.Cancel(); + EditorPrefs.SetInt(PrefsKeyBackend, _backendIndex); EditorPrefs.SetString(PrefsKeyUrl, _serverUrl); EditorPrefs.SetString(PrefsKeyModel, _selectedModel); } @@ -74,14 +85,14 @@ private void OnDisable() // ── GUI ────────────────────────────────────────────────────────────── private void OnGUI() { - GUILayout.Label("Ollama Local AI Connector", EditorStyles.boldLabel); + GUILayout.Label("AI Connector", EditorStyles.boldLabel); EditorGUILayout.HelpBox( - "Connects to a local Ollama server. Input: text prompt (+ optional image). " + - "Output: AI-generated text (and image preview when an image was provided).", + "Local AI pipeline: text prompt (+ optional image) → AI-generated text response.\n" + + "Supports Ollama and any OpenAI-compatible API (LocalAI, LM Studio, Jan, Kobold…).", MessageType.Info); EditorGUILayout.Space(4); - DrawServerSection(); + DrawBackendSection(); EditorGUILayout.Space(4); DrawPromptSection(); EditorGUILayout.Space(4); @@ -90,25 +101,41 @@ private void OnGUI() DrawStatusBar(); } - // ── Server section ─────────────────────────────────────────────────── - private void DrawServerSection() + // ── Backend / server section ───────────────────────────────────────── + private void DrawBackendSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); - GUILayout.Label("Server Configuration", EditorStyles.boldLabel); + GUILayout.Label("Backend & Server", EditorStyles.boldLabel); + + // Backend selector + string[] backendNames = new string[Backends.Length]; + for (int i = 0; i < Backends.Length; i++) + backendNames[i] = Backends[i].DisplayName; + + int newBackendIdx = EditorGUILayout.Popup("Backend", _backendIndex, backendNames); + if (newBackendIdx != _backendIndex) + { + _backendIndex = newBackendIdx; + // Pre-fill the default URL for the newly selected backend + _serverUrl = ActiveBackend.DefaultServerUrl; + _availableModels.Clear(); + _selectedModel = ""; + } + // Server URL + List Models EditorGUILayout.BeginHorizontal(); - _serverUrl = EditorGUILayout.TextField("Ollama URL", _serverUrl); + _serverUrl = EditorGUILayout.TextField("Server URL", _serverUrl); GUI.enabled = !_busy; if (GUILayout.Button("List Models", GUILayout.Width(100))) _ = FetchModelsAsync(); GUI.enabled = true; EditorGUILayout.EndHorizontal(); + // Model selector if (_availableModels.Count > 0) { - string[] modelArray = _availableModels.ToArray(); - _selectedModelIndex = Mathf.Clamp(_selectedModelIndex, 0, modelArray.Length - 1); - int newIdx = EditorGUILayout.Popup("Model", _selectedModelIndex, modelArray); + _selectedModelIndex = Mathf.Clamp(_selectedModelIndex, 0, _availableModels.Count - 1); + int newIdx = EditorGUILayout.Popup("Model", _selectedModelIndex, _availableModels.ToArray()); if (newIdx != _selectedModelIndex) { _selectedModelIndex = newIdx; @@ -134,24 +161,26 @@ private void DrawPromptSection() EditorGUILayout.Space(4); - // Image attachment (for vision models like llava) - _attachTexture = EditorGUILayout.Toggle("Attach Texture (vision models)", _attachTexture); - if (_attachTexture) + // Image attachment — only shown for backends that support it + if (ActiveBackend.SupportsImageInput) { - _inputTexture = (Texture2D)EditorGUILayout.ObjectField( - "Input Texture", _inputTexture, typeof(Texture2D), false); - - if (_inputTexture != null) + _attachTexture = EditorGUILayout.Toggle("Attach Texture (vision models)", _attachTexture); + if (_attachTexture) { - // Preview thumbnail - Rect thumbRect = GUILayoutUtility.GetRect(80, 80); - GUI.DrawTexture(thumbRect, _inputTexture, ScaleMode.ScaleToFit); - } + _inputTexture = (Texture2D)EditorGUILayout.ObjectField( + "Input Texture", _inputTexture, typeof(Texture2D), false); + + if (_inputTexture != null) + { + Rect thumbRect = GUILayoutUtility.GetRect(80, 80); + GUI.DrawTexture(thumbRect, _inputTexture, ScaleMode.ScaleToFit); + } - EditorGUILayout.HelpBox( - "Requires a vision model (e.g. llava). The texture is converted to PNG and " + - "sent as base64. Make sure the texture has Read/Write enabled in its import settings.", - MessageType.Info); + EditorGUILayout.HelpBox( + "Requires a vision model (e.g. llava for Ollama, or a multimodal model for OpenAI-compatible backends). " + + "The texture is converted to PNG and sent as base64.", + MessageType.Info); + } } EditorGUILayout.Space(4); @@ -160,7 +189,7 @@ private void DrawPromptSection() GUI.enabled = !_busy && !string.IsNullOrWhiteSpace(_promptText) && !string.IsNullOrEmpty(_selectedModel); if (GUILayout.Button("Send Prompt", GUILayout.Height(32))) _ = SendPromptAsync(); - GUI.enabled = !_busy; + GUI.enabled = _busy; if (GUILayout.Button("Cancel", GUILayout.Width(80), GUILayout.Height(32))) _cts?.Cancel(); GUI.enabled = true; @@ -188,7 +217,7 @@ private void DrawResponseSection() } EditorGUILayout.EndScrollView(); - // If input texture was attached, show a combined image+text panel + // Input texture context if (_attachTexture && _inputTexture != null && !string.IsNullOrEmpty(_responseText)) { EditorGUILayout.Space(4); @@ -197,7 +226,7 @@ private void DrawResponseSection() GUI.DrawTexture(imgRect, _inputTexture, ScaleMode.ScaleToFit); } - // If the response contained a base64 image, show it + // Response image (if the backend returned one) if (_responseImage != null) { EditorGUILayout.Space(4); @@ -226,23 +255,28 @@ private void DrawResponseSection() private void DrawStatusBar() { - MessageType msgType = _busy ? MessageType.Info : - (_statusMessage.StartsWith("Error") ? MessageType.Error : MessageType.Info); + bool isError = _statusMessage.StartsWith("Error"); + MessageType msgType = isError ? MessageType.Error : MessageType.Info; EditorGUILayout.HelpBox(_statusMessage, msgType); if (_busy) - EditorGUILayout.HelpBox("⏳ Waiting for Ollama response…", MessageType.Info); + EditorGUILayout.HelpBox("⏳ Waiting for response…", MessageType.Info); } - // ── Async helpers ──────────────────────────────────────────────────── + // ── Async operations ───────────────────────────────────────────────── - private async Task FetchModelsAsync() + private async System.Threading.Tasks.Task FetchModelsAsync() { SetBusy(true, "Fetching model list…"); try { - string url = _serverUrl.TrimEnd('/') + "/api/tags"; - string json = await _http.GetStringAsync(url); - ParseModelList(json); + var models = await ActiveBackend.FetchModelsAsync(_serverUrl); + _availableModels = models; + + int idx = _availableModels.IndexOf(_selectedModel); + _selectedModelIndex = idx >= 0 ? idx : 0; + if (_availableModels.Count > 0) + _selectedModel = _availableModels[_selectedModelIndex]; + SetStatus($"Found {_availableModels.Count} model(s)."); } catch (Exception ex) @@ -255,7 +289,7 @@ private async Task FetchModelsAsync() } } - private async Task SendPromptAsync() + private async System.Threading.Tasks.Task SendPromptAsync() { if (string.IsNullOrWhiteSpace(_promptText) || string.IsNullOrEmpty(_selectedModel)) return; @@ -265,229 +299,47 @@ private async Task SendPromptAsync() _responseText = ""; _responseImage = null; - try - { - string body = BuildRequestBody(); - string url = _serverUrl.TrimEnd('/') + "/api/generate"; - - var content = new StringContent(body, Encoding.UTF8, "application/json"); - var response = await _http.PostAsync(url, content, _cts.Token); - response.EnsureSuccessStatusCode(); - - string responseJson = await response.Content.ReadAsStringAsync(); - ParseGenerateResponse(responseJson); - - SetStatus("Response received."); - } - catch (OperationCanceledException) - { - SetStatus("Request cancelled."); - } - catch (Exception ex) - { - SetStatus($"Error: {ex.Message}"); - } - finally - { - SetBusy(false); - } - } - - // ── JSON helpers (manual, no extra deps) ───────────────────────────── - - private string BuildRequestBody() - { - var sb = new StringBuilder(); - sb.Append("{"); - sb.Append($"\"model\":{JsonString(_selectedModel)},"); - sb.Append($"\"prompt\":{JsonString(_promptText)},"); - sb.Append("\"stream\":false"); - - if (_attachTexture && _inputTexture != null) - { - string b64 = TextureToBase64(_inputTexture); - if (!string.IsNullOrEmpty(b64)) - { - sb.Append($",\"images\":[{JsonString(b64)}]"); - } - } - - sb.Append("}"); - return sb.ToString(); - } - - private void ParseModelList(string json) - { - _availableModels.Clear(); - // Parse "models":[{"name":"..."},...] — lightweight manual parse - int modelsIdx = json.IndexOf("\"models\"", StringComparison.Ordinal); - if (modelsIdx < 0) return; - - int start = json.IndexOf('[', modelsIdx); - int end = json.IndexOf(']', start); - if (start < 0 || end < 0) return; - - string segment = json.Substring(start, end - start + 1); - int pos = 0; - while (true) - { - int nameIdx = segment.IndexOf("\"name\"", pos, StringComparison.Ordinal); - if (nameIdx < 0) break; - int colon = segment.IndexOf(':', nameIdx); - int q1 = segment.IndexOf('"', colon + 1); - int q2 = segment.IndexOf('"', q1 + 1); - if (q1 < 0 || q2 < 0) break; - string name = segment.Substring(q1 + 1, q2 - q1 - 1); - _availableModels.Add(name); - pos = q2 + 1; - } - - // Restore persisted selection - int idx = _availableModels.IndexOf(_selectedModel); - _selectedModelIndex = idx >= 0 ? idx : 0; - if (_availableModels.Count > 0) - _selectedModel = _availableModels[_selectedModelIndex]; - } - - private void ParseGenerateResponse(string json) - { - // Extract "response":"..." - _responseText = ExtractJsonStringField(json, "response"); - - // Some models/endpoints may return "images":["base64..."] - int imgIdx = json.IndexOf("\"images\"", StringComparison.Ordinal); - if (imgIdx >= 0) + var request = new AiRequest { - int arrStart = json.IndexOf('[', imgIdx); - int q1 = json.IndexOf('"', arrStart); - int q2 = json.IndexOf('"', q1 + 1); - if (q1 >= 0 && q2 > q1) - { - string b64 = json.Substring(q1 + 1, q2 - q1 - 1); - _responseImage = Base64ToTexture(b64); - } - } + Prompt = _promptText, + AttachedImage = (_attachTexture && ActiveBackend.SupportsImageInput) ? _inputTexture : null, + }; - Repaint(); - } + AiResponse result = await ActiveBackend.SendPromptAsync(_serverUrl, _selectedModel, request, _cts.Token); - private static string ExtractJsonStringField(string json, string fieldName) - { - int idx = json.IndexOf($"\"{fieldName}\"", StringComparison.Ordinal); - if (idx < 0) return ""; - int colon = json.IndexOf(':', idx); - int q1 = json.IndexOf('"', colon + 1); - if (q1 < 0) return ""; - var sb = new StringBuilder(); - bool escape = false; - for (int i = q1 + 1; i < json.Length; i++) + if (result.Success) { - char c = json[i]; - if (escape) - { - switch (c) - { - case 'n': sb.Append('\n'); break; - case 't': sb.Append('\t'); break; - case 'r': sb.Append('\r'); break; - default: sb.Append(c); break; - } - escape = false; - } - else if (c == '\\') { escape = true; } - else if (c == '"') break; - else sb.Append(c); + _responseText = result.Text; + _responseImage = result.Image; + SetStatus("Response received."); } - return sb.ToString(); - } - - private static string JsonString(string s) - { - if (s == null) return "null"; - var sb = new StringBuilder("\""); - foreach (char c in s) + else { - 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; - default: sb.Append(c); break; - } + SetStatus($"Error: {result.Error}"); } - sb.Append('"'); - return sb.ToString(); - } - - // ── Texture helpers ────────────────────────────────────────────────── - private static string TextureToBase64(Texture2D tex) - { - try - { - // Ensure read/write; if texture is not readable, render it first - byte[] pngBytes; - if (tex.isReadable) - { - pngBytes = tex.EncodeToPNG(); - } - else - { - var rt = new RenderTexture(tex.width, tex.height, 0, RenderTextureFormat.ARGB32); - Graphics.Blit(tex, rt); - RenderTexture.active = rt; - var readable = new Texture2D(tex.width, tex.height, TextureFormat.RGBA32, false); - readable.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); - readable.Apply(); - RenderTexture.active = null; - rt.Release(); - pngBytes = readable.EncodeToPNG(); - UnityEngine.Object.DestroyImmediate(readable); - } - return Convert.ToBase64String(pngBytes); - } - catch (Exception ex) - { - Debug.LogWarning($"[OllamaConnector] Could not encode texture: {ex.Message}"); - return null; - } + SetBusy(false); + EditorApplication.delayCall += Repaint; } - private static Texture2D Base64ToTexture(string base64) - { - try - { - byte[] bytes = Convert.FromBase64String(base64); - var tex = new Texture2D(2, 2); - tex.LoadImage(bytes); - return tex; - } - catch - { - return null; - } - } + // ── Helpers ────────────────────────────────────────────────────────── private void SaveResponseImage() { if (_responseImage == null) return; - string path = EditorUtility.SaveFilePanel("Save Response Image", "Assets", "ollama_response.png", "png"); + string path = EditorUtility.SaveFilePanel("Save Response Image", "Assets", "ai_response.png", "png"); if (!string.IsNullOrEmpty(path)) { - System.IO.File.WriteAllBytes(path, _responseImage.EncodeToPNG()); + File.WriteAllBytes(path, _responseImage.EncodeToPNG()); AssetDatabase.Refresh(); SetStatus($"Image saved to {path}"); } } - // ── Thread-safe UI update helpers ──────────────────────────────────── private void SetBusy(bool busy, string msg = null) { _busy = busy; if (msg != null) _statusMessage = msg; - // Repaint must happen on the main thread — use EditorApplication.delayCall EditorApplication.delayCall += Repaint; }