-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(asset-gen): add MiniMax music cover support #1325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: beta
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,247 @@ | ||
| using System; | ||
| 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 | ||
| { | ||
| /// <summary> | ||
| /// MiniMax music-cover adapter for the synchronous music-generation endpoint. A cover accepts | ||
| /// one raw reference source (URL or base64 audio) and an optional preprocessed feature id, then | ||
| /// exposes the returned URL or hex payload through the normal audio job pipeline. | ||
| /// </summary> | ||
| public sealed class MiniMaxAudioAdapter : IAudioProviderAdapter | ||
| { | ||
| internal const string GlobalEndpoint = "https://api.minimax.io/v1/music_generation"; | ||
| internal const string ChinaEndpoint = "https://api.minimaxi.com/v1/music_generation"; | ||
| internal const string RegionEnvVar = "MCPFORUNITY_MINIMAX_REGION"; | ||
| internal const string DefaultModel = "music-cover"; | ||
|
|
||
| private const string GlobalHost = "api.minimax.io"; | ||
| private const string ChinaHost = "api.minimaxi.com"; | ||
| private const string FreeModel = "music-cover-free"; | ||
|
|
||
| private byte[] _inlineData; | ||
| private string _downloadUrl; | ||
| private string _resultExt; | ||
| private string _error; | ||
|
|
||
| public string Id => "minimax"; | ||
|
|
||
| internal static bool IsCoverModel(string model) | ||
| => string.Equals(model, DefaultModel, StringComparison.Ordinal) | ||
| || string.Equals(model, FreeModel, StringComparison.Ordinal); | ||
|
|
||
| public async Task<string> 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.IsNullOrWhiteSpace(req.Model) ? DefaultModel : req.Model; | ||
| ValidateRequest(req, model); | ||
| ResolveRegion(out string endpoint, out string host, out bool chinaRegion); | ||
| ProviderHttp.RequireHost(endpoint, host, apiKey, "MiniMax cover submit"); | ||
|
|
||
| var spec = new HttpRequestSpec | ||
| { | ||
| Method = "POST", | ||
| Url = endpoint, | ||
| ContentType = "application/json", | ||
| Body = Encoding.UTF8.GetBytes(BuildBody(req, model, chinaRegion).ToString(Formatting.None)) | ||
| }; | ||
| spec.Headers["Authorization"] = "Bearer " + apiKey; | ||
|
|
||
| HttpResult response = await http.SendAsync(spec, ct); | ||
| JObject json = ParseOk(response, apiKey); | ||
|
|
||
| int statusCode = AsInt(json["base_resp"]?["status_code"], -1); | ||
| if (statusCode != 0) | ||
| { | ||
| string statusMessage = json["base_resp"]?["status_msg"]?.ToString(); | ||
| _error = SecretRedactor.Scrub( | ||
| $"MiniMax music cover failed (status_code={statusCode}): {statusMessage ?? "unknown error"}", | ||
| apiKey); | ||
| return "ready"; | ||
| } | ||
|
|
||
| JToken data = json["data"]; | ||
| int generationStatus = AsInt(data?["status"], -1); | ||
| if (generationStatus != 2) | ||
| { | ||
| _error = generationStatus == 1 | ||
| ? "MiniMax music cover is still in progress, but the response included no query endpoint." | ||
| : $"MiniMax music cover returned an unexpected status ({generationStatus})."; | ||
| return "ready"; | ||
| } | ||
|
|
||
| string audio = data?["audio"]?.ToString(); | ||
| if (string.IsNullOrWhiteSpace(audio)) | ||
| { | ||
| _error = "MiniMax music cover completed without audio data."; | ||
| return "ready"; | ||
| } | ||
|
|
||
| if (Uri.TryCreate(audio, UriKind.Absolute, out Uri audioUri) | ||
| && (audioUri.Scheme == Uri.UriSchemeHttp || audioUri.Scheme == Uri.UriSchemeHttps)) | ||
| { | ||
| _downloadUrl = audio; | ||
| } | ||
| else | ||
| { | ||
| _inlineData = TryDecodeHex(audio); | ||
| if (_inlineData == null || _inlineData.Length == 0) | ||
| { | ||
| _error = "MiniMax returned an unrecognized audio payload."; | ||
| return "ready"; | ||
| } | ||
| } | ||
|
|
||
| _resultExt = NormalizeAudioFormat(req.AudioFormat); | ||
| return "ready"; | ||
| } | ||
|
|
||
| public Task<ProviderPollResult> PollAsync( | ||
| string providerJobId, | ||
| string apiKey, | ||
| IHttpTransport http, | ||
| CancellationToken ct) | ||
| { | ||
| var result = new ProviderPollResult { Progress = 1f }; | ||
| if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl))) | ||
| { | ||
| result.State = ProviderPollState.Failed; | ||
| result.Error = _error ?? "MiniMax produced no cover audio."; | ||
| } | ||
| else | ||
| { | ||
| result.State = ProviderPollState.Succeeded; | ||
| result.InlineData = _inlineData; | ||
| result.DownloadUrl = _downloadUrl; | ||
| result.ResultExt = _resultExt; | ||
| } | ||
| return Task.FromResult(result); | ||
| } | ||
|
|
||
| private static void ValidateRequest(AudioGenRequest req, string model) | ||
| { | ||
| if (!IsCoverModel(model)) | ||
| throw new NotSupportedException("MiniMax audio supports music-cover and music-cover-free."); | ||
|
|
||
| int sourceCount = 0; | ||
| if (!string.IsNullOrWhiteSpace(req.AudioUrl)) sourceCount++; | ||
| if (!string.IsNullOrWhiteSpace(req.AudioBase64)) sourceCount++; | ||
| if (sourceCount != 1) | ||
| throw new ArgumentException("Provide exactly one cover source: audio_url or audio_base64."); | ||
|
|
||
| NormalizeOutputFormat(req.OutputFormat); | ||
| NormalizeAudioFormat(req.AudioFormat); | ||
| } | ||
|
|
||
| private static JObject BuildBody(AudioGenRequest req, string model, bool chinaRegion) | ||
| { | ||
| var body = new JObject | ||
| { | ||
| ["model"] = model, | ||
| ["stream"] = false, | ||
| ["output_format"] = NormalizeOutputFormat(req.OutputFormat), | ||
| ["audio_setting"] = new JObject | ||
| { | ||
| ["format"] = NormalizeAudioFormat(req.AudioFormat) | ||
| } | ||
| }; | ||
|
|
||
| if (!string.IsNullOrWhiteSpace(req.Prompt)) body["prompt"] = req.Prompt; | ||
| if (!string.IsNullOrWhiteSpace(req.Lyrics)) body["lyrics"] = req.Lyrics; | ||
| if (req.LyricsOptimizer.HasValue) body["lyrics_optimizer"] = req.LyricsOptimizer.Value; | ||
| if (req.IsInstrumental.HasValue) body["is_instrumental"] = req.IsInstrumental.Value; | ||
| if (!string.IsNullOrWhiteSpace(req.AudioUrl)) body["audio_url"] = req.AudioUrl; | ||
| if (!string.IsNullOrWhiteSpace(req.AudioBase64)) body["audio_base64"] = req.AudioBase64; | ||
| if (!string.IsNullOrWhiteSpace(req.CoverFeatureId)) body["cover_feature_id"] = req.CoverFeatureId; | ||
| if (chinaRegion && req.AigcWatermark.HasValue) body["aigc_watermark"] = req.AigcWatermark.Value; | ||
| return body; | ||
| } | ||
|
|
||
| private static string NormalizeOutputFormat(string value) | ||
| { | ||
| string normalized = string.IsNullOrWhiteSpace(value) ? "url" : value.Trim().ToLowerInvariant(); | ||
| if (normalized != "url" && normalized != "hex") | ||
| throw new ArgumentException("output_format must be 'url' or 'hex'."); | ||
| return normalized; | ||
| } | ||
|
|
||
| private static string NormalizeAudioFormat(string value) | ||
| { | ||
| string normalized = string.IsNullOrWhiteSpace(value) ? "mp3" : value.Trim().ToLowerInvariant(); | ||
| if (normalized != "mp3" && normalized != "wav" && normalized != "pcm") | ||
| throw new ArgumentException("audio_format must be 'mp3', 'wav', or 'pcm'."); | ||
| return normalized; | ||
|
Comment on lines
+180
to
+185
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
fd -a 'ProjectVersion.txt' .
fd -a -a 'AudioImportPipeline.cs' MCPForUnity TestProjects
rg -n -C 5 --glob '*.cs' 'pcm|AudioImportPipeline|AudioClip|AssetImporter' MCPForUnity TestProjectsRepository: CoplayDev/unity-mcp Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- MiniMaxAudioAdapter ---'
sed -n '1,240p' MCPForUnity/Editor/Services/AssetGen/Providers/MiniMaxAudioAdapter.cs
printf '%s\n' '--- AssetGenJobManager write/import flow ---'
sed -n '120,190p' MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
sed -n '390,520p' MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
printf '%s\n' '--- AudioImportPipeline ---'
cat -n MCPForUnity/Editor/Services/AssetGen/Import/AudioImportPipeline.cs
printf '%s\n' '--- Audio-related request/result models and tests ---'
rg -n -C 4 --glob '*.cs' 'class .*Audio|AudioFormat|OutputFormat|ResultExt|InlineData|OverrideExt|IsAllowedResultExtension|ImportOverrideForTests' MCPForUnity/Editor/Services/AssetGen TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGenRepository: CoplayDev/unity-mcp Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Runner poll/download/import state transitions ---'
sed -n '300,438p' MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
printf '%s\n' '--- MiniMax adapter tests ---'
cat -n TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MiniMaxAudioAdapterTests.cs
printf '%s\n' '--- Audio pipeline tests ---'
cat -n TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AudioImportPipelineTests.cs
printf '%s\n' '--- Extension allowlist tests and audio test helpers ---'
rg -n -C 8 --glob '*.cs' 'IsAllowedResultExtension|StartAudioGeneration|ImportOverrideForTests|OutputFormat|AudioFormat|MiniMax' TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen MCPForUnity/Editor/Services/AssetGenRepository: CoplayDev/unity-mcp Length of output: 50376 🌐 Web query:
💡 Result: Unity does not support the import of raw PCM (.pcm) files directly [1][2][3]. While "PCM" is a widely supported compression format within Unity for runtime audio storage and playback, this refers to an uncompressed encoding method that Unity can apply to audio files during the import and transcoding process [4][5]. It is not a supported source file format for the AudioImporter [3][6]. The official supported source audio file formats that can be imported into Unity are: - Microsoft Wave (.wav) [1][2][3] - Audio Interchange File Format (.aiff,.aif) [1][2][3] - MPEG layer 3 (.mp3) [1][2][3] - Ogg Vorbis (.ogg) [1][2][3] - Free Lossless Audio Codec (.flac) [1][2][3] - Tracker modules (.mod,.it,.s3m,.xm) [7][2][3] When you import any of these supported files, the Unity AudioImporter allows you to specify the runtime compression format, where "PCM" can be selected to ensure the audio remains uncompressed for optimal CPU performance at the cost of larger file size [4][5]. Raw PCM data lacking a container format (like a.wav header) cannot be natively processed by the AudioImporter [3][6]. Citations:
Do not expose raw The job writes
📍 Affects 3 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| private static void ResolveRegion(out string endpoint, out string host, out bool chinaRegion) | ||
| { | ||
| string region = (Environment.GetEnvironmentVariable(RegionEnvVar) ?? string.Empty) | ||
| .Trim().ToLowerInvariant(); | ||
| chinaRegion = region == "cn" || region == "cn_zh" || region == "china"; | ||
| endpoint = chinaRegion ? ChinaEndpoint : GlobalEndpoint; | ||
| host = chinaRegion ? ChinaHost : GlobalHost; | ||
| } | ||
|
|
||
| private static int AsInt(JToken token, int fallback) | ||
| { | ||
| if (token == null || token.Type == JTokenType.Null) return fallback; | ||
| if (token.Type == JTokenType.Integer) return (int)token; | ||
| return int.TryParse(token.ToString(), out int parsed) ? parsed : fallback; | ||
| } | ||
|
|
||
| private static byte[] TryDecodeHex(string hex) | ||
| { | ||
| if (string.IsNullOrEmpty(hex) || (hex.Length % 2) != 0) return null; | ||
| var bytes = new byte[hex.Length / 2]; | ||
| for (int i = 0; i < bytes.Length; i++) | ||
| { | ||
| int high = HexValue(hex[i * 2]); | ||
| int low = HexValue(hex[i * 2 + 1]); | ||
| if (high < 0 || low < 0) return null; | ||
| bytes[i] = (byte)((high << 4) | low); | ||
| } | ||
| return bytes; | ||
| } | ||
|
|
||
| private static int HexValue(char value) | ||
| { | ||
| if (value >= '0' && value <= '9') return value - '0'; | ||
| if (value >= 'a' && value <= 'f') return value - 'a' + 10; | ||
| if (value >= 'A' && value <= 'F') return value - 'A' + 10; | ||
| return -1; | ||
| } | ||
|
|
||
| private static JObject ParseOk(HttpResult response, string apiKey) | ||
| { | ||
| string text = ProviderHttp.BodyText(response); | ||
| JObject json = null; | ||
| if (!string.IsNullOrEmpty(text)) | ||
| { | ||
| try { json = JObject.Parse(text); } | ||
| catch { /* handled below */ } | ||
| } | ||
|
|
||
| if (response?.Ok != true) | ||
| { | ||
| string detail = json?["base_resp"]?["status_msg"]?.ToString() | ||
| ?? json?["error"]?.ToString() | ||
| ?? ProviderHttp.Truncate(text); | ||
| throw new Exception(SecretRedactor.Scrub( | ||
| $"MiniMax cover request failed (status={response?.Status}): {detail}", apiKey)); | ||
| } | ||
| return json ?? new JObject(); | ||
| } | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Restrict provider-returned download URLs before use.
audiocan contain any HTTP(S) URL.AssetGenJobManager.IsAllowedDownloadUrlchecks only the scheme. It permits loopback, link-local, and private-network hosts.A compromised or malicious provider response can make the Unity Editor request internal services. Enforce private-address and redirect validation in the shared transport or download policy before the request starts.
🤖 Prompt for AI Agents