chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,533 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Import;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen
{
public enum AssetGenJobState { Queued, Running, Importing, Done, Failed, Canceled }
/// <summary>
/// Snapshot of a generation/import job. Persisted to SessionState so a `status` query
/// still works after an unrelated domain reload. NEVER carries a key or secret.
/// </summary>
public sealed class AssetGenJob
{
public string JobId;
public string Kind; // model | image | marketplace
public string Provider;
public string Action;
public AssetGenJobState State;
public float Progress;
public string Format;
public float TargetSize = 1f;
public string AnimationType; // FBX/OBJ rig mode: null/none | generic | humanoid | legacy
public string AssetPath;
public string AssetGuid;
public string Error;
}
/// <summary>
/// Drives asset-generation jobs on the Unity main thread via EditorApplication.update. Each
/// job runs a generic submit → poll → (download | inline) → import state machine; the model
/// and image paths supply their own submit/poll/import delegates. Because UnityWebRequest
/// completes on the main thread, polling Task.IsCompleted from the update loop is
/// main-thread-safe — we never block or use threadpool waits. The provider key is read once at
/// submit time, captured only inside the submit/poll closures — never stored on the job,
/// persisted, or logged.
/// </summary>
[InitializeOnLoad]
public static class AssetGenJobManager
{
private const string JobKeyPrefix = "MCPForUnity.AssetGen.Job.";
private const string JobIndexKey = "MCPForUnity.AssetGen.JobIndex";
// Test seams (overridable; defaults are the production implementations).
internal static IHttpTransport TransportOverrideForTests;
internal static Func<AssetGenJob, string, AssetGenJob> ImportOverrideForTests;
internal static double PollIntervalSeconds = 3.0;
internal static double TimeoutSeconds = 600.0;
private static readonly Dictionary<string, AssetGenJob> Jobs = new();
private static readonly Dictionary<string, Runner> Runners = new();
private static readonly List<string> _tickIds = new();
private static bool _ticking;
static AssetGenJobManager()
{
try
{
string index = SessionState.GetString(JobIndexKey, string.Empty);
if (string.IsNullOrEmpty(index)) return;
foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
{
string json = SessionState.GetString(JobKeyPrefix + id, string.Empty);
if (string.IsNullOrEmpty(json)) continue;
var job = JsonConvert.DeserializeObject<AssetGenJob>(json);
if (job == null) continue;
if (!IsTerminal(job.State))
{
job.State = AssetGenJobState.Failed;
job.Error = "Interrupted by an editor reload; please retry.";
Persist(job);
}
Jobs[id] = job;
}
}
catch { /* recovery is best-effort */ }
}
public static AssetGenJob StartModelGeneration(ModelGenRequest req)
{
if (req == null) throw new ArgumentNullException(nameof(req));
string provider = string.IsNullOrEmpty(req.Provider) ? "tripo" : req.Provider;
IModelProviderAdapter adapter = AssetGenProviders.Model(provider); // throws NotSupportedException if unimplemented
var job = NewJob("model", provider, "generate");
job.Format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format;
job.TargetSize = req.TargetSize <= 0 ? 1f : req.TargetSize;
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 ?? ModelImportPipeline.ImportInto,
Transport = transport,
OutputFolder = req.OutputFolder,
Ext = (job.Format ?? "glb").TrimStart('.'),
Name = NameFrom(req.Name, req.Prompt, job.JobId),
Subfolder = "Models",
};
Register(job, runner);
return job;
}
public static AssetGenJob StartImageGeneration(ImageGenRequest req)
{
if (req == null) throw new ArgumentNullException(nameof(req));
string provider = string.IsNullOrEmpty(req.Provider) ? "fal" : req.Provider;
IImageProviderAdapter adapter = AssetGenProviders.Image(provider); // throws NotSupportedException if unimplemented
var job = NewJob("image", provider, "generate");
job.Format = "png";
if (!TryResolveKey(provider, job, out string apiKey)) return job;
IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport();
bool asSprite = req.AsSprite;
bool transparent = req.Transparent;
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 ?? ((j, path) => ImageImportPipeline.ImportInto(j, path, asSprite, transparent, isColor: true)),
Transport = transport,
OutputFolder = req.OutputFolder,
Ext = "png",
Name = NameFrom(req.Name, req.Prompt, job.JobId),
Subfolder = "Images",
};
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");
var adapter = AssetGenProviders.Marketplace("sketchfab"); // throws NotSupported if unimplemented
var job = NewJob("marketplace", "sketchfab", "import");
job.TargetSize = targetSize <= 0 ? 1f : targetSize;
if (!TryResolveKey("sketchfab", job, out string apiKey)) return job;
var transport = TransportOverrideForTests ?? new UnityWebRequestTransport();
var runner = new Runner
{
Job = job,
SubmitFn = ct => adapter.ResolveDownloadUrlAsync(uid, apiKey, transport, ct), // returns the zip/gltf URL as providerJobId
PollFn = (pid, ct) => Task.FromResult(new ProviderPollResult { State = ProviderPollState.Succeeded, Progress = 1f, DownloadUrl = pid, ResultExt = "zip" }),
ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto,
Transport = transport,
OutputFolder = outputFolder,
Ext = "zip",
Name = NameFrom(name, uid, job.JobId),
Subfolder = "Sketchfab",
};
Register(job, runner);
return job;
}
public static AssetGenJob GetJob(string jobId)
=> string.IsNullOrEmpty(jobId) ? null : (Jobs.TryGetValue(jobId, out var j) ? j : null);
/// <summary>Most-recent-first snapshot of known jobs (for the GUI readout). Never contains keys.</summary>
public static IReadOnlyList<AssetGenJob> RecentJobs(int max = 20)
{
var all = new List<AssetGenJob>(Jobs.Values);
int start = Math.Max(0, all.Count - max);
var slice = all.GetRange(start, all.Count - start);
slice.Reverse();
return slice;
}
public static bool Cancel(string jobId)
{
if (string.IsNullOrEmpty(jobId)) return false;
if (Runners.TryGetValue(jobId, out var r))
{
r.Canceled = true;
try { r.Cts.Cancel(); } catch { }
return true;
}
if (Jobs.TryGetValue(jobId, out var job) && job.State == AssetGenJobState.Queued)
{
job.State = AssetGenJobState.Canceled;
Persist(job);
return true;
}
return false;
}
// ---------- runner ----------
private enum RunnerPhase { Submit, AwaitSubmit, Poll, AwaitPoll, Download, AwaitDownload, Import }
private sealed class Runner
{
public AssetGenJob Job;
public Func<CancellationToken, Task<string>> SubmitFn;
public Func<string, CancellationToken, Task<ProviderPollResult>> PollFn;
public Func<AssetGenJob, string, AssetGenJob> ImportFn;
public IHttpTransport Transport;
public string OutputFolder;
public string Ext;
public string OverrideExt;
public string Name;
public string Subfolder;
public CancellationTokenSource Cts = new();
public RunnerPhase Phase = RunnerPhase.Submit;
public double StartedAt;
public double NextPollAt;
public string ProviderJobId;
public string DownloadUrl;
public string LocalPath;
public Task<string> SubmitTask;
public Task<ProviderPollResult> PollTask;
public Task<HttpResult> DownloadTask;
public bool Canceled;
}
private static bool TryResolveKey(string provider, AssetGenJob job, out string apiKey)
{
if (!SecureKeyStore.Current.TryGet(provider, out apiKey) || string.IsNullOrEmpty(apiKey))
{
job.State = AssetGenJobState.Failed;
job.Error = AssetGenProviders.MissingKeyMessage(provider);
Jobs[job.JobId] = job;
Persist(job);
return false;
}
return true;
}
private static void Register(AssetGenJob job, Runner runner)
{
runner.StartedAt = Now();
Jobs[job.JobId] = job;
Runners[job.JobId] = runner;
Persist(job);
EnsureTicking();
}
private static void EnsureTicking()
{
if (_ticking) return;
EditorApplication.update += Tick;
_ticking = true;
}
private static void Tick()
{
if (Runners.Count == 0)
{
EditorApplication.update -= Tick;
_ticking = false;
return;
}
// Snapshot keys into a reused buffer so Advance can mutate Runners mid-iteration
// without churning the GC on every editor-update frame.
_tickIds.Clear();
_tickIds.AddRange(Runners.Keys);
foreach (string id in _tickIds)
{
if (Runners.TryGetValue(id, out var r)) Advance(r);
}
}
/// <summary>Advance one job one step. Returns true when the job is terminal.</summary>
internal static bool TryAdvanceForTests(string jobId)
{
if (Runners.TryGetValue(jobId, out var r))
{
Advance(r);
return IsTerminal(r.Job.State);
}
return Jobs.TryGetValue(jobId, out var j) && IsTerminal(j.State);
}
private static void Advance(Runner r)
{
if (IsTerminal(r.Job.State)) { Finalize(r); return; }
if (r.Canceled) { r.Job.State = AssetGenJobState.Canceled; Persist(r.Job); Finalize(r); return; }
if (Now() - r.StartedAt > TimeoutSeconds) { Fail(r, $"Timed out after {TimeoutSeconds:0}s."); return; }
try
{
switch (r.Phase)
{
case RunnerPhase.Submit:
r.Job.State = AssetGenJobState.Running;
Persist(r.Job);
r.SubmitTask = r.SubmitFn(r.Cts.Token);
r.Phase = RunnerPhase.AwaitSubmit;
break;
case RunnerPhase.AwaitSubmit:
if (!r.SubmitTask.IsCompleted) break;
if (Faulted(r.SubmitTask, out string subErr)) { Fail(r, subErr); break; }
r.ProviderJobId = r.SubmitTask.Result;
if (string.IsNullOrEmpty(r.ProviderJobId)) { Fail(r, "Provider returned no job id."); break; }
r.NextPollAt = Now();
r.Phase = RunnerPhase.Poll;
break;
case RunnerPhase.Poll:
if (Now() < r.NextPollAt) break;
r.PollTask = r.PollFn(r.ProviderJobId, r.Cts.Token);
r.Phase = RunnerPhase.AwaitPoll;
break;
case RunnerPhase.AwaitPoll:
if (!r.PollTask.IsCompleted) break;
if (Faulted(r.PollTask, out string pollErr)) { Fail(r, pollErr); break; }
ProviderPollResult pr = r.PollTask.Result;
r.Job.Progress = Mathf.Clamp01(pr.Progress);
Persist(r.Job);
if (pr.State == ProviderPollState.Succeeded)
{
r.OverrideExt = pr.ResultExt;
if (pr.InlineData != null && pr.InlineData.Length > 0)
{
r.LocalPath = WriteFile(r, pr.InlineData);
r.Job.State = AssetGenJobState.Importing;
Persist(r.Job);
r.Phase = RunnerPhase.Import;
}
else if (!string.IsNullOrEmpty(pr.DownloadUrl))
{
r.DownloadUrl = pr.DownloadUrl;
r.Phase = RunnerPhase.Download;
}
else
{
Fail(r, "Provider succeeded but returned no result data.");
}
}
else if (pr.State == ProviderPollState.Failed)
{
Fail(r, string.IsNullOrEmpty(pr.Error) ? "Provider reported failure." : pr.Error);
}
else
{
r.NextPollAt = Now() + PollIntervalSeconds;
r.Phase = RunnerPhase.Poll;
}
break;
case RunnerPhase.Download:
// The download URL comes from an untrusted provider response. Only fetch
// http(s) — refuse file://, ftp://, etc. so a malicious response can't read
// a local file into the project or hit an internal host.
if (!IsAllowedDownloadUrl(r.DownloadUrl))
{
Fail(r, "Refusing to fetch a non-http(s) download URL returned by the provider.");
break;
}
r.DownloadTask = r.Transport.SendAsync(
new HttpRequestSpec { Method = "GET", Url = r.DownloadUrl }, r.Cts.Token);
r.Phase = RunnerPhase.AwaitDownload;
break;
case RunnerPhase.AwaitDownload:
if (!r.DownloadTask.IsCompleted) break;
if (Faulted(r.DownloadTask, out string dlErr)) { Fail(r, dlErr); break; }
HttpResult res = r.DownloadTask.Result;
if (res == null || !res.IsSuccess || res.Body == null || res.Body.Length == 0)
{
Fail(r, $"Download failed (HTTP {res?.Status}).");
break;
}
r.LocalPath = WriteFile(r, res.Body);
r.Job.State = AssetGenJobState.Importing;
Persist(r.Job);
r.Phase = RunnerPhase.Import;
break;
case RunnerPhase.Import:
// The result file was just written via File.WriteAllBytes (outside the
// AssetDatabase). Refresh so Unity registers it before we import it,
// mirroring ImportModelFile. Skipped under the test import seam.
if (ImportOverrideForTests == null) AssetDatabase.Refresh();
AssetGenJob imported = r.ImportFn(r.Job, r.LocalPath);
if (imported != null) r.Job = imported;
if (r.Job.State != AssetGenJobState.Failed)
{
r.Job.State = AssetGenJobState.Done;
r.Job.Progress = 1f;
}
Persist(r.Job);
Finalize(r);
break;
}
}
catch (Exception e)
{
Fail(r, SecretRedactor.Scrub(e.Message));
}
}
private static string WriteFile(Runner r, byte[] bytes)
{
string chosen = !string.IsNullOrEmpty(r.OverrideExt) ? r.OverrideExt : r.Ext;
string ext = string.IsNullOrEmpty(chosen) ? "bin" : chosen.TrimStart('.').ToLowerInvariant();
string requestedRoot = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder
: (AssetGenPrefs.OutputRoot + "/" + r.Subfolder);
if (!AssetGenPaths.TryGetAssetsFolder(requestedRoot, out string root))
root = AssetGenPrefs.DefaultOutputRoot + "/" + r.Subfolder;
string absRoot = AssetGenPaths.ToAbsolute(root);
Directory.CreateDirectory(absRoot);
string baseName = SanitizeName(r.Name);
string fileName = baseName + "." + ext;
string abs = Path.Combine(absRoot, fileName);
int n = 1;
while (File.Exists(abs)) { fileName = baseName + "_" + n++ + "." + ext; abs = Path.Combine(absRoot, fileName); }
File.WriteAllBytes(abs, bytes);
return (root.TrimEnd('/') + "/" + fileName).Replace('\\', '/');
}
private static string NameFrom(string explicitName, string prompt, string jobId)
{
if (!string.IsNullOrWhiteSpace(explicitName)) return explicitName;
if (!string.IsNullOrWhiteSpace(prompt)) return prompt;
return "asset_" + jobId.Substring(0, 8);
}
private static string SanitizeName(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "asset";
var sb = new System.Text.StringBuilder();
foreach (char c in raw.Trim())
{
if (char.IsLetterOrDigit(c) || c == '_' || c == '-') sb.Append(c);
else if (c == ' ') sb.Append('_');
if (sb.Length >= 48) break;
}
string s = sb.ToString().Trim('_', '-');
return string.IsNullOrEmpty(s) ? "asset" : s;
}
private static void Fail(Runner r, string message)
{
r.Job.State = AssetGenJobState.Failed;
r.Job.Error = SecretRedactor.Scrub(string.IsNullOrEmpty(message) ? "Generation failed." : message);
Persist(r.Job);
Finalize(r);
}
private static void Finalize(Runner r)
{
Runners.Remove(r.Job.JobId);
try { r.Cts?.Dispose(); } catch { }
}
private static AssetGenJob NewJob(string kind, string provider, string action)
{
return new AssetGenJob
{
JobId = Guid.NewGuid().ToString("N"),
Kind = kind,
Provider = provider,
Action = action,
State = AssetGenJobState.Queued,
Progress = 0f,
};
}
private static void Persist(AssetGenJob job)
{
try
{
SessionState.SetString(JobKeyPrefix + job.JobId, JsonConvert.SerializeObject(job));
string index = SessionState.GetString(JobIndexKey, string.Empty);
var ids = new HashSet<string>(index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
if (ids.Add(job.JobId))
SessionState.SetString(JobIndexKey, string.Join(",", ids));
}
catch { /* persistence is best-effort */ }
}
private static bool Faulted(Task t, out string error)
{
if (t.IsFaulted)
{
Exception ex = t.Exception?.GetBaseException();
error = SecretRedactor.Scrub(ex?.Message ?? "request failed");
return true;
}
if (t.IsCanceled) { error = "Canceled."; return true; }
error = null;
return false;
}
private static bool IsTerminal(AssetGenJobState s)
=> s == AssetGenJobState.Done || s == AssetGenJobState.Failed || s == AssetGenJobState.Canceled;
/// <summary>Only http(s) download URLs are allowed; provider responses are untrusted.</summary>
private static bool IsAllowedDownloadUrl(string url)
=> Uri.TryCreate(url, UriKind.Absolute, out Uri u)
&& (u.Scheme == Uri.UriSchemeHttps || u.Scheme == Uri.UriSchemeHttp);
private static double Now() => EditorApplication.timeSinceStartup;
internal static void ResetForTests()
{
foreach (var r in new List<Runner>(Runners.Values))
{
try { r.Cts?.Cancel(); r.Cts?.Dispose(); } catch { }
}
Runners.Clear();
string index = SessionState.GetString(JobIndexKey, string.Empty);
foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
SessionState.EraseString(JobKeyPrefix + id);
SessionState.EraseString(JobIndexKey);
Jobs.Clear();
TransportOverrideForTests = null;
ImportOverrideForTests = null;
PollIntervalSeconds = 3.0;
TimeoutSeconds = 600.0;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1955ee1b47384a8a880f00d384fff433
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 29756f9999cd4779b156e75b9370ca8a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// Test double for <see cref="IHttpTransport"/>. Records every request it is handed and
/// returns a canned response, so provider adapters can be exercised without a network.
/// Match responses either with the <see cref="Handler"/> delegate (full control) or with
/// <see cref="ByUrlSubstring"/> (first entry whose key is contained in the request URL).
/// </summary>
public sealed class FakeHttpTransport : IHttpTransport
{
public List<HttpRequestSpec> RecordedRequests { get; } = new List<HttpRequestSpec>();
/// <summary>Highest-priority responder; return null to fall through to <see cref="ByUrlSubstring"/>.</summary>
public Func<HttpRequestSpec, HttpResult> Handler { get; set; }
/// <summary>Canned responses keyed by a substring expected to appear in the request URL.</summary>
public Dictionary<string, HttpResult> ByUrlSubstring { get; } = new Dictionary<string, HttpResult>();
public Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct)
{
RecordedRequests.Add(spec);
HttpResult result = Handler?.Invoke(spec);
if (result == null && spec?.Url != null)
{
foreach (var kv in ByUrlSubstring)
{
if (spec.Url.IndexOf(kv.Key, StringComparison.Ordinal) >= 0)
{
result = kv.Value;
break;
}
}
}
if (result == null)
{
result = new HttpResult
{
Status = 500,
IsSuccess = false,
Text = "FakeHttpTransport: no canned response matched " + (spec?.Url ?? "<null>")
};
}
return Task.FromResult(result);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 46a96ea76bac45cab5025a37f56ba671
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// Transport-agnostic description of a single HTTP request. Provider adapters build one
/// of these and hand it to an <see cref="IHttpTransport"/>; this keeps adapters free of
/// any direct dependency on UnityWebRequest so they can be unit-tested without a network.
/// </summary>
public sealed class HttpRequestSpec
{
public string Method;
public string Url;
public Dictionary<string, string> Headers = new Dictionary<string, string>();
public byte[] Body;
public string ContentType;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 82093efd40c44dc3bd83e481484ef827
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,19 @@
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// Transport-agnostic result of an <see cref="HttpRequestSpec"/>. <see cref="Status"/> is
/// the numeric HTTP status code; <see cref="IsSuccess"/> reflects the transport's own view
/// of success (e.g. UnityWebRequest.Result.Success), which adapters combine with their own
/// body-level checks.
/// </summary>
public sealed class HttpResult
{
public int Status;
public byte[] Body;
public string Text;
public bool IsSuccess;
/// <summary>True when the transport reports success or the status code is 2xx.</summary>
public bool Ok => IsSuccess || (Status >= 200 && Status < 300);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 507ef9b8ac9d4f42be51a3bc9203eeff
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// The HTTP seam that provider adapters depend on. Production uses
/// <see cref="UnityWebRequestTransport"/>; tests inject <see cref="FakeHttpTransport"/> so
/// adapter request/response shaping can be verified without touching the network.
/// </summary>
public interface IHttpTransport
{
Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 746824fdb60a436b880f22fee8cdb177
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,80 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine.Networking;
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// Production <see cref="IHttpTransport"/> backed by UnityWebRequest. Must be invoked on the
/// Unity main thread (the asset-gen job manager guarantees this in Phase 3). The send is
/// awaited via a <see cref="TaskCompletionSource{T}"/> wired to the async op's completed
/// callback, so the call never blocks the editor loop.
/// </summary>
public sealed class UnityWebRequestTransport : IHttpTransport
{
public Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct)
{
if (spec == null) throw new ArgumentNullException(nameof(spec));
var tcs = new TaskCompletionSource<HttpResult>();
var request = new UnityWebRequest(spec.Url, spec.Method ?? UnityWebRequest.kHttpVerbGET)
{
downloadHandler = new DownloadHandlerBuffer()
};
if (spec.Body != null)
{
request.uploadHandler = new UploadHandlerRaw(spec.Body);
}
if (!string.IsNullOrEmpty(spec.ContentType))
{
request.SetRequestHeader("Content-Type", spec.ContentType);
}
if (spec.Headers != null)
{
foreach (var kv in spec.Headers)
{
request.SetRequestHeader(kv.Key, kv.Value);
}
}
CancellationTokenRegistration ctReg = default;
if (ct.CanBeCanceled)
{
ctReg = ct.Register(() =>
{
try { request.Abort(); } catch { /* ignore */ }
tcs.TrySetCanceled();
});
}
var op = request.SendWebRequest();
op.completed += _ =>
{
try
{
var result = new HttpResult
{
Status = (int)request.responseCode,
Body = request.downloadHandler?.data,
Text = request.downloadHandler?.text,
IsSuccess = request.result == UnityWebRequest.Result.Success
};
tcs.TrySetResult(result);
}
catch (Exception e)
{
tcs.TrySetException(e);
}
finally
{
ctReg.Dispose();
request.Dispose();
}
};
return tcs.Task;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 333575db1d8f487a9f2d32fa78dbb009
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8915ae71731f43f1a1c03cc6f901a39d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,63 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using UnityEditor;
namespace MCPForUnity.Editor.Services.AssetGen.Import
{
/// <summary>
/// Imports a generated 2D image (PNG, already under Assets/) and applies TextureImporter
/// settings: Sprite vs Default, alpha-is-transparency, and sRGB (color) vs linear (data maps).
/// </summary>
public static class ImageImportPipeline
{
public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath, bool asSprite, bool transparent, bool isColor)
{
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);
if (AssetImporter.GetAtPath(rel) is TextureImporter importer)
{
importer.textureType = asSprite ? TextureImporterType.Sprite : TextureImporterType.Default;
importer.alphaIsTransparency = transparent;
importer.sRGBTexture = isColor; // color maps sRGB; normal/roughness/metallic would be linear
if (asSprite)
{
importer.spriteImportMode = SpriteImportMode.Single;
importer.mipmapEnabled = false;
}
importer.SaveAndReimport();
}
job.AssetPath = rel;
job.AssetGuid = AssetDatabase.AssetPathToGUID(rel);
if (string.IsNullOrEmpty(job.AssetGuid))
return Fail(job, "Imported the image 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 AssetGenJob Fail(AssetGenJob job, string message)
{
job.State = AssetGenJobState.Failed;
job.Error = message;
return job;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5088530e3bc348e1b6539464588bc8bf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Security;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Import
{
/// <summary>
/// Imports a downloaded model file (already under Assets/) into the project and applies
/// import settings. GLB/glTF require the optional glTFast package (installed from the
/// Dependencies tab); FBX/OBJ use Unity's built-in ModelImporter. Optionally normalizes
/// the model's scale to a target size.
/// </summary>
public static class ModelImportPipeline
{
// Inert asset types permitted out of an UNTRUSTED provider archive (Sketchfab et al.).
// Anything else — scripts, assemblies, asmdefs — is skipped on extraction so it can never
// compile or load inside the Editor. See SafeZipExtractor for the enforcement.
private static readonly HashSet<string> ArchiveAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".gltf", ".glb", ".bin", ".fbx", ".obj", ".mtl",
".png", ".jpg", ".jpeg", ".tga", ".bmp", ".tif", ".tiff", ".webp", ".exr", ".hdr",
".ktx", ".ktx2", ".basis", ".dds",
};
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.");
string ext = Path.GetExtension(rel).ToLowerInvariant();
if (ext == ".zip")
return ImportArchive(job, rel);
return ImportModelFile(job, rel, ext);
}
catch (Exception e)
{
return Fail(job, SecretRedactor.Scrub(e.Message));
}
}
private static AssetGenJob ImportModelFile(AssetGenJob job, string rel, string ext)
{
bool isGltf = ext == ".glb" || ext == ".gltf";
if (isGltf && !IsGltfastAvailable())
{
return Fail(job,
"GLB import requires glTFast. Install it from the MCP for Unity → Dependencies tab, or choose FBX output.");
}
AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate);
if (!isGltf)
ApplyModelImporterSettings(rel, job);
job.AssetPath = rel;
job.AssetGuid = AssetDatabase.AssetPathToGUID(rel);
if (string.IsNullOrEmpty(job.AssetGuid))
return Fail(job, "Imported the file but Unity did not register it as an asset.");
if (job.State != AssetGenJobState.Failed)
job.State = AssetGenJobState.Done;
return job;
}
/// <summary>
/// Unpack a downloaded archive (Sketchfab ships .zip) into a sibling folder named
/// after the archive, import it, then locate the first model file inside and import that.
/// FBX/OBJ are preferred over glTF; a glTF-only archive still requires glTFast.
/// </summary>
private static AssetGenJob ImportArchive(AssetGenJob job, string zipRel)
{
string zipAbs = AssetGenPaths.ToAbsolute(zipRel);
if (!File.Exists(zipAbs))
return Fail(job, "Downloaded archive was not found on disk.");
string folderRel = zipRel.Substring(0, zipRel.Length - ".zip".Length);
string folderAbs = AssetGenPaths.ToAbsolute(folderRel);
Directory.CreateDirectory(folderAbs);
// Provider archives are untrusted: only inert model/texture files are written under
// Assets/ — scripts/assemblies are skipped so they can't be compiled on import.
SafeZipExtractor.ExtractTo(zipAbs, folderAbs, ArchiveAllowedExtensions);
AssetDatabase.Refresh();
AssetDatabase.ImportAsset(folderRel, ImportAssetOptions.ImportRecursive | ImportAssetOptions.ForceUpdate);
string modelRel = FindFirstModel(folderAbs);
if (string.IsNullOrEmpty(modelRel))
return Fail(job, "Archive extracted but no model file (.fbx/.obj/.glb/.gltf) was found inside.");
string ext = Path.GetExtension(modelRel).ToLowerInvariant();
bool isGltf = ext == ".glb" || ext == ".gltf";
if (isGltf && !IsGltfastAvailable())
{
return Fail(job,
"This model is glTF (.glb/.gltf), which requires glTFast. Install it from the MCP for Unity → Dependencies tab.");
}
AssetDatabase.ImportAsset(modelRel, ImportAssetOptions.ForceUpdate);
if (!isGltf)
ApplyModelImporterSettings(modelRel, job);
job.AssetPath = modelRel;
job.AssetGuid = AssetDatabase.AssetPathToGUID(modelRel);
if (string.IsNullOrEmpty(job.AssetGuid))
return Fail(job, "Imported the extracted model but Unity did not register it as an asset.");
if (job.State != AssetGenJobState.Failed)
job.State = AssetGenJobState.Done;
return job;
}
/// <summary>
/// Walk the extracted directory for a model file, preferring FBX/OBJ (built-in importer)
/// over glTF (needs glTFast). Returns a project-relative path or null when none found.
/// </summary>
private static string FindFirstModel(string folderAbs)
{
string[] all;
try { all = Directory.GetFiles(folderAbs, "*", SearchOption.AllDirectories); }
catch { return null; }
string firstGltf = null;
foreach (string abs in all)
{
string e = Path.GetExtension(abs).ToLowerInvariant();
if (e == ".fbx" || e == ".obj")
return AssetGenPaths.ToProjectRelative(abs);
if (firstGltf == null && (e == ".glb" || e == ".gltf"))
firstGltf = abs;
}
return firstGltf == null ? null : AssetGenPaths.ToProjectRelative(firstGltf);
}
private static void ApplyModelImporterSettings(string rel, AssetGenJob job)
{
if (!(AssetImporter.GetAtPath(rel) is ModelImporter importer)) return;
importer.useFileScale = true;
importer.materialImportMode = ModelImporterMaterialImportMode.ImportStandard;
importer.animationType = ParseAnimationType(job?.AnimationType);
if (AssetGenPrefs.AutoNormalize && job.TargetSize > 0f)
{
float maxDim = ComputeMaxDimension(rel);
if (maxDim > 0.0001f)
{
float scale = job.TargetSize / maxDim;
if (scale > 0f && Math.Abs(scale - 1f) > 0.01f)
{
importer.useFileScale = false;
importer.globalScale = Mathf.Clamp(importer.globalScale * scale, 0.0001f, 1_000_000f);
}
}
}
importer.SaveAndReimport();
}
/// <summary>
/// Map the caller's rig mode to a <see cref="ModelImporterAnimationType"/>. Defaults to
/// <c>None</c> (no rig) when unset — pass "generic"/"humanoid" to surface a rigged FBX/OBJ's
/// AnimationClips, which the None default otherwise hides. "legacy" selects Unity's
/// legacy Animation system — rarely needed.
/// </summary>
internal static ModelImporterAnimationType ParseAnimationType(string value)
{
switch ((value ?? string.Empty).Trim().ToLowerInvariant())
{
case "generic": return ModelImporterAnimationType.Generic;
case "humanoid":
case "human": return ModelImporterAnimationType.Human;
case "legacy": return ModelImporterAnimationType.Legacy;
default: return ModelImporterAnimationType.None;
}
}
private static float ComputeMaxDimension(string rel)
{
try
{
var go = AssetDatabase.LoadAssetAtPath<GameObject>(rel);
if (go == null) return 0f;
bool any = false;
Bounds acc = new Bounds(Vector3.zero, Vector3.zero);
foreach (var mf in go.GetComponentsInChildren<MeshFilter>(true))
{
if (mf.sharedMesh == null) continue;
if (!any) { acc = mf.sharedMesh.bounds; any = true; }
else acc.Encapsulate(mf.sharedMesh.bounds);
}
foreach (var smr in go.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (smr.sharedMesh == null) continue;
if (!any) { acc = smr.sharedMesh.bounds; any = true; }
else acc.Encapsulate(smr.sharedMesh.bounds);
}
if (!any) return 0f;
Vector3 s = acc.size;
return Mathf.Max(s.x, Mathf.Max(s.y, s.z));
}
catch { return 0f; }
}
private static bool? _gltfastAvailable;
/// <summary>
/// True when the glTFast package is present. Cached after the first probe — the result only
/// changes on a package install/uninstall, which triggers a domain reload that resets this
/// static. Shared with the Asset Gen settings tab so the reflection scan runs at most once.
/// </summary>
internal static bool IsGltfastAvailable()
{
if (_gltfastAvailable.HasValue) return _gltfastAvailable.Value;
bool found = Type.GetType("GLTFast.GltfImport, glTFast") != null;
if (!found)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try { if (asm.GetType("GLTFast.GltfImport") != null) { found = true; break; } }
catch { /* dynamic/!resolvable assembly */ }
}
}
_gltfastAvailable = found;
return found;
}
private static AssetGenJob Fail(AssetGenJob job, string message)
{
job.State = AssetGenJobState.Failed;
job.Error = message;
return job;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d63a324540d4a4a904e8b345ccf81bb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
namespace MCPForUnity.Editor.Services.AssetGen.Import
{
/// <summary>
/// Extracts a .zip into a destination directory while rejecting Zip-Slip path traversal:
/// every entry's resolved target must stay inside <c>destDir</c>. Directory entries are
/// created; file entries are written by copying the entry stream (no reliance on the
/// ZipFileExtensions helper). Used to unpack marketplace model archives (e.g. Sketchfab).
///
/// When <paramref name="allowedExtensions"/> is supplied, file entries whose extension is not
/// on the allowlist are SKIPPED (not written). Callers that extract UNTRUSTED archives into the
/// Assets tree MUST pass an allowlist of inert asset types so executable content (.cs/.dll/
/// .asmdef) can never land under Assets/ and be compiled/loaded by the Editor.
/// </summary>
public static class SafeZipExtractor
{
public static void ExtractTo(string zipPath, string destDir, ISet<string> allowedExtensions = null)
{
if (string.IsNullOrEmpty(zipPath)) throw new ArgumentException("zipPath required", nameof(zipPath));
if (string.IsNullOrEmpty(destDir)) throw new ArgumentException("destDir required", nameof(destDir));
Directory.CreateDirectory(destDir);
string destFull = Path.GetFullPath(destDir);
string prefix = destFull.EndsWith(Path.DirectorySeparatorChar.ToString())
? destFull
: destFull + Path.DirectorySeparatorChar;
using (FileStream fs = File.OpenRead(zipPath))
using (var archive = new ZipArchive(fs, ZipArchiveMode.Read))
{
foreach (ZipArchiveEntry entry in archive.Entries)
{
string name = entry.FullName;
if (string.IsNullOrEmpty(name)) continue;
// Reject traversal / absolute paths up front.
if (name.Contains("..") || Path.IsPathRooted(name))
throw new IOException($"Unsafe zip entry rejected: {name}");
string target = Path.GetFullPath(Path.Combine(destDir, name));
if (!target.StartsWith(prefix, StringComparison.Ordinal))
throw new IOException($"Unsafe zip entry escapes destination: {name}");
// A directory entry has an empty Name (FullName ends with a separator).
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(target);
continue;
}
// Allowlist gate: skip anything that isn't an inert asset type the caller permits.
if (allowedExtensions != null && allowedExtensions.Count > 0
&& !allowedExtensions.Contains(Path.GetExtension(entry.Name).ToLowerInvariant()))
{
continue;
}
string parent = Path.GetDirectoryName(target);
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent);
using (Stream src = entry.Open())
using (FileStream dst = File.Create(target))
{
src.CopyTo(dst);
}
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2dcd4c25a7b44e96b22ea605ffd73409
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3b451683dd204e6e9d28fd7dbe4676a6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using MCPForUnity.Editor.Security;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// 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
/// <see cref="NotSupportedException"/>. <see cref="List"/> advertises providers and reports
/// <c>Configured</c> existence only — never a key value.
/// </summary>
public static class AssetGenProviders
{
public static IModelProviderAdapter Model(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
{
case "tripo":
return new TripoAdapter();
case "meshy":
return new MeshyAdapter();
default:
throw new NotSupportedException($"Unknown model provider '{id}'.");
}
}
public static IImageProviderAdapter Image(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
{
case "fal":
return new FalAdapter();
case "openrouter":
return new OpenRouterAdapter();
default:
throw new NotSupportedException($"Unknown image provider '{id}'.");
}
}
public static IMarketplaceProviderAdapter Marketplace(string id)
{
switch ((id ?? string.Empty).ToLowerInvariant())
{
case "sketchfab":
return new SketchfabAdapter();
default:
throw new NotSupportedException($"Unknown marketplace provider '{id}'.");
}
}
public static IReadOnlyList<ProviderInfo> List()
{
return new List<ProviderInfo>
{
new ProviderInfo { Id = "tripo", Kind = "model", Configured = IsConfigured("tripo"), Capabilities = new[] { "text", "image" } },
new ProviderInfo { Id = "meshy", Kind = "model", Configured = IsConfigured("meshy"), Capabilities = new[] { "text", "image" } },
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" } },
};
}
private static bool IsConfigured(string id)
{
try { return SecureKeyStore.Current.Has(id); }
catch { return false; }
}
/// <summary>
/// Standard "no key" message: points the user at the Asset Generation tab and the env override.
/// Shared by the asset-gen tools and the job manager so the wording stays in one place.
/// </summary>
public static string MissingKeyMessage(string provider)
=> $"No API key configured for '{provider}'. Add it in the MCP for Unity → Asset Generation tab " +
$"(or set MCPFORUNITY_{(provider ?? string.Empty).ToUpperInvariant()}_API_KEY).";
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ab11255ce314c7193df3bca43286f83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,162 @@
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>
/// fal.ai image provider via the queue API. Submits to queue.fal.run/{model} (auth header
/// "Authorization: Key &lt;key&gt;"), polls the request's status_url, then fetches the result and
/// returns the first image URL for the job manager to download.
/// </summary>
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";
public string Id => "fal";
public async Task<string> SubmitAsync(ImageGenRequest 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;
bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty, ["num_images"] = 1 };
string url;
if (image)
{
// image→image / editing lives on the model's /edit endpoint and takes an image_urls
// array; each entry accepts a hosted URL or an inline base64 data URI (local image_path).
url = QueueBase + model + "/edit";
string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath);
body["image_urls"] = new JArray(imageRef);
}
else
{
url = QueueBase + model;
}
// Forward explicit output dimensions for text→image only; fal's image_size accepts a
// {width,height} object. (/edit derives size from the source image and may reject it.
// FLUX has no transparency param — transparent backgrounds aren't a generation-time option.)
if (!image && req.Width > 0 && req.Height > 0)
body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height };
var spec = new HttpRequestSpec
{
Method = "POST",
Url = url,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Key " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "submit");
// Prefer response_url; fall back to building it from request_id.
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));
// Queue request URLs are namespaced by owner/app without the action sub-path,
// so build from the base model id (not `url`, which may end in /edit).
responseUrl = QueueBase + model + "/requests/" + requestId;
}
return responseUrl;
}
public async Task<ProviderPollResult> 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;
}
// Completed: fetch the result payload and extract the first image URL.
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");
result.Progress = 1f;
result.DownloadUrl = ExtractImageUrl(resultJson);
if (string.IsNullOrEmpty(result.DownloadUrl))
{
result.State = ProviderPollState.Failed;
result.Error = "fal completed but no image URL was present in the result.";
}
return result;
}
private static string ExtractImageUrl(JObject result)
{
JToken images = result["images"];
if (images is JArray arr && arr.Count > 0)
{
string u = arr[0]?["url"]?.ToString();
if (!string.IsNullOrEmpty(u)) return u;
}
string single = result["image"]?["url"]?.ToString();
return string.IsNullOrEmpty(single) ? null : single;
}
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();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a36ad2c4285c4799ae9eb4495813a44c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,35 @@
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Services.AssetGen.Http;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// A generative 3D model provider (Tripo, Meshy, ...). Submit mints a provider-side
/// job id; poll reports progress and, on success, the download URL. The api key is passed in
/// at call time and never cached on the adapter.
/// </summary>
public interface IModelProviderAdapter
{
string Id { get; }
Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct);
Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct);
}
/// <summary>A generative 2D image provider (fal, OpenRouter, ...). Phase 7.</summary>
public interface IImageProviderAdapter
{
string Id { get; }
Task<string> SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct);
Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct);
}
/// <summary>A 3D marketplace provider (Sketchfab, ...). Search/preview/resolve, not generative. Phase 6.</summary>
public interface IMarketplaceProviderAdapter
{
string Id { get; }
Task<string> SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct);
Task<string> PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
Task<string> ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f76130e504cd4423abbe82bbb718d25b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.IO;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Helpers for feeding a LOCAL on-disk image to a provider: resolve+verify the path, and encode
/// it as a base64 <c>data:</c> URI — the inline form fal, Meshy, and OpenRouter accept for image
/// input (no hosting/upload needed). Tripo does NOT accept data URIs and is handled separately.
/// </summary>
internal static class LocalImage
{
// Extensions that can be inlined as a data URI for provider image input.
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".png", ".jpg", ".jpeg", ".webp", ".gif" };
/// <summary>
/// Resolve an image path under the project's Assets folder to an existing absolute file of
/// a supported image type. Returns false with a user-facing <paramref name="error"/> for
/// an unsafe path, a missing file, or an unsupported extension, so the handler can fail
/// fast before any provider request is made.
/// </summary>
public static bool ResolveExisting(string path, out string absPath, out string error)
{
absPath = null;
error = null;
if (string.IsNullOrWhiteSpace(path)) { error = "image_path is empty."; return false; }
if (!AssetGenPaths.TryGetAssetsRelativePath(path, out string rel))
{
error = "image_path must point to a file under the project's Assets folder.";
return false;
}
string abs = AssetGenPaths.ToAbsolute(rel);
if (!File.Exists(abs)) { error = $"Source image not found: {path}"; return false; }
if (!SupportedExtensions.Contains(Path.GetExtension(abs)))
{
error = $"Unsupported image type '{Path.GetExtension(abs)}'. Use .png, .jpg, .jpeg, .webp, or .gif.";
return false;
}
absPath = abs;
return true;
}
/// <summary>
/// Read a local image and return a "data:image/&lt;mime&gt;;base64,..." URI. Throws
/// <see cref="NotSupportedException"/> for an unsupported extension.
/// </summary>
public static string ToDataUri(string absPath)
{
if (!AssetGenPaths.TryGetAssetsRelativePath(absPath, out string rel))
throw new UnauthorizedAccessException("image_path must point to a file under the project's Assets folder.");
absPath = AssetGenPaths.ToAbsolute(rel);
string mime = MimeFromExtension(Path.GetExtension(absPath));
byte[] bytes = File.ReadAllBytes(absPath);
return "data:" + mime + ";base64," + Convert.ToBase64String(bytes);
}
private static string MimeFromExtension(string ext)
{
switch ((ext ?? string.Empty).ToLowerInvariant())
{
case ".png": return "image/png";
case ".jpg":
case ".jpeg": return "image/jpeg";
case ".webp": return "image/webp";
case ".gif": return "image/gif";
default:
throw new NotSupportedException(
$"Unsupported image type '{ext}' for image input. Use .png, .jpg, .jpeg, .webp, or .gif.");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1f8290c406df4661b9ad2ff6cd324871
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,208 @@
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;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Meshy model provider. Text→3D posts a "preview" task to the v2 text-to-3d endpoint (geometry
/// only); when textures are requested it then issues a "refine" task and surfaces the textured
/// result. Image→3D posts to the v1 image-to-3d endpoint, which textures in a single call. Each
/// task is polled at its OWN endpoint (text vs image). The bearer key is supplied per call and
/// never logged; every error is run through <see cref="SecretRedactor"/>.
/// </summary>
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";
public string Id => "meshy";
// Stashed at submit so poll picks the right endpoint, model_urls entry, and texture flow.
private string _format = "glb";
private bool _isImage;
private bool _wantTexture = true;
private string _refineTaskId;
private bool _refineSubmitted;
public async Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));
_format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant();
_wantTexture = req.Texture;
_isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
JObject body;
string url;
if (_isImage)
{
// image→3D textures in a single call (no separate refine task). image_url accepts a
// hosted URL or an inline base64 data URI (for a local image_path).
url = ImageEndpoint;
string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath);
body = new JObject
{
["image_url"] = imageRef,
["ai_model"] = "meshy-6",
["should_texture"] = _wantTexture
};
}
else
{
// text→3D preview is geometry only; texturing happens via a follow-up refine task.
url = TextEndpoint;
body = new JObject
{
["mode"] = "preview",
["prompt"] = req.Prompt ?? string.Empty,
["ai_model"] = "meshy-6"
};
}
return await PostTask(url, body, apiKey, http, ct, "submit");
}
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
if (http == null) throw new ArgumentNullException(nameof(http));
bool refinePhase = _refineSubmitted;
string pollId = refinePhase ? _refineTaskId : providerJobId;
// image tasks live on the v1 image endpoint; preview/refine tasks on v2 text-to-3d.
string statusBase = (_isImage && !refinePhase) ? ImageEndpoint : TextEndpoint;
var spec = new HttpRequestSpec { Method = "GET", Url = statusBase + "/" + pollId };
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "poll");
ProviderPollState state = MapState(json["status"]?.ToString());
var result = new ProviderPollResult { State = state };
// Two-phase (text + texture) splits progress across preview (0..0.5) and refine (0.5..1).
bool twoPhase = !_isImage && _wantTexture;
float raw = 0f;
JToken prog = json["progress"];
if (prog != null && prog.Type != JTokenType.Null) raw = Mathf.Clamp01(prog.Value<float>() / 100f);
result.Progress = !twoPhase ? raw : (refinePhase ? 0.5f + raw * 0.5f : raw * 0.5f);
if (state == ProviderPollState.Succeeded)
{
// Preview just finished and textures were requested: start the refine task and keep
// polling it; never surface the untextured preview result.
if (twoPhase && !refinePhase)
{
var refineBody = new JObject
{
["mode"] = "refine",
["preview_task_id"] = providerJobId,
["ai_model"] = "meshy-6"
};
_refineTaskId = await PostTask(TextEndpoint, refineBody, apiKey, http, ct, "refine");
_refineSubmitted = true;
result.State = ProviderPollState.Running;
result.Progress = 0.5f;
return result;
}
result.Progress = 1f;
result.DownloadUrl = ExtractModelUrl(json["model_urls"] as JObject);
if (string.IsNullOrEmpty(result.DownloadUrl))
{
result.State = ProviderPollState.Failed;
result.Error = "Meshy reported success but no model URL was present in the response.";
}
}
else if (state == ProviderPollState.Failed)
{
string err = json["task_error"]?["message"]?.ToString()
?? json["message"]?.ToString()
?? "Meshy task failed.";
result.Error = SecretRedactor.Scrub(err, apiKey);
}
return result;
}
/// <summary>POST a task body and return its <c>result</c> task id (or null).</summary>
private static async Task<string> PostTask(string url, JObject body, string apiKey, IHttpTransport http, CancellationToken ct, string phase)
{
var spec = new HttpRequestSpec
{
Method = "POST",
Url = url,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, phase);
string id = json["result"]?.ToString();
if (string.IsNullOrEmpty(id))
throw new Exception(SecretRedactor.Scrub(
$"Meshy {phase} returned no task id: " + ProviderHttp.Truncate(ProviderHttp.BodyText(res)), apiKey));
return id;
}
private string ExtractModelUrl(JObject urls)
{
if (urls == null) return null;
string byFormat = urls[_format]?.ToString();
if (!string.IsNullOrEmpty(byFormat)) return byFormat;
string glb = urls["glb"]?.ToString();
if (!string.IsNullOrEmpty(glb)) return glb;
string fbx = urls["fbx"]?.ToString();
return string.IsNullOrEmpty(fbx) ? null : fbx;
}
private static ProviderPollState MapState(string status)
{
switch ((status ?? string.Empty).ToUpperInvariant())
{
case "SUCCEEDED":
return ProviderPollState.Succeeded;
case "FAILED":
case "EXPIRED":
case "CANCELED":
case "CANCELLED":
return ProviderPollState.Failed;
case "IN_PROGRESS":
return ProviderPollState.Running;
case "PENDING":
default:
return ProviderPollState.Queued;
}
}
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?["message"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"Meshy {phase} failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bb6ba98874ed46cca5f019c89b2edb0a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,155 @@
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>
/// OpenRouter image provider via the (synchronous) chat-completions endpoint with an
/// image-capable multimodal model. The image is returned inline (base64 data URL), so the
/// work happens in <see cref="SubmitAsync"/> and <see cref="PollAsync"/> returns it immediately.
/// One adapter instance handles a single job (the job manager captures it for submit+poll).
/// </summary>
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";
public string Id => "openrouter";
private byte[] _inlineData;
private string _downloadUrl;
private string _error;
public async Task<string> SubmitAsync(ImageGenRequest 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;
// image->image: attach the reference image as an image_url content part alongside the
// text prompt (OpenRouter content-array form). image_url.url takes an http(s) URL or a
// base64 data URI. Plain text->image uses a string content.
bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
// image_url.url accepts a hosted URL or an inline base64 data URI (for a local image_path).
string imageRef = image
? (!string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath))
: null;
JToken content = image
? new JArray(
new JObject { ["type"] = "text", ["text"] = req.Prompt ?? string.Empty },
new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = imageRef } })
: (JToken)(req.Prompt ?? string.Empty);
var body = new JObject
{
["model"] = model,
["modalities"] = new JArray("image", "text"),
["messages"] = new JArray(new JObject
{
["role"] = "user",
["content"] = content
})
};
var spec = new HttpRequestSpec
{
Method = "POST",
Url = Endpoint,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey);
string url = ExtractImageUrl(json);
if (string.IsNullOrEmpty(url))
{
_error = "OpenRouter returned no image. The selected model may not support image output.";
return "ready";
}
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
int comma = url.IndexOf("base64,", StringComparison.OrdinalIgnoreCase);
if (comma < 0) { _error = "OpenRouter returned an unrecognized image payload."; return "ready"; }
try { _inlineData = Convert.FromBase64String(url.Substring(comma + "base64,".Length)); }
catch { _error = "OpenRouter image was not valid base64."; }
}
else
{
_downloadUrl = url;
}
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 ?? "OpenRouter produced no image.";
}
else
{
result.State = ProviderPollState.Succeeded;
result.InlineData = _inlineData;
result.DownloadUrl = _downloadUrl;
}
return Task.FromResult(result);
}
private static string ExtractImageUrl(JObject json)
{
JToken message = json["choices"]?[0]?["message"];
if (message == null) return null;
// Preferred: message.images[].image_url.url
if (message["images"] is JArray imgs && imgs.Count > 0)
{
string u = imgs[0]?["image_url"]?["url"]?.ToString() ?? imgs[0]?["url"]?.ToString();
if (!string.IsNullOrEmpty(u)) return u;
}
// Fallback: a content array with image_url parts
if (message["content"] is JArray parts)
{
foreach (JToken part in parts)
{
string u = part?["image_url"]?["url"]?.ToString();
if (!string.IsNullOrEmpty(u)) return u;
}
}
return null;
}
private static JObject ParseOk(HttpResult res, string apiKey)
{
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?["error"]?["message"]?.ToString() ?? json?["error"]?.ToString()
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub($"OpenRouter request failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 55b1b120b06948f6aeada0381b51ce86
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
using System.Text;
using MCPForUnity.Editor.Services.AssetGen.Http;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Shared HTTP-response helpers for provider adapters: read the response text (falling back to
/// a UTF-8 decode of the raw body) and truncate long bodies for inclusion in error messages.
/// </summary>
internal static class ProviderHttp
{
/// <summary>Response text, falling back to a UTF-8 decode of the raw body when Text is empty.</summary>
public static string BodyText(HttpResult res)
{
string text = res?.Text;
if (string.IsNullOrEmpty(text) && res?.Body != null)
text = Encoding.UTF8.GetString(res.Body);
return text;
}
/// <summary>Cap a (possibly null) string at 500 chars for inclusion in an error message.</summary>
public static string Truncate(string s)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= 500 ? s : s.Substring(0, 500) + "…";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1584e5cffb5b45e6816fec509f1bfc8b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,75 @@
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>Normalized lifecycle state reported by a provider poll, across all providers.</summary>
public enum ProviderPollState
{
Queued,
Running,
Succeeded,
Failed
}
/// <summary>
/// Outcome of a single provider poll. <see cref="Progress"/> is normalized to 0..1.
/// On <see cref="ProviderPollState.Succeeded"/>, <see cref="DownloadUrl"/> points at the
/// result the C# side will download into the project. On
/// <see cref="ProviderPollState.Failed"/>, <see cref="Error"/> carries a redacted message.
/// </summary>
public sealed class ProviderPollResult
{
public ProviderPollState State;
public float Progress;
public string DownloadUrl;
/// <summary>Inline result bytes for synchronous providers that return base64 (e.g. OpenRouter),
/// so the job manager skips the download step. Takes precedence over <see cref="DownloadUrl"/>.</summary>
public byte[] InlineData;
/// <summary>Overrides the downloaded file extension, e.g. "zip" for archive results.</summary>
public string ResultExt;
public string Error;
}
/// <summary>Request to generate a 3D model. Shared by every model provider adapter.</summary>
public sealed class ModelGenRequest
{
public string Provider;
public string Mode; // text | image
public string Prompt;
public string ImagePath;
public string ImageUrl;
public string Format = "glb";
public float TargetSize = 1f;
public bool Texture = true;
public string Tier;
public string Name;
public string OutputFolder;
}
/// <summary>Request to generate a 2D image. Shared by every image provider adapter.</summary>
public sealed class ImageGenRequest
{
public string Provider;
public string Mode; // text | image
public string Prompt;
public string ImagePath;
public string ImageUrl;
public string Model;
public bool Transparent;
public bool AsSprite = true; // import as Sprite (2D/UI) vs Default texture
public int Width;
public int Height;
public string Name;
public string OutputFolder;
}
/// <summary>
/// Public, key-free description of a provider for <c>list_providers</c>. Never carries a key
/// value — <see cref="Configured"/> reports existence only.
/// </summary>
public sealed class ProviderInfo
{
public string Id;
public string Kind; // model | image | marketplace
public bool Configured;
public string[] Capabilities;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 65be6fd6b6384effb45822a57e541f04
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,96 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Http;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Sketchfab marketplace provider. Search/preview are read-only GETs returning the raw API
/// JSON to the caller; <see cref="ResolveDownloadUrlAsync"/> hits the model download endpoint
/// and returns the signed glTF archive (.zip) URL the job manager will fetch. Auth uses the
/// "Token &lt;key&gt;" scheme; the key is supplied per call and never logged.
/// </summary>
public sealed class SketchfabAdapter : IMarketplaceProviderAdapter
{
private const string SearchEndpoint = "https://api.sketchfab.com/v3/search";
private const string ModelsEndpoint = "https://api.sketchfab.com/v3/models";
public string Id => "sketchfab";
public async Task<string> SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (http == null) throw new ArgumentNullException(nameof(http));
string url = SearchEndpoint + "?type=models&downloadable=" + (downloadable ? "true" : "false")
+ "&q=" + Uri.EscapeDataString(query ?? string.Empty);
if (!string.IsNullOrEmpty(categories)) url += "&categories=" + Uri.EscapeDataString(categories);
if (count.HasValue) url += "&count=" + count.Value;
if (!string.IsNullOrEmpty(cursor)) url += "&cursor=" + Uri.EscapeDataString(cursor);
var spec = new HttpRequestSpec { Method = "GET", Url = url };
spec.Headers["Authorization"] = "Token " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
// The raw response carries pagination (`cursors.next` / `next`) for the caller to page.
return RawOk(res, apiKey, "search");
}
public async Task<string> PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid };
spec.Headers["Authorization"] = "Token " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
return RawOk(res, apiKey, "preview");
}
public async Task<string> ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid + "/download" };
spec.Headers["Authorization"] = "Token " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseOk(res, apiKey, "download");
string url = json["gltf"]?["url"]?.ToString();
if (string.IsNullOrEmpty(url))
{
throw new Exception(SecretRedactor.Scrub(
$"Sketchfab download returned no gltf url for '{uid}': {ProviderHttp.Truncate(res?.Text)}", apiKey));
}
return url;
}
private static string RawOk(HttpResult res, string apiKey, string phase)
{
string text = ProviderHttp.BodyText(res);
bool ok = res?.Ok == true;
if (!ok)
throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {ProviderHttp.Truncate(text)}", apiKey));
return text ?? string.Empty;
}
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($"Sketchfab {phase} failed (status={res?.Status}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 860350c25d8d490aaaa925d76ce29407
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,222 @@
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;
using UnityEngine;
namespace MCPForUnity.Editor.Services.AssetGen.Providers
{
/// <summary>
/// Tripo3D model provider. Submits text→3D / image→3D tasks to the OpenAPI task endpoint and
/// polls for completion. The bearer key is supplied per call and never logged; every error
/// message is run through <see cref="SecretRedactor"/> before it is surfaced.
/// </summary>
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";
public string Id => "tripo";
public async Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (req == null) throw new ArgumentNullException(nameof(req));
if (http == null) throw new ArgumentNullException(nameof(http));
// Tripo rejects base64 data URIs and needs a multipart upload→token flow for local files,
// which isn't wired yet — fail clearly rather than silently falling back to text mode.
bool imageMode = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase);
if (imageMode && string.IsNullOrEmpty(req.ImageUrl))
throw new Exception("Tripo image input requires a hosted 'image_url'; local 'image_path' upload is not yet supported for Tripo (use Meshy for local-image→3D, or host the image).");
JObject body;
bool image = imageMode && !string.IsNullOrEmpty(req.ImageUrl);
if (image)
{
body = new JObject
{
["type"] = "image_to_model",
["file"] = new JObject
{
["type"] = "url",
["url"] = req.ImageUrl
}
};
}
else
{
body = new JObject
{
["type"] = "text_to_model",
["prompt"] = req.Prompt ?? string.Empty,
["model_version"] = ModelVersion
};
}
var spec = new HttpRequestSpec
{
Method = "POST",
Url = TaskEndpoint,
ContentType = "application/json",
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseAndValidate(res, apiKey, "submit");
string taskId = json["data"]?["task_id"]?.ToString();
if (string.IsNullOrEmpty(taskId))
{
throw new Exception(SecretRedactor.Scrub(
"Tripo submit returned no task_id: " + ProviderHttp.Truncate(res?.Text), apiKey));
}
return taskId;
}
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
{
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
if (http == null) throw new ArgumentNullException(nameof(http));
var spec = new HttpRequestSpec
{
Method = "GET",
Url = TaskEndpoint + "/" + providerJobId
};
spec.Headers["Authorization"] = "Bearer " + apiKey;
HttpResult res = await http.SendAsync(spec, ct);
JObject json = ParseAndValidate(res, apiKey, "poll");
JObject data = json["data"] as JObject ?? new JObject();
var result = new ProviderPollResult { State = MapState(data["status"]?.ToString()) };
JToken progressTok = data["progress"];
if (progressTok != null && progressTok.Type != JTokenType.Null)
{
result.Progress = Mathf.Clamp01(progressTok.Value<float>() / 100f);
}
if (result.State == ProviderPollState.Succeeded)
{
result.Progress = 1f;
result.DownloadUrl = ExtractDownloadUrl(data);
if (string.IsNullOrEmpty(result.DownloadUrl))
{
result.State = ProviderPollState.Failed;
result.Error = "Tripo reported success but no model URL was present in the response.";
}
}
else if (result.State == ProviderPollState.Failed)
{
string err = data["error"]?.ToString()
?? data["message"]?.ToString()
?? json["message"]?.ToString()
?? "Tripo task failed.";
result.Error = SecretRedactor.Scrub(err, apiKey);
}
return result;
}
private static ProviderPollState MapState(string status)
{
switch ((status ?? string.Empty).ToLowerInvariant())
{
case "success":
case "succeeded":
return ProviderPollState.Succeeded;
case "failed":
case "error":
case "cancelled":
case "canceled":
case "banned":
case "expired":
return ProviderPollState.Failed;
case "running":
case "processing":
return ProviderPollState.Running;
default:
return ProviderPollState.Queued;
}
}
/// <summary>
/// Resolve the model download URL, defensive about Tripo's nested output shapes. Prefer a
/// textured / PBR model; fall back to the base model. Accepts both the newer flat form
/// (<c>output.pbr_model</c> = url string) and the older nested form
/// (<c>result.pbr_model.url</c> = object with a url field).
/// </summary>
private static string ExtractDownloadUrl(JObject data)
{
JObject output = data["output"] as JObject;
JObject resultObj = data["result"] as JObject;
return UrlOf(output?["pbr_model"])
?? UrlOf(resultObj?["pbr_model"])
?? UrlOf(output?["model"])
?? UrlOf(resultObj?["model"])
?? UrlOf(output?["base_model"])
?? UrlOf(resultObj?["base_model"]);
}
/// <summary>A field may be a plain URL string or an object carrying a "url" property.</summary>
private static string UrlOf(JToken token)
{
if (token == null || token.Type == JTokenType.Null) return null;
if (token.Type == JTokenType.String)
{
string s = token.ToString();
return string.IsNullOrEmpty(s) ? null : s;
}
if (token is JObject obj)
{
string u = obj["url"]?.ToString();
return string.IsNullOrEmpty(u) ? null : u;
}
return null;
}
/// <summary>
/// Validate transport success + Tripo's body-level <c>code</c> (0 == ok), parse the JSON,
/// and throw a redacted exception otherwise.
/// </summary>
private static JObject ParseAndValidate(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 body; handled below */ }
}
bool httpOk = res?.Ok == true;
int code = 0;
JToken codeTok = json?["code"];
if (codeTok != null && codeTok.Type != JTokenType.Null)
{
try { code = codeTok.Value<int>(); } catch { code = -1; }
}
if (!httpOk || code != 0)
{
string detail = json?["message"]?.ToString()
?? json?["error"]?.ToString()
?? ProviderHttp.Truncate(text);
throw new Exception(SecretRedactor.Scrub(
$"Tripo {phase} failed (status={res?.Status}, code={code}): {detail}", apiKey));
}
return json ?? new JObject();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a0761a0b205e4041950b0ab29a3d6466
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: