Skip to content

Commit c818d8b

Browse files
committed
Fix concurrent request inpainting issue and demo review update
- Add clone() for Text2ImagePipeline and Image2ImagePipeline to enable thread-safe concurrent requests - Add InpaintingQueueGuard with Queue<int>(1) to serialize concurrent inpainting requests (InpaintingPipeline lacks clone()) - Propagate extraQuantizationParams to all optimum export functions - Add mask acceptance test for image edits endpoint - Fix Sphinx tab formatting in image generation README - Remove CPU from dedicated inpainting deployment section in README
1 parent 0a65e68 commit c818d8b

8 files changed

Lines changed: 113 additions & 47 deletions

File tree

demos/image_generation/README.md

Lines changed: 29 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -370,24 +370,20 @@ ovms --rest_port 8000 ^
370370

371371
Wait for the model to load. You can check the status with a simple command:
372372
```console
373-
curl http://localhost:8000/v1/config
373+
curl http://localhost:8000/v3/models
374374
```
375375

376376
```json
377377
{
378-
"OpenVINO/stable-diffusion-v1-5-int8-ov" :
379-
{
380-
"model_version_status": [
381-
{
382-
"version": "1",
383-
"state": "AVAILABLE",
384-
"status": {
385-
"error_code": "OK",
386-
"error_message": "OK"
378+
"object": "list",
379+
"data": [
380+
{
381+
"id": "OpenVINO/stable-diffusion-v1-5-int8-ov",
382+
"object": "model",
383+
"created": 0,
384+
"owned_by": "openvinotoolkit"
387385
}
388-
}
389386
]
390-
}
391387
}
392388
```
393389

@@ -399,7 +395,7 @@ A single servable exposes the following endpoints:
399395
- **Inpainting**: `images/edits` — multipart form with `image` + `mask` + `prompt`
400396
- **Outpainting**: `images/edits` — multipart form with `image` + `mask` + `prompt` (image placed on larger canvas, mask marks the area to fill)
401397

402-
> **Note:** For inpainting/outpainting, dedicated inpainting models (e.g. `stable-diffusion-v1-5/stable-diffusion-inpainting`) only support the `images/edits` endpoint. Base models (e.g. `stable-diffusion-v1-5/stable-diffusion-v1-5`) support all endpoints.
398+
> **Note:** For inpainting/outpainting, dedicated inpainting models (e.g. `stable-diffusion-v1-5/stable-diffusion-inpainting`) only support the `images/edits` endpoint. Check [supported models](https://openvinotoolkit.github.io/openvino.genai/docs/supported-models/#image-generation-models).
403399
404400
All requests are processed in unary format, with no streaming capabilities.
405401

@@ -532,7 +528,9 @@ Inpainting replaces a masked region in an image based on the prompt. The `mask`
532528

533529
![cat](./cat.png) ![cat_mask](./cat_mask.png)
534530

535-
Linux
531+
::::{tab-set}
532+
:::{tab-item} Linux
533+
:sync: linux
536534
```bash
537535
curl http://localhost:8000/v3/images/edits \
538536
-F "model=diffusers/stable-diffusion-xl-1.0-inpainting-0.1" \
@@ -542,8 +540,10 @@ curl http://localhost:8000/v3/images/edits \
542540
-F "num_inference_steps=50" \
543541
-F "size=1024x1024" | jq -r '.data[0].b64_json' | base64 --decode > inpaint_output.png
544542
```
543+
:::
545544

546-
Windows Command Prompt
545+
:::{tab-item} Windows Command Prompt
546+
:sync: windows
547547
```bat
548548
curl http://localhost:8000/v3/images/edits ^
549549
-F "model=diffusers/stable-diffusion-xl-1.0-inpainting-0.1" ^
@@ -553,6 +553,9 @@ curl http://localhost:8000/v3/images/edits ^
553553
-F "num_inference_steps=50" ^
554554
-F "size=1024x1024"
555555
```
556+
:::
557+
558+
::::
556559

557560
Expected output (`inpaint_output.png`):
558561

@@ -596,7 +599,9 @@ Outpainting extends an image beyond its original borders. Prepare two images:
596599

597600
![outpaint_input](./outpaint_input.png) ![outpaint_mask](./outpaint_mask.png)
598601

599-
Linux
602+
::::{tab-set}
603+
:::{tab-item} Linux
604+
:sync: linux
600605
```bash
601606
curl http://localhost:8000/v3/images/edits \
602607
-F "model=stable-diffusion-v1-5/stable-diffusion-inpainting" \
@@ -606,8 +611,10 @@ curl http://localhost:8000/v3/images/edits \
606611
-F "num_inference_steps=50" \
607612
-F "size=768x768" | jq -r '.data[0].b64_json' | base64 --decode > outpaint_output.png
608613
```
614+
:::
609615

610-
Windows Command Prompt
616+
:::{tab-item} Windows Command Prompt
617+
:sync: windows
611618
```bat
612619
curl http://localhost:8000/v3/images/edits ^
613620
-F "model=stable-diffusion-v1-5/stable-diffusion-inpainting" ^
@@ -617,6 +624,9 @@ curl http://localhost:8000/v3/images/edits ^
617624
-F "num_inference_steps=50" ^
618625
-F "size=768x768"
619626
```
627+
:::
628+
629+
::::
620630

621631
Expected output (`outpaint_output.png`):
622632

@@ -665,22 +675,6 @@ For the full list see [supported image generation models](https://openvinotoolki
665675
> **Note:** Dedicated inpainting models only expose the `images/edits` endpoint (with mask). Text-to-image and image-to-image requests will return an error indicating the pipeline is not available for this model. Base models (e.g. `stable-diffusion-v1-5/stable-diffusion-v1-5`) support all endpoints including inpainting.
666676
667677
::::{tab-set}
668-
:::{tab-item} Docker (Linux) — CPU
669-
:sync: docker
670-
```bash
671-
mkdir -p models
672-
673-
docker run -d --rm --user $(id -u):$(id -g) -p 8000:8000 -v $(pwd)/models:/models/:rw \
674-
-e http_proxy=$http_proxy -e https_proxy=$https_proxy -e no_proxy=$no_proxy \
675-
openvino/model_server:latest \
676-
--rest_port 8000 \
677-
--model_repository_path /models/ \
678-
--task image_generation \
679-
--source_model stable-diffusion-v1-5/stable-diffusion-inpainting \
680-
--weight-format int8
681-
```
682-
:::
683-
684678
:::{tab-item} Docker (Linux) — GPU
685679
:sync: docker-gpu
686680
```bash
@@ -708,7 +702,8 @@ ovms --rest_port 8000 ^
708702
--model_repository_path ./models/ ^
709703
--task image_generation ^
710704
--source_model stable-diffusion-v1-5/stable-diffusion-inpainting ^
711-
--weight-format int8
705+
--weight-format int8 ^
706+
--target_device GPU
712707
```
713708
:::
714709

src/image_gen/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ ovms_cc_library(
2424
deps = [
2525
"imagegenpipelineargs",
2626
"//src:libovmslogging",
27+
"//src:libovms_queue",
2728
"//src:libovmsstring_utils",
2829
"//third_party:genai",],
2930
visibility = ["//visibility:public"],

src/image_gen/http_image_gen_calculator.cc

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,10 @@ class ImageGenCalculator : public CalculatorBase {
179179
SET_OR_RETURN(std::string, prompt, getPromptField(*payload.parsedJson));
180180
SET_OR_RETURN(ov::AnyMap, requestOptions, getImageGenerationRequestOptions(*payload.parsedJson, pipe->args));
181181

182-
// single request assumption - use pipeline instance directly
183182
if (!pipe->text2ImagePipeline)
184183
return absl::FailedPreconditionError("Text-to-image pipeline is not available for this model");
185-
auto status = generateTensor(*pipe->text2ImagePipeline, prompt, requestOptions, images);
184+
auto t2i = pipe->text2ImagePipeline->clone();
185+
auto status = generateTensor(t2i, prompt, requestOptions, images);
186186
if (!status.ok()) {
187187
return status;
188188
}
@@ -218,13 +218,18 @@ class ImageGenCalculator : public CalculatorBase {
218218
if (!status.ok()) {
219219
return status;
220220
}
221-
SPDLOG_LOGGER_DEBUG(llm_calculator_logger, "ImageGenCalculator [Node: {}] Inpainting: mask tensor decoded, invoking generate()", cc->NodeName());
221+
SPDLOG_LOGGER_DEBUG(llm_calculator_logger, "ImageGenCalculator [Node: {}] Inpainting: mask tensor decoded, acquiring inpainting queue slot", cc->NodeName());
222+
InpaintingQueueGuard inpaintingGuard(*pipe->inpaintingQueue, ImageGenerationPipelines::DEFAULT_INPAINTING_TIMEOUT);
223+
if (!inpaintingGuard.acquired()) {
224+
return absl::DeadlineExceededError("Inpainting pipeline is busy, request timed out");
225+
}
226+
SPDLOG_LOGGER_DEBUG(llm_calculator_logger, "ImageGenCalculator [Node: {}] Inpainting: queue slot acquired, invoking generate()", cc->NodeName());
222227
status = generateTensorInpainting(*pipe->inpaintingPipeline, prompt, imageTensor, maskTensor, requestOptions, images);
223228
} else {
224229
if (!pipe->image2ImagePipeline)
225230
return absl::FailedPreconditionError("Image-to-image pipeline is not available for this model");
226-
// image-to-image path - single pipeline instance, no clone needed
227-
status = generateTensorImg2Img(*pipe->image2ImagePipeline, prompt, imageTensor, requestOptions, images);
231+
auto i2i = pipe->image2ImagePipeline->clone();
232+
status = generateTensorImg2Img(i2i, prompt, imageTensor, requestOptions, images);
228233
}
229234
if (!status.ok()) {
230235
return status;

src/image_gen/pipelines.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ ImageGenerationPipelines::ImageGenerationPipelines(const ImageGenPipelineArgs& a
138138
throw std::runtime_error("Failed to create any image generation pipeline from: " + args.modelsPath);
139139
}
140140

141+
// InpaintingPipeline does not support clone(), so concurrent inpainting
142+
// requests must be serialized. Queue size = 1 acts as a mutex.
143+
if (inpaintingPipeline) {
144+
inpaintingQueue = std::make_unique<Queue<int>>(1);
145+
}
146+
141147
SPDLOG_INFO("Image Generation Pipelines ready — T2I: {} | I2I: {} | INP: {}",
142148
text2ImagePipeline ? "OK" : "N/A",
143149
image2ImagePipeline ? "OK" : "N/A",

src/image_gen/pipelines.hpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,61 @@
1515
//*****************************************************************************
1616
#pragma once
1717

18+
#include <chrono>
19+
#include <future>
1820
#include <memory>
21+
#include <optional>
1922
#include <string>
2023

2124
#include <openvino/genai/image_generation/image2image_pipeline.hpp>
2225
#include <openvino/genai/image_generation/inpainting_pipeline.hpp>
2326
#include <openvino/genai/image_generation/text2image_pipeline.hpp>
2427

2528
#include "imagegenpipelineargs.hpp"
29+
#include "src/queue.hpp"
2630

2731
namespace ovms {
32+
33+
// RAII guard that acquires a slot from a Queue<int>(1) on construction
34+
// and returns it on destruction, serializing concurrent inpainting requests.
35+
class InpaintingQueueGuard {
36+
public:
37+
// Acquires a slot or sets acquired_ = false on timeout.
38+
InpaintingQueueGuard(Queue<int>& queue, std::chrono::seconds timeout) :
39+
queue_(queue) {
40+
auto future = queue_.getIdleStream();
41+
if (future.wait_for(timeout) == std::future_status::ready) {
42+
streamId_ = future.get();
43+
acquired_ = true;
44+
}
45+
}
46+
~InpaintingQueueGuard() {
47+
if (acquired_) {
48+
queue_.returnStream(streamId_);
49+
}
50+
}
51+
bool acquired() const { return acquired_; }
52+
53+
InpaintingQueueGuard(const InpaintingQueueGuard&) = delete;
54+
InpaintingQueueGuard& operator=(const InpaintingQueueGuard&) = delete;
55+
56+
private:
57+
Queue<int>& queue_;
58+
int streamId_ = -1;
59+
bool acquired_ = false;
60+
};
61+
2862
struct ImageGenerationPipelines {
2963
std::unique_ptr<ov::genai::Image2ImagePipeline> image2ImagePipeline;
3064
std::unique_ptr<ov::genai::Text2ImagePipeline> text2ImagePipeline;
3165
std::unique_ptr<ov::genai::InpaintingPipeline> inpaintingPipeline;
3266
ImageGenPipelineArgs args;
3367

68+
// Serializes concurrent inpainting requests (InpaintingPipeline lacks clone()).
69+
// Queue size = 1: only one inpainting inference runs at a time.
70+
std::unique_ptr<Queue<int>> inpaintingQueue;
71+
static constexpr std::chrono::seconds DEFAULT_INPAINTING_TIMEOUT{300}; // 5 minutes
72+
3473
ImageGenerationPipelines() = delete;
3574
ImageGenerationPipelines(const ImageGenPipelineArgs& args);
3675
ImageGenerationPipelines(const ImageGenerationPipelines&) = delete;

src/pull_module/optimum_export.cpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ std::string OptimumDownloader::getExportCmdEmbeddings() {
5454
oss << "--disable-convert-tokenizer --task feature-extraction --library sentence_transformers";
5555
oss << " --model " << this->sourceModel << " --trust-remote-code ";
5656
oss << " --weight-format " << this->exportSettings.precision;
57+
if (this->exportSettings.extraQuantizationParams.has_value()) {
58+
oss << " " << this->exportSettings.extraQuantizationParams.value();
59+
}
5760
oss << " " << this->downloadPath;
5861
// clang-format on
5962

@@ -69,6 +72,9 @@ std::string OptimumDownloader::getExportCmdTextToSpeech() {
6972
}
7073
oss << "--model " << this->sourceModel << " --trust-remote-code ";
7174
oss << " --weight-format " << this->exportSettings.precision;
75+
if (this->exportSettings.extraQuantizationParams.has_value()) {
76+
oss << " " << this->exportSettings.extraQuantizationParams.value();
77+
}
7278
oss << " " << this->downloadPath;
7379
// clang-format on
7480

@@ -81,6 +87,9 @@ std::string OptimumDownloader::getExportCmdSpeechToText() {
8187
oss << this->OPTIMUM_CLI_EXPORT_COMMAND;
8288
oss << "--model " << this->sourceModel << " --trust-remote-code ";
8389
oss << " --weight-format " << this->exportSettings.precision;
90+
if (this->exportSettings.extraQuantizationParams.has_value()) {
91+
oss << " " << this->exportSettings.extraQuantizationParams.value();
92+
}
8493
oss << " " << this->downloadPath;
8594
// clang-format on
8695

@@ -95,6 +104,9 @@ std::string OptimumDownloader::getExportCmdRerank() {
95104
oss << " --trust-remote-code ";
96105
oss << " --weight-format " << this->exportSettings.precision;
97106
oss << " --task text-classification ";
107+
if (this->exportSettings.extraQuantizationParams.has_value()) {
108+
oss << " " << this->exportSettings.extraQuantizationParams.value();
109+
}
98110
oss << " " << this->downloadPath;
99111
// clang-format on
100112

@@ -109,7 +121,7 @@ std::string OptimumDownloader::getExportCmdImageGeneration() {
109121
oss << " --weight-format " << this->exportSettings.precision;
110122
if (this->exportSettings.extraQuantizationParams.has_value()) {
111123
oss << " " << this->exportSettings.extraQuantizationParams.value();
112-
} // TODO FIXME check if its not needed to propagate to other exports
124+
}
113125
oss << " " << this->downloadPath;
114126
// clang-format on
115127

src/test/pull_hf_model_test.cpp

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ class TestOptimumDownloaderSetup : public ::testing::Test {
345345
inHfSettings.sourceModel = "model/name";
346346
inHfSettings.downloadPath = "/path/to/Download";
347347
inHfSettings.exportSettings.precision = "fp64";
348-
inHfSettings.exportSettings.extraQuantizationParams = "--param --param value";
348+
inHfSettings.exportSettings.extraQuantizationParams = "--someOptimumParam --anotherOptParam value";
349349
inHfSettings.task = ovms::TEXT_GENERATION_GRAPH;
350350
inHfSettings.downloadType = ovms::OPTIMUM_CLI_DOWNLOAD;
351351
#ifdef _WIN32
@@ -371,7 +371,7 @@ class TestOptimumDownloaderSetupWithFile : public TestOptimumDownloaderSetup {
371371
TEST_F(TestOptimumDownloaderSetup, Methods) {
372372
std::unique_ptr<TestOptimumDownloader> optimumDownloader = std::make_unique<TestOptimumDownloader>(inHfSettings);
373373
std::string expectedPath = inHfSettings.downloadPath + "/" + inHfSettings.sourceModel;
374-
std::string expectedCmd = "optimum-cli export openvino --model model/name --trust-remote-code --weight-format fp64 --param --param value \\path\\to\\Download\\model\\name";
374+
std::string expectedCmd = "optimum-cli export openvino --model model/name --trust-remote-code --weight-format fp64 --someOptimumParam --anotherOptParam value \\path\\to\\Download\\model\\name";
375375
std::string expectedCmd2 = "convert_tokenizer model/name --with-detokenizer -o \\path\\to\\Download\\model\\name";
376376
#ifdef _WIN32
377377
std::replace(expectedPath.begin(), expectedPath.end(), '/', '\\');
@@ -388,7 +388,7 @@ TEST_F(TestOptimumDownloaderSetup, Methods) {
388388
TEST_F(TestOptimumDownloaderSetup, RerankExportCmd) {
389389
inHfSettings.task = ovms::RERANK_GRAPH;
390390
std::unique_ptr<TestOptimumDownloader> optimumDownloader = std::make_unique<TestOptimumDownloader>(inHfSettings);
391-
std::string expectedCmd = "optimum-cli export openvino --disable-convert-tokenizer --model model/name --trust-remote-code --weight-format fp64 --task text-classification \\path\\to\\Download\\model\\name";
391+
std::string expectedCmd = "optimum-cli export openvino --disable-convert-tokenizer --model model/name --trust-remote-code --weight-format fp64 --task text-classification --someOptimumParam --anotherOptParam value \\path\\to\\Download\\model\\name";
392392
std::string expectedCmd2 = "convert_tokenizer model/name -o \\path\\to\\Download\\model\\name";
393393
#ifdef __linux__
394394
std::replace(expectedCmd.begin(), expectedCmd.end(), '\\', '/');
@@ -401,7 +401,7 @@ TEST_F(TestOptimumDownloaderSetup, RerankExportCmd) {
401401
TEST_F(TestOptimumDownloaderSetup, ImageGenExportCmd) {
402402
inHfSettings.task = ovms::IMAGE_GENERATION_GRAPH;
403403
std::unique_ptr<TestOptimumDownloader> optimumDownloader = std::make_unique<TestOptimumDownloader>(inHfSettings);
404-
std::string expectedCmd = "optimum-cli export openvino --model model/name --weight-format fp64 --param --param value \\path\\to\\Download\\model\\name";
404+
std::string expectedCmd = "optimum-cli export openvino --model model/name --weight-format fp64 --someOptimumParam --anotherOptParam value \\path\\to\\Download\\model\\name";
405405
std::string expectedCmd2 = "";
406406
#ifdef __linux__
407407
std::replace(expectedCmd.begin(), expectedCmd.end(), '\\', '/');
@@ -426,7 +426,7 @@ TEST_F(TestOptimumDownloaderSetup, ImageGenExportCmdNoExtraParams) {
426426
TEST_F(TestOptimumDownloaderSetup, EmbeddingsExportCmd) {
427427
inHfSettings.task = ovms::EMBEDDINGS_GRAPH;
428428
std::unique_ptr<TestOptimumDownloader> optimumDownloader = std::make_unique<TestOptimumDownloader>(inHfSettings);
429-
std::string expectedCmd = "optimum-cli export openvino --disable-convert-tokenizer --task feature-extraction --library sentence_transformers --model model/name --trust-remote-code --weight-format fp64 \\path\\to\\Download\\model\\name";
429+
std::string expectedCmd = "optimum-cli export openvino --disable-convert-tokenizer --task feature-extraction --library sentence_transformers --model model/name --trust-remote-code --weight-format fp64 --someOptimumParam --anotherOptParam value \\path\\to\\Download\\model\\name";
430430
std::string expectedCmd2 = "convert_tokenizer model/name -o \\path\\to\\Download\\model\\name";
431431
#ifdef __linux__
432432
std::replace(expectedCmd.begin(), expectedCmd.end(), '\\', '/');
@@ -440,7 +440,7 @@ TEST_F(TestOptimumDownloaderSetup, TextToSpeechExportCmd) {
440440
inHfSettings.task = ovms::TEXT_TO_SPEECH_GRAPH;
441441
inHfSettings.exportSettings.vocoder = "microsoft/speecht5_hifigan";
442442
std::unique_ptr<TestOptimumDownloader> optimumDownloader = std::make_unique<TestOptimumDownloader>(inHfSettings);
443-
std::string expectedCmd = "optimum-cli export openvino --model-kwargs \"{\"vocoder\": \"microsoft/speecht5_hifigan\"}\" --model model/name --trust-remote-code --weight-format fp64 \\path\\to\\Download\\model\\name";
443+
std::string expectedCmd = "optimum-cli export openvino --model-kwargs \"{\"vocoder\": \"microsoft/speecht5_hifigan\"}\" --model model/name --trust-remote-code --weight-format fp64 --someOptimumParam --anotherOptParam value \\path\\to\\Download\\model\\name";
444444
std::string expectedCmd2 = "convert_tokenizer model/name -o \\path\\to\\Download\\model\\name";
445445
#ifdef __linux__
446446
std::replace(expectedCmd.begin(), expectedCmd.end(), '\\', '/');
@@ -453,7 +453,7 @@ TEST_F(TestOptimumDownloaderSetup, TextToSpeechExportCmd) {
453453
TEST_F(TestOptimumDownloaderSetup, SpeechToTextExportCmd) {
454454
inHfSettings.task = ovms::SPEECH_TO_TEXT_GRAPH;
455455
std::unique_ptr<TestOptimumDownloader> optimumDownloader = std::make_unique<TestOptimumDownloader>(inHfSettings);
456-
std::string expectedCmd = "optimum-cli export openvino --model model/name --trust-remote-code --weight-format fp64 \\path\\to\\Download\\model\\name";
456+
std::string expectedCmd = "optimum-cli export openvino --model model/name --trust-remote-code --weight-format fp64 --someOptimumParam --anotherOptParam value \\path\\to\\Download\\model\\name";
457457
std::string expectedCmd2 = "convert_tokenizer model/name -o \\path\\to\\Download\\model\\name";
458458
#ifdef __linux__
459459
std::replace(expectedCmd.begin(), expectedCmd.end(), '\\', '/');

src/test/text2image_test.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,14 @@ TEST(Image2ImageTest, getImageEditRequestOptionsRejectedFields) {
797797
ASSERT_FALSE(std::holds_alternative<ov::AnyMap>(requestOptions));
798798
}
799799

800+
TEST(Image2ImageTest, getImageEditRequestOptionsMaskAccepted) {
801+
MockedMultiPartParser multipartParser;
802+
ON_CALL(multipartParser, getFieldByName("prompt")).WillByDefault(Return("test prompt"));
803+
ON_CALL(multipartParser, getAllFieldNames()).WillByDefault(Return(std::set<std::string>{"prompt", "mask"}));
804+
auto requestOptions = ovms::getImageEditRequestOptions(multipartParser, DEFAULTIMAGE_GEN_ARGS);
805+
ASSERT_TRUE(std::holds_alternative<ov::AnyMap>(requestOptions));
806+
}
807+
800808
using mediapipe::CalculatorGraphConfig;
801809
using ovms::ImageGenPipelineArgs;
802810
TEST(ImageGenCalculatorOptionsTest, PositiveAllfields) {

0 commit comments

Comments
 (0)