diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 5fdfeb0c9..6f7ca9f04 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -77,6 +77,11 @@ internal static class EditorPrefKeys // secure store (MCPForUnity.Editor.Security.SecureKeyStore), never in EditorPrefs. internal const string AssetGenSelectedModelProvider = "MCPForUnity.AssetGen.ModelProvider"; internal const string AssetGenSelectedImageProvider = "MCPForUnity.AssetGen.ImageProvider"; + internal const string AssetGenSelectedAudioProvider = "MCPForUnity.AssetGen.AudioProvider"; + // Selected model id per (kind, provider): key = prefix + ".". Empty => use + // the catalog default. Per-provider (not per-type) so e.g. the Tripo and Meshy 3D dropdowns + // — which have disjoint model lists — never clobber each other's selection. + internal const string AssetGenSelectedModelPrefix = "MCPForUnity.AssetGen.Model."; internal const string AssetGenDefaultFormat = "MCPForUnity.AssetGen.Format"; internal const string AssetGenOutputRoot = "MCPForUnity.AssetGen.OutputRoot"; internal const string AssetGenAutoNormalize = "MCPForUnity.AssetGen.AutoNormalize"; diff --git a/MCPForUnity/Editor/Helpers/AssetGenPaths.cs b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs index 290cb8de0..9b59fdc1c 100644 --- a/MCPForUnity/Editor/Helpers/AssetGenPaths.cs +++ b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs @@ -81,6 +81,21 @@ public static bool TryGetAssetsFolder(string path, out string projectRelative) return true; } + /// + /// Validate an optional caller-supplied output folder. Empty is allowed (the tool picks a + /// default); a non-empty value must resolve under the project's Assets folder. Shared by the + /// generate_* tools. + /// + public static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error) + { + normalized = outputFolder; + error = null; + if (string.IsNullOrWhiteSpace(outputFolder)) return true; + if (TryGetAssetsFolder(outputFolder, out normalized)) return true; + error = "'output_folder' must resolve under the project's Assets folder."; + return false; + } + private static string ProjectRoot() { string dataPath = Path.GetFullPath(Application.dataPath).Replace('\\', '/'); diff --git a/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs index b34ea47f0..03a8b6b36 100644 --- a/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs +++ b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs @@ -16,6 +16,7 @@ public static class AssetGenPrefs public const string DefaultOutputRoot = "Assets/Generated"; public const string DefaultModelProvider = "tripo"; public const string DefaultImageProvider = "fal"; + public const string DefaultAudioProvider = "fal"; public const string DefaultFormatValue = "glb"; public static string ModelProvider @@ -30,6 +31,33 @@ public static string ImageProvider set => SetOrDelete(EditorPrefKeys.AssetGenSelectedImageProvider, value); } + public static string AudioProvider + { + get => EditorPrefs.GetString(EditorPrefKeys.AssetGenSelectedAudioProvider, DefaultAudioProvider); + set => SetOrDelete(EditorPrefKeys.AssetGenSelectedAudioProvider, value); + } + + /// + /// Selected model id for a (kind, provider) pair — the GUI dropdown writes it and the + /// generate_* tools read it when the caller omits `model`. Empty by design: the tool's + /// empty -> catalog-default chain is the single source of the default, so a blank value + /// means "use the catalog default for this provider". Per-provider (not per-type) so + /// disjoint provider model lists (Tripo vs Meshy, fal vs OpenRouter) never clobber each other. + /// + public static string GetSelectedModel(string kind, string providerId) + => string.IsNullOrEmpty(providerId) + ? string.Empty + : EditorPrefs.GetString(ModelKey(kind, providerId), string.Empty); + + public static void SetSelectedModel(string kind, string providerId, string value) + { + if (string.IsNullOrEmpty(providerId)) return; + SetOrDelete(ModelKey(kind, providerId), value); + } + + private static string ModelKey(string kind, string providerId) + => EditorPrefKeys.AssetGenSelectedModelPrefix + kind + "." + providerId; + public static string DefaultFormat { get => EditorPrefs.GetString(EditorPrefKeys.AssetGenDefaultFormat, DefaultFormatValue); diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs index 1f23fa578..e9fa9e674 100644 --- a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs @@ -23,7 +23,7 @@ public enum AssetGenJobState { Queued, Running, Importing, Done, Failed, Cancele public sealed class AssetGenJob { public string JobId; - public string Kind; // model | image | marketplace + public string Kind; // model | image | audio | marketplace public string Provider; public string Action; public AssetGenJobState State; @@ -145,6 +145,34 @@ public static AssetGenJob StartImageGeneration(ImageGenRequest req) return job; } + public static AssetGenJob StartAudioGeneration(AudioGenRequest req) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + string provider = string.IsNullOrEmpty(req.Provider) ? "fal" : req.Provider; + IAudioProviderAdapter adapter = AssetGenProviders.Audio(provider); // throws NotSupportedException if unimplemented + + var job = NewJob("audio", provider, "generate"); + job.Format = "wav"; + + if (!TryResolveKey(provider, job, out string apiKey)) return job; + + IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport(); + var runner = new Runner + { + Job = job, + SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), + ImportFn = ImportOverrideForTests ?? AudioImportPipeline.ImportInto, + Transport = transport, + OutputFolder = req.OutputFolder, + Ext = "wav", // default; the poll's ResultExt (wav/mp3) overrides at write time + Name = NameFrom(req.Name, req.Prompt, job.JobId), + Subfolder = "Audio", + }; + Register(job, runner); + return job; + } + public static AssetGenJob StartMarketplaceImport(string uid, float targetSize, string name, string outputFolder) { if (string.IsNullOrEmpty(uid)) throw new ArgumentException("uid required"); @@ -409,10 +437,51 @@ private static void Advance(Runner r) } } + // Per-kind allowlist of result file extensions. The write extension can come from a + // provider-controlled result URL (OverrideExt), so a rogue provider could otherwise land a + // .cs/.asmdef/.meta/.asset under Assets/ and get it compiled/imported on Refresh — Editor + // RCE. Anything outside these sets is rejected. Mirrors ModelImportPipeline's allowlist style. + private static readonly HashSet AudioAllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + "wav", "mp3", "ogg", "aiff", "aif", "flac", + }; + private static readonly HashSet ImageAllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + "png", "jpg", "jpeg", "exr", "tga", "psd", "tiff", "webp", "gif", "bmp", + }; + private static readonly HashSet ModelAllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + "glb", "gltf", "fbx", "obj", "usd", "usdz", "dae", "ply", "stl", "zip", + }; + // Fail closed: an unexpected kind allows nothing, so the RCE boundary never opens by default. + private static readonly HashSet NoAllowedExtensions = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Whether (no leading dot) is an allowed result extension for the + /// job (audio | image | model | marketplace). Internal so the + /// allowlist can be unit-tested directly. + /// + internal static bool IsAllowedResultExtension(string kind, string ext) + => AllowedExtensionsFor(kind).Contains((ext ?? string.Empty).TrimStart('.')); + + private static HashSet AllowedExtensionsFor(string kind) + { + switch ((kind ?? string.Empty).ToLowerInvariant()) + { + case "audio": return AudioAllowedExtensions; + case "image": return ImageAllowedExtensions; + case "model": + case "marketplace": return ModelAllowedExtensions; + default: return NoAllowedExtensions; // fail closed for unexpected kinds + } + } + private static string WriteFile(Runner r, byte[] bytes) { string chosen = !string.IsNullOrEmpty(r.OverrideExt) ? r.OverrideExt : r.Ext; string ext = string.IsNullOrEmpty(chosen) ? "bin" : chosen.TrimStart('.').ToLowerInvariant(); + if (!IsAllowedResultExtension(r.Job.Kind, ext)) + throw new Exception($"provider returned a disallowed file type '.{ext}'"); string requestedRoot = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder : (AssetGenPrefs.OutputRoot + "/" + r.Subfolder); if (!AssetGenPaths.TryGetAssetsFolder(requestedRoot, out string root)) diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs new file mode 100644 index 000000000..ef25af0da --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.AssetGen.Providers; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + /// + /// One selectable model in the Asset Generation panel, plus the metadata the GUI shows + /// (use-case / price / max duration) and the license caveat to surface. is the + /// exact string passed as the tool's model param — it reaches the adapter request as the + /// fal endpoint, the Tripo model_version, the Meshy ai_model, or the OpenRouter + /// model slug. + /// + public sealed class ModelEntry + { + public string Id; + public string Label; + public string Provider; + public string Kind; // image | model | audio + public string UseCase; + public string PriceLabel; + public float MaxDurationSeconds; // 0 => not time-bounded (image / 3D) + // Audio duration knob. DurationField is the request key ("seconds_total" / "duration"); + // null => the model has no duration control (prompt-only). DefaultDurationSeconds is used + // when the caller passes 0 to a model whose endpoint requires a duration. MinDurationSeconds + // is the clamp floor. + public string DurationField; + public float DefaultDurationSeconds; + public float MinDurationSeconds; + public bool Loopable; + public string CommercialNote; // non-null => show a license caveat under the dropdown + public bool FromRefresh; // true => merged from a fal-catalog refresh (Phase 5) + } + + /// + /// Curated, always-present registry of selectable models per provider+kind, with metadata for + /// the Asset Generation panel. The first curated entry per (provider, kind) is the default, and + /// each default's references the owning adapter's constant directly + /// — so the panel's shown default always equals what an omitted model param resolves to + /// (a drift-guard test pins the two). A fal-catalog refresh overlay is layered on in Phase 5. + /// + public static class AssetGenModelCatalog + { + private static readonly ModelEntry[] Curated = + { + // Image — fal (FalAdapter.DefaultModel is first => the default) + new ModelEntry { Id = FalAdapter.DefaultModel, Label = "FLUX.2", Provider = "fal", Kind = "image", UseCase = "General image" }, + new ModelEntry { Id = "fal-ai/flux-2/flash", Label = "FLUX.2 Flash", Provider = "fal", Kind = "image", UseCase = "Fast / cheap image" }, + new ModelEntry { Id = "fal-ai/flux-2-pro", Label = "FLUX.2 Pro", Provider = "fal", Kind = "image", UseCase = "Top-quality image" }, + + // Image — openrouter + new ModelEntry { Id = OpenRouterAdapter.DefaultModel, Label = "Gemini 2.5 Flash Image", Provider = "openrouter", Kind = "image", UseCase = "General image" }, + + // 3D — tripo / meshy (defaults reference the adapter constants) + new ModelEntry { Id = TripoAdapter.ModelVersion, Label = "Tripo v3.1", Provider = "tripo", Kind = "model", UseCase = "Text / image -> 3D" }, + new ModelEntry { Id = "P1-20260311", Label = "Tripo P1 (premium)", Provider = "tripo", Kind = "model", UseCase = "Premium 3D" }, + new ModelEntry { Id = MeshyAdapter.DefaultModel, Label = "Meshy 6", Provider = "meshy", Kind = "model", UseCase = "Text / image -> 3D" }, + + // Audio — fal (order: stable-audio, cassette SFX, cassette music, lyria). DurationField + // is the request key each endpoint expects; null (Lyria) => prompt-only, no duration knob. + new ModelEntry { Id = FalAudioAdapter.DefaultModel, Label = "Stable Audio 2.5", Provider = "fal", Kind = "audio", UseCase = "Music + SFX", PriceLabel = "$0.20/gen", MaxDurationSeconds = 190f, + DurationField = "seconds_total", DefaultDurationSeconds = 30f, + CommercialNote = "Free under $1M annual revenue (Stability Community License); an Enterprise license is required at or above $1M." }, + new ModelEntry { Id = "cassetteai/sound-effects-generator", Label = "CassetteAI SFX", Provider = "fal", Kind = "audio", UseCase = "Sound effects", PriceLabel = "$0.01/gen", MaxDurationSeconds = 30f, + DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f }, + new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.02/min", MaxDurationSeconds = 180f, + DurationField = "duration", DefaultDurationSeconds = 10f, MinDurationSeconds = 1f }, + new ModelEntry { Id = "fal-ai/lyria2", Label = "Google Lyria 2", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.10/30s", MaxDurationSeconds = 30f }, + }; + + /// Curated entries for a provider+kind, in curated order (default first). Never null. + public static IReadOnlyList ForProvider(string provider, string kind) + { + var result = new List(); + foreach (ModelEntry e in Curated) + if (Eq(e.Provider, provider) && Eq(e.Kind, kind)) result.Add(e); + return result; + } + + /// The curated entry with this exact id, or null. + public static ModelEntry Find(string id) + { + if (string.IsNullOrEmpty(id)) return null; + foreach (ModelEntry e in Curated) + if (Eq(e.Id, id)) return e; + return null; + } + + /// The default model id for a provider+kind (the first curated entry), or null. + public static string DefaultModelId(string provider, string kind) + { + foreach (ModelEntry e in Curated) + if (Eq(e.Provider, provider) && Eq(e.Kind, kind)) return e.Id; + return null; + } + + /// + /// The model id a generate_* tool should use: an explicit wins, + /// else the GUI-selected model for this (kind, provider), else the curated default. Null when + /// nothing resolves (the adapter then falls back to its own constant). Single home for the + /// empty -> GUI-selected -> catalog-default precedence shared by all three generate tools. + /// + public static string ResolveModel(string kind, string provider, string requested) + { + string model = requested; + if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel(kind, provider); + if (string.IsNullOrWhiteSpace(model)) model = DefaultModelId(provider, kind); + return string.IsNullOrWhiteSpace(model) ? null : model; + } + + /// Clears any test/refresh state. The refresh overlay is added in Phase 5; no-op today. + internal static void ResetForTests() { } + + private static bool Eq(string a, string b) + => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs.meta b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs.meta new file mode 100644 index 000000000..1451f9aaa --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b6497ad09f4143c4a4a27bb6c00a6e3c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs index 395fc0beb..5b0a0f100 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs @@ -38,6 +38,10 @@ public Task SendAsync(HttpRequestSpec spec, CancellationToken ct) request.SetRequestHeader(kv.Key, kv.Value); } } + // UnityWebRequest re-sends the Authorization header to a 3xx target by default. Never + // follow a redirect on an auth-bearing request — the key must not leak to the redirect + // host. No-auth downloads may still follow. + if (CarriesAuth(spec)) request.redirectLimit = 0; CancellationTokenRegistration ctReg = default; if (ct.CanBeCanceled) @@ -76,5 +80,15 @@ public Task SendAsync(HttpRequestSpec spec, CancellationToken ct) return tcs.Task; } + + /// True iff the request carries an Authorization header (case-insensitive key). + internal static bool CarriesAuth(HttpRequestSpec spec) + { + if (spec?.Headers == null) return false; + foreach (var kv in spec.Headers) + if (string.Equals(kv.Key, "Authorization", StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } } } diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs new file mode 100644 index 000000000..f76508579 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen.Import +{ + /// + /// Imports a downloaded audio clip (already under Assets/) and applies AudioImporter settings. + /// Load type is chosen by clip length: short one-shots (SFX) decompress into memory for + /// zero-latency playback; medium clips stay compressed in memory; long BGM streams — so a + /// generated soundtrack doesn't sit fully decompressed in RAM. + /// + public static class AudioImportPipeline + { + public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath) + { + if (job == null) return null; + try + { + if (string.IsNullOrEmpty(localFilePath)) + return Fail(job, "No file to import."); + + if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel)) + return Fail(job, "Generated file is not under the Assets folder."); + + // Defense-in-depth: never import a non-audio file even if one slipped past WriteFile. + if (!AssetGenJobManager.IsAllowedResultExtension("audio", Path.GetExtension(rel))) + return Fail(job, "Refusing to import a non-audio file type."); + + AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate); + ApplyAudioImporterSettings(rel); + + job.AssetPath = rel; + job.AssetGuid = AssetDatabase.AssetPathToGUID(rel); + if (string.IsNullOrEmpty(job.AssetGuid)) + return Fail(job, "Imported the audio but Unity did not register it as an asset."); + + if (job.State != AssetGenJobState.Failed) + job.State = AssetGenJobState.Done; + return job; + } + catch (Exception e) + { + return Fail(job, SecretRedactor.Scrub(e.Message)); + } + } + + private static void ApplyAudioImporterSettings(string rel) + { + if (!(AssetImporter.GetAtPath(rel) is AudioImporter importer)) return; + + AudioClip clip = AssetDatabase.LoadAssetAtPath(rel); + float len = clip != null ? clip.length : 0f; + + AudioImporterSampleSettings s = importer.defaultSampleSettings; + if (len > 30f) s.loadType = AudioClipLoadType.Streaming; // long BGM + else if (len > 10f) s.loadType = AudioClipLoadType.CompressedInMemory; // medium track + else s.loadType = AudioClipLoadType.DecompressOnLoad; // short SFX one-shot + importer.defaultSampleSettings = s; + + importer.forceToMono = false; + importer.loadInBackground = false; + importer.SaveAndReimport(); + } + + private static AssetGenJob Fail(AssetGenJob job, string message) + { + job.State = AssetGenJobState.Failed; + job.Error = message; + return job; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs.meta new file mode 100644 index 000000000..00d1f7b45 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cb94201110f1247c7bdf60548a93a30e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs index c1cfb0862..35137766e 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs @@ -23,6 +23,10 @@ public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath, bool if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel)) return Fail(job, "Generated file is not under the Assets folder."); + // Defense-in-depth: never import a non-image file even if one slipped past WriteFile. + if (!AssetGenJobManager.IsAllowedResultExtension("image", Path.GetExtension(rel))) + return Fail(job, "Refusing to import a non-image file type."); + AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate); if (AssetImporter.GetAtPath(rel) is TextureImporter importer) diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs index 132a5581a..026de16b3 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs @@ -6,8 +6,8 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers { /// /// Factory + registry for asset-gen provider adapters. Resolves a provider id to its adapter - /// (model: tripo/meshy; image: fal/openrouter; marketplace: sketchfab); unknown ids throw - /// . advertises providers and reports + /// (model: tripo/meshy; image: fal/openrouter; audio: fal; marketplace: sketchfab); unknown ids + /// throw . advertises providers and reports /// Configured existence only — never a key value. /// public static class AssetGenProviders @@ -38,6 +38,17 @@ public static IImageProviderAdapter Image(string id) } } + public static IAudioProviderAdapter Audio(string id) + { + switch ((id ?? string.Empty).ToLowerInvariant()) + { + case "fal": + return new FalAudioAdapter(); + default: + throw new NotSupportedException($"Unknown audio provider '{id}'."); + } + } + public static IMarketplaceProviderAdapter Marketplace(string id) { switch ((id ?? string.Empty).ToLowerInvariant()) @@ -58,6 +69,8 @@ public static IReadOnlyList List() new ProviderInfo { Id = "sketchfab", Kind = "marketplace", Configured = IsConfigured("sketchfab"), Capabilities = new[] { "search", "import" } }, new ProviderInfo { Id = "fal", Kind = "image", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } }, new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } }, + // fal appears twice by design — once per kind (image + audio) — sharing the single "fal" key. + new ProviderInfo { Id = "fal", Kind = "audio", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "music", "sfx" } }, }; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs index d888a0f63..35ec7ce65 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs @@ -17,9 +17,11 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers public sealed class FalAdapter : IImageProviderAdapter { private const string QueueBase = "https://queue.fal.run/"; + private const string QueueHost = "queue.fal.run"; // FLUX.2 [dev] — current SOTA default (cheaper and better than FLUX.1 dev). Alternatives: // fal-ai/flux-2/flash (fastest/cheapest), fal-ai/flux-2-pro (top quality). - private const string DefaultModel = "fal-ai/flux-2"; + // internal so the model catalog references it directly (single source of truth, drift-guarded). + internal const string DefaultModel = "fal-ai/flux-2"; public string Id => "fal"; @@ -52,6 +54,8 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT if (!image && req.Width > 0 && req.Height > 0) body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height }; + ProviderHttp.RequireHost(url, QueueHost, apiKey, "fal submit"); + var spec = new HttpRequestSpec { Method = "POST", @@ -75,6 +79,9 @@ public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpT // so build from the base model id (not `url`, which may end in /edit). responseUrl = QueueBase + model + "/requests/" + requestId; } + // The response_url is provider-controlled; refuse to later attach the key to any host + // other than the fal queue. + ProviderHttp.RequireHost(responseUrl, QueueHost, apiKey, "fal submit response_url"); return responseUrl; } @@ -82,6 +89,9 @@ public async Task PollAsync(string providerJobId, string api { if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); string responseUrl = providerJobId; + // providerJobId is provider-supplied (the submit-time response_url). Re-validate before + // attaching the key so a poisoned URL can never exfiltrate it. + ProviderHttp.RequireHost(responseUrl, QueueHost, apiKey, "fal poll"); var statusSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl + "/status" }; statusSpec.Headers["Authorization"] = "Key " + apiKey; @@ -108,7 +118,10 @@ public async Task PollAsync(string providerJobId, string api result.Error = SecretRedactor.Scrub(statusJson["error"]?.ToString() ?? "fal task failed.", apiKey); return result; default: - result.State = ProviderPollState.Running; + // An unmapped terminal status would otherwise poll until the 600s job timeout — + // fail fast instead. + result.State = ProviderPollState.Failed; + result.Error = SecretRedactor.Scrub($"fal returned an unexpected status '{status}'.", apiKey); return result; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs new file mode 100644 index 000000000..dbf682ca0 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// fal.ai audio provider via the queue API. One adapter fronts every v1 audio model + /// (stable-audio-25, cassetteai/*, lyria2); the model id in + /// selects the endpoint. Submits to queue.fal.run/{model} (auth header + /// "Authorization: Key <key>"), polls status, then returns the result audio URL for the job + /// manager to download. Reuses the single existing "fal" secure key. + /// + public sealed class FalAudioAdapter : IAudioProviderAdapter + { + private const string QueueBase = "https://queue.fal.run/"; + private const string QueueHost = "queue.fal.run"; + // Stable Audio 2.5: music + SFX in one model, up to ~190s. The catalog default. + // internal so the model catalog references it directly (single source of truth, drift-guarded). + internal const string DefaultModel = "fal-ai/stable-audio-25/text-to-audio"; + + public string Id => "fal"; + + public async Task SubmitAsync(AudioGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model; + string url = QueueBase + model; + ProviderHttp.RequireHost(url, QueueHost, apiKey, "fal submit"); + + var spec = new HttpRequestSpec + { + Method = "POST", + Url = url, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(BuildBody(model, req).ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Key " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey, "submit"); + + string responseUrl = json["response_url"]?.ToString(); + if (string.IsNullOrEmpty(responseUrl)) + { + string requestId = json["request_id"]?.ToString(); + if (string.IsNullOrEmpty(requestId)) + throw new Exception(SecretRedactor.Scrub("fal submit returned no request_id: " + ProviderHttp.Truncate(res?.Text), apiKey)); + responseUrl = QueueBase + model + "/requests/" + requestId; + } + // The response_url is provider-controlled; refuse to later attach the key to any host + // other than the fal queue. + ProviderHttp.RequireHost(responseUrl, QueueHost, apiKey, "fal submit response_url"); + return responseUrl; + } + + // Duration is catalog-driven: the model's ModelEntry names the request key (seconds_total / + // duration) and the clamp bounds. A duration-controllable endpoint (e.g. CassetteAI Music) + // always sends a duration >= 1 — a prompt-only body is rejected with fal 422 + // "duration Field required" — while a non-duration model (Lyria) or an unknown model stays + // prompt-only. + private static JObject BuildBody(string model, AudioGenRequest req) + { + var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty }; + + ModelEntry entry = AssetGenModelCatalog.Find(model); + if (entry != null && !string.IsNullOrEmpty(entry.DurationField)) + { + float dur = req.Duration > 0f ? req.Duration : entry.DefaultDurationSeconds; + float floor = Math.Max(1f, entry.MinDurationSeconds); + dur = Math.Min(Math.Max(dur, floor), entry.MaxDurationSeconds); + // Floor (not round) so we never exceed the requested duration, then enforce >= 1. + int seconds = Math.Max(1, (int)Math.Floor(dur)); + body[entry.DurationField] = seconds; + } + return body; + } + + public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); + string responseUrl = providerJobId; + // providerJobId is provider-supplied (the submit-time response_url). Re-validate before + // attaching the key so a poisoned URL can never exfiltrate it. + ProviderHttp.RequireHost(responseUrl, QueueHost, apiKey, "fal poll"); + + var statusSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl + "/status" }; + statusSpec.Headers["Authorization"] = "Key " + apiKey; + HttpResult statusRes = await http.SendAsync(statusSpec, ct); + JObject statusJson = ParseOk(statusRes, apiKey, "status"); + + string status = (statusJson["status"]?.ToString() ?? string.Empty).ToUpperInvariant(); + var result = new ProviderPollResult(); + switch (status) + { + case "COMPLETED": + case "OK": + result.State = ProviderPollState.Succeeded; + break; + case "IN_PROGRESS": + result.State = ProviderPollState.Running; + return result; + case "IN_QUEUE": + result.State = ProviderPollState.Queued; + return result; + case "ERROR": + case "FAILED": + result.State = ProviderPollState.Failed; + result.Error = SecretRedactor.Scrub(statusJson["error"]?.ToString() ?? "fal task failed.", apiKey); + return result; + default: + // An unmapped terminal status would otherwise poll until the 600s job timeout — + // fail fast instead. + result.State = ProviderPollState.Failed; + result.Error = SecretRedactor.Scrub($"fal returned an unexpected status '{status}'.", apiKey); + return result; + } + + var resultSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl }; + resultSpec.Headers["Authorization"] = "Key " + apiKey; + HttpResult resultRes = await http.SendAsync(resultSpec, ct); + JObject resultJson = ParseOk(resultRes, apiKey, "result"); + + string audioUrl = ExtractAudioUrl(resultJson); + if (string.IsNullOrEmpty(audioUrl)) + { + result.State = ProviderPollState.Failed; + result.Error = "fal completed but no audio URL was present in the result."; + return result; + } + result.Progress = 1f; + result.DownloadUrl = audioUrl; + // CassetteAI/Lyria return mp3, Stable Audio wav — derive the ext from the result URL. + result.ResultExt = ExtractExt(audioUrl); + return result; + } + + private static string ExtractAudioUrl(JObject result) + { + string u = result["audio"]?["url"]?.ToString(); + if (!string.IsNullOrEmpty(u)) return u; + u = result["audio_file"]?["url"]?.ToString(); + if (!string.IsNullOrEmpty(u)) return u; + u = result["audio_url"]?.ToString(); + return string.IsNullOrEmpty(u) ? null : u; + } + + private static string ExtractExt(string url) + { + try + { + string ext = Path.GetExtension(new Uri(url).AbsolutePath).TrimStart('.').ToLowerInvariant(); + return string.IsNullOrEmpty(ext) ? "wav" : ext; + } + catch { return "wav"; } + } + + private static JObject ParseOk(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"fal {phase} failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs.meta new file mode 100644 index 000000000..0c6b10862 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 13bdc532957f463e94feb4c25df0329f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs index 561923c7f..3ba8b19cb 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs @@ -24,6 +24,18 @@ public interface IImageProviderAdapter Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct); } + /// + /// A generative audio provider (fal.ai for v1). All v1 audio models route through the single + /// fal adapter; the model is chosen inside . The api key is + /// passed in at call time and never cached on the adapter. + /// + public interface IAudioProviderAdapter + { + string Id { get; } + Task SubmitAsync(AudioGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct); + Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct); + } + /// A 3D marketplace provider (Sketchfab, ...). Search/preview/resolve, not generative. Phase 6. public interface IMarketplaceProviderAdapter { diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs index 58a8a182c..d4a720545 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs @@ -21,6 +21,8 @@ public sealed class MeshyAdapter : IModelProviderAdapter { private const string TextEndpoint = "https://api.meshy.ai/openapi/v2/text-to-3d"; private const string ImageEndpoint = "https://api.meshy.ai/openapi/v1/image-to-3d"; + // Default model; the catalog mirrors this and the drift-guard test pins the two together. + internal const string DefaultModel = "meshy-6"; public string Id => "meshy"; @@ -28,6 +30,7 @@ public sealed class MeshyAdapter : IModelProviderAdapter private string _format = "glb"; private bool _isImage; private bool _wantTexture = true; + private string _aiModel = DefaultModel; // stashed so the refine task reuses the submit model private string _refineTaskId; private bool _refineSubmitted; @@ -38,6 +41,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT _format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant(); _wantTexture = req.Texture; + _aiModel = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model; _isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); @@ -52,7 +56,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT body = new JObject { ["image_url"] = imageRef, - ["ai_model"] = "meshy-6", + ["ai_model"] = _aiModel, ["should_texture"] = _wantTexture }; } @@ -64,7 +68,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT { ["mode"] = "preview", ["prompt"] = req.Prompt ?? string.Empty, - ["ai_model"] = "meshy-6" + ["ai_model"] = _aiModel }; } @@ -107,7 +111,7 @@ public async Task PollAsync(string providerJobId, string api { ["mode"] = "refine", ["preview_task_id"] = providerJobId, - ["ai_model"] = "meshy-6" + ["ai_model"] = _aiModel }; _refineTaskId = await PostTask(TextEndpoint, refineBody, apiKey, http, ct, "refine"); _refineSubmitted = true; diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs index 52a8b0d00..3fb6fc2d0 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs @@ -18,7 +18,8 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers public sealed class OpenRouterAdapter : IImageProviderAdapter { private const string Endpoint = "https://openrouter.ai/api/v1/chat/completions"; - private const string DefaultModel = "google/gemini-2.5-flash-image"; + // internal so the model catalog references it directly (single source of truth). + internal const string DefaultModel = "google/gemini-2.5-flash-image"; public string Id => "openrouter"; diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs index 1539e491e..f9e863887 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs @@ -1,4 +1,6 @@ +using System; using System.Text; +using MCPForUnity.Editor.Security; using MCPForUnity.Editor.Services.AssetGen.Http; namespace MCPForUnity.Editor.Services.AssetGen.Providers @@ -9,6 +11,24 @@ namespace MCPForUnity.Editor.Services.AssetGen.Providers /// internal static class ProviderHttp { + /// + /// Throw unless is an absolute https URL whose host is exactly + /// . Adapters route every auth-bearing request URL through + /// this so a malicious/MITM'd provider response (e.g. a rogue response_url) can't redirect + /// the API key to an attacker host. The error is scrubbed of the key. + /// + public static void RequireHost(string url, string allowedHost, string apiKey, string context) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri u) + || u.Scheme != Uri.UriSchemeHttps + || !string.Equals(u.Host, allowedHost, StringComparison.OrdinalIgnoreCase)) + { + throw new Exception(SecretRedactor.Scrub( + $"{context}: refusing to send credentials to an unexpected host in URL '{url}' (expected https://{allowedHost}).", + apiKey)); + } + } + /// Response text, falling back to a UTF-8 decode of the raw body when Text is empty. public static string BodyText(HttpResult res) { diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs index 4ee117562..1e8d0f9e1 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs @@ -40,6 +40,7 @@ public sealed class ModelGenRequest public float TargetSize = 1f; public bool Texture = true; public string Tier; + public string Model; // provider model id/version; null => adapter DefaultModel public string Name; public string OutputFolder; } @@ -61,6 +62,21 @@ public sealed class ImageGenRequest public string OutputFolder; } + /// + /// Request to generate an audio clip (fal.ai for v1). selects the fal + /// endpoint (stable-audio-25 / cassetteai/* / lyria2). is a per-gen + /// input; 0 => provider default. Never carries a key; never persisted (transient request only). + /// + public sealed class AudioGenRequest + { + public string Provider; // "fal" for v1 + public string Model; // fal model id, e.g. fal-ai/stable-audio-25/text-to-audio + public string Prompt; + public float Duration; // seconds; 0 => per-model default. Soft-clamped per model in the adapter. + public string Name; + public string OutputFolder; + } + /// /// Public, key-free description of a provider for list_providers. Never carries a key /// value — reports existence only. @@ -68,7 +84,7 @@ public sealed class ImageGenRequest public sealed class ProviderInfo { public string Id; - public string Kind; // model | image | marketplace + public string Kind; // model | image | audio | marketplace public bool Configured; public string[] Capabilities; } diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs index da9765f04..f5a356afd 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs @@ -19,7 +19,8 @@ public sealed class TripoAdapter : IModelProviderAdapter { private const string TaskEndpoint = "https://api.tripo3d.ai/v2/openapi/task"; // Current recommended Tripo model (v3.1). Premium alternative: P1-20260311. - private const string ModelVersion = "v3.1-20260211"; + // internal so the model catalog references it directly (single source of truth, drift-guarded). + internal const string ModelVersion = "v3.1-20260211"; public string Id => "tripo"; @@ -41,6 +42,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT body = new JObject { ["type"] = "image_to_model", + ["model_version"] = string.IsNullOrEmpty(req.Model) ? ModelVersion : req.Model, ["file"] = new JObject { ["type"] = "url", @@ -54,7 +56,7 @@ public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpT { ["type"] = "text_to_model", ["prompt"] = req.Prompt ?? string.Empty, - ["model_version"] = ModelVersion + ["model_version"] = string.IsNullOrEmpty(req.Model) ? ModelVersion : req.Model }; } diff --git a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs new file mode 100644 index 000000000..e2955f82a --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Providers; + +namespace MCPForUnity.Editor.Tools.AssetGen +{ + /// + /// Shared action handlers for the generate_* asset tools (audio / image / model). The three tools + /// differ only in their generate step and a kind label; status, cancel, and + /// list_providers are identical modulo that label, so they live here once. + /// + internal static class AssetGenToolHelpers + { + /// + /// Poll a job by id and map its state to a client response. + /// prefixes the human-readable message (e.g. "Audio", "Image", "3D model"). + /// + public static object Status(ToolParams p, string kindLabel, double pollIntervalSeconds) + { + string jobId = p.Get("job_id"); + if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status."); + AssetGenJob job = AssetGenJobManager.GetJob(jobId); + if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'."); + + switch (job.State) + { + case AssetGenJobState.Done: + return new SuccessResponse( + $"{kindLabel} generated: {job.AssetPath}", + new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f }); + case AssetGenJobState.Failed: + return new ErrorResponse(job.Error ?? "Generation failed.", new { state = "failed" }); + case AssetGenJobState.Canceled: + return new SuccessResponse("Generation canceled.", new { state = "canceled" }); + default: + return new PendingResponse( + $"{kindLabel} {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).", + pollIntervalSeconds: pollIntervalSeconds, + data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress }); + } + } + + /// Request cancellation of a job by id. + public static object Cancel(ToolParams p) + { + string jobId = p.Get("job_id"); + if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel."); + return AssetGenJobManager.Cancel(jobId) + ? new SuccessResponse($"Cancel requested for job '{jobId}'.") + : new ErrorResponse($"No cancelable job found with ID '{jobId}'."); + } + + /// List the configured providers for a given kind (audio / image / model). + public static object ListProviders(string kind) + { + var list = new List(); + foreach (ProviderInfo info in AssetGenProviders.List()) + { + if (info.Kind != kind) continue; + list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities }); + } + return new SuccessResponse($"{list.Count} {kind} provider(s).", new { providers = list }); + } + } +} diff --git a/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs.meta b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs.meta new file mode 100644 index 000000000..210f04527 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/AssetGenToolHelpers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8320589d7a679498c8ad56b59f662878 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs new file mode 100644 index 000000000..598d7c443 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs @@ -0,0 +1,87 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools.AssetGen +{ + /// + /// Audio generation (SFX / music) via fal.ai. Triggered here (never from the GUI); the C# side + /// reads the fal key from the secure store and runs the job. Returns a job_id immediately; the + /// client polls the `status` action. When `model` is omitted it falls back to the model selected + /// in the Asset Generation tab, then the catalog default. Status / cancel / list_providers are + /// shared across the generate_* tools via . + /// + [McpForUnityTool("generate_audio", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 600)] + public static class GenerateAudio + { + public static object HandleCommand(JObject @params) + { + if (@params == null) return new ErrorResponse("Parameters cannot be null."); + var p = new ToolParams(@params); + string action = (p.Get("action") ?? string.Empty).ToLowerInvariant(); + try + { + switch (action) + { + case "generate": return Generate(p); + case "status": return AssetGenToolHelpers.Status(p, "Audio", 3.0); + case "cancel": return AssetGenToolHelpers.Cancel(p); + case "list_providers": return AssetGenToolHelpers.ListProviders("audio"); + case "": return new ErrorResponse("'action' parameter is required."); + default: + return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers."); + } + } + catch (NotSupportedException nse) + { + return new ErrorResponse(nse.Message); + } + catch (Exception e) + { + return new ErrorResponse(SecretRedactor.Scrub(e.Message)); + } + } + + private static object Generate(ToolParams p) + { + string provider = (p.Get("provider", "fal") ?? "fal").ToLowerInvariant(); + AssetGenProviders.Audio(provider); // throws NotSupportedException for unknown providers + + if (!SecureKeyStore.Current.Has(provider)) + return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider)); + + string prompt = p.Get("prompt"); + if (string.IsNullOrWhiteSpace(prompt)) + return new ErrorResponse("'prompt' is required for audio generation."); + + // Empty -> GUI-selected model -> catalog default. A null model reaches the adapter's own + // default; a resolved id is passed through verbatim (the catalog default equals the + // adapter constant, so an omitted model is a no-op either way). + string model = AssetGenModelCatalog.ResolveModel("audio", provider, p.Get("model")); + + var req = new AudioGenRequest + { + Provider = provider, + Model = model, + Prompt = prompt, + Duration = p.GetFloat("duration", 0f) ?? 0f, + Name = p.Get("name"), + OutputFolder = p.Get("outputFolder"), + }; + if (!AssetGenPaths.NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr)) + return new ErrorResponse(outputErr); + + AssetGenJob job = AssetGenJobManager.StartAudioGeneration(req); + if (job.State == AssetGenJobState.Failed) + return new ErrorResponse(job.Error ?? "Failed to start generation."); + + return new PendingResponse( + $"Audio generation started with '{provider}'. Poll the status action with this job_id.", + pollIntervalSeconds: 3.0, + data: new { job_id = job.JobId, provider, status = "pending" }); + } + } +} diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs.meta b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs.meta new file mode 100644 index 000000000..cb5420cc0 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9ed859d728fc4de4b9d54b2dc23f3746 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs index a3e3e087e..1da99d563 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Security; using MCPForUnity.Editor.Services.AssetGen; @@ -11,7 +10,8 @@ namespace MCPForUnity.Editor.Tools.AssetGen /// /// 2D image generation via an aggregator (fal.ai / OpenRouter). Triggered here (never from the /// GUI); the C# side reads the provider key from the secure store and runs the job. Returns a - /// job_id immediately; the client polls the `status` action. + /// job_id immediately; the client polls the `status` action. Status / cancel / list_providers are + /// shared across the generate_* tools via . /// [McpForUnityTool("generate_image", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)] public static class GenerateImage @@ -28,9 +28,9 @@ public static object HandleCommand(JObject @params) case "generate": return Generate(p); case "remove_background": return new ErrorResponse("remove_background is not implemented in this version."); - case "status": return Status(p); - case "cancel": return Cancel(p); - case "list_providers": return ListProviders(); + case "status": return AssetGenToolHelpers.Status(p, "Image", 2.0); + case "cancel": return AssetGenToolHelpers.Cancel(p); + case "list_providers": return AssetGenToolHelpers.ListProviders("image"); case "": return new ErrorResponse("'action' parameter is required."); default: return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, remove_background, status, cancel, list_providers."); @@ -54,6 +54,10 @@ private static object Generate(ToolParams p) if (!SecureKeyStore.Current.Has(provider)) return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider)); + // Empty -> GUI-selected model -> catalog default. Null still reaches the adapter's own + // default (no regression when nothing is selected anywhere). + string model = AssetGenModelCatalog.ResolveModel("image", provider, p.Get("model")); + var req = new ImageGenRequest { Provider = provider, @@ -61,7 +65,7 @@ private static object Generate(ToolParams p) Prompt = p.Get("prompt"), ImagePath = p.Get("imagePath"), ImageUrl = p.Get("imageUrl"), - Model = p.Get("model"), + Model = model, Transparent = p.GetBool("transparent", false), AsSprite = p.GetBool("asSprite", true), Width = p.GetInt("width", 0) ?? 0, @@ -69,7 +73,7 @@ private static object Generate(ToolParams p) Name = p.Get("name"), OutputFolder = p.Get("outputFolder"), }; - if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr)) + if (!AssetGenPaths.NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr)) return new ErrorResponse(outputErr); if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt)) @@ -92,60 +96,5 @@ private static object Generate(ToolParams p) pollIntervalSeconds: 2.0, data: new { job_id = job.JobId, provider, status = "pending" }); } - - private static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error) - { - normalized = outputFolder; - error = null; - if (string.IsNullOrWhiteSpace(outputFolder)) return true; - if (AssetGenPaths.TryGetAssetsFolder(outputFolder, out normalized)) return true; - error = "'output_folder' must resolve under the project's Assets folder."; - return false; - } - - private static object Status(ToolParams p) - { - string jobId = p.Get("job_id"); - if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status."); - AssetGenJob job = AssetGenJobManager.GetJob(jobId); - if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'."); - - switch (job.State) - { - case AssetGenJobState.Done: - return new SuccessResponse( - $"Image generated: {job.AssetPath}", - new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f }); - case AssetGenJobState.Failed: - return new ErrorResponse(job.Error ?? "Generation failed.", new { state = "failed" }); - case AssetGenJobState.Canceled: - return new SuccessResponse("Generation canceled.", new { state = "canceled" }); - default: - return new PendingResponse( - $"Image {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).", - pollIntervalSeconds: 2.0, - data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress }); - } - } - - private static object Cancel(ToolParams p) - { - string jobId = p.Get("job_id"); - if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel."); - return AssetGenJobManager.Cancel(jobId) - ? new SuccessResponse($"Cancel requested for job '{jobId}'.") - : new ErrorResponse($"No cancelable job found with ID '{jobId}'."); - } - - private static object ListProviders() - { - var list = new List(); - foreach (ProviderInfo info in AssetGenProviders.List()) - { - if (info.Kind != "image") continue; - list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities }); - } - return new SuccessResponse($"{list.Count} image provider(s).", new { providers = list }); - } } } diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs index 0a3180699..718c92a94 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Security; using MCPForUnity.Editor.Services.AssetGen; @@ -12,6 +11,8 @@ namespace MCPForUnity.Editor.Tools.AssetGen /// 3D model generation (Tripo/Meshy). Generation is triggered here (never from /// the GUI); the C# side reads the provider key from the secure store and runs the job. /// Long-running: returns a job_id immediately and the client polls the `status` action. + /// Status / cancel / list_providers are shared across the generate_* tools via + /// . /// [McpForUnityTool("generate_model", AutoRegister = false, Group = "asset_gen", RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)] public static class GenerateModel @@ -26,9 +27,9 @@ public static object HandleCommand(JObject @params) switch (action) { case "generate": return Generate(p); - case "status": return Status(p); - case "cancel": return Cancel(p); - case "list_providers": return ListProviders(); + case "status": return AssetGenToolHelpers.Status(p, "3D model", 3.0); + case "cancel": return AssetGenToolHelpers.Cancel(p); + case "list_providers": return AssetGenToolHelpers.ListProviders("model"); case "": return new ErrorResponse("'action' parameter is required."); default: return new ErrorResponse($"Unknown action: '{action}'. Supported: generate, status, cancel, list_providers."); @@ -52,6 +53,10 @@ private static object Generate(ToolParams p) if (!SecureKeyStore.Current.Has(provider)) return new ErrorResponse(AssetGenProviders.MissingKeyMessage(provider)); + // Empty -> GUI-selected model -> catalog default. Null still reaches the adapter's own + // default (Tripo ModelVersion / Meshy meshy-6). + string model = AssetGenModelCatalog.ResolveModel("model", provider, p.Get("model")); + var req = new ModelGenRequest { Provider = provider, @@ -63,10 +68,11 @@ private static object Generate(ToolParams p) TargetSize = p.GetFloat("targetSize", 1f) ?? 1f, Texture = p.GetBool("texture", true), Tier = p.Get("tier"), + Model = model, Name = p.Get("name"), OutputFolder = p.Get("outputFolder"), }; - if (!NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr)) + if (!AssetGenPaths.NormalizeOutputFolder(req.OutputFolder, out req.OutputFolder, out string outputErr)) return new ErrorResponse(outputErr); if (req.Mode == "text" && string.IsNullOrWhiteSpace(req.Prompt)) @@ -91,57 +97,5 @@ private static object Generate(ToolParams p) pollIntervalSeconds: 3.0, data: new { job_id = job.JobId, provider, status = "pending" }); } - - private static bool NormalizeOutputFolder(string outputFolder, out string normalized, out string error) - { - normalized = outputFolder; - error = null; - if (string.IsNullOrWhiteSpace(outputFolder)) return true; - if (AssetGenPaths.TryGetAssetsFolder(outputFolder, out normalized)) return true; - error = "'output_folder' must resolve under the project's Assets folder."; - return false; - } - - private static object Status(ToolParams p) - { - string jobId = p.Get("job_id"); - if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for status."); - AssetGenJob job = AssetGenJobManager.GetJob(jobId); - if (job == null) return new ErrorResponse($"No job found with ID '{jobId}'."); - - switch (job.State) - { - case AssetGenJobState.Done: - return new SuccessResponse( - $"Generation complete: {job.AssetPath}", - new { state = "done", asset_path = job.AssetPath, asset_guid = job.AssetGuid, progress = 1f }); - case AssetGenJobState.Failed: - return new ErrorResponse(job.Error ?? "Generation failed.", new { state = "failed" }); - case AssetGenJobState.Canceled: - return new SuccessResponse("Generation canceled.", new { state = "canceled" }); - default: - return new PendingResponse( - $"Generation {job.State.ToString().ToLowerInvariant()} ({job.Progress:P0}).", - pollIntervalSeconds: 3.0, - data: new { job_id = job.JobId, state = job.State.ToString().ToLowerInvariant(), progress = job.Progress }); - } - } - - private static object Cancel(ToolParams p) - { - string jobId = p.Get("job_id"); - if (string.IsNullOrEmpty(jobId)) return new ErrorResponse("'job_id' is required for cancel."); - return AssetGenJobManager.Cancel(jobId) - ? new SuccessResponse($"Cancel requested for job '{jobId}'.") - : new ErrorResponse($"No cancelable job found with ID '{jobId}'."); - } - - private static object ListProviders() - { - var list = new List(); - foreach (ProviderInfo info in AssetGenProviders.List()) - list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities }); - return new SuccessResponse($"{list.Count} provider(s).", new { providers = list }); - } } } diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs index 4a44e3845..6b0e6d113 100644 --- a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen; using MCPForUnity.Editor.Services.AssetGen.Import; using UnityEngine; using UnityEngine.UIElements; @@ -41,6 +42,8 @@ private static readonly (string Id, string Label)[] ImageProviders = private DropdownField formatDropdown; private TextField outputRootField; private Toggle autoNormalizeToggle; + private Button refreshButton; + private Label refreshStatusLabel; // Per-provider enable toggles for the GLB-capable (model) providers, used to // recompute the glTFast notice when a toggle changes. @@ -63,6 +66,8 @@ private void CacheUIElements() formatDropdown = Root.Q("assetgen-format-dropdown"); outputRootField = Root.Q("assetgen-output-root"); autoNormalizeToggle = Root.Q("assetgen-auto-normalize"); + refreshButton = Root.Q