diff --git a/Endpoints/WorkflowEndpoints.cs b/Endpoints/WorkflowEndpoints.cs index 9d73dbb..7e9d46f 100644 --- a/Endpoints/WorkflowEndpoints.cs +++ b/Endpoints/WorkflowEndpoints.cs @@ -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; @@ -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(); + 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 +); diff --git a/LocalLLMServerManager.Shared/Models/AppSettings.cs b/LocalLLMServerManager.Shared/Models/AppSettings.cs index e0f506d..834b801 100644 --- a/LocalLLMServerManager.Shared/Models/AppSettings.cs +++ b/LocalLLMServerManager.Shared/Models/AppSettings.cs @@ -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 = "" ); diff --git a/LocalLLMServerManager.Tests/AppSettingsTests.cs b/LocalLLMServerManager.Tests/AppSettingsTests.cs index 188508d..f96fbd2 100644 --- a/LocalLLMServerManager.Tests/AppSettingsTests.cs +++ b/LocalLLMServerManager.Tests/AppSettingsTests.cs @@ -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] @@ -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); diff --git a/LocalLLMServerManager.Tests/WorkflowEndpointsTests.cs b/LocalLLMServerManager.Tests/WorkflowEndpointsTests.cs new file mode 100644 index 0000000..2963056 --- /dev/null +++ b/LocalLLMServerManager.Tests/WorkflowEndpointsTests.cs @@ -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 +{ + 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(); + 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(); + 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(); + 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); + } + } + } +} diff --git a/Services/VramOrchestrator.cs b/Services/VramOrchestrator.cs index f3e9d0c..c722ca5 100644 --- a/Services/VramOrchestrator.cs +++ b/Services/VramOrchestrator.cs @@ -62,7 +62,7 @@ public async Task EnsureVramForImageGenerationAsync() public async Task EnsureVramForComfyUiAsync() { - await UnloadOllamaModelsAsync("ComfyUI Workflows (3D / Image)"); + await UnloadOllamaModelsAsync("ComfyUI Workflows (Video / 3D / Image)"); } public async Task FreeComfyUiVramAsync(string comfyUrl = "http://127.0.0.1:8188") diff --git a/Workflows/Video/hunyuanvideo1.5_t2v.json b/Workflows/Video/hunyuanvideo1.5_t2v.json new file mode 100644 index 0000000..53d3eb4 --- /dev/null +++ b/Workflows/Video/hunyuanvideo1.5_t2v.json @@ -0,0 +1,86 @@ +{ + "1": { + "inputs": { + "text": "{{PROMPT}}" + }, + "class_type": "CLIPTextEncode" + }, + "2": { + "inputs": { + "text": "{{NEGATIVE_PROMPT}}" + }, + "class_type": "CLIPTextEncode" + }, + "3": { + "inputs": { + "ckpt_name": "hunyuan_video_1.5_720p.safetensors" + }, + "class_type": "CheckpointLoaderSimple" + }, + "4": { + "inputs": { + "width": "{{WIDTH}}", + "height": "{{HEIGHT}}", + "batch_size": "{{FRAMES}}" + }, + "class_type": "EmptyLatentImage" + }, + "5": { + "inputs": { + "seed": "{{SEED}}", + "steps": 30, + "cfg": 6.0, + "sampler_name": "euler_ancestral", + "scheduler": "karras", + "denoise": 1.0, + "model": [ + "3", + 0 + ], + "positive": [ + "1", + 0 + ], + "negative": [ + "2", + 0 + ], + "latent_image": [ + "4", + 0 + ] + }, + "class_type": "KSampler" + }, + "6": { + "inputs": { + "samples": [ + "5", + 0 + ], + "vae": [ + "3", + 2 + ] + }, + "class_type": "VAEDecode" + }, + "7": { + "inputs": { + "frame_rate": "{{FPS}}", + "loop_count": 0, + "filename_prefix": "HunyuanVideo1.5", + "format": "video/h264-mp4", + "pix_fmt": "yuv420p", + "crf": 18, + "save_metadata": true, + "pingpong": false, + "save_output": true, + "images": [ + "6", + 0 + ] + }, + "class_type": "VHS_VideoCombine" + } +} diff --git a/Workflows/Video/ltx2.5_t2v.json b/Workflows/Video/ltx2.5_t2v.json new file mode 100644 index 0000000..98f1394 --- /dev/null +++ b/Workflows/Video/ltx2.5_t2v.json @@ -0,0 +1,86 @@ +{ + "1": { + "inputs": { + "text": "{{PROMPT}}" + }, + "class_type": "CLIPTextEncode" + }, + "2": { + "inputs": { + "text": "{{NEGATIVE_PROMPT}}" + }, + "class_type": "CLIPTextEncode" + }, + "3": { + "inputs": { + "ckpt_name": "ltx-video-2.5-13b.safetensors" + }, + "class_type": "CheckpointLoaderSimple" + }, + "4": { + "inputs": { + "width": "{{WIDTH}}", + "height": "{{HEIGHT}}", + "batch_size": "{{FRAMES}}" + }, + "class_type": "EmptyLatentImage" + }, + "5": { + "inputs": { + "seed": "{{SEED}}", + "steps": 30, + "cfg": 3.0, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + "model": [ + "3", + 0 + ], + "positive": [ + "1", + 0 + ], + "negative": [ + "2", + 0 + ], + "latent_image": [ + "4", + 0 + ] + }, + "class_type": "KSampler" + }, + "6": { + "inputs": { + "samples": [ + "5", + 0 + ], + "vae": [ + "3", + 2 + ] + }, + "class_type": "VAEDecode" + }, + "7": { + "inputs": { + "frame_rate": "{{FPS}}", + "loop_count": 0, + "filename_prefix": "LTX2.5_AudioSync", + "format": "video/h264-mp4", + "pix_fmt": "yuv420p", + "crf": 18, + "save_metadata": true, + "pingpong": false, + "save_output": true, + "images": [ + "6", + 0 + ] + }, + "class_type": "VHS_VideoCombine" + } +} diff --git a/Workflows/Video/wan2.2_i2v.json b/Workflows/Video/wan2.2_i2v.json new file mode 100644 index 0000000..1a95eab --- /dev/null +++ b/Workflows/Video/wan2.2_i2v.json @@ -0,0 +1,107 @@ +{ + "1": { + "inputs": { + "text": "{{PROMPT}}" + }, + "class_type": "CLIPTextEncode" + }, + "2": { + "inputs": { + "text": "{{NEGATIVE_PROMPT}}" + }, + "class_type": "CLIPTextEncode" + }, + "3": { + "inputs": { + "ckpt_name": "wan2.1_i2v_720p_14B_fp8.safetensors" + }, + "class_type": "CheckpointLoaderSimple" + }, + "4": { + "inputs": { + "image": "{{IMAGE}}" + }, + "class_type": "LoadImage" + }, + "5": { + "inputs": { + "width": "{{WIDTH}}", + "height": "{{HEIGHT}}", + "video_frames": "{{FRAMES}}", + "motion_bucket_id": 127, + "fps": "{{FPS}}", + "augmentation_level": 0.0, + "clip_vision": [ + "3", + 1 + ], + "init_image": [ + "4", + 0 + ], + "vae": [ + "3", + 2 + ] + }, + "class_type": "Wan_img2vid_Conditioning" + }, + "6": { + "inputs": { + "seed": "{{SEED}}", + "steps": 20, + "cfg": 6.0, + "sampler_name": "uni_pc", + "scheduler": "simple", + "denoise": 1.0, + "model": [ + "3", + 0 + ], + "positive": [ + "5", + 0 + ], + "negative": [ + "5", + 1 + ], + "latent_image": [ + "5", + 2 + ] + }, + "class_type": "KSampler" + }, + "7": { + "inputs": { + "samples": [ + "6", + 0 + ], + "vae": [ + "3", + 2 + ] + }, + "class_type": "VAEDecode" + }, + "8": { + "inputs": { + "frame_rate": "{{FPS}}", + "loop_count": 0, + "filename_prefix": "Wan2.2_I2V", + "format": "video/h264-mp4", + "pix_fmt": "yuv420p", + "crf": 19, + "save_metadata": true, + "pingpong": false, + "save_output": true, + "images": [ + "7", + 0 + ] + }, + "class_type": "VHS_VideoCombine" + } +} diff --git a/Workflows/Video/wan2.2_t2v.json b/Workflows/Video/wan2.2_t2v.json new file mode 100644 index 0000000..f810bd2 --- /dev/null +++ b/Workflows/Video/wan2.2_t2v.json @@ -0,0 +1,86 @@ +{ + "1": { + "inputs": { + "text": "{{PROMPT}}" + }, + "class_type": "CLIPTextEncode" + }, + "2": { + "inputs": { + "text": "{{NEGATIVE_PROMPT}}" + }, + "class_type": "CLIPTextEncode" + }, + "3": { + "inputs": { + "ckpt_name": "wan2.1_t2v_14B_fp8.safetensors" + }, + "class_type": "CheckpointLoaderSimple" + }, + "4": { + "inputs": { + "width": "{{WIDTH}}", + "height": "{{HEIGHT}}", + "batch_size": "{{FRAMES}}" + }, + "class_type": "EmptyLatentImage" + }, + "5": { + "inputs": { + "seed": "{{SEED}}", + "steps": 20, + "cfg": 6.0, + "sampler_name": "uni_pc", + "scheduler": "simple", + "denoise": 1.0, + "model": [ + "3", + 0 + ], + "positive": [ + "1", + 0 + ], + "negative": [ + "2", + 0 + ], + "latent_image": [ + "4", + 0 + ] + }, + "class_type": "KSampler" + }, + "6": { + "inputs": { + "samples": [ + "5", + 0 + ], + "vae": [ + "3", + 2 + ] + }, + "class_type": "VAEDecode" + }, + "7": { + "inputs": { + "frame_rate": "{{FPS}}", + "loop_count": 0, + "filename_prefix": "Wan2.2_T2V", + "format": "video/h264-mp4", + "pix_fmt": "yuv420p", + "crf": 19, + "save_metadata": true, + "pingpong": false, + "save_output": true, + "images": [ + "6", + 0 + ] + }, + "class_type": "VHS_VideoCombine" + } +} diff --git a/tests/layout-inspector/declarations.d.ts b/tests/layout-inspector/declarations.d.ts new file mode 100644 index 0000000..51b6261 --- /dev/null +++ b/tests/layout-inspector/declarations.d.ts @@ -0,0 +1,11 @@ +declare global { + namespace PlaywrightTest { + interface Matchers { + toHaveNoLayoutOverflow(): R; + toHaveMobileFit(): R; + toHaveTouchFriendlyTargets(options?: { minSize?: number }): R; + } + } +} + +export {};