Files
2026-07-13 12:49:17 +08:00

534 lines
23 KiB
C#

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;
}
}
}