From 66b96d2626302f338472531c6c82eded7d4043b6 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:59:53 -0700 Subject: [PATCH 01/10] =?UTF-8?q?feat(asset-gen):=20audio=20backend=20?= =?UTF-8?q?=E2=80=94=20fal=20adapter,=20import=20pipeline,=20job=20manager?= =?UTF-8?q?=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of fal.ai audio generation. Adds AudioGenRequest + IAudioProviderAdapter, a FalAudioAdapter fronting the v1 fal audio models (stable-audio-25, cassetteai/*, lyria2), an AudioImportPipeline that branches AudioImporter load type on clip length, AssetGenProviders.Audio + an audio List row, and AssetGenJobManager.StartAudioGeneration. Also threads ModelGenRequest.Model (consumed in Phase 3). Compiles on the 2021.3 floor; adapter/import/e2e tests green. Claude-Session: https://claude.ai/code/session_01GCxmdd4qo7MG6J4M6WcT9Y --- .../Services/AssetGen/AssetGenJobManager.cs | 30 ++- .../AssetGen/Import/AudioImportPipeline.cs | 71 +++++ .../Import/AudioImportPipeline.cs.meta | 11 + .../AssetGen/Providers/AssetGenProviders.cs | 17 +- .../AssetGen/Providers/FalAudioAdapter.cs | 166 ++++++++++++ .../Providers/FalAudioAdapter.cs.meta | 11 + .../AssetGen/Providers/IProviderAdapters.cs | 12 + .../AssetGen/Providers/ProviderModels.cs | 18 +- .../AssetGen/AssetGenJobManagerTests.cs | 75 ++++++ .../AssetGen/AssetGenProvidersTests.cs | 45 ++++ .../AssetGen/AudioImportPipelineTests.cs | 38 +++ .../AssetGen/AudioImportPipelineTests.cs.meta | 11 + .../EditMode/AssetGen/FalAudioAdapterTests.cs | 246 ++++++++++++++++++ .../AssetGen/FalAudioAdapterTests.cs.meta | 11 + 14 files changed, 758 insertions(+), 4 deletions(-) create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs.meta diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs index 1f23fa578..58f95e77c 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"); diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs new file mode 100644 index 000000000..f7aa204f7 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs @@ -0,0 +1,71 @@ +using System; +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."); + + 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/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/FalAudioAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs new file mode 100644 index 000000000..23dcb0ae5 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs @@ -0,0 +1,166 @@ +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/"; + // Stable Audio 2.5: music + SFX in one model, up to ~190s. The catalog default. + private 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; + + 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; + } + return responseUrl; + } + + // Only Stable Audio and CassetteAI SFX have a confirmed duration knob. CassetteAI Music and + // Lyria 2 ship prompt-only until their body schemas are verified against a live response. + private static JObject BuildBody(string model, AudioGenRequest req) + { + var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty }; + if (req.Duration > 0f) + { + if (model.IndexOf("stable-audio-25", StringComparison.OrdinalIgnoreCase) >= 0) + body["seconds_total"] = (int)Math.Min(req.Duration, 190f); + else if (model.IndexOf("sound-effects", StringComparison.OrdinalIgnoreCase) >= 0) + body["duration"] = (int)Math.Min(req.Duration, 30f); + } + 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; + + 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: + result.State = ProviderPollState.Running; + 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/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/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs index bb3d76354..69a3ba39d 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs @@ -19,6 +19,9 @@ public class AssetGenJobManagerTests { private const string EnvVar = "MCPFORUNITY_TRIPO_API_KEY"; private const string Secret = "tsk_e2e_secret_value"; + private const string FalEnvVar = "MCPFORUNITY_FAL_API_KEY"; + private const string FalSecret = "fal_e2e_secret_value"; + private const string AudioResp = "https://queue.fal.run/fal-ai/stable-audio-25/text-to-audio/requests/r1"; private const string TestFolder = "Assets/Generated/__assetgen_jobtest"; private FakeHttpTransport _fake; @@ -28,6 +31,7 @@ public void SetUp() { AssetGenJobManager.ResetForTests(); Environment.SetEnvironmentVariable(EnvVar, Secret); + Environment.SetEnvironmentVariable(FalEnvVar, FalSecret); _fake = new FakeHttpTransport(); AssetGenJobManager.TransportOverrideForTests = _fake; AssetGenJobManager.PollIntervalSeconds = 0; @@ -40,6 +44,7 @@ public void TearDown() { AssetGenJobManager.ResetForTests(); Environment.SetEnvironmentVariable(EnvVar, null); + Environment.SetEnvironmentVariable(FalEnvVar, null); try { string abs = Path.Combine(ProjectRoot(), TestFolder); @@ -68,6 +73,14 @@ private static string ProjectRoot() OutputFolder = TestFolder, }; + private static AudioGenRequest AudioReq() => new AudioGenRequest + { + Provider = "fal", + Prompt = "gentle rain", + Name = "audiotest", + OutputFolder = TestFolder, + }; + private static void Pump(string jobId) { int guard = 0; @@ -96,6 +109,68 @@ public void EndToEnd_ReachesDone_WithAssetPath() Assert.AreEqual(1f, job.Progress); } + [Test] + public void Audio_EndToEnd_ReachesDone_WithWavAssetPath_AndLeaksNoKey() + { + _fake.Handler = spec => + { + if (spec.Method == "POST") return Json("{\"response_url\":\"" + AudioResp + "\"}"); + if (spec.Url.EndsWith("/status")) return Json("{\"status\":\"COMPLETED\"}"); + if (spec.Url.Contains("cdn.example.com")) + return new HttpResult { Status = 200, IsSuccess = true, Body = new byte[] { 1, 2, 3, 4 } }; + return Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/a.wav\"}}"); // result payload + }; + + AssetGenJob job = AssetGenJobManager.StartAudioGeneration(AudioReq()); + Pump(job.JobId); + + Assert.AreEqual(AssetGenJobState.Done, job.State); + Assert.IsNotNull(job.AssetPath); + StringAssert.EndsWith("audiotest.wav", job.AssetPath); + Assert.AreEqual(1f, job.Progress); + + string serialized = JsonConvert.SerializeObject(job); + StringAssert.DoesNotContain(FalSecret, serialized); + } + + [Test] + public void Audio_Mp3Result_DownloadsWithMp3Ext() + { + _fake.Handler = spec => + { + if (spec.Method == "POST") return Json("{\"response_url\":\"" + AudioResp + "\"}"); + if (spec.Url.EndsWith("/status")) return Json("{\"status\":\"COMPLETED\"}"); + if (spec.Url.Contains("cdn.example.com")) + return new HttpResult { Status = 200, IsSuccess = true, Body = new byte[] { 1, 2, 3, 4 } }; + return Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/track.mp3\"}}"); + }; + + AssetGenJob job = AssetGenJobManager.StartAudioGeneration(AudioReq()); + Pump(job.JobId); + + Assert.AreEqual(AssetGenJobState.Done, job.State); + StringAssert.EndsWith("audiotest.mp3", job.AssetPath); + } + + [Test] + public void Audio_NoKey_FailsImmediately() + { + Environment.SetEnvironmentVariable(FalEnvVar, null); // remove the env key + string dir = Path.Combine(Path.GetTempPath(), "mcp_jobmgr_audio_nokey_" + Guid.NewGuid().ToString("N")); + MCPForUnity.Editor.Security.SecureKeyStore.OverrideForTests(new MCPForUnity.Editor.Security.EncryptedFileKeyStore(dir)); + try + { + AssetGenJob job = AssetGenJobManager.StartAudioGeneration(AudioReq()); + Assert.AreEqual(AssetGenJobState.Failed, job.State); + StringAssert.Contains("No API key", job.Error); + } + finally + { + MCPForUnity.Editor.Security.SecureKeyStore.ResetForTests(); + try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { } + } + } + [Test] public void FailedPoll_FailsJob_AndLeaksNoKey() { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs index a6349e33b..2b8e0b84f 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs @@ -27,6 +27,42 @@ public void Model_Unimplemented_Throws() Assert.Throws(() => AssetGenProviders.Model("hunyuan")); } + [Test] + public void Audio_Fal_ReturnsAdapter() + { + IAudioProviderAdapter adapter = AssetGenProviders.Audio("fal"); + Assert.IsNotNull(adapter); + Assert.AreEqual("fal", adapter.Id); + } + + [Test] + public void Audio_Unimplemented_Throws() + { + Assert.Throws(() => AssetGenProviders.Audio("elevenlabs")); + } + + [Test] + public void List_IncludesFalAudioRow() + { + ProviderInfo row = FindByIdKind("fal", "audio"); + Assert.IsNotNull(row, "List() should advertise a fal audio row"); + Assert.AreEqual("audio", row.Kind); + } + + [Test] + public void List_HasExactlyOneFalImage_AndOneFalAudio() + { + int image = 0, audio = 0; + foreach (ProviderInfo p in AssetGenProviders.List()) + { + if (p.Id != "fal") continue; + if (p.Kind == "image") image++; + else if (p.Kind == "audio") audio++; + } + Assert.AreEqual(1, image, "exactly one fal image row"); + Assert.AreEqual(1, audio, "exactly one fal audio row"); + } + [Test] public void List_IncludesTripo_ConfiguredIsBool() { @@ -65,5 +101,14 @@ private static ProviderInfo FindTripo() } return null; } + + private static ProviderInfo FindByIdKind(string id, string kind) + { + foreach (ProviderInfo p in AssetGenProviders.List()) + { + if (p.Id == id && p.Kind == kind) return p; + } + return null; + } } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs new file mode 100644 index 000000000..5367852ca --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs @@ -0,0 +1,38 @@ +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Import; +using NUnit.Framework; + +namespace MCPForUnityTests.Editor.AssetGen +{ + /// + /// Validates the audio import pipeline's guard paths without fabricating audio bytes. Real + /// AudioClip import + AudioImporter settings are left to the licensed editor (manual checklist / + /// live smoke test), matching ModelImportPipelineTests' guard-only philosophy. + /// + public class AudioImportPipelineTests + { + private static AssetGenJob Job() => new AssetGenJob + { + JobId = "test", + Kind = "audio", + Provider = "fal", + State = AssetGenJobState.Importing, + Format = "wav", + }; + + [Test] + public void ImportInto_NullPath_Fails() + { + AssetGenJob result = AudioImportPipeline.ImportInto(Job(), null); + Assert.AreEqual(AssetGenJobState.Failed, result.State); + } + + [Test] + public void ImportInto_PathOutsideAssets_Fails() + { + AssetGenJob result = AudioImportPipeline.ImportInto(Job(), "/tmp/somewhere/sound.wav"); + Assert.AreEqual(AssetGenJobState.Failed, result.State); + StringAssert.Contains("Assets", result.Error); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs.meta new file mode 100644 index 000000000..2867b40c5 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a74b37c946289499eb717c4e37dbd3a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs new file mode 100644 index 000000000..254156b97 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs @@ -0,0 +1,246 @@ +using System.Text; +using System.Threading; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace MCPForUnityTests.Editor.AssetGen +{ + public class FalAudioAdapterTests + { + private const string StableAudio = "fal-ai/stable-audio-25/text-to-audio"; + private const string Resp = "https://queue.fal.run/fal-ai/stable-audio-25/text-to-audio/requests/r1"; + + private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body }; + + private static AudioGenRequest Req(string model = null, float duration = 0f) => + new AudioGenRequest { Provider = "fal", Model = model, Prompt = "gentle rain", Duration = duration }; + + private static string SubmittedBody(FakeHttpTransport fake) => + Encoding.UTF8.GetString(fake.RecordedRequests[0].Body); + + [Test] + public void Submit_PostsModelEndpoint_WithKeyHeader_ReturnsResponseUrl() + { + var fake = new FakeHttpTransport + { + Handler = _ => Json("{\"request_id\":\"r1\",\"response_url\":\"" + Resp + "\"}") + }; + var adapter = new FalAudioAdapter(); + + string pid = adapter.SubmitAsync(Req(), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(Resp, pid); + HttpRequestSpec sent = fake.RecordedRequests[0]; + Assert.AreEqual("POST", sent.Method); + StringAssert.Contains(StableAudio, sent.Url); + Assert.IsTrue(sent.Headers.ContainsKey("Authorization")); + StringAssert.StartsWith("Key ", sent.Headers["Authorization"]); + } + + [Test] + public void Submit_ModelOverride_UsesModelEndpoint() + { + var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") }; + var adapter = new FalAudioAdapter(); + + adapter.SubmitAsync(Req("cassetteai/sound-effects-generator"), "falkey123", fake, CancellationToken.None) + .GetAwaiter().GetResult(); + + StringAssert.Contains("cassetteai/sound-effects-generator", fake.RecordedRequests[0].Url); + } + + [Test] + public void Submit_StableAudio_WithDuration_IncludesSecondsTotal() + { + var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") }; + var adapter = new FalAudioAdapter(); + + adapter.SubmitAsync(Req(StableAudio, 30f), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + StringAssert.Contains("seconds_total", SubmittedBody(fake)); + } + + [Test] + public void Submit_StableAudio_OverMax_ClampsTo190() + { + var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") }; + var adapter = new FalAudioAdapter(); + + adapter.SubmitAsync(Req(StableAudio, 250f), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + JObject body = JObject.Parse(SubmittedBody(fake)); + Assert.AreEqual(190, (int)body["seconds_total"]); + } + + [Test] + public void Submit_CassetteSfx_WithDuration_IncludesDuration_ClampsTo30() + { + var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") }; + var adapter = new FalAudioAdapter(); + + adapter.SubmitAsync(Req("cassetteai/sound-effects-generator", 45f), "falkey123", fake, CancellationToken.None) + .GetAwaiter().GetResult(); + + JObject body = JObject.Parse(SubmittedBody(fake)); + Assert.AreEqual(30, (int)body["duration"]); + } + + [Test] + public void Submit_Lyria_And_CassetteMusic_PromptOnly() + { + foreach (string model in new[] { "fal-ai/lyria2", "cassetteai/music-generator" }) + { + var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") }; + var adapter = new FalAudioAdapter(); + + adapter.SubmitAsync(Req(model, 30f), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + string body = SubmittedBody(fake); + StringAssert.DoesNotContain("seconds_total", body); + StringAssert.DoesNotContain("duration", body); + StringAssert.Contains("prompt", body); + } + } + + [Test] + public void Submit_FallbackResponseUrl_UsesModelRequestsPath() + { + var fake = new FakeHttpTransport { Handler = _ => Json("{\"request_id\":\"r1\"}") }; // no response_url + var adapter = new FalAudioAdapter(); + + string pid = adapter.SubmitAsync(Req(), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + StringAssert.Contains(StableAudio + "/requests/r1", pid); + } + + [Test] + public void Poll_Status_KeyHeaderPresent() + { + var fake = new FakeHttpTransport + { + Handler = spec => spec.Url.EndsWith("/status") + ? Json("{\"status\":\"COMPLETED\"}") + : Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/a.wav\"}}") + }; + var adapter = new FalAudioAdapter(); + + adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + foreach (HttpRequestSpec sent in fake.RecordedRequests) + { + Assert.IsTrue(sent.Headers.ContainsKey("Authorization")); + StringAssert.StartsWith("Key ", sent.Headers["Authorization"]); + } + } + + [Test] + public void Poll_Completed_WavResult_ReturnsWavExt() + { + var fake = new FakeHttpTransport + { + Handler = spec => spec.Url.EndsWith("/status") + ? Json("{\"status\":\"COMPLETED\"}") + : Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/a.wav\"}}") + }; + var adapter = new FalAudioAdapter(); + + ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Succeeded, pr.State); + Assert.AreEqual("https://cdn.example.com/a.wav", pr.DownloadUrl); + Assert.AreEqual("wav", pr.ResultExt); + } + + [Test] + public void Poll_Completed_Mp3Result_ReturnsMp3Ext() + { + var fake = new FakeHttpTransport + { + Handler = spec => spec.Url.EndsWith("/status") + ? Json("{\"status\":\"COMPLETED\"}") + : Json("{\"audio_file\":{\"url\":\"https://cdn.example.com/track.mp3\"}}") + }; + var adapter = new FalAudioAdapter(); + + ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual("mp3", pr.ResultExt); + } + + [Test] + public void Poll_AudioShape_And_BareUrl_Extracted() + { + foreach (string result in new[] + { + "{\"audio\":{\"url\":\"https://cdn.example.com/a.wav\"}}", + "{\"audio_url\":\"https://cdn.example.com/a.wav\"}" + }) + { + var fake = new FakeHttpTransport + { + Handler = spec => spec.Url.EndsWith("/status") ? Json("{\"status\":\"COMPLETED\"}") : Json(result) + }; + var adapter = new FalAudioAdapter(); + + ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Succeeded, pr.State); + Assert.AreEqual("https://cdn.example.com/a.wav", pr.DownloadUrl); + } + } + + [Test] + public void Poll_Completed_NoUrl_Fails() + { + var fake = new FakeHttpTransport + { + Handler = spec => spec.Url.EndsWith("/status") ? Json("{\"status\":\"COMPLETED\"}") : Json("{}") + }; + var adapter = new FalAudioAdapter(); + + ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Failed, pr.State); + StringAssert.Contains("audio", pr.Error); + } + + [Test] + public void Poll_InProgress_Running() + { + var fake = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"IN_PROGRESS\"}") }; + var adapter = new FalAudioAdapter(); + + ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Running, pr.State); + } + + [Test] + public void Poll_InQueue_Queued() + { + var fake = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"IN_QUEUE\"}") }; + var adapter = new FalAudioAdapter(); + + ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Queued, pr.State); + } + + [Test] + public void Poll_Failed_RedactsError() + { + var fake = new FakeHttpTransport + { + Handler = _ => Json("{\"status\":\"ERROR\",\"error\":\"boom falkey123 leaked\"}") + }; + var adapter = new FalAudioAdapter(); + + ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult(); + + Assert.AreEqual(ProviderPollState.Failed, pr.State); + StringAssert.DoesNotContain("falkey123", pr.Error); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs.meta new file mode 100644 index 000000000..743d94540 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAudioAdapterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dc7e0ad00c6945a88a078ae0e52c54a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 8c0a5dfe0d140d5f88eadf27fe9068c20fafa2c1 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:20:16 -0700 Subject: [PATCH 02/10] feat(asset-gen): model catalog + generate_audio tool + default-model wiring Phases 2-3. Adds a curated AssetGenModelCatalog (image/3D/audio models with price/duration/use-case metadata; defaults reference the adapter constants so the panel default equals what an omitted model resolves to). Adds the generate_audio MCP tool + CLI, threads a per-(kind,provider) selected-model pref so a GUI choice becomes the default generate_* uses when no model is passed, and makes Tripo/Meshy consume req.Model. Per-provider prefs (not per-type) so disjoint model lists never clobber. Compiles on 2021.3 floor; Python + adapter/catalog tests green. Claude-Session: https://claude.ai/code/session_01GCxmdd4qo7MG6J4M6WcT9Y --- .../Editor/Constants/EditorPrefKeys.cs | 5 + MCPForUnity/Editor/Helpers/AssetGenPrefs.cs | 28 ++++ .../Services/AssetGen/AssetGenModelCatalog.cs | 89 +++++++++++ .../AssetGen/AssetGenModelCatalog.cs.meta | 11 ++ .../Services/AssetGen/Providers/FalAdapter.cs | 3 +- .../AssetGen/Providers/FalAudioAdapter.cs | 3 +- .../AssetGen/Providers/MeshyAdapter.cs | 10 +- .../AssetGen/Providers/OpenRouterAdapter.cs | 3 +- .../AssetGen/Providers/TripoAdapter.cs | 5 +- .../Editor/Tools/AssetGen/GenerateAudio.cs | 144 +++++++++++++++++ .../Tools/AssetGen/GenerateAudio.cs.meta | 11 ++ .../Editor/Tools/AssetGen/GenerateImage.cs | 8 +- .../Editor/Tools/AssetGen/GenerateModel.cs | 12 +- Server/src/cli/commands/asset_gen.py | 43 ++++++ Server/src/services/tools/generate_audio.py | 79 ++++++++++ Server/src/services/tools/generate_model.py | 5 +- Server/tests/test_asset_gen_audio.py | 145 ++++++++++++++++++ Server/tests/test_asset_gen_model.py | 11 +- .../AssetGen/AssetGenModelCatalogTests.cs | 71 +++++++++ .../AssetGenModelCatalogTests.cs.meta | 11 ++ .../EditMode/AssetGen/AssetGenPrefsTests.cs | 31 ++++ .../EditMode/AssetGen/GenerateAudioTests.cs | 85 ++++++++++ .../AssetGen/GenerateAudioTests.cs.meta | 11 ++ .../EditMode/AssetGen/GenerateImageTests.cs | 49 ++++++ .../EditMode/AssetGen/MeshyAdapterTests.cs | 26 ++++ .../EditMode/AssetGen/TripoAdapterTests.cs | 26 ++++ 26 files changed, 913 insertions(+), 12 deletions(-) create mode 100644 MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs.meta create mode 100644 MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs create mode 100644 MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs.meta create mode 100644 Server/src/services/tools/generate_audio.py create mode 100644 Server/tests/test_asset_gen_audio.py create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs.meta 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/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/AssetGenModelCatalog.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs new file mode 100644 index 000000000..c80e7b2b5 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenModelCatalog.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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) + 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) + new ModelEntry { Id = FalAudioAdapter.DefaultModel, Label = "Stable Audio 2.5", Provider = "fal", Kind = "audio", UseCase = "Music + SFX", PriceLabel = "$0.20/gen", MaxDurationSeconds = 190f, + 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 }, + new ModelEntry { Id = "cassetteai/music-generator", Label = "CassetteAI Music", Provider = "fal", Kind = "audio", UseCase = "Background music", PriceLabel = "$0.02/min", MaxDurationSeconds = 180f }, + 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) + => ForProvider(provider, kind).FirstOrDefault()?.Id; + + /// 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/Providers/FalAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs index d888a0f63..48ec19760 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs @@ -19,7 +19,8 @@ public sealed class FalAdapter : IImageProviderAdapter private const string QueueBase = "https://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"; diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs index 23dcb0ae5..f2345b2c8 100644 --- a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAudioAdapter.cs @@ -21,7 +21,8 @@ public sealed class FalAudioAdapter : IAudioProviderAdapter { private const string QueueBase = "https://queue.fal.run/"; // Stable Audio 2.5: music + SFX in one model, up to ~190s. The catalog default. - private const string DefaultModel = "fal-ai/stable-audio-25/text-to-audio"; + // 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"; 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/TripoAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs index da9765f04..20ebe8a15 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"; @@ -54,7 +55,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/GenerateAudio.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs new file mode 100644 index 000000000..d4b931474 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateAudio.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +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. + /// + [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 Status(p); + case "cancel": return Cancel(p); + case "list_providers": return ListProviders(); + 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 = p.Get("model"); + if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel("audio", provider); + if (string.IsNullOrWhiteSpace(model)) model = AssetGenModelCatalog.DefaultModelId(provider, "audio"); + + var req = new AudioGenRequest + { + Provider = provider, + Model = string.IsNullOrWhiteSpace(model) ? null : model, + Prompt = prompt, + Duration = p.GetFloat("duration", 0f) ?? 0f, + Name = p.Get("name"), + OutputFolder = p.Get("outputFolder"), + }; + if (!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" }); + } + + 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( + $"Audio 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( + $"Audio {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()) + { + if (info.Kind != "audio") continue; + list.Add(new { id = info.Id, kind = info.Kind, configured = info.Configured, capabilities = info.Capabilities }); + } + return new SuccessResponse($"{list.Count} audio provider(s).", new { providers = list }); + } + } +} 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..31ee7356d 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs @@ -54,6 +54,12 @@ 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 = p.Get("model"); + if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel("image", provider); + if (string.IsNullOrWhiteSpace(model)) model = AssetGenModelCatalog.DefaultModelId(provider, "image"); + var req = new ImageGenRequest { Provider = provider, @@ -61,7 +67,7 @@ private static object Generate(ToolParams p) Prompt = p.Get("prompt"), ImagePath = p.Get("imagePath"), ImageUrl = p.Get("imageUrl"), - Model = p.Get("model"), + Model = string.IsNullOrWhiteSpace(model) ? null : model, Transparent = p.GetBool("transparent", false), AsSprite = p.GetBool("asSprite", true), Width = p.GetInt("width", 0) ?? 0, diff --git a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs index 0a3180699..c7bd8966c 100644 --- a/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs +++ b/MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs @@ -52,6 +52,12 @@ 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 = p.Get("model"); + if (string.IsNullOrWhiteSpace(model)) model = AssetGenPrefs.GetSelectedModel("model", provider); + if (string.IsNullOrWhiteSpace(model)) model = AssetGenModelCatalog.DefaultModelId(provider, "model"); + var req = new ModelGenRequest { Provider = provider, @@ -63,6 +69,7 @@ private static object Generate(ToolParams p) TargetSize = p.GetFloat("targetSize", 1f) ?? 1f, Texture = p.GetBool("texture", true), Tier = p.Get("tier"), + Model = string.IsNullOrWhiteSpace(model) ? null : model, Name = p.Get("name"), OutputFolder = p.Get("outputFolder"), }; @@ -140,8 +147,11 @@ private static object ListProviders() { var list = new List(); foreach (ProviderInfo info in AssetGenProviders.List()) + { + if (info.Kind != "model") continue; // keep image/audio rows out of the 3D provider 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 }); + } + return new SuccessResponse($"{list.Count} model provider(s).", new { providers = list }); } } } diff --git a/Server/src/cli/commands/asset_gen.py b/Server/src/cli/commands/asset_gen.py index c2d093248..c6bd940f9 100644 --- a/Server/src/cli/commands/asset_gen.py +++ b/Server/src/cli/commands/asset_gen.py @@ -38,6 +38,7 @@ def _emit(result, config, verb): @click.option("--target-size", default=None, type=float, help="Normalize largest dimension (meters).") @click.option("--texture/--no-texture", "texture", default=None, help="Generate textures.") @click.option("--tier", default=None, help="Provider quality/cost tier.") +@click.option("--model", default=None, help="Provider model id/version (omit for the GUI-selected default).") @click.option("--name", default=None, help="Base name for the imported asset.") @click.option("--output-folder", default=None, help="Destination folder under Assets/.") @handle_unity_errors @@ -51,6 +52,7 @@ def generate_model( target_size: Optional[float], texture: Optional[bool], tier: Optional[str], + model: Optional[str], name: Optional[str], output_folder: Optional[str], ): @@ -74,6 +76,7 @@ def generate_model( "targetSize": target_size, "texture": texture, "tier": tier, + "model": model, "name": name, "outputFolder": output_folder, } @@ -197,6 +200,46 @@ def generate_image( _emit(result, config, "Generation") +@asset_gen.command("generate-audio") +@click.option("--provider", default=None, help="Provider id (fal).") +@click.option("--prompt", default=None, help="Text prompt describing the sound or music.") +@click.option("--model", default=None, help="fal model id (omit for the GUI-selected default).") +@click.option("--duration", default=None, type=float, help="Requested length in seconds (soft-clamped per model).") +@click.option("--name", default=None, help="Base name for the imported asset.") +@click.option("--output-folder", default=None, help="Destination folder under Assets/.") +@handle_unity_errors +def generate_audio( + provider: Optional[str], + prompt: Optional[str], + model: Optional[str], + duration: Optional[float], + name: Optional[str], + output_folder: Optional[str], +): + """Generate audio (SFX / music) with a fal.ai model. + + \b + Examples: + unity-mcp asset-gen generate-audio --provider fal --prompt "8-bit coin pickup" + unity-mcp asset-gen generate-audio --prompt "calm ambient loop" --duration 30 + """ + config = get_config() + + params: dict[str, Any] = {"action": "generate"} + optional = { + "provider": provider, + "prompt": prompt, + "model": model, + "duration": duration, + "name": name, + "outputFolder": output_folder, + } + params.update({k: v for k, v in optional.items() if v is not None}) + + result = run_command("generate_audio", params, config) + _emit(result, config, "Generation") + + @asset_gen.command("status") @click.option("--job-id", "job_id", required=True, help="Job id returned by a generate/import command.") @handle_unity_errors diff --git a/Server/src/services/tools/generate_audio.py b/Server/src/services/tools/generate_audio.py new file mode 100644 index 000000000..04466f837 --- /dev/null +++ b/Server/src/services/tools/generate_audio.py @@ -0,0 +1,79 @@ +""" +Defines the generate_audio tool for AI audio (SFX / music) generation in Unity. + +Thin pass-through: this tool carries NO API keys and NO file bytes. The C# side +reads the user's fal.ai key from the OS secure store, performs the provider +HTTPS call, downloads the result, and imports it as an AudioClip. +""" +from typing import Annotated, Any, Literal + +from fastmcp import Context +from mcp.types import ToolAnnotations + +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + group="asset_gen", + description=( + "Generate audio (sound effects and background music) with fal.ai models and import " + "them as AudioClips into the Unity project. Bring-your-own-key: the fal key lives in " + "the editor's secure store (shared with image generation) and never crosses the bridge.\n\n" + "MODELS (all via fal.ai): fal-ai/stable-audio-25/text-to-audio (music + SFX, <=190s), " + "cassetteai/sound-effects-generator (SFX, <=30s), cassetteai/music-generator (music), " + "fal-ai/lyria2 (music). Omit model to use the model selected in the " + "MCP for Unity -> Asset Generation tab.\n\n" + "ACTIONS:\n" + "- generate: Submit an audio job from a text prompt. Returns { job_id }; poll with the " + "status action. Params: provider (fal), prompt, model, duration (seconds), name, " + "output_folder.\n" + "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" + "- cancel: Cancel an in-flight job by job_id.\n" + "- list_providers: List configured audio providers and capabilities (no key values)." + ), + annotations=ToolAnnotations( + title="Generate Audio", + destructiveHint=False, + ), +) +async def generate_audio( + ctx: Context, + action: Annotated[Literal["generate", "status", "cancel", "list_providers"], + "Action to perform."], + + provider: Annotated[str, "Provider id (fal)."] | None = None, + prompt: Annotated[str, "Text prompt describing the sound or music."] | None = None, + model: Annotated[str, "fal model id (e.g. fal-ai/stable-audio-25/text-to-audio). " + "Omit to use the GUI-selected default."] | None = None, + duration: Annotated[float, "Requested length in seconds (soft-clamped per model)."] | None = None, + name: Annotated[str, "Base name for the imported asset."] | None = None, + output_folder: Annotated[str, "Destination folder under Assets/ for the import."] | None = None, + job_id: Annotated[str, "Job id for status/cancel."] | None = None, +) -> dict[str, Any]: + unity_instance = await get_unity_instance_from_context(ctx) + + params_dict = { + "action": action.lower(), + "provider": provider, + "prompt": prompt, + "model": model, + "duration": duration, + "name": name, + "outputFolder": output_folder, + "jobId": job_id, + } + + # Remove None values + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + result = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "generate_audio", + params_dict, + ) + + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/src/services/tools/generate_model.py b/Server/src/services/tools/generate_model.py index 5ea5bd35a..0befe52a3 100644 --- a/Server/src/services/tools/generate_model.py +++ b/Server/src/services/tools/generate_model.py @@ -26,7 +26,7 @@ "- generate: Submit a generation job (text->3D or image->3D). Returns { job_id } " "immediately; poll with the status action. Params: provider, mode (text|image), " "prompt, image_path|image_url, format (glb|fbx|obj|usdz), target_size, texture, " - "tier, name, output_folder.\n" + "tier, model, name, output_folder.\n" "- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n" "- cancel: Cancel an in-flight job by job_id.\n" "- list_providers: List configured 3D providers and capabilities (no key values)." @@ -50,6 +50,8 @@ async def generate_model( target_size: Annotated[float, "Normalize the largest dimension to this size (meters)."] | None = None, texture: Annotated[bool, "Whether to generate textures for the model."] | None = None, tier: Annotated[str, "Provider quality/cost tier."] | None = None, + model: Annotated[str, "Provider model id/version (e.g. Tripo v3.1, Meshy meshy-6). " + "Omit for the GUI-selected default."] | None = None, name: Annotated[str, "Base name for the imported asset."] | None = None, output_folder: Annotated[str, "Destination folder under Assets/ for the import."] | None = None, job_id: Annotated[str, "Job id for status/cancel."] | None = None, @@ -67,6 +69,7 @@ async def generate_model( "targetSize": target_size, "texture": texture, "tier": tier, + "model": model, "name": name, "outputFolder": output_folder, "jobId": job_id, diff --git a/Server/tests/test_asset_gen_audio.py b/Server/tests/test_asset_gen_audio.py new file mode 100644 index 000000000..462854f7c --- /dev/null +++ b/Server/tests/test_asset_gen_audio.py @@ -0,0 +1,145 @@ +"""Tests for the generate_audio asset-gen tool and CLI command (fal.ai). + +Pass-through tool: NO API keys, NO file bytes. Unity transport fully mocked. +""" + +import asyncio +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from click.testing import CliRunner + +from cli.commands.asset_gen import asset_gen +from cli.utils.config import CLIConfig +from services.registry import get_registered_tools + +from services.tools import generate_audio as generate_audio_module +from services.tools.generate_audio import generate_audio + + +COMMAND = "generate_audio" +ALLOWED_KEYS = { + "action", "provider", "prompt", "model", "duration", "name", "outputFolder", "jobId", +} + + +def _call_tool(**kwargs): + ctx = MagicMock() + with patch.object(generate_audio_module, "get_unity_instance_from_context", + new=AsyncMock(return_value="unity-1")): + with patch.object(generate_audio_module, "send_with_unity_instance", + new=AsyncMock(return_value={"success": True, "data": {}})) as mock_send: + result = asyncio.run(generate_audio(ctx, **kwargs)) + return result, mock_send.call_args.args + + +def _sent_command(sent_args): + return sent_args[2] + + +def _sent_params(sent_args): + return sent_args[3] + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mock_config(): + return CLIConfig(host="127.0.0.1", port=8080, timeout=30, format="text", unity_instance=None) + + +@pytest.fixture +def cli_runner(runner, mock_config): + def _invoke(args): + with patch("cli.commands.asset_gen.get_config", return_value=mock_config): + with patch("cli.commands.asset_gen.run_command", + return_value={"success": True, "message": "OK", "data": {}}) as mock_run: + result = runner.invoke(asset_gen, args) + return result, mock_run + return _invoke + + +class TestGenerateAudioRegistration: + def test_tool_registered_under_asset_gen_group(self): + tools = get_registered_tools() + tool = next((t for t in tools if t["name"] == "generate_audio"), None) + assert tool is not None + assert tool["group"] == "asset_gen" + + +class TestGenerateAudioRouting: + def test_generate_routes_to_command(self): + _, sent = _call_tool(action="generate", provider="fal", prompt="8-bit coin pickup") + assert _sent_command(sent) == COMMAND + assert _sent_params(sent)["action"] == "generate" + + def test_status_and_job_id_mapping(self): + _, sent = _call_tool(action="status", job_id="j5") + assert _sent_params(sent) == {"action": "status", "jobId": "j5"} + + def test_param_camelcase_mapping(self): + _, sent = _call_tool( + action="generate", provider="fal", prompt="rain", duration=30.0, + output_folder="Assets/Generated/Audio", + ) + params = _sent_params(sent) + assert params["outputFolder"] == "Assets/Generated/Audio" + assert params["duration"] == 30.0 + for snake in ("output_folder", "job_id"): + assert snake not in params + + def test_action_is_lowercased(self): + _, sent = _call_tool(action="GENERATE", prompt="p") + assert _sent_params(sent)["action"] == "generate" + + def test_omitting_model_drops_it(self): + # Load-bearing precondition for the GUI-selected default: an omitted model must NOT be + # sent, so the C# side falls back to the panel selection / catalog default. + _, sent = _call_tool(action="generate", provider="fal", prompt="p") + params = _sent_params(sent) + assert "model" not in params + assert params == {"action": "generate", "provider": "fal", "prompt": "p"} + + def test_explicit_model_passes_through(self): + _, sent = _call_tool(action="generate", provider="fal", prompt="p", + model="cassetteai/sound-effects-generator") + assert _sent_params(sent)["model"] == "cassetteai/sound-effects-generator" + + def test_no_secret_keys_in_payload(self): + _, sent = _call_tool( + action="generate", provider="fal", prompt="p", + model="fal-ai/stable-audio-25/text-to-audio", duration=12.5, name="Sfx", + output_folder="Assets/Generated/Audio", job_id="j", + ) + params = _sent_params(sent) + assert set(params.keys()).issubset(ALLOWED_KEYS) + joined = " ".join(params.keys()).lower() + for forbidden in ("key", "secret", "token", "apikey", "password"): + assert forbidden not in joined + + def test_non_dict_response_guarded(self): + ctx = MagicMock() + with patch.object(generate_audio_module, "get_unity_instance_from_context", + new=AsyncMock(return_value="u")): + with patch.object(generate_audio_module, "send_with_unity_instance", + new=AsyncMock(return_value=42)): + result = asyncio.run(generate_audio(ctx, action="status", job_id="j")) + assert result["success"] is False + assert "42" in result["message"] + + +class TestGenerateAudioCLI: + def test_generate_audio_cli(self, cli_runner): + result, mock_run = cli_runner([ + "generate-audio", "--provider", "fal", "--prompt", "ambient", "--duration", "30", + ]) + assert result.exit_code == 0 + command = mock_run.call_args.args[0] + params = mock_run.call_args.args[1] + assert command == COMMAND + assert params["action"] == "generate" + assert params["provider"] == "fal" + assert params["duration"] == 30.0 + assert set(params.keys()).issubset(ALLOWED_KEYS) diff --git a/Server/tests/test_asset_gen_model.py b/Server/tests/test_asset_gen_model.py index c8283dda0..7de8d5bfc 100644 --- a/Server/tests/test_asset_gen_model.py +++ b/Server/tests/test_asset_gen_model.py @@ -22,7 +22,7 @@ # Every camelCase key the tool is allowed to send. Crucially, no key/secret param. ALLOWED_KEYS = { "action", "provider", "mode", "prompt", "imagePath", "imageUrl", - "format", "targetSize", "texture", "tier", "name", "outputFolder", "jobId", + "format", "targetSize", "texture", "tier", "model", "name", "outputFolder", "jobId", } @@ -130,6 +130,15 @@ def test_param_camelcase_mapping(self): for snake in ("image_path", "image_url", "target_size", "output_folder", "job_id"): assert snake not in params + def test_model_param_passthrough(self): + _, sent = _call_tool(action="generate", provider="tripo", prompt="x", model="v3.1-20260211") + params = _sent_params(sent) + assert params["model"] == "v3.1-20260211" + + def test_model_omitted_is_dropped(self): + _, sent = _call_tool(action="generate", provider="tripo", prompt="x") + assert "model" not in _sent_params(sent) + def test_none_values_stripped(self): _, sent = _call_tool(action="generate", provider="tripo", prompt="x") params = _sent_params(sent) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs new file mode 100644 index 000000000..233b7547d --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using NUnit.Framework; + +namespace MCPForUnityTests.Editor.AssetGen +{ + /// + /// Covers the curated model catalog: the four audio models + metadata, per-kind/per-provider + /// defaults, the adapter-constant drift guard, and exact-id lookup. + /// + public class AssetGenModelCatalogTests + { + [Test] + public void Curated_HasAllFourAudioModels() + { + IReadOnlyList audio = AssetGenModelCatalog.ForProvider("fal", "audio"); + + CollectionAssert.AreEqual( + new[] + { + "fal-ai/stable-audio-25/text-to-audio", + "cassetteai/sound-effects-generator", + "cassetteai/music-generator", + "fal-ai/lyria2", + }, + audio.Select(e => e.Id).ToList()); + + CollectionAssert.AreEqual( + new[] { 190f, 30f, 180f, 30f }, + audio.Select(e => e.MaxDurationSeconds).ToList()); + + Assert.IsNotNull(audio[0].CommercialNote, "Stable Audio should carry a license caveat"); + Assert.IsNull(audio[1].CommercialNote); + Assert.IsNull(audio[2].CommercialNote); + Assert.IsNull(audio[3].CommercialNote); + } + + [Test] + public void DefaultModelId_PerKindPerProvider() + { + Assert.AreEqual("fal-ai/flux-2", AssetGenModelCatalog.DefaultModelId("fal", "image")); + Assert.AreEqual("fal-ai/stable-audio-25/text-to-audio", AssetGenModelCatalog.DefaultModelId("fal", "audio")); + Assert.AreEqual("v3.1-20260211", AssetGenModelCatalog.DefaultModelId("tripo", "model")); + Assert.AreEqual("meshy-6", AssetGenModelCatalog.DefaultModelId("meshy", "model")); + Assert.IsNull(AssetGenModelCatalog.DefaultModelId("nope", "audio"), "unknown provider => null, not a throw"); + } + + [Test] + public void DefaultModelId_MatchesAdapterConstants() + { + // Drift guard: the panel's shown default must equal what an omitted `model` param + // resolves to in the adapter. The catalog references the adapter constant, so these are + // pinned together — this test fails loudly if anyone reorders or reliterals a default. + Assert.AreEqual(TripoAdapter.ModelVersion, AssetGenModelCatalog.DefaultModelId("tripo", "model")); + Assert.AreEqual(MeshyAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("meshy", "model")); + Assert.AreEqual(FalAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("fal", "image")); + Assert.AreEqual(FalAudioAdapter.DefaultModel, AssetGenModelCatalog.DefaultModelId("fal", "audio")); + } + + [Test] + public void Find_ReturnsEntry_ByExactId() + { + ModelEntry e = AssetGenModelCatalog.Find("cassetteai/music-generator"); + Assert.IsNotNull(e); + Assert.AreEqual("audio", e.Kind); + Assert.IsNull(AssetGenModelCatalog.Find("does/not/exist")); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs.meta new file mode 100644 index 000000000..6344e2ab3 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenModelCatalogTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cf9e75d8f0a84ffb8b6ac1eafb407ec7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenPrefsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenPrefsTests.cs index 6364ef008..3c323236f 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenPrefsTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenPrefsTests.cs @@ -16,6 +16,11 @@ public class AssetGenPrefsTests { EditorPrefKeys.AssetGenSelectedModelProvider, EditorPrefKeys.AssetGenSelectedImageProvider, + EditorPrefKeys.AssetGenSelectedAudioProvider, + EditorPrefKeys.AssetGenSelectedModelPrefix + "image.fal", + EditorPrefKeys.AssetGenSelectedModelPrefix + "model.tripo", + EditorPrefKeys.AssetGenSelectedModelPrefix + "model.meshy", + EditorPrefKeys.AssetGenSelectedModelPrefix + "audio.fal", EditorPrefKeys.AssetGenDefaultFormat, EditorPrefKeys.AssetGenOutputRoot, EditorPrefKeys.AssetGenAutoNormalize, @@ -57,6 +62,32 @@ public void RoundTrip_PersistsValues() Assert.IsTrue(AssetGenPrefs.IsProviderEnabled("tripo")); } + [Test] + public void Defaults_ModelSelectionsEmpty_WhenUnset() + { + Assert.AreEqual(string.Empty, AssetGenPrefs.GetSelectedModel("image", "fal")); + Assert.AreEqual(string.Empty, AssetGenPrefs.GetSelectedModel("model", "tripo")); + Assert.AreEqual(string.Empty, AssetGenPrefs.GetSelectedModel("audio", "fal")); + Assert.AreEqual("fal", AssetGenPrefs.AudioProvider); + } + + [Test] + public void SelectedModels_RoundTrip_PerProvider_AndClearOnEmpty() + { + AssetGenPrefs.SetSelectedModel("model", "tripo", "P1-20260311"); + AssetGenPrefs.SetSelectedModel("model", "meshy", "meshy-6"); + AssetGenPrefs.SetSelectedModel("audio", "fal", "cassetteai/sound-effects-generator"); + + // Per-provider: Tripo and Meshy selections are independent, no clobbering. + Assert.AreEqual("P1-20260311", AssetGenPrefs.GetSelectedModel("model", "tripo")); + Assert.AreEqual("meshy-6", AssetGenPrefs.GetSelectedModel("model", "meshy")); + Assert.AreEqual("cassetteai/sound-effects-generator", AssetGenPrefs.GetSelectedModel("audio", "fal")); + + // Setting empty deletes the key (SetOrDelete) -> back to the empty default. + AssetGenPrefs.SetSelectedModel("audio", "fal", ""); + Assert.AreEqual(string.Empty, AssetGenPrefs.GetSelectedModel("audio", "fal")); + } + [Test] public void OutputRoot_FallsBackToDefault_WhenClearedToEmpty() { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs new file mode 100644 index 000000000..748b9c34d --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Tools.AssetGen; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace MCPForUnityTests.Editor.AssetGen +{ + public class GenerateAudioTests + { + private string _dir; + private EncryptedFileKeyStore _store; + + [SetUp] + public void SetUp() + { + AssetGenJobManager.ResetForTests(); + Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", null); + _dir = Path.Combine(Path.GetTempPath(), "mcp_audiohandler_" + Guid.NewGuid().ToString("N")); + _store = new EncryptedFileKeyStore(_dir); + SecureKeyStore.OverrideForTests(_store); + AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport(); + } + + [TearDown] + public void TearDown() + { + AssetGenJobManager.ResetForTests(); + SecureKeyStore.ResetForTests(); + try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { } + } + + private static JObject Call(JObject p) + => JObject.Parse(JsonConvert.SerializeObject(GenerateAudio.HandleCommand(p))); + + [Test] + public void Generate_WithKey_ReturnsPendingJobId() + { + _store.Set("fal", "falkey"); + JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["prompt"] = "gentle rain" }); + Assert.AreEqual("pending", (string)gen["_mcp_status"]); + Assert.IsFalse(string.IsNullOrEmpty((string)gen["data"]["job_id"])); + } + + [Test] + public void Generate_MissingKey_ReturnsError() + { + JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["prompt"] = "gentle rain" }); + Assert.AreEqual(false, (bool)resp["success"]); + StringAssert.Contains("No API key", (string)resp["error"]); + } + + [Test] + public void Generate_EmptyPrompt_ReturnsError() + { + _store.Set("fal", "falkey"); + JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal" }); + Assert.AreEqual(false, (bool)resp["success"]); + StringAssert.Contains("prompt", ((string)resp["error"]).ToLowerInvariant()); + } + + [Test] + public void Generate_UnknownProvider_ReturnsError() + { + JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "elevenlabs", ["prompt"] = "gentle rain" }); + Assert.AreEqual(false, (bool)resp["success"]); + } + + [Test] + public void ListProviders_FiltersAudioKind() + { + JObject resp = Call(new JObject { ["action"] = "list_providers" }); + Assert.AreEqual(true, (bool)resp["success"]); + string s = resp.ToString(); + StringAssert.Contains("fal", s); + StringAssert.Contains("audio", s); + StringAssert.DoesNotContain("tripo", s); // model providers excluded + StringAssert.DoesNotContain("openrouter", s); // image providers excluded + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs.meta new file mode 100644 index 000000000..1338f96fb --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateAudioTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d3a53bbf67324d27b68a813da5c82637 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs index f3b0712e4..f75cdc96f 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Security; using MCPForUnity.Editor.Services.AssetGen; using MCPForUnity.Editor.Services.AssetGen.Http; @@ -146,6 +147,54 @@ public void Generate_UnknownProvider_ReturnsError() Assert.AreEqual(false, (bool)resp["success"]); } + private static HttpResult FalStatus(HttpRequestSpec spec) => + spec.Url.EndsWith("/status") + ? new HttpResult { Status = 200, IsSuccess = true, Text = "{\"status\":\"IN_PROGRESS\"}" } + : new HttpResult { Status = 200, IsSuccess = true, Text = "{\"response_url\":\"https://queue.fal.run/x/requests/r1\"}" }; + + [Test] + public void Generate_NoModelParam_UsesSelectedImageModelPref() + { + _store.Set("fal", "falkey"); + AssetGenPrefs.SetSelectedModel("image", "fal", "fal-ai/flux-2-pro"); + var fake = new FakeHttpTransport { Handler = FalStatus }; + AssetGenJobManager.TransportOverrideForTests = fake; + AssetGenJobManager.PollIntervalSeconds = 0; + try + { + JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "text", ["prompt"] = "a cat" }); + string jobId = (string)gen["data"]["job_id"]; + for (int i = 0; i < 6; i++) AssetGenJobManager.TryAdvanceForTests(jobId); + + HttpRequestSpec post = fake.RecordedRequests.Find(r => r.Method == "POST"); + Assert.IsNotNull(post, "expected a submit POST"); + StringAssert.Contains("flux-2-pro", post.Url); // pref drove the model when none was passed + } + finally { AssetGenPrefs.SetSelectedModel("image", "fal", ""); } + } + + [Test] + public void Generate_ExplicitModel_OverridesPref() + { + _store.Set("fal", "falkey"); + AssetGenPrefs.SetSelectedModel("image", "fal", "fal-ai/flux-2-pro"); + var fake = new FakeHttpTransport { Handler = FalStatus }; + AssetGenJobManager.TransportOverrideForTests = fake; + AssetGenJobManager.PollIntervalSeconds = 0; + try + { + JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "text", ["prompt"] = "a cat", ["model"] = "fal-ai/flux-2/flash" }); + string jobId = (string)gen["data"]["job_id"]; + for (int i = 0; i < 6; i++) AssetGenJobManager.TryAdvanceForTests(jobId); + + HttpRequestSpec post = fake.RecordedRequests.Find(r => r.Method == "POST"); + Assert.IsNotNull(post); + StringAssert.Contains("flux-2/flash", post.Url); + StringAssert.DoesNotContain("flux-2-pro", post.Url); + } + finally { AssetGenPrefs.SetSelectedModel("image", "fal", ""); } + } + [Test] public void OpenRouterInline_EndToEnd_ReachesDone() { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MeshyAdapterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MeshyAdapterTests.cs index 7ecad981f..a02bdd838 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MeshyAdapterTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MeshyAdapterTests.cs @@ -61,6 +61,32 @@ public void Submit_PostsTextEndpoint_WithBearerHeader_AndReturnsResultId() StringAssert.Contains("preview", body); } + [Test] + public void Submit_UsesRequestModel_WhenProvided() + { + var http = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"t\"}") }; + var adapter = new MeshyAdapter(); + var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Model = "meshy-5" }; + + adapter.SubmitAsync(req, "k", http, CancellationToken.None).GetAwaiter().GetResult(); + + string body = Encoding.UTF8.GetString(http.RecordedRequests[0].Body); + StringAssert.Contains("\"ai_model\":\"meshy-5\"", body); + } + + [Test] + public void Submit_FallsBackToDefault_WhenModelEmpty() + { + var http = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"t\"}") }; + var adapter = new MeshyAdapter(); + var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x" }; // Model null + + adapter.SubmitAsync(req, "k", http, CancellationToken.None).GetAwaiter().GetResult(); + + string body = Encoding.UTF8.GetString(http.RecordedRequests[0].Body); + StringAssert.Contains("\"ai_model\":\"" + MeshyAdapter.DefaultModel + "\"", body); + } + [Test] public void Poll_Succeeded_ReturnsGlbUrl() { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs index c37a74835..a0580edeb 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs @@ -99,6 +99,32 @@ public void Poll_MapsFailed_WithError() Assert.IsNotEmpty(res.Error); } + [Test] + public void Submit_UsesRequestModel_WhenProvided() + { + var http = new FakeHttpTransport { Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"t\"}}") }; + var adapter = new TripoAdapter(); + var req = new ModelGenRequest { Provider = "tripo", Mode = "text", Prompt = "x", Model = "P1-20260311" }; + + adapter.SubmitAsync(req, "k", http, CancellationToken.None).GetAwaiter().GetResult(); + + string body = Encoding.UTF8.GetString(http.RecordedRequests[0].Body); + StringAssert.Contains("\"model_version\":\"P1-20260311\"", body); + } + + [Test] + public void Submit_FallsBackToDefault_WhenModelEmpty() + { + var http = new FakeHttpTransport { Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"t\"}}") }; + var adapter = new TripoAdapter(); + var req = new ModelGenRequest { Provider = "tripo", Mode = "text", Prompt = "x" }; // Model null + + adapter.SubmitAsync(req, "k", http, CancellationToken.None).GetAwaiter().GetResult(); + + string body = Encoding.UTF8.GetString(http.RecordedRequests[0].Body); + StringAssert.Contains("\"model_version\":\"" + TripoAdapter.ModelVersion + "\"", body); + } + [Test] public void Submit_ImageMode_UsesImageToModel() { From 236e718babef52f4c0a0712d471f4749a6c8eeb9 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:21:25 -0700 Subject: [PATCH 03/10] feat(asset-gen): GUI model dropdowns + fal audio row + refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 4-5. Extends the existing Asset Gen tab (no new window): per-provider 'Model' dropdowns on the image + 3D rows with price/duration/use-case metadata, a fal audio row (no key field — reuses the shared fal key) with a model dropdown and the Stable Audio license caveat, and a Refresh button that re-validates key presence + the curated catalog. Selecting a model writes the per-(kind,provider) pref that generate_* reads as its default. fal has no public list-models API, so Refresh is a curated re-validate (the plan's gated fallback), never a network fetch. Characterization tests assert the new builders + three-phase lifecycle. Claude-Session: https://claude.ai/code/session_01GCxmdd4qo7MG6J4M6WcT9Y --- .../Components/AssetGen/McpAssetGenSection.cs | 139 +++++++++++++++++- .../AssetGen/McpAssetGenSection.uxml | 4 + .../Windows_Characterization.cs | 42 ++++++ 3 files changed, 182 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs index 4a44e3845..fd64600f1 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