223 lines
8.7 KiB
C#
223 lines
8.7 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|