Skip to content

Commit 00d5891

Browse files
committed
implement GetComfyResultsCommand
1 parent 053b0e9 commit 00d5891

File tree

8 files changed

+243
-40
lines changed

8 files changed

+243
-40
lines changed

AiServer.ServiceInterface/AppData.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ public MediaProvider AssertComfyProvider(string name) => ComfyProviders.FirstOrD
4747
.Where(x => x.MediaType.Provider == AiServiceProvider.Comfy)
4848
.ToArray();
4949

50+
public string ContentRootPath => env.ContentRootPath;
51+
public string WebRootPath => env.ContentRootPath.CombineWith("wwwroot");
52+
5053
public string? ReadTextFile(string path)
5154
{
5255
var fullPath = Path.Combine(env.ContentRootPath, path);

AiServer.ServiceInterface/ComfyConverters.cs

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ public static class ComfyConverters
245245
/// <returns>The JSON content for the /api/prompt endpoint.</returns>
246246
/// <exception cref="InvalidOperationException">Thrown if object_info.json was not loaded.</exception>
247247
/// <exception cref="JsonException">Thrown if the workflow JSON is invalid.</exception>
248-
public static string ConvertWorkflowToApiPrompt(string workflowJson, Dictionary<string, NodeInfo> nodeDefs, ILogger? log=null)
248+
public static string ConvertWorkflowToApiPrompt(string workflowJson, Dictionary<string, NodeInfo> nodeDefs, string? clientId=null, ILogger? log=null)
249249
{
250250
log ??= NullLogger.Instance;
251251

@@ -268,7 +268,8 @@ public static string ConvertWorkflowToApiPrompt(string workflowJson, Dictionary<
268268
ExtraData = new JsonObject {
269269
["extra_pnginfo"] = new JsonObject {
270270
["workflow"] = workflowJsonNode
271-
}
271+
},
272+
["client_id"] = clientId ?? Guid.NewGuid().ToString("N"),
272273
},
273274
};
274275

@@ -450,7 +451,7 @@ public static ComfyResult ParseComfyResult(string resultJson, string? comfyApiBa
450451
// Extract Node Images
451452
if (nodeOutput.Value.TryGetProperty("images", out var imagesArray))
452453
{
453-
ret.ImageOutputs ??= [];
454+
ret.Assets ??= [];
454455
foreach (var image in imagesArray.EnumerateArray())
455456
{
456457
if (image.TryGetProperty("filename", out var elFilename) &&
@@ -461,25 +462,37 @@ public static ComfyResult ParseComfyResult(string resultJson, string? comfyApiBa
461462

462463
image.TryGetProperty("subfolder", out var elSubFolder);
463464

464-
//view?filename=ComfyUI_00424_.png&type=output&subfolder=
465+
var mimeType = MimeTypes.GetMimeType(filename);
466+
var assetType = mimeType.StartsWith("image")
467+
? AssetType.Image
468+
: mimeType.StartsWith("video")
469+
? AssetType.Video
470+
: mimeType.StartsWith("audio")
471+
? AssetType.Audio
472+
: mimeType.StartsWith("text")
473+
? AssetType.Text
474+
: AssetType.Binary;
475+
465476
var path = "/view"
466477
.AddQueryParam("filename", filename)
467478
.AddQueryParam("type", type.GetString() ?? "")
468479
.AddQueryParam("subfolder", elSubFolder.GetString() ?? "");
469480

470-
ret.ImageOutputs.Add(new() {
481+
ret.Assets.Add(new() {
471482
NodeId = nodeId,
472-
Url = comfyApiBaseUrl.CombineWith(path)
483+
Type = assetType,
484+
FileName = filename,
485+
Url = comfyApiBaseUrl.CombineWith(path),
473486
});
474487
}
475488
}
476489
}
477490
if (nodeOutput.Value.TryGetProperty("text", out var textArray))
478491
{
479-
ret.TextOutputs ??= [];
492+
ret.Texts ??= [];
480493
foreach (var text in textArray.EnumerateArray())
481494
{
482-
ret.TextOutputs.Add(new() {
495+
ret.Texts.Add(new() {
483496
NodeId = nodeId,
484497
Text = text.GetString()
485498
});

AiServer.ServiceInterface/ComfyGateway.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@ namespace AiServer.ServiceInterface;
1111

1212
public class ComfyGateway(ILogger<ComfyGateway> log, IHttpClientFactory clientFactory, ComfyMetadata metadata)
1313
{
14-
public HttpClient CreateHttpClient(string url, string apiKey)
14+
public HttpClient CreateHttpClient(string url, string? apiKey=null)
1515
{
1616
HttpClient? client = null;
1717
try
1818
{
1919
client = clientFactory.CreateClient(nameof(ComfyGateway));
2020
client.BaseAddress = new Uri(url);
21-
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
21+
if (apiKey is { Length: > 0 })
22+
{
23+
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
24+
}
2225
return client;
2326
}
2427
catch
@@ -58,7 +61,7 @@ public async Task<ComfyWorkflowInfo> GetWorkflowInfoAsync(string url, string api
5861
return workflowInfo ?? throw HttpError.NotFound($"Could not parse {workflow}");
5962
}
6063

61-
public async Task<string> ExecuteApiPromptAsync(string url, string apiKey, string promptJson)
64+
public async Task<string> ExecuteApiPromptAsync(string url, string? apiKey, string promptJson)
6265
{
6366
using var client = CreateHttpClient(url, apiKey);
6467
var response = await client.PostAsync("/api/prompt",
@@ -81,4 +84,14 @@ public async Task<string> ExecuteApiPromptAsync(string url, string apiKey, strin
8184
var result = await response.Content.ReadAsStringAsync();
8285
return result;
8386
}
87+
88+
public async Task<string> GetPromptHistoryAsync(string url, string? apiKey, string promptId, CancellationToken token)
89+
{
90+
using var client = CreateHttpClient(url, apiKey);
91+
var response = await client.GetAsync($"/api/history/{promptId}", token);
92+
response.EnsureSuccessStatusCode();
93+
return await response.Content.ReadAsStringAsync(token);
94+
}
95+
96+
8497
}

AiServer.ServiceInterface/ComfyServices.cs

Lines changed: 170 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
using System.Reflection;
22
using AiServer.ServiceModel;
3+
using AiServer.ServiceModel.Types;
34
using Microsoft.AspNetCore.Hosting;
45
using Microsoft.Extensions.Logging;
56
using ServiceStack;
7+
using ServiceStack.Jobs;
68

79
namespace AiServer.ServiceInterface;
810

911
public class ComfyServices(ILogger<ComfyServices> log,
10-
IWebHostEnvironment env,
12+
AppData appData,
1113
ComfyMetadata metadata,
12-
ComfyGateway comfyGateway)
14+
ComfyGateway comfyGateway,
15+
IBackgroundJobs jobs)
1316
: Service
1417
{
18+
public const string ComfyBaseUrl = "http://localhost:7860/api";
19+
public const string ComfyApiKey = "";
20+
1521
public List<string> Get(GetComfyWorkflows request)
1622
{
17-
var workflowsPath = env.WebRootPath.CombineWith("lib", "data", "workflows");
23+
var workflowsPath = appData.WebRootPath.CombineWith("lib", "data", "workflows");
1824
var files = Directory.GetFiles(workflowsPath, "*.json", SearchOption.AllDirectories);
1925

2026
var allWorkflows = files.Map(x => x[workflowsPath.Length..].TrimStart('/'));
2127

22-
var overrideWorkflowPath = env.ContentRootPath.CombineWith("App_Data", "overrides", "workflows");
28+
var overrideWorkflowPath = appData.ContentRootPath.CombineWith("App_Data", "overrides", "workflows");
2329
var overrideFiles = Directory.GetFiles(overrideWorkflowPath, "*.json", SearchOption.AllDirectories);
2430

2531
allWorkflows.AddRange(overrideFiles.Map(x => x[overrideWorkflowPath.Length..].TrimStart('/')));
@@ -52,11 +58,11 @@ public async Task<ComfyWorkflowInfo> GetWorkflowInfoAsync(string path)
5258
private async Task<string?> GetWorkflowJsonAsync(string path)
5359
{
5460
path = path.Replace('\\', '/');
55-
var workflowsPath = env.WebRootPath.CombineWith("lib", "data", "workflows");
61+
var workflowsPath = appData.WebRootPath.CombineWith("lib", "data", "workflows");
5662
if (!path.IsPathSafe(workflowsPath))
5763
throw new ArgumentNullException(nameof(GetComfyWorkflowInfo.Workflow), "Invalid Workflow Path");
5864

59-
var overridePath = env.ContentRootPath.CombineWith("App_Data", "overrides", "workflows").Replace('\\', '/');
65+
var overridePath = appData.ContentRootPath.CombineWith("App_Data", "overrides", "workflows").Replace('\\', '/');
6066
string? workflowJson = null;
6167

6268
if (File.Exists(overridePath.CombineWith(path)))
@@ -98,23 +104,29 @@ public async Task<ComfyWorkflowInfo> GetWorkflowInfoAsync(string path)
98104
return workflowJson;
99105
}
100106

101-
public const string ComfyBaseUrl = "http://localhost:7860/api";
102-
public const string ComfyApiKey = "";
103-
104107
public async Task<string> Get(GetComfyApiPrompt request)
105108
{
106109
var client = comfyGateway.CreateHttpClient(ComfyBaseUrl, ComfyApiKey);
107110
var nodeDefs = await metadata.LoadNodeDefinitionsAsync(client);
108111
var workflowInfo = await GetWorkflowInfoAsync(request.Workflow);
109112
var workflowJson = await GetWorkflowJsonAsync(workflowInfo.Path)
110113
?? throw HttpError.NotFound("Workflow not found");
111-
var apiPromptJson = ComfyConverters.ConvertWorkflowToApiPrompt(workflowJson, nodeDefs, log);
114+
var apiPromptJson = ComfyConverters.ConvertWorkflowToApiPrompt(workflowJson, nodeDefs, log:log);
112115
return apiPromptJson;
113116
}
114117

115-
public async Task<object> Post(ExecuteComfyWorkflow request)
118+
public async Task<object> Post(QueueComfyWorkflow request)
116119
{
117-
var client = comfyGateway.CreateHttpClient(ComfyBaseUrl, ComfyApiKey);
120+
var candidates = appData.MediaProviders
121+
.Where(x => x is { Enabled: true, OfflineDate: null, MediaTypeId: "ComfyUI" }).ToList();
122+
123+
if (candidates.Count == 0)
124+
throw new Exception("No ComfyUI providers available");
125+
126+
var randomCandidate = candidates[new Random().Next(candidates.Count)];
127+
var comfyUiApiBaseUrl = randomCandidate.ApiBaseUrl.CombineWith("api");
128+
129+
var client = comfyGateway.CreateHttpClient(comfyUiApiBaseUrl, randomCandidate.ApiKey);
118130
var nodeDefs = await metadata.LoadNodeDefinitionsAsync(client);
119131
var workflowInfo = await GetWorkflowInfoAsync(request.Workflow);
120132
var workflowJson = await GetWorkflowJsonAsync(workflowInfo.Path)
@@ -125,9 +137,152 @@ public async Task<object> Post(ExecuteComfyWorkflow request)
125137
var result = ComfyWorkflowParser.MergeWorkflow(workflowJson, request.Args, nodeDefs);
126138
workflowJson = result.Result;
127139
}
140+
141+
var clientId = Guid.NewGuid().ToString("N");
142+
var apiPromptJson = ComfyConverters.ConvertWorkflowToApiPrompt(workflowJson, nodeDefs, clientId, log:log);
143+
var resultJson = await comfyGateway.ExecuteApiPromptAsync(comfyUiApiBaseUrl, randomCandidate.ApiKey, apiPromptJson);
144+
var resultObj = (Dictionary<string, object>)JSON.parse(resultJson);
145+
var promptId = resultObj.GetValueOrDefault("prompt_id")?.ToString()
146+
?? throw new Exception("Invalid ComfyUI Queue Result");
147+
148+
var KeyId = (Request.GetApiKey() as ApiKeysFeature.ApiKey)?.Id ?? 0;
149+
log.LogInformation("Received QueueComfyWorkflow from '{KeyId}' to execute workflow '{Workflow}' using '{Provider}'",
150+
KeyId, request.Workflow, randomCandidate.ApiBaseUrl);
151+
152+
var args = new Dictionary<string, string> {
153+
[nameof(KeyId)] = $"{KeyId}",
154+
};
128155

129-
var apiPromptJson = ComfyConverters.ConvertWorkflowToApiPrompt(workflowJson, nodeDefs, log);
130-
var resultJson = await comfyGateway.ExecuteApiPromptAsync(ComfyBaseUrl, ComfyApiKey, apiPromptJson);
131-
return resultJson;
156+
var jobRef = jobs.EnqueueCommand<GetComfyResultsCommand>(new GetComfyResults
157+
{
158+
MediaProviderId = randomCandidate.Id,
159+
ClientId = clientId,
160+
PromptId = promptId,
161+
}, new() { RefId = clientId, Args = args });
162+
163+
return new QueueComfyWorkflowResponse
164+
{
165+
MediaProviderId = randomCandidate.Id,
166+
RefId = clientId,
167+
PromptId = promptId,
168+
JobId = jobRef.Id,
169+
};
132170
}
133171
}
172+
173+
public class GetComfyResults
174+
{
175+
public long MediaProviderId { get; set; }
176+
public string PromptId { get; set; }
177+
public string ClientId { get; set; }
178+
public TimeSpan? Timeout { get; set; }
179+
}
180+
181+
public class GetComfyResultsCommand(
182+
ILogger<GetComfyResultsCommand> logger,
183+
IBackgroundJobs jobs,
184+
AppData appData,
185+
AppConfig appConfig,
186+
ComfyGateway comfyGateway)
187+
: AsyncCommandWithResult<GetComfyResults,ComfyResult>
188+
{
189+
protected override async Task<ComfyResult> RunAsync(GetComfyResults request, CancellationToken token)
190+
{
191+
var job = Request.GetBackgroundJob();
192+
var log = Request.CreateJobLogger(jobs, logger);
193+
194+
var mediaProvider = appData.MediaProviders.FirstOrDefault(x => x.Id == request.MediaProviderId)
195+
?? throw new Exception($"Media Provider {request.MediaProviderId} not available");
196+
197+
var keyId = job.Args?.TryGetValue("KeyId", out var oKeyId) == true ? oKeyId : "0";
198+
var timeout = request.Timeout ?? TimeSpan.FromSeconds(5 * 60);
199+
var startedAt = DateTime.UtcNow;
200+
while (DateTime.UtcNow - startedAt < timeout)
201+
{
202+
using var client = comfyGateway.CreateHttpClient(mediaProvider.ApiBaseUrl!, mediaProvider.ApiKey);
203+
var response = await client.GetAsync($"/api/history/{request.PromptId}", token);
204+
response.EnsureSuccessStatusCode();
205+
var historyJson = await response.Content.ReadAsStringAsync(token);
206+
207+
if (historyJson.IndexOf(request.PromptId, StringComparison.OrdinalIgnoreCase) >= 0)
208+
{
209+
log.LogInformation("Prompt {Prompt} from {Url} has completed", request.PromptId, mediaProvider.ApiBaseUrl);
210+
211+
var now = DateTime.UtcNow;
212+
var result = ComfyConverters.ParseComfyResult(historyJson, mediaProvider.ApiBaseUrl.CombineWith("api"));
213+
214+
if (result.Assets?.Count > 0)
215+
{
216+
log.LogInformation("Downloading {Count} Assets for {Prompt} from {Url}",
217+
result.Assets.Count, request.PromptId, mediaProvider.ApiBaseUrl);
218+
219+
var tasks = result.Assets.Map(async x =>
220+
{
221+
var output = new ComfyAssetOutput
222+
{
223+
NodeId = x.NodeId,
224+
Type = x.Type,
225+
FileName = x.FileName,
226+
};
227+
var url = x.Url;
228+
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
229+
{
230+
url = mediaProvider.ApiBaseUrl.CombineWith(url);
231+
}
232+
233+
var ext = output.FileName.LastRightPart('.');
234+
if (output.Type == AssetType.Image)
235+
{
236+
url = url.AddQueryParam("preview", "webp");
237+
ext = "webp";
238+
}
239+
240+
var response = await client.GetAsync(new Uri(url), token);
241+
if (!response.IsSuccessStatusCode)
242+
{
243+
log.LogError("Failed to download {Url}: {Message}",
244+
url, response.ReasonPhrase ?? response.StatusCode.ToString());
245+
return output;
246+
}
247+
248+
var imageBytes = await response.Content.ReadAsByteArrayAsync(token);
249+
var sha256 = imageBytes.ComputeSha256();
250+
output.FileName = $"{sha256}.{ext}";
251+
var relativePath = $"{now:yyyy}/{now:MM}/{now:dd}/{keyId}/{output.FileName}";
252+
var path = appConfig.ArtifactsPath.CombineWith(relativePath);
253+
Path.GetDirectoryName(path).AssertDir();
254+
await File.WriteAllBytesAsync(path, imageBytes, token);
255+
output.Url = $"/artifacts/{relativePath}";
256+
return output;
257+
});
258+
259+
var allTasks = await Task.WhenAll(tasks);
260+
var completedTasks = allTasks
261+
.Where(x => x.Url != null).ToList();
262+
263+
log.LogInformation("Downloaded {Count}/{Total} Assets for Prompt {Prompt}:\n{Urls}",
264+
completedTasks.Count, allTasks.Length, request.PromptId,
265+
string.Join('\n',completedTasks.Map(x => appConfig.AssetsBaseUrl.CombineWith(x.Url))));
266+
267+
result.Assets = completedTasks;
268+
}
269+
else if ((result.Texts?.Count ?? 0) == 0)
270+
{
271+
log.LogError("Prompt {Prompt} from {Url} did not return any results",
272+
request.PromptId, mediaProvider.ApiBaseUrl);
273+
274+
throw new Exception($"Prompt {request.PromptId} from {mediaProvider.ApiBaseUrl} did not return any results");
275+
}
276+
277+
return result;
278+
}
279+
280+
await Task.Delay(1000, token);
281+
}
282+
283+
log.LogError("Exceeded timeout of {Seconds} seconds for Prompt {Prompt}",
284+
timeout.TotalSeconds, request.PromptId);
285+
286+
throw new TimeoutException($"Exceeded timeout of {timeout.TotalSeconds} seconds for Prompt {request.PromptId}");
287+
}
288+
}

0 commit comments

Comments
 (0)