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/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 new file mode 100644 index 0000000..8826576 --- /dev/null +++ b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using UnityEditor; +using UnityEngine; + +namespace LuticaLab.TextureCocktail +{ + /// + /// Multi-backend AI connector editor window. + /// + /// Open via: LuticaLab → AI Connector + /// + /// INPUT : text prompt + (optional) Texture2D for vision models + /// OUTPUT : AI-generated text (and an image preview when the response contains one) + /// + /// 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/AI Connector")] + public static void ShowWindow() + { + GetWindow("AI Connector"); + } + + // ── 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 int _backendIndex = 0; + private string _serverUrl = ""; + 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; + private Vector2 _responseScroll; + + private bool _busy; + private string _statusMessage = ""; + private CancellationTokenSource _cts; + + private AiBackendBase ActiveBackend => Backends[_backendIndex]; + + // ── Lifecycle ──────────────────────────────────────────────────────── + private void OnEnable() + { + _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); + } + + // ── GUI ────────────────────────────────────────────────────────────── + private void OnGUI() + { + GUILayout.Label("AI Connector", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "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); + DrawBackendSection(); + EditorGUILayout.Space(4); + DrawPromptSection(); + EditorGUILayout.Space(4); + DrawResponseSection(); + EditorGUILayout.Space(4); + DrawStatusBar(); + } + + // ── Backend / server section ───────────────────────────────────────── + private void DrawBackendSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + 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("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) + { + _selectedModelIndex = Mathf.Clamp(_selectedModelIndex, 0, _availableModels.Count - 1); + int newIdx = EditorGUILayout.Popup("Model", _selectedModelIndex, _availableModels.ToArray()); + 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 — only shown for backends that support it + if (ActiveBackend.SupportsImageInput) + { + _attachTexture = EditorGUILayout.Toggle("Attach Texture (vision models)", _attachTexture); + if (_attachTexture) + { + _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 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); + + 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(); + + // Input texture context + 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); + } + + // Response image (if the backend returned one) + 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() + { + bool isError = _statusMessage.StartsWith("Error"); + MessageType msgType = isError ? MessageType.Error : MessageType.Info; + EditorGUILayout.HelpBox(_statusMessage, msgType); + if (_busy) + EditorGUILayout.HelpBox("⏳ Waiting for response…", MessageType.Info); + } + + // ── Async operations ───────────────────────────────────────────────── + + private async System.Threading.Tasks.Task FetchModelsAsync() + { + SetBusy(true, "Fetching model list…"); + try + { + 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) + { + SetStatus($"Error fetching models: {ex.Message}"); + } + finally + { + SetBusy(false); + } + } + + private async System.Threading.Tasks.Task SendPromptAsync() + { + if (string.IsNullOrWhiteSpace(_promptText) || string.IsNullOrEmpty(_selectedModel)) + return; + + _cts = new CancellationTokenSource(); + SetBusy(true, "Sending prompt…"); + _responseText = ""; + _responseImage = null; + + var request = new AiRequest + { + Prompt = _promptText, + AttachedImage = (_attachTexture && ActiveBackend.SupportsImageInput) ? _inputTexture : null, + }; + + AiResponse result = await ActiveBackend.SendPromptAsync(_serverUrl, _selectedModel, request, _cts.Token); + + if (result.Success) + { + _responseText = result.Text; + _responseImage = result.Image; + SetStatus("Response received."); + } + else + { + SetStatus($"Error: {result.Error}"); + } + + SetBusy(false); + EditorApplication.delayCall += Repaint; + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private void SaveResponseImage() + { + if (_responseImage == null) return; + string path = EditorUtility.SaveFilePanel("Save Response Image", "Assets", "ai_response.png", "png"); + if (!string.IsNullOrEmpty(path)) + { + File.WriteAllBytes(path, _responseImage.EncodeToPNG()); + AssetDatabase.Refresh(); + SetStatus($"Image saved to {path}"); + } + } + + private void SetBusy(bool busy, string msg = null) + { + _busy = busy; + if (msg != null) _statusMessage = msg; + 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..0663d27 --- /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", + $"Analyzing: {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: