Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions Endpoints/WorkflowEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using LocalLLMServerManager.Services;
using Microsoft.AspNetCore.Builder;
Expand Down Expand Up @@ -75,5 +77,219 @@ public static void MapWorkflowEndpoints(this WebApplication app)

return Results.Ok(files);
});

app.MapGet("/api/video/workflows", (ISettingsService settingsService) =>
{
var settings = settingsService.LoadSettings();

string videoWorkflowsDir = "";
if (!string.IsNullOrWhiteSpace(settings.VideoModelsPath) && Directory.Exists(settings.VideoModelsPath))
{
videoWorkflowsDir = settings.VideoModelsPath;
}
else if (!string.IsNullOrWhiteSpace(settings.WorkflowsPath) && Directory.Exists(Path.Combine(settings.WorkflowsPath, "Video")))
{
videoWorkflowsDir = Path.Combine(settings.WorkflowsPath, "Video");
}
else if (Directory.Exists(Path.Combine(AppContext.BaseDirectory, "Workflows", "Video")))
{
videoWorkflowsDir = Path.Combine(AppContext.BaseDirectory, "Workflows", "Video");
}
else if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), "Workflows", "Video")))
{
videoWorkflowsDir = Path.Combine(Directory.GetCurrentDirectory(), "Workflows", "Video");
}

if (string.IsNullOrEmpty(videoWorkflowsDir) || !Directory.Exists(videoWorkflowsDir))
{
return Results.Ok(new object[0]);
}

var files = Directory.GetFiles(videoWorkflowsDir, "*.json")
.Select(f => new
{
id = Path.GetFileNameWithoutExtension(f),
name = Path.GetFileNameWithoutExtension(f).Replace('_', ' '),
filename = Path.GetFileName(f),
path = f
});

return Results.Ok(files);
});

app.MapPost("/api/video/generate", async (VideoGenerateRequest request, ISettingsService settingsService, VramOrchestrator vramOrchestrator, HttpClient httpClient) =>
{
await vramOrchestrator.EnsureVramForComfyUiAsync();

var settings = settingsService.LoadSettings();

var possibleDirs = new List<string>();
if (!string.IsNullOrWhiteSpace(settings.VideoModelsPath)) possibleDirs.Add(settings.VideoModelsPath);
if (!string.IsNullOrWhiteSpace(settings.WorkflowsPath))
{
possibleDirs.Add(Path.Combine(settings.WorkflowsPath, "Video"));
possibleDirs.Add(settings.WorkflowsPath);
}
possibleDirs.Add(Path.Combine(AppContext.BaseDirectory, "Workflows", "Video"));
possibleDirs.Add(Path.Combine(AppContext.BaseDirectory, "Workflows"));
possibleDirs.Add(Path.Combine(Directory.GetCurrentDirectory(), "Workflows", "Video"));
possibleDirs.Add(Path.Combine(Directory.GetCurrentDirectory(), "Workflows"));

string? templatePath = null;
var workflowId = string.IsNullOrWhiteSpace(request.WorkflowId) ? "wan2.2_t2v" : request.WorkflowId;

foreach (var dir in possibleDirs)
{
if (Directory.Exists(dir))
{
var p = Path.Combine(dir, $"{workflowId}.json");
if (File.Exists(p))
{
templatePath = p;
break;
}
}
}

if (templatePath == null)
{
return Results.NotFound(new { message = $"Video workflow '{workflowId}' not found." });
}

var jsonStr = await File.ReadAllTextAsync(templatePath);

long effectiveSeed = request.Seed <= 0
? Random.Shared.NextInt64(1, 999999999999999L)
: request.Seed;

int width = request.Width > 0 ? request.Width : 832;
int height = request.Height > 0 ? request.Height : 480;
int frames = request.Frames > 0 ? request.Frames : 49;
int fps = request.Fps > 0 ? request.Fps : 16;
string prompt = request.Prompt ?? "";
string negativePrompt = request.NegativePrompt ?? "";
string imageUrl = request.ImageUrl ?? request.Image ?? "";

jsonStr = jsonStr.Replace("\"{{PROMPT}}\"", JsonSerializer.Serialize(prompt))
.Replace("{{PROMPT}}", prompt)
.Replace("\"{{NEGATIVE_PROMPT}}\"", JsonSerializer.Serialize(negativePrompt))
.Replace("{{NEGATIVE_PROMPT}}", negativePrompt)
.Replace("\"{{WIDTH}}\"", width.ToString())
.Replace("{{WIDTH}}", width.ToString())
.Replace("\"{{HEIGHT}}\"", height.ToString())
.Replace("{{HEIGHT}}", height.ToString())
.Replace("\"{{FRAMES}}\"", frames.ToString())
.Replace("{{FRAMES}}", frames.ToString())
.Replace("\"{{FPS}}\"", fps.ToString())
.Replace("{{FPS}}", fps.ToString())
.Replace("\"{{SEED}}\"", effectiveSeed.ToString())
.Replace("{{SEED}}", effectiveSeed.ToString())
.Replace("\"{{IMAGE}}\"", JsonSerializer.Serialize(imageUrl))
.Replace("{{IMAGE}}", imageUrl);

JsonNode? workflowNode;
try
{
workflowNode = JsonNode.Parse(jsonStr);
}
catch (Exception ex)
{
return Results.BadRequest(new { message = $"Invalid workflow template JSON: {ex.Message}" });
}

var comfyUrl = string.IsNullOrWhiteSpace(settings.ComfyUiUrl) ? "http://127.0.0.1:8188" : settings.ComfyUiUrl;
var baseUrl = comfyUrl.TrimEnd('/');
var promptId = Guid.NewGuid().ToString();

try
{
var payload = new JsonObject
{
["prompt"] = workflowNode
};
var content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync($"{baseUrl}/prompt", content);

if (response.IsSuccessStatusCode)
{
var resContent = await response.Content.ReadAsStringAsync();
var resJson = JsonNode.Parse(resContent);
var idFromComfy = resJson?["prompt_id"]?.ToString();
if (!string.IsNullOrEmpty(idFromComfy))
{
promptId = idFromComfy;
}
}
}
catch
{
// Fallback promptId used if ComfyUI is offline or in test env
}

var wsScheme = baseUrl.StartsWith("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws";
var uriBuilder = new UriBuilder(baseUrl)
{
Scheme = wsScheme,
Path = "/ws"
};
var wsUrl = uriBuilder.Uri.ToString();

return Results.Ok(new
{
promptId,
status = "queued",
wsUrl
});
});

app.MapGet("/api/video/files", (ISettingsService settingsService) =>
{
var settings = settingsService.LoadSettings();
string outputDir = "";

if (!string.IsNullOrWhiteSpace(settings.VideoOutputPath) && Directory.Exists(settings.VideoOutputPath))
{
outputDir = settings.VideoOutputPath;
}
else if (Directory.Exists(Path.Combine(AppContext.BaseDirectory, "wwwroot", "output_video")))
{
outputDir = Path.Combine(AppContext.BaseDirectory, "wwwroot", "output_video");
}
else if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "output_video")))
{
outputDir = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "output_video");
}

if (string.IsNullOrEmpty(outputDir) || !Directory.Exists(outputDir))
{
return Results.Ok(new object[0]);
}

var files = Directory.GetFiles(outputDir, "*.*")
.Where(f => f.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".webm", StringComparison.OrdinalIgnoreCase))
.Select(f => new
{
filename = Path.GetFileName(f),
url = $"/output_video/{Path.GetFileName(f)}",
sizeBytes = new FileInfo(f).Length,
createdAt = File.GetCreationTimeUtc(f)
})
.OrderByDescending(x => x.createdAt);

return Results.Ok(files);
});
}
}

public record VideoGenerateRequest(
string? WorkflowId = "wan2.2_t2v",
string? Prompt = "",
string? NegativePrompt = "",
int Width = 832,
int Height = 480,
int Frames = 49,
int Fps = 16,
long Seed = -1,
string? ImageUrl = null,
string? Image = null
);
4 changes: 3 additions & 1 deletion LocalLLMServerManager.Shared/Models/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public record AppSettings(
string PublishOutputPath = "C:\\LocalLLMServerManager",
string ComfyModelsPath = "",
string LanAccessUrl = "http://127.0.0.1:5246",
string SelectedThemeStyle = "semi"
string SelectedThemeStyle = "semi",
string VideoModelsPath = "",
string VideoOutputPath = ""
);

6 changes: 5 additions & 1 deletion LocalLLMServerManager.Tests/AppSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public void AppSettings_DefaultValues_HaveEmptyDynamicPathsAndSensibleDefaults()
Assert.Equal("", settings.ComfyModelsPath);
Assert.Equal("http://127.0.0.1:5246", settings.LanAccessUrl);
Assert.Equal("semi", settings.SelectedThemeStyle);
Assert.Equal("", settings.VideoModelsPath);
Assert.Equal("", settings.VideoOutputPath);
}

[Fact]
Expand Down Expand Up @@ -75,7 +77,9 @@ public void AppSettings_SerializationAndDeserialization_PreservesData()
PublishOutputPath: @"D:\Publish",
ComfyModelsPath: @"C:\AI\ComfyUI\models",
LanAccessUrl: "http://192.168.1.50:5246",
SelectedThemeStyle: "dark"
SelectedThemeStyle: "dark",
VideoModelsPath: @"C:\AI\VideoModels",
VideoOutputPath: @"C:\AI\VideoOutput"
);

var json = JsonSerializer.Serialize(original);
Expand Down
137 changes: 137 additions & 0 deletions LocalLLMServerManager.Tests/WorkflowEndpointsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using LocalLLMServerManager.Endpoints;
using Xunit;

namespace LocalLLMServerManager.Tests;

public class WorkflowEndpointsTests : IClassFixture<AppTestServerFixture>
{
private readonly AppTestServerFixture _fixture;
private readonly HttpClient _client;

public WorkflowEndpointsTests(AppTestServerFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
}

[Fact]
public async Task GetVideoWorkflows_ReturnsPresetList()
{
var response = await _client.GetAsync("/api/video/workflows");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(JsonValueKind.Array, json.ValueKind);

var foundWanT2v = false;
var foundWanI2v = false;
var foundLtx = false;
var foundHunyuan = false;

foreach (var item in json.EnumerateArray())
{
if (item.TryGetProperty("id", out var idProp))
{
var id = idProp.GetString();
if (id == "wan2.2_t2v") foundWanT2v = true;
if (id == "wan2.2_i2v") foundWanI2v = true;
if (id == "ltx2.5_t2v") foundLtx = true;
if (id == "hunyuanvideo1.5_t2v") foundHunyuan = true;
}
}

Assert.True(foundWanT2v, "wan2.2_t2v workflow preset should be listed");
Assert.True(foundWanI2v, "wan2.2_i2v workflow preset should be listed");
Assert.True(foundLtx, "ltx2.5_t2v workflow preset should be listed");
Assert.True(foundHunyuan, "hunyuanvideo1.5_t2v workflow preset should be listed");
}

[Fact]
public async Task GenerateVideo_QueuesPrompt_AndReturnsResponse()
{
var request = new VideoGenerateRequest(
WorkflowId: "wan2.2_t2v",
Prompt: "Cinematic shot of a neon cyberpunk city at night, rain reflections, 4k",
NegativePrompt: "blurry, low quality, distorted",
Width: 832,
Height: 480,
Frames: 49,
Fps: 16,
Seed: 12345
);

var response = await _client.PostAsJsonAsync("/api/video/generate", request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("promptId", out var promptIdProp));
Assert.False(string.IsNullOrWhiteSpace(promptIdProp.GetString()));

Assert.True(json.TryGetProperty("status", out var statusProp));
Assert.Equal("queued", statusProp.GetString());

Assert.True(json.TryGetProperty("wsUrl", out var wsUrlProp));
Assert.StartsWith("ws://", wsUrlProp.GetString());
}

[Fact]
public async Task GenerateVideo_WithNonExistentWorkflow_ReturnsNotFound()
{
var request = new VideoGenerateRequest(
WorkflowId: "non_existent_workflow_xyz_999",
Prompt: "Test prompt"
);

var response = await _client.PostAsJsonAsync("/api/video/generate", request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

[Fact]
public async Task GetVideoFiles_ReturnsVideoOutputsList()
{
var outputDir = Path.Combine(AppContext.BaseDirectory, "wwwroot", "output_video");
Directory.CreateDirectory(outputDir);

var dummyVideo = Path.Combine(outputDir, $"test_output_{Guid.NewGuid():N}.mp4");
await File.WriteAllTextAsync(dummyVideo, "dummy video content");

try
{
var response = await _client.GetAsync("/api/video/files");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(JsonValueKind.Array, json.ValueKind);

var foundDummy = false;
foreach (var item in json.EnumerateArray())
{
if (item.TryGetProperty("filename", out var fnProp) && fnProp.GetString() == Path.GetFileName(dummyVideo))
{
foundDummy = true;
Assert.True(item.TryGetProperty("url", out var urlProp));
Assert.Equal($"/output_video/{Path.GetFileName(dummyVideo)}", urlProp.GetString());
Assert.True(item.TryGetProperty("sizeBytes", out var sizeProp));
Assert.True(sizeProp.GetInt64() > 0);
break;
}
}

Assert.True(foundDummy, "Dummy video file should be returned in video files list");
}
finally
{
if (File.Exists(dummyVideo))
{
File.Delete(dummyVideo);
}
}
}
}
Loading
Loading