chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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