chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 <key>"), 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/<mime>;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 <key>" 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:
|
||||
Reference in New Issue
Block a user