diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..381459476
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+/start-server.sh text eol=lf
+/start-server.bat text eol=crlf
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 9539e9a33..35418c280 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -26,6 +26,8 @@ on:
"ggml",
"examples/server/frontend",
"examples/server/frontend/**",
+ "start-server.sh",
+ "start-server.bat",
]
pull_request:
types: [opened, synchronize, reopened]
@@ -44,6 +46,8 @@ on:
"ggml",
"examples/server/frontend",
"examples/server/frontend/**",
+ "start-server.sh",
+ "start-server.bat",
]
env:
@@ -107,7 +111,8 @@ jobs:
run: |
cp ggml/LICENSE ./build/bin/ggml.txt
cp LICENSE ./build/bin/stable-diffusion.cpp.txt
- zip -j sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-${{ steps.system-info.outputs.OS_TYPE }}-${{ steps.system-info.outputs.OS_NAME }}-${{ steps.system-info.outputs.OS_VERSION }}-${{ steps.system-info.outputs.CPU_ARCH }}.zip ./build/bin/*
+ chmod +x start-server.sh
+ zip -j sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-${{ steps.system-info.outputs.OS_TYPE }}-${{ steps.system-info.outputs.OS_NAME }}-${{ steps.system-info.outputs.OS_VERSION }}-${{ steps.system-info.outputs.CPU_ARCH }}.zip ./build/bin/* ./start-server.sh ./start-server.bat
- name: Upload artifacts
if: ${{ ( github.event_name == 'push' && github.ref == 'refs/heads/master' ) || github.event.inputs.create_release == 'true' }}
@@ -171,7 +176,8 @@ jobs:
run: |
cp ggml/LICENSE ./build/bin/ggml.txt
cp LICENSE ./build/bin/stable-diffusion.cpp.txt
- zip -j sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-${{ steps.system-info.outputs.OS_TYPE }}-${{ steps.system-info.outputs.OS_NAME }}-${{ steps.system-info.outputs.OS_VERSION }}-${{ steps.system-info.outputs.CPU_ARCH }}-vulkan.zip ./build/bin/*
+ chmod +x start-server.sh
+ zip -j sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-${{ steps.system-info.outputs.OS_TYPE }}-${{ steps.system-info.outputs.OS_NAME }}-${{ steps.system-info.outputs.OS_VERSION }}-${{ steps.system-info.outputs.CPU_ARCH }}-vulkan.zip ./build/bin/* ./start-server.sh ./start-server.bat
- name: Upload artifacts
if: ${{ ( github.event_name == 'push' && github.ref == 'refs/heads/master' ) || github.event.inputs.create_release == 'true' }}
@@ -326,7 +332,8 @@ jobs:
run: |
cp ggml/LICENSE ./build/bin/ggml.txt
cp LICENSE ./build/bin/stable-diffusion.cpp.txt
- zip -j sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-${{ steps.system-info.outputs.OS_TYPE }}-${{ steps.system-info.outputs.OS_NAME }}-${{ steps.system-info.outputs.OS_VERSION }}-${{ steps.system-info.outputs.CPU_ARCH }}.zip ./build/bin/*
+ chmod +x start-server.sh
+ zip -j sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-${{ steps.system-info.outputs.OS_TYPE }}-${{ steps.system-info.outputs.OS_NAME }}-${{ steps.system-info.outputs.OS_VERSION }}-${{ steps.system-info.outputs.CPU_ARCH }}.zip ./build/bin/* ./start-server.sh ./start-server.bat
- name: Upload artifacts
if: ${{ ( github.event_name == 'push' && github.ref == 'refs/heads/master' ) || github.event.inputs.create_release == 'true' }}
@@ -418,7 +425,7 @@ jobs:
ls .\build\bin
throw "Can't find stable-diffusion.dll"
}
- 7z a sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-win-${{ matrix.build }}-x64.zip $filePath
+ 7z a sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-win-${{ matrix.build }}-x64.zip $filePath .\start-server.sh .\start-server.bat
- name: Copy and pack Cuda runtime
id: pack_cuda_runtime
@@ -579,7 +586,7 @@ jobs:
- name: Pack artifacts
if: ${{ ( github.event_name == 'push' && github.ref == 'refs/heads/master' ) || github.event.inputs.create_release == 'true' }}
run: |
- 7z a sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-win-rocm-${{ env.ROCM_VERSION }}-x64.zip .\build\bin\*
+ 7z a sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-win-rocm-${{ env.ROCM_VERSION }}-x64.zip .\build\bin\* .\start-server.sh .\start-server.bat
- name: Upload artifacts
if: ${{ ( github.event_name == 'push' && github.ref == 'refs/heads/master' ) || github.event.inputs.create_release == 'true' }}
@@ -743,7 +750,8 @@ jobs:
run: |
cp ggml/LICENSE ./build/bin/ggml.txt
cp LICENSE ./build/bin/stable-diffusion.cpp.txt
- zip -y -r sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-${{ steps.system-info.outputs.OS_TYPE }}-Ubuntu-${{ env.UBUNTU_VERSION }}-${{ steps.system-info.outputs.CPU_ARCH }}-rocm-${{ matrix.ROCM_VERSION }}.zip ./build/bin
+ chmod +x start-server.sh
+ zip -y -r sd-${{ env.BRANCH_NAME }}-${{ steps.commit.outputs.short }}-bin-${{ steps.system-info.outputs.OS_TYPE }}-Ubuntu-${{ env.UBUNTU_VERSION }}-${{ steps.system-info.outputs.CPU_ARCH }}-rocm-${{ matrix.ROCM_VERSION }}.zip ./build/bin ./start-server.sh ./start-server.bat
- name: Upload artifacts
if: ${{ ( github.event_name == 'push' && github.ref == 'refs/heads/master' ) || github.event.inputs.create_release == 'true' }}
diff --git a/.gitignore b/.gitignore
index 80599b3f8..b55ea0f81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,10 @@ tests/
.cache/
*.swp
*.bat
+!/start-server.bat
+/start-server.local.sh
+/start-server.local.bat
+/start-server.local.ps1
*.bin
*.exe
*.gguf
diff --git a/AGENTS.md b/AGENTS.md
index 4953e2278..a48040869 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,6 +83,8 @@ documentation.
and debugging.
* `examples/common/` - shared example support code.
* `examples/server/` - server application built on top of the library.
+ Before modifying built-in recipes, read and follow
+ [the built-in recipe policy](examples/server/recipes/README.md).
* `examples/server/frontend/` - git submodule containing independent frontend
code. Avoid modifying it unless explicitly requested.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index bd7c6d9e9..d7bfda599 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -352,9 +352,9 @@ if (SD_HIPBLAS)
set_property(SOURCE src/core/ggml_extend_backend.cpp APPEND PROPERTY COMPILE_DEFINITIONS SD_HIP_MFMA)
endif()
endif()
-target_include_directories(${SD_LIB} PUBLIC . src include)
-target_include_directories(${SD_LIB} PRIVATE src/core)
-target_include_directories(${SD_LIB} PUBLIC . thirdparty)
+target_include_directories(${SD_LIB} PUBLIC include)
+target_include_directories(${SD_LIB} PRIVATE src src/core)
+target_include_directories(${SD_LIB} PUBLIC thirdparty)
target_compile_features(${SD_LIB} PUBLIC c_std_11 cxx_std_17)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1f4c06792..ca64ad198 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,6 +33,17 @@ PRs should include:
The [UIs list in the README](README.md#uis) only includes open-source projects that use `stable-diffusion.cpp` as a backend. Do not submit PRs to add closed-source UIs.
+## Server Recipes
+
+Before adding or changing a built-in server recipe, read and follow the
+[built-in recipe policy](examples/server/recipes/README.md). The policy governs
+native editing, community editing LoRAs, distinct task checkpoints, external
+adapters, native components and layer-count variants. Do not add recipes solely
+for style or acceleration LoRAs, separately stored native components, or added
+or removed model layers. Explain the task, component compatibility or capability
+difference that justifies a new recipe, and verify its required components,
+feature declarations and embedded API representation.
+
## Code Style
Format code according to the repository style before submitting changes.
diff --git a/README.md b/README.md
index abb2dcb20..b4ab2f5ab 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ API and command-line option may change frequently.***
## π₯Important News
+* **2026/10/16** π stable-diffusion.cpp adds **Day-0 support for MOSS-Image-Base / MOSS-Image-SFT**
* **2026/10/16** π stable-diffusion.cpp adds **Day-0 support for FLUX.3 Image Klein / Klein Base**
* **2026/09/20** π stable-diffusion.cpp adds **Day-0 support for Qwen-Image-2.1**
* **2026/08/20** π stable-diffusion.cpp now supports **LTX-2.5**
@@ -46,6 +47,7 @@ API and command-line option may change frequently.***
- [FLUX.1-dev/FLUX.1-schnell](./docs/flux.md)
- [FLUX.2-dev/FLUX.2-klein](./docs/flux2.md)
- [FLUX.3 Image Klein / Klein Base](./docs/flux3.md)
+ - [MOSS-Image-Base / MOSS-Image-SFT](./docs/moss_image.md)
- [Lens](./docs/lens.md)
- [Chroma](./docs/chroma.md)
- [Chroma1-Radiance](./docs/chroma_radiance.md)
diff --git a/assets/moss/sft.png b/assets/moss/sft.png
new file mode 100644
index 000000000..ede42d1f7
Binary files /dev/null and b/assets/moss/sft.png differ
diff --git a/docker/Dockerfile.musa b/docker/Dockerfile.musa
index 7dda431aa..08d6f1d43 100644
--- a/docker/Dockerfile.musa
+++ b/docker/Dockerfile.musa
@@ -1,7 +1,6 @@
-ARG MUSA_VERSION=rc4.2.0
-ARG UBUNTU_VERSION=22.04
+ARG MUSA_IMAGE=registry.mthreads.com/mcconline/inference/pytorch:2.9.1.post1-py3.10-musa5.2.0-mp31-devel-ubuntu22.04-amd64
-FROM mthreads/musa:${MUSA_VERSION}-devel-ubuntu${UBUNTU_VERSION}-amd64 as build
+FROM ${MUSA_IMAGE} AS build
# sd-server embeds the web UI at build time, so the build image needs Node/pnpm.
RUN apt-get update && apt-get install -y --no-install-recommends ccache cmake git ca-certificates curl gnupg && \
@@ -22,9 +21,8 @@ COPY . .
RUN mkdir build && cd build && \
cmake .. -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
- -DCMAKE_C_FLAGS="${CMAKE_C_FLAGS} -fopenmp -I/usr/lib/llvm-14/lib/clang/14.0.0/include -L/usr/lib/llvm-14/lib" \
- -DCMAKE_CXX_FLAGS="${CMAKE_CXX_FLAGS} -fopenmp -I/usr/lib/llvm-14/lib/clang/14.0.0/include -L/usr/lib/llvm-14/lib" \
-DSD_MUSA=ON \
+ -DMUSA_ARCHITECTURES=31 \
-DSD_BUILD_SHARED_LIBS=ON \
-DGGML_NATIVE=OFF \
-DSD_BUILD_SHARED_GGML_LIB=ON \
@@ -35,7 +33,7 @@ RUN mkdir build && cd build && \
-DCMAKE_BUILD_TYPE=Release && \
cmake --build . --config Release
-FROM mthreads/musa:${MUSA_VERSION}-runtime-ubuntu${UBUNTU_VERSION}-amd64 as runtime
+FROM ${MUSA_IMAGE} AS runtime
COPY --from=build /sd.cpp/build/bin /sd.cpp/bin
RUN printf '#!/bin/sh\nexec /sd.cpp/bin/sd-cli "$@"\n' > /sd-cli && \
@@ -45,3 +43,4 @@ RUN printf '#!/bin/sh\nexec /sd.cpp/bin/sd-cli "$@"\n' > /sd-cli && \
WORKDIR /sd.cpp
ENTRYPOINT [ "/sd-cli" ]
+CMD []
diff --git a/docs/build.md b/docs/build.md
index 9daaa219b..6ec0e40f0 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -119,14 +119,16 @@ cmake --build . --config Release
## Build with MUSA
-This provides GPU acceleration using Moore Threads GPU. Make sure to have the MUSA toolkit installed.
+This provides GPU acceleration using Moore Threads GPUs. The configuration below follows upstream validation with MUSA SDK 5.2.0 and MTT S5000 (PH1, `mp_31`). Install that toolkit before building. Older SDKs such as 4.2 may lack the CUB headers required by the current backend.
```shell
mkdir build && cd build
-cmake .. -DCMAKE_C_COMPILER=/usr/local/musa/bin/clang -DCMAKE_CXX_COMPILER=/usr/local/musa/bin/clang++ -DSD_MUSA=ON -DCMAKE_BUILD_TYPE=Release
+cmake .. -DCMAKE_C_COMPILER=/usr/local/musa/bin/clang -DCMAKE_CXX_COMPILER=/usr/local/musa/bin/clang++ -DSD_MUSA=ON -DMUSA_ARCHITECTURES=31 -DCMAKE_BUILD_TYPE=Release
cmake --build . --config Release
```
+The MUSA Docker image uses the same SDK 5.2.0 development image as upstream for both build and runtime stages, and targets `mp_31` only. It includes the development tools and PyTorch from that base image, so it is larger than a minimal runtime image. Other GPU architectures require separate validation.
+
## Build with Metal
Using Metal makes the computation run on the GPU. Currently, there are some issues with Metal when performing operations on very large matrices, making it highly inefficient at the moment. Performance improvements are expected in the near future.
diff --git a/docs/flux3.md b/docs/flux3.md
index dd11e4044..8bc0b2ec2 100644
--- a/docs/flux3.md
+++ b/docs/flux3.md
@@ -6,8 +6,10 @@ FLUX.3 Image Klein uses a FLUX.3 diffusion transformer, the FLUX.2 VAE, and Qwen
- Download FLUX.3 Image Klein
- safetensors: https://huggingface.co/black-forest-labs/flux-3-image-klein/tree/main
+ - gguf: https://huggingface.co/leejet/flux-3-image-klein-GGUF/tree/main
- Download FLUX.3 Image Klein Base
- safetensors: https://huggingface.co/black-forest-labs/flux-3-image-klein-base/tree/main
+ - gguf: https://huggingface.co/leejet/flux-3-image-klein-base-GGUF/tree/main
- Download vae
- safetensors: https://huggingface.co/black-forest-labs/FLUX.2-dev/tree/main
- The `ae.safetensors` included with FLUX.3 can also be used.
diff --git a/docs/moss_image.md b/docs/moss_image.md
new file mode 100644
index 000000000..27ab26f80
--- /dev/null
+++ b/docs/moss_image.md
@@ -0,0 +1,37 @@
+# How to Use
+
+MOSS-Image uses a 5B diffusion transformer, the FLUX.2 VAE, and Qwen3.5 9B as the LLM text encoder. Both Base and SFT are supported. Prompt enhancement is not included.
+
+## Download weights
+
+- Download MOSS-Image Base or SFT
+ - safetensors: https://huggingface.co/Comfy-Org/MOSS-Image/tree/main/diffusion_models
+ - gguf:
+ - Base: https://huggingface.co/leejet/MOSS-Image-Base-GGUF/tree/main
+ - SFT: https://huggingface.co/leejet/MOSS-Image-SFT-GGUF/tree/main
+- Download vae
+ - safetensors: https://huggingface.co/Comfy-Org/MOSS-Image/tree/main/vae
+- Download Qwen3.5 9B
+ - gguf: https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/tree/main
+- Download `tokenizer.json`
+ - https://huggingface.co/OpenMOSS-Team/MOSS-Image-Base/tree/main/tokenizer
+
+Pass the external `tokenizer.json` with `--tokenizer`; Qwen3.5 uses a different vocabulary from the embedded Qwen tokenizer.
+
+## Examples
+
+### MOSS-Image Base
+
+```
+.\bin\Release\sd-cli.exe --diffusion-model ..\models\diffusion_models\moss_image_base_int8_convrot_attn.safetensors --llm ..\models\text_encoders\Qwen3.5-9B-Q4_K_M.gguf --vae ..\models\vae\flux2_ae.safetensors --tokenizer ..\models\tokenizers\tokenizer_qwen3.5.json --cfg-scale 3.0 -p "a lovely cat holding a sign says 'moss-image.cpp'" -v --offload-to-cpu -H 1024 -W 1024
+```
+
+### MOSS-Image SFT
+
+```
+.\bin\Release\sd-cli.exe --diffusion-model ..\models\diffusion_models\moss_image_sft_int8_convrot_attn.safetensors --llm ..\models\text_encoders\Qwen3.5-9B-Q4_K_M.gguf --vae ..\models\vae\flux2_ae.safetensors --tokenizer ..\models\tokenizers\tokenizer_qwen3.5.json --cfg-scale 3.0 -p "a lovely cat holding a sign says 'moss-image.cpp'" -v --offload-to-cpu -H 1024 -W 1024
+```
+
+
+
+Use dimensions divisible by 16. The default discrete scheduler uses the upstream time shift of 5. Use `--flow-shift` to override it. The negative CFG branch skips DiT layer 22 (zero-based), matching the upstream pipeline.
diff --git a/examples/cli/image_metadata.cpp b/examples/cli/image_metadata.cpp
index 8dd339c88..ba0d6da23 100644
--- a/examples/cli/image_metadata.cpp
+++ b/examples/cli/image_metadata.cpp
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -276,7 +277,7 @@ namespace {
}
bool read_file(const std::string& path, std::vector& data, std::string& error) {
- std::ifstream fin(path, std::ios::binary);
+ std::ifstream fin(std::filesystem::u8path(path), std::ios::binary);
if (!fin) {
error = "failed to open file: " + path;
return false;
diff --git a/examples/cli/main.cpp b/examples/cli/main.cpp
index e8174d27c..ca8ce4e79 100644
--- a/examples/cli/main.cpp
+++ b/examples/cli/main.cpp
@@ -188,7 +188,7 @@ struct SDCliParams {
if (++index >= argc) {
return -1;
}
- imatrix_in.push_back(argv[index]);
+ imatrix_in.push_back(argv_to_utf8(index, argv));
return 1;
};
@@ -323,13 +323,14 @@ void sd_log_cb(enum sd_log_level_t level, const char* log, void* data) {
bool load_images_from_dir(const std::string dir,
std::vector& images,
int max_image_num = 0) {
- if (!fs::exists(dir) || !fs::is_directory(dir)) {
+ const fs::path dir_path = fs::u8path(dir);
+ if (!fs::exists(dir_path) || !fs::is_directory(dir_path)) {
LOG_ERROR("'%s' is not a valid directory\n", dir.c_str());
return false;
}
std::vector entries;
- for (const auto& entry : fs::directory_iterator(dir)) {
+ for (const auto& entry : fs::directory_iterator(dir_path)) {
if (entry.is_regular_file()) {
entries.push_back(entry);
}
@@ -337,12 +338,12 @@ bool load_images_from_dir(const std::string dir,
std::sort(entries.begin(), entries.end(),
[](const fs::directory_entry& a, const fs::directory_entry& b) {
- return a.path().filename().string() < b.path().filename().string();
+ return a.path().filename().u8string() < b.path().filename().u8string();
});
for (const auto& entry : entries) {
- std::string path = entry.path().string();
- std::string ext = entry.path().extension().string();
+ std::string path = entry.path().u8string();
+ std::string ext = entry.path().extension().u8string();
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
if (ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".bmp" || ext == ".webp") {
@@ -397,19 +398,19 @@ void step_callback(int step, int frame_count, sd_image_t* image, bool is_noisy,
// is_noisy is set to true if the preview corresponds to noisy latents, false if it's denoised latents
// unused in this app, it will either be always noisy or always denoised here
if (frame_count == 1) {
- fs::path path = cli_params->preview_path;
- if (encoded_image_format_from_path(path.string()) == EncodedImageFormat::UNKNOWN)
+ fs::path path = fs::u8path(cli_params->preview_path);
+ if (encoded_image_format_from_path(path.u8string()) == EncodedImageFormat::UNKNOWN)
path += ".png";
- if (std::regex_search(path.string(), format_specifier_regex))
- path = fs::path(format_frame_idx(path.string(), continuous_preview_counter++));
- if (!write_image_to_file(path.string(),
+ if (std::regex_search(path.u8string(), format_specifier_regex))
+ path = fs::u8path(format_frame_idx(path.u8string(), continuous_preview_counter++));
+ if (!write_image_to_file(path.u8string(),
image->data,
image->width,
image->height,
image->channel,
"",
cli_params->compression_quality)) {
- LOG_ERROR("save preview image to '%s' failed", path.string().c_str());
+ LOG_ERROR("save preview image to '%s' failed", path.u8string().c_str());
}
} else {
int fps = cli_params->preview_method == PREVIEW_PROJ ? cli_params->preview_fps / 4 : cli_params->preview_fps;
@@ -420,12 +421,12 @@ void step_callback(int step, int frame_count, sd_image_t* image, bool is_noisy,
}
static fs::path get_video_audio_sidecar_path(const SDCliParams& cli_params) {
- fs::path out_path = cli_params.output_path;
+ fs::path out_path = fs::u8path(cli_params.output_path);
fs::path base_path = out_path;
fs::path ext = out_path.has_extension() ? out_path.extension() : fs::path{};
- std::string ext_lower = ext.string();
+ std::string ext_lower = ext.u8string();
std::transform(ext_lower.begin(), ext_lower.end(), ext_lower.begin(), ::tolower);
- const EncodedImageFormat output_format = encoded_image_format_from_path(out_path.string());
+ const EncodedImageFormat output_format = encoded_image_format_from_path(out_path.u8string());
if (!ext.empty()) {
if (output_format == EncodedImageFormat::JPEG ||
output_format == EncodedImageFormat::PNG ||
@@ -450,14 +451,14 @@ bool save_results(const SDCliParams& cli_params,
}
namespace fs = std::filesystem;
- fs::path out_path = cli_params.output_path;
+ fs::path out_path = fs::u8path(cli_params.output_path);
if (!out_path.parent_path().empty()) {
std::error_code ec;
fs::create_directories(out_path.parent_path(), ec);
if (ec) {
LOG_ERROR("failed to create directory '%s': %s",
- out_path.parent_path().string().c_str(), ec.message().c_str());
+ out_path.parent_path().u8string().c_str(), ec.message().c_str());
return false;
}
}
@@ -465,9 +466,9 @@ bool save_results(const SDCliParams& cli_params,
fs::path base_path = out_path;
fs::path ext = out_path.has_extension() ? out_path.extension() : fs::path{};
- std::string ext_lower = ext.string();
+ std::string ext_lower = ext.u8string();
std::transform(ext_lower.begin(), ext_lower.end(), ext_lower.begin(), ::tolower);
- const EncodedImageFormat output_format = encoded_image_format_from_path(out_path.string());
+ const EncodedImageFormat output_format = encoded_image_format_from_path(out_path.u8string());
if (!ext.empty()) {
if (output_format == EncodedImageFormat::JPEG ||
output_format == EncodedImageFormat::PNG ||
@@ -493,8 +494,8 @@ bool save_results(const SDCliParams& cli_params,
std::string params = gen_params.embed_image_metadata
? get_image_params(ctx_params, gen_params, metadata_seed, cli_params.mode)
: "";
- const bool ok = write_image_to_file(path.string(), img.data, img.width, img.height, img.channel, params, cli_params.compression_quality);
- LOG_INFO("save result image %d to '%s' (%s)", idx, path.string().c_str(), ok ? "success" : "failure");
+ const bool ok = write_image_to_file(path.u8string(), img.data, img.width, img.height, img.channel, params, cli_params.compression_quality);
+ LOG_INFO("save result image %d to '%s' (%s)", idx, path.u8string().c_str(), ok ? "success" : "failure");
return ok;
};
@@ -502,14 +503,14 @@ bool save_results(const SDCliParams& cli_params,
if (generated_audio == nullptr) {
return;
}
- if (write_wav_to_file(wav_path.string(),
+ if (write_wav_to_file(wav_path.u8string(),
generated_audio->data,
generated_audio->sample_count,
generated_audio->channels,
generated_audio->sample_rate)) {
- LOG_INFO("save result audio to '%s'", wav_path.string().c_str());
+ LOG_INFO("save result audio to '%s'", wav_path.u8string().c_str());
} else {
- LOG_WARN("failed to save result audio to '%s'", wav_path.string().c_str());
+ LOG_WARN("failed to save result audio to '%s'", wav_path.u8string().c_str());
}
};
@@ -522,7 +523,7 @@ bool save_results(const SDCliParams& cli_params,
pattern += ext;
for (int i = 0; i < num_results; ++i) {
- fs::path img_path = format_frame_idx(pattern.string(), output_begin_idx + i);
+ fs::path img_path = fs::u8path(format_frame_idx(pattern.u8string(), output_begin_idx + i));
if (write_image(img_path, i)) {
sucessful_reults++;
}
@@ -540,11 +541,11 @@ bool save_results(const SDCliParams& cli_params,
fs::path video_path = base_path;
video_path += ext;
- std::string final_ext_lower = ext.string();
+ std::string final_ext_lower = ext.u8string();
std::transform(final_ext_lower.begin(), final_ext_lower.end(), final_ext_lower.begin(), ::tolower);
const bool mux_audio = generated_audio != nullptr && (final_ext_lower == ".avi" || final_ext_lower == ".webm");
- if (create_video_from_sd_images(video_path.string().c_str(), results, num_results, gen_params.fps, cli_params.compression_quality, mux_audio ? generated_audio : nullptr, params) == 0) {
- LOG_INFO("save result video to '%s'", video_path.string().c_str());
+ if (create_video_from_sd_images(video_path.u8string().c_str(), results, num_results, gen_params.fps, cli_params.compression_quality, mux_audio ? generated_audio : nullptr, params) == 0) {
+ LOG_INFO("save result video to '%s'", video_path.u8string().c_str());
if (generated_audio != nullptr && !mux_audio) {
fs::path wav_path = video_path;
wav_path.replace_extension(".wav");
@@ -552,7 +553,7 @@ bool save_results(const SDCliParams& cli_params,
}
return true;
} else {
- LOG_ERROR("Failed to save result video to '%s'", video_path.string().c_str());
+ LOG_ERROR("Failed to save result video to '%s'", video_path.u8string().c_str());
return false;
}
}
@@ -701,7 +702,7 @@ int main(int argc, const char* argv[]) {
LOG_DEBUG("%s", gen_params.to_string().c_str());
if (!cli_params.imatrix_out.empty()) {
- if (fs::exists(cli_params.imatrix_out) &&
+ if (fs::exists(fs::u8path(cli_params.imatrix_out)) &&
std::find(cli_params.imatrix_in.begin(), cli_params.imatrix_in.end(), cli_params.imatrix_out) == cli_params.imatrix_in.end()) {
LOG_WARN("imatrix file '%s' already exists and will be overwritten", cli_params.imatrix_out.c_str());
}
diff --git a/examples/common/common.cpp b/examples/common/common.cpp
index 11b531f46..9e7851fef 100644
--- a/examples/common/common.cpp
+++ b/examples/common/common.cpp
@@ -75,7 +75,7 @@ static std::string utf16_to_utf8(const std::wstring& wstr) {
return utf8;
}
-static std::string argv_to_utf8(int index, const char** argv) {
+std::string argv_to_utf8(int index, const char** argv) {
(void)argv;
int argc;
wchar_t** argv_w = CommandLineToArgvW(GetCommandLineW(), &argc);
@@ -91,7 +91,7 @@ static std::string argv_to_utf8(int index, const char** argv) {
}
#else // Linux / macOS
-static std::string argv_to_utf8(int index, const char** argv) {
+std::string argv_to_utf8(int index, const char** argv) {
return std::string(argv[index]);
}
@@ -839,16 +839,17 @@ ArgOptions SDContextParams::get_options() {
void SDContextParams::build_embedding_map() {
static const std::vector valid_ext = {".gguf", ".safetensors", ".pt", ".ckpt"};
- if (!fs::exists(embedding_dir) || !fs::is_directory(embedding_dir)) {
+ const fs::path embedding_dir_path = fs::u8path(embedding_dir);
+ if (!fs::exists(embedding_dir_path) || !fs::is_directory(embedding_dir_path)) {
return;
}
- for (auto& p : fs::directory_iterator(embedding_dir)) {
+ for (auto& p : fs::directory_iterator(embedding_dir_path)) {
if (!p.is_regular_file())
continue;
auto path = p.path();
- std::string ext = path.extension().string();
+ std::string ext = path.extension().u8string();
bool valid = false;
for (auto& e : valid_ext) {
@@ -860,8 +861,8 @@ void SDContextParams::build_embedding_map() {
if (!valid)
continue;
- std::string key = path.stem().string();
- std::string value = path.string();
+ std::string key = path.stem().u8string();
+ std::string value = path.u8string();
embedding_map[key] = value;
}
@@ -1572,7 +1573,7 @@ ArgOptions SDGenerationParams::get_options() {
if (++index >= argc) {
return -1;
}
- ref_image_paths.push_back(argv[index]);
+ ref_image_paths.push_back(argv_to_utf8(index, argv));
return 1;
};
@@ -1580,7 +1581,7 @@ ArgOptions SDGenerationParams::get_options() {
if (++index >= argc) {
return -1;
}
- ref_video_paths.push_back(argv[index]);
+ ref_video_paths.push_back(argv_to_utf8(index, argv));
return 1;
};
@@ -1588,7 +1589,7 @@ ArgOptions SDGenerationParams::get_options() {
if (++index >= argc) {
return -1;
}
- ref_video_audio_paths.push_back(argv[index]);
+ ref_video_audio_paths.push_back(argv_to_utf8(index, argv));
return 1;
};
@@ -1596,7 +1597,7 @@ ArgOptions SDGenerationParams::get_options() {
if (++index >= argc) {
return -1;
}
- ref_audio_paths.push_back(argv[index]);
+ ref_audio_paths.push_back(argv_to_utf8(index, argv));
return 1;
};
@@ -1604,7 +1605,7 @@ ArgOptions SDGenerationParams::get_options() {
if (++index >= argc) {
return -1;
}
- ref_audio_paths.push_back(argv[index]);
+ ref_audio_paths.push_back(argv_to_utf8(index, argv));
return 1;
};
@@ -1703,15 +1704,15 @@ ArgOptions SDGenerationParams::get_options() {
if (++index >= argc) {
return -1;
}
- const char* arg = argv[index];
- std::ifstream f(arg, std::ios::binary);
+ const std::string arg = argv_to_utf8(index, argv);
+ std::ifstream f(fs::u8path(arg), std::ios::binary);
try {
prompt = std::string(std::istreambuf_iterator{f}, {});
} catch (const std::ios_base::failure&) {
f.setstate(std::ios_base::failbit);
}
if (f.fail()) {
- LOG_ERROR("error: failed to read prompt file '%s'\n", arg);
+ LOG_ERROR("error: failed to read prompt file '%s'\n", arg.c_str());
return -1;
}
return 1;
@@ -1721,15 +1722,15 @@ ArgOptions SDGenerationParams::get_options() {
if (++index >= argc) {
return -1;
}
- const char* arg = argv[index];
- std::ifstream f(arg, std::ios::binary);
+ const std::string arg = argv_to_utf8(index, argv);
+ std::ifstream f(fs::u8path(arg), std::ios::binary);
try {
negative_prompt = std::string(std::istreambuf_iterator{f}, {});
} catch (const std::ios_base::failure&) {
f.setstate(std::ios_base::failbit);
}
if (f.fail()) {
- LOG_ERROR("error: failed to read negative prompt file '%s'\n", arg);
+ LOG_ERROR("error: failed to read negative prompt file '%s'\n", arg.c_str());
return -1;
}
return 1;
@@ -2050,22 +2051,22 @@ static bool resolve_model_file_from_dir(const std::string& model_name,
if (model_name.empty() ||
model_name.find('/') != std::string::npos ||
model_name.find('\\') != std::string::npos ||
- fs::path(model_name).has_root_path() ||
+ fs::u8path(model_name).has_root_path() ||
ends_with_valid_ext()) {
LOG_ERROR("%s must be a model name without path or extension: %s", label, model_name.c_str());
return false;
}
- fs::path model_dir_path = model_dir;
+ fs::path model_dir_path = fs::u8path(model_dir);
for (const auto& ext : valid_ext) {
- fs::path try_path = model_dir_path / (model_name + ext);
+ fs::path try_path = model_dir_path / fs::u8path(model_name + ext);
if (fs::exists(try_path) && fs::is_regular_file(try_path)) {
- resolved_path = try_path.lexically_normal().string();
+ resolved_path = try_path.lexically_normal().u8string();
return true;
}
}
- LOG_ERROR("can not find %s %s in %s", label, model_name.c_str(), model_dir_path.lexically_normal().string().c_str());
+ LOG_ERROR("can not find %s %s in %s", label, model_name.c_str(), model_dir_path.lexically_normal().u8string().c_str());
return false;
}
@@ -2217,7 +2218,8 @@ bool SDGenerationParams::from_json_str(
}
if (target_custom_sigmas != nullptr &&
sample_json.contains("custom_sigmas") &&
- sample_json["custom_sigmas"].is_array()) {
+ sample_json["custom_sigmas"].is_array() &&
+ !sample_json["custom_sigmas"].empty()) { // empty [] = "not specified", keep server/CLI default
*target_custom_sigmas = sample_json["custom_sigmas"].get>();
}
if (sample_json.contains("sample_method") && sample_json["sample_method"].is_string()) {
@@ -2359,10 +2361,18 @@ bool SDGenerationParams::extract_and_remove_lora(const std::string& lora_model_d
}
fs::path final_path;
- if (is_absolute_path(raw_path)) {
- final_path = raw_path;
- } else {
- final_path = fs::path(lora_model_dir) / raw_path;
+ try {
+ if (is_absolute_path(raw_path)) {
+ final_path = fs::u8path(raw_path);
+ } else {
+ final_path = fs::u8path(lora_model_dir) / fs::u8path(raw_path);
+ }
+ } catch (const std::exception&) { // not UTF-8, e.g. a prompt file in another encoding
+ LOG_WARN("invalid lora path %s", raw_path.c_str());
+ tmp = m.suffix().str();
+ prompt = std::regex_replace(prompt, re, "", std::regex_constants::format_first_only);
+ missing = true;
+ continue;
}
if (!fs::exists(final_path)) {
bool found = false;
@@ -2376,7 +2386,7 @@ bool SDGenerationParams::extract_and_remove_lora(const std::string& lora_model_d
}
}
if (!found) {
- LOG_WARN("can not found lora %s", final_path.lexically_normal().string().c_str());
+ LOG_WARN("can not found lora %s", final_path.lexically_normal().u8string().c_str());
tmp = m.suffix().str();
prompt = std::regex_replace(prompt, re, "", std::regex_constants::format_first_only);
missing = true;
@@ -2384,7 +2394,7 @@ bool SDGenerationParams::extract_and_remove_lora(const std::string& lora_model_d
}
}
- const std::string key = final_path.lexically_normal().string();
+ const std::string key = final_path.lexically_normal().u8string();
if (is_high_noise)
high_noise_lora_map[key] += mul;
diff --git a/examples/common/common.h b/examples/common/common.h
index 74a057d0b..91e75c685 100644
--- a/examples/common/common.h
+++ b/examples/common/common.h
@@ -12,6 +12,10 @@
#include "resource_owners.hpp"
#include "stable-diffusion.h"
+// The command-line argument at `index` as UTF-8 (on Windows, read again from
+// the UTF-16 command line: argv itself is in the ANSI code page).
+std::string argv_to_utf8(int index, const char** argv);
+
#define SAFE_STR(s) ((s) ? (s) : "")
#define BOOL_STR(b) ((b) ? "true" : "false")
diff --git a/examples/common/media_io.cpp b/examples/common/media_io.cpp
index ba0e8c5b2..baac6d200 100644
--- a/examples/common/media_io.cpp
+++ b/examples/common/media_io.cpp
@@ -15,10 +15,12 @@
#define STB_IMAGE_IMPLEMENTATION
#define STB_IMAGE_STATIC
+#define STBI_WINDOWS_UTF8
#include "stb_image.h"
#define STB_IMAGE_WRITE_IMPLEMENTATION
#define STB_IMAGE_WRITE_STATIC
+#define STBIW_WINDOWS_UTF8
#include "stb_image_write.h"
#define STB_IMAGE_RESIZE_IMPLEMENTATION
@@ -147,7 +149,7 @@ class MemoryMkvWriter : public mkvmuxer::IMkvWriter {
#endif
bool read_binary_file_bytes(const char* path, std::vector& data) {
- std::ifstream fin(fs::path(path), std::ios::binary);
+ std::ifstream fin(fs::u8path(path), std::ios::binary);
if (!fin) {
return false;
}
@@ -170,7 +172,7 @@ bool read_binary_file_bytes(const char* path, std::vector& data) {
}
bool write_binary_file_bytes(const std::string& path, const std::vector& data) {
- std::ofstream fout(fs::path(path), std::ios::binary);
+ std::ofstream fout(fs::u8path(path), std::ios::binary);
if (!fout) {
return false;
}
@@ -714,7 +716,7 @@ static std::pair audio_sample_range_for_video_frame(const sd
}
EncodedImageFormat encoded_image_format_from_path(const std::string& path) {
- std::string ext = fs::path(path).extension().string();
+ std::string ext = fs::u8path(path).extension().u8string();
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
if (ext == ".jpg" || ext == ".jpeg" || ext == ".jpe") {
@@ -1413,7 +1415,7 @@ bool write_wav_to_file(const std::string& path,
return false;
}
- std::ofstream file(path, std::ios::binary);
+ std::ofstream file(fs::u8path(path), std::ios::binary);
if (!file.is_open()) {
return false;
}
@@ -1475,7 +1477,7 @@ bool load_wav_from_file(const std::string& path,
sample_rate = 0;
channels = 0;
- std::ifstream file(path, std::ios::binary);
+ std::ifstream file(fs::u8path(path), std::ios::binary);
uint8_t riff_header[12];
if (!file.read(reinterpret_cast(riff_header), sizeof(riff_header)) ||
std::memcmp(riff_header, "RIFF", 4) != 0 ||
diff --git a/examples/server/CMakeLists.txt b/examples/server/CMakeLists.txt
index 302a8d609..58260be0d 100644
--- a/examples/server/CMakeLists.txt
+++ b/examples/server/CMakeLists.txt
@@ -68,6 +68,8 @@ add_executable(${TARGET}
../common/media_io.cpp
main.cpp
runtime.cpp
+ model_catalog.cpp
+ routes_models.cpp
async_jobs.cpp
routes_index.cpp
routes_openai.cpp
@@ -88,6 +90,17 @@ else()
message(STATUS "HAVE_INDEX_HTML disabled")
endif()
+file(GLOB BUILTIN_RECIPE_FILES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/recipes/*.json")
+set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${BUILTIN_RECIPE_FILES})
+# Keep recipes in separate literals to stay within MSVC's string size limit.
+set(SD_BUILTIN_RECIPES "")
+foreach(RECIPE_FILE IN LISTS BUILTIN_RECIPE_FILES)
+ file(READ "${RECIPE_FILE}" RECIPE_JSON)
+ string(APPEND SD_BUILTIN_RECIPES "R\"recipes(${RECIPE_JSON})recipes\",\n")
+endforeach()
+configure_file(recipes_builtin.h.in "${CMAKE_CURRENT_BINARY_DIR}/recipes_builtin.h" @ONLY)
+target_include_directories(${TARGET} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}")
+
install(TARGETS ${TARGET} RUNTIME)
target_link_libraries(${TARGET} PRIVATE stable-diffusion ${CMAKE_THREAD_LIBS_INIT})
if(SD_WEBP)
diff --git a/examples/server/README.md b/examples/server/README.md
index 7ead1d9e2..1bb49a59f 100644
--- a/examples/server/README.md
+++ b/examples/server/README.md
@@ -1,3 +1,248 @@
+# Model selection
+
+Start without model arguments to select models in the web UI:
+
+```sh
+sd-server --model-dir ./models
+```
+
+From a source checkout, you can also use `sh ./start-server.sh` on Linux/macOS
+or `.\start-server.bat` on Windows. The launchers look for the server relative
+to the script: beside the script, then in `bin/Release/`, `bin/`,
+`build/bin/Release/`, and `build/bin/`, in that order.
+For a different build directory, set `SD_SERVER_PATH` to the executable path:
+
+```sh
+SD_SERVER_PATH="./build_vulkan/bin/sd-server" sh ./start-server.sh --listen-port 8080
+```
+
+```powershell
+$env:SD_SERVER_PATH = ".\build_vulkan\bin\Release\sd-server.exe"
+.\start-server.bat --listen-port 8080
+```
+
+Binary release archives include both launchers. Keep the launchers under version
+control and put personal settings in a separate file beside them:
+
+- Windows batch: `start-server.local.bat`
+- Linux/macOS: `start-server.local.sh`
+
+These local files are ignored by Git and excluded from release archives. They
+are loaded automatically after the launcher's defaults. They can override
+`SD_SERVER_PATH`, `MODEL_DIR`, and `COMMANDLINE_ARGS`; there is no need to edit
+the tracked launchers. For example:
+
+```bat
+@echo off
+set "MODEL_DIR=D:\stable-diffusion-webui\models"
+set "COMMANDLINE_ARGS=--listen-port 8080 --offload-to-cpu"
+```
+
+```sh
+MODEL_DIR="/data/stable-diffusion-webui/models"
+COMMANDLINE_ARGS="--listen-port 8080 --offload-to-cpu"
+```
+
+Each launcher prints the resolved command before execution and forwards the
+server's exit code.
+
+`SD_SERVER_PATH` inherits the environment value by default; leave it empty for
+automatic discovery, or set it in the local file to select another executable.
+Settings are applied in this order: launcher defaults, local configuration,
+then invocation arguments. Repeated `--model-dir` arguments add roots, so they
+do not replace the `MODEL_DIR` root. Local configuration files are shell scripts
+and use the corresponding shell's syntax.
+
+The launchers preserve the current working directory, so the default
+`MODEL_DIR="./models"` is relative to where you start the launcher. Arguments
+passed when invoking the launcher follow these defaults. Repeated `--model-dir`
+arguments add search roots rather than replacing `MODEL_DIR`; set `MODEL_DIR` in
+the local configuration to change the first root. Other single-value options can override the defaults.
+In the shell script, `COMMANDLINE_ARGS` is a
+whitespace-separated list; pass values containing spaces as quoted arguments
+when invoking the script. `MODEL_DIR` itself supports spaces in both scripts.
+A leading `--version` bypasses the configured defaults. An explicit
+`SD_SERVER_PATH` is used without falling back to another build if it is invalid.
+
+Choose a recipe, select the required component files, and click **Load models**.
+Recipe and component selectors support searching by name or relative file ID.
+Use the arrow keys and Enter to select, or Escape to cancel a search. With
+**Apply recipe defaults** enabled, selecting a recipe immediately applies its
+declared generation defaults while preserving prompts, input images and fields
+not specified by the recipe. Disable it to keep your current parameters when
+switching recipes. This preference defaults to enabled and is saved locally.
+Model loading and server-info refreshes do not reapply defaults or reset the form.
+Recipes declaring `video_frames` also switch the form to Video Generation;
+other recipes switch to Image Generation. Generation is enabled after the
+selected recipe is loaded. Changing a selection does not reload the model
+until you click Load.
+The server can also start with the existing model arguments shown below;
+existing API clients then work without selecting a recipe. The UI automatically
+selects **Command-line model (all features)** and uses the startup generation
+defaults. This runtime-only recipe exposes all controls for the supported
+image/video mode. Selecting another recipe previews a model switch; selecting
+the command-line entry again restores its generation defaults. The entry is
+removed when the command-line context is unloaded or replaced.
+
+Built-in recipes cover SD 1/2/3/3.5, SDXL and distilled checkpoints;
+FLUX 1/2/3, Chroma, Qwen Image and editing variants; Z-Image, Anima, ERNIE,
+LENS, PiD and other supported image families; AnimateDiff, Wan, LTX,
+HunyuanVideo, LingBot Video and MiniMax-H3. Model-specific text encoder
+labels identify the expected encoder family and size. Loading a video model enables **Video Generation**; I2V also requires a
+start image. The Models panel can be collapsed and remembers its open state.
+Its header shows elapsed loading time from the status API and keeps the final
+time after success or failure, including after a page refresh.
+Built-ins are embedded in the
+binary from [recipes/](recipes/), with one JSON file per recipe. Adding or
+editing a built-in requires rebuilding the server. User recipes are read from
+`/recipes/*.json`. A valid user recipe with the same ID replaces
+the entire built-in recipe. Across roots, the first valid user recipe wins;
+overrides are logged. Duplicate user IDs within one root are rejected; invalid
+recipes are reported without removing a valid recipe from another root or the
+built-in version. Slots need only `id` and
+`required`; the server supplies labels and search directories. Use `label`
+only when a recipe needs a more specific display name.
+
+Recipes use `features` to enable specialized controls and `disabled_features`
+to turn off shared defaults. Disabled features take precedence. The UI hides
+unsupported fields, empty sections and irrelevant sampler/cache options, and
+omits hidden values from generation requests. See the
+[feature declarations](api.md#recipe-feature-declarations) for the defaults
+and complete list of supported names.
+
+Built-in additions and changes must follow the [recipe policy](recipes/README.md).
+Split recipes by task weights or added capabilities, not by style or acceleration
+LoRAs. Quantizations, file formats and compatible layer-count variants share a
+recipe. Native encoders, projectors and connectors stay in the model's recipe.
+Community editing recipes require selecting the matching LoRA separately; a
+recipe does not enable it automatically.
+
+See [generation defaults and sources](recipes/defaults.md) for the selected
+publisher recommendations and reference-code defaults, with step defaults capped
+at 20. Recipes link to the
+original publisher's Hugging Face repository where one is available. DiT
+recipes default to Euler, except for models requiring a specialized sampler.
+
+Compatible recipes offer a TAE slot alongside VAE. When the checkpoint does not
+bundle a VAE, select at least one of VAE or TAE. The Models panel shows a reminder
+below the model selectors until this requirement is met. A selected TAE is used for
+encoding and decoding, including when a full VAE is also selected; use complete,
+model-compatible TAE weights rather than decoder-only preview weights. Qwen
+Image 2.1 uses the same recipe for its full VAE and TAEQI2.1 alternatives.
+
+The following variants have dedicated recipes:
+
+| Model | Recipe IDs / notes |
+| --- | --- |
+| Z-Image L2P | `z-image-l2p`; no VAE |
+| LLaDA-Image | `llada-image` (base) and `llada-image-turbo`; use matching encoders and connectors |
+| PixArt Alpha | `pixart-alpha-512`, `pixart-alpha-1024`; Alpha VAE scale and positional settings |
+| PiD / PiD 1.5 | `pid` / `pid-15` for Flux or Z-Image VAE; append `-sd3`, `-flux2`, or `-qwen-image` for other latent formats |
+| SDXL adapters | `sdxl-ip-adapter` supports classic and Plus ViT-H adapters; `sdxl-controlnet` requires compatible SDXL weights |
+| FLUX.1 Fill / Depth | `flux1-fill` uses Init Image and Mask Image; `flux1-depth` uses a precomputed depth map in Control Image |
+| SD2 / SDXL inpainting | `sd2-inpainting`, `sdxl-inpainting` |
+| SD-Turbo | `sd-turbo`; single-file SD2.1-derived checkpoint |
+| Community editing LoRAs | `anima-edit`, `krea2-raw-edit`, `krea2-turbo-edit`; apply a matching LoRA after loading |
+| HunyuanVideo 1.5 | `hunyuan-video-15-t2v`, `hunyuan-video-15-i2v`; choose the task's checkpoint |
+| Wan 2.1 / compatible single Wan 2.2 experts | `wan21-t2v`, `wan21-i2v`; the I2V recipe also accepts compatible SkyReels V2 weights |
+| Qwen Image 2.1 Turbo | `qwen-image-21-turbo`; standalone Turbo weights with the fixed 8-step schedule, Euler and CFG 1 |
+| MOSS Image | `moss-image-base`, `moss-image-sft`; choose the corresponding checkpoint |
+
+PiD always requires a reference image and uses the first one. Its recipes expose
+reference inputs rather than ordinary image-to-image inputs. IP-Adapter recipes
+expose a separate IP-Adapter image and strength. FLUX Canny/Depth consume Control
+Image directly without a separate ControlNet; the UI does not calculate edge
+or depth maps. Their strength is controlled by guidance, not ControlNet strength.
+Krea2 edit recipes use the default `krea2_ostris_edit` reference preset, so the
+editing LoRA must match that convention. Ordinary Anima and Krea2 recipes do not
+expose reference-image editing inputs.
+
+Fill/Depth guidance defaults follow the [Diffusers FLUX documentation](https://huggingface.co/docs/diffusers/api/pipelines/flux).
+SD-Turbo uses the [model card's one-step configuration](https://huggingface.co/stabilityai/sd-turbo),
+with CFG disabled using scale 1 in sd.cpp.
+
+SVD is not included: legacy model recognition and UNet branches remain, but the
+current runner/conditioning setup does not provide a dedicated SVD image-conditioning
+path. Adding a recipe alone would not establish a working SVD pipeline.
+
+Reference images, masks, audio, first/last video frames, and generation options
+such as upscaling remain generation inputs. Hardware and memory options remain
+startup arguments. Tiny SD/Tiny SD2 and Diffusers LCM directories must first be
+converted into single-file checkpoints for the file catalog. LCM checkpoints
+and LoRAs use the matching SD family recipe; select LCM sampling and adjust
+steps/CFG for the chosen weights.
+Metadata inspection, conversion, standalone upscaling and ADetailer are CLI
+workflows rather than model-loading recipes. ESRGAN and LTX latent upscalers
+are selected through generation upscaling settings with `--hires-upscalers-dir`;
+textual inversion uses the startup `--embd-dir` option.
+
+Repeat `--model-dir` to share multiple model directories, including an existing
+SD WebUI model directory:
+
+```sh
+sd-server --model-dir ./models --model-dir /data/stable-diffusion-webui/models
+```
+
+With no `--model-dir`, the server uses `./models`. Explicit roots replace that
+default and are scanned in argument order. Duplicate canonical roots and file
+paths are scanned once. Every root can contain a `recipes/` directory.
+Directory names are matched case-insensitively on all platforms:
+
+| Category | Directory names |
+| --- | --- |
+| Checkpoints | `checkpoints`, `Stable-diffusion` |
+| VAE | `vae`, `VAE` |
+| LoRA | `loras`, `Lora` |
+| ControlNet | `controlnet`, `ControlNet` |
+
+Other categories keep their existing directory names. Classification uses only
+the containing directory: `*.vae.safetensors`, `*.vae.ckpt`, and similar names
+inside `checkpoints/` or `Stable-diffusion/` remain checkpoints.
+
+Place model files in category directories, for example:
+
+```text
+models/
+ checkpoints/
+ diffusion_models/
+ text_encoders/
+ vae/
+ clip_vision/
+ loras/
+ recipes/
+ my-flux.json
+```
+
+Subdirectories within a model category are supported. Use **Refresh files &
+recipes** after adding files or changing recipes. Scanning lists file names
+without loading model weights. Category membership does not guarantee
+compatibility; the model loader performs the final compatibility check.
+Symlinks resolving outside the model directory are rejected. Dropdown labels
+omit the category prefix while retaining subdirectory names. Identical display
+paths are displayed as full file paths, with source directories before the
+filename instead of a hash suffix. The same applies to duplicate LoRA paths. File IDs include a
+root qualifier for additional roots; clients should use the returned IDs.
+Empty optional categories do not display missing-file hints.
+
+Place Tiny AutoEncoder (TAE) files in `vae/` and select them in the **TAE**
+slot. The legacy `vae_approx/` directory is also scanned for TAE selections.
+
+LoRAs are discovered automatically in both `loras/` and `Lora/` under every
+model root. Use `--lora-model-dir` to replace automatic discovery with one
+explicit LoRA directory. Duplicate names remain separate selectable entries. The LoRA selectors support
+searching by name or path with the same keyboard controls as model selectors.
+
+The server keeps one model context loaded. Loading or unloading is rejected
+while generation requests or queued jobs are pending. A switch validates the
+selection before unloading the old context, then loads the replacement.
+If loading fails, the service stays online with no active model and reports
+the error. Backend and memory options supplied at startup apply to subsequent
+loads. Recipe defaults apply to new loads; explicitly supplied generation
+request parameters take precedence.
+
+See [the API reference](api.md#model-management) for recipe fields, supported
+slots, management endpoints, and compatibility behavior.
+
# Example
The following example starts `sd-server` with a standalone diffusion model, VAE, and LLM text encoder:
diff --git a/examples/server/api.md b/examples/server/api.md
index cff4e8455..bb4252366 100644
--- a/examples/server/api.md
+++ b/examples/server/api.md
@@ -59,6 +59,293 @@ Current endpoints include:
- `POST /sdcpp/v1/vid_gen`
- `POST /sdcpp/v1/upscale`
+## Model management
+
+Model management is additive: existing generation request bodies, successful
+responses, job polling and cancellation remain unchanged. All three API families
+use the current loaded context. Starting with the existing model arguments
+preserves command-line preloading and its generation defaults; it does not
+require a recipe.
+
+Recipe defaults apply when a generation request omits the corresponding
+parameters. Image editing endpoints retain their existing input-image size
+inference when dimensions are omitted.
+
+Without a loaded model, generation endpoints return HTTP 503 with
+`{"error":"No model is loaded","code":"model_not_loaded"}`.
+While loading/unloading, endpoints that access the context return HTTP 503
+with `code: "model_loading"`. These errors retain the existing string-valued
+`error` field. Job polling/cancellation, catalog queries and model status remain
+available during loading. Model lists are empty when no model is loaded;
+`/v1/models` retains the `sd-cpp-local` ID when ready.
+
+| Method | Endpoint | Response / behavior |
+| --- | --- | --- |
+| GET | `/sdcpp/v1/recipes` | `{recipes: [...], errors: [...]}` |
+| GET | `/sdcpp/v1/model-files` | `{files: [{id, name, category, root, relative_path}], errors: [...]}` |
+| POST | `/sdcpp/v1/models/refresh` | Rescan recipes and files; return `{errors: [...]}` |
+| GET | `/sdcpp/v1/models/status` | Current state, recipe, selection, error, loading time and optional revision |
+| POST | `/sdcpp/v1/models/load` | Load the submitted recipe and selection; return status after completion |
+| POST | `/sdcpp/v1/models/unload` | Release the active context; return empty status |
+
+Load request:
+
+```json
+{
+ "recipe": "flux1-dev",
+ "models": {
+ "diffusion_model": "diffusion_models/flux1-dev.gguf",
+ "clip_l": "text_encoders/clip_l.safetensors",
+ "t5xxl": "text_encoders/t5xxl.safetensors",
+ "vae": "vae/ae.safetensors"
+ }
+}
+```
+
+File IDs are opaque strings returned by `model-files`. The first model root
+retains the existing relative IDs, such as `checkpoints/nested/model.gguf`.
+Additional roots use `@/` so identical paths in different
+roots remain selectable. Root qualifiers derive from the canonical root path;
+adding unrelated roots or refreshing the catalog does not change them. Moving
+a root changes its qualifier. Do not construct IDs or use display names in load
+requests.
+
+The display `name` omits the physical category directory (including aliases such
+as `Stable-diffusion`). Duplicate display paths use the full absolute file path,
+with the source directories before the filename and no hash suffix.
+`root` is an opaque source identifier, and
+`relative_path` is the file path beneath that root. Duplicate canonical files
+appear only once, using the first root that discovers them. Categories depend
+on directories, never on filename suffixes such as `.vae.safetensors`.
+
+Repeated `--model-dir` arguments merge roots in order; absent arguments default
+to `./models`. User recipes override built-ins; the earliest root containing a
+valid user recipe wins across roots. Duplicate recipe IDs within one root are
+rejected. Directory aliases do not change recipe slot categories.
+
+LoRA lists retain relative `path` IDs for `loras/` in the first model root and
+for an explicit `--lora-model-dir`, and qualify other directories. The additive `display_path` field provides a readable
+relative path, or the full absolute file path when needed to distinguish duplicates.
+Continue sending `path` in structured LoRA requests. `--lora-model-dir` overrides
+automatic discovery across all model roots.
+Absolute paths, unknown files, wrong categories and
+paths escaping the configured root are rejected. Optional slots can be omitted
+or set to an empty string. Load/unload returns HTTP 409 (`model_busy`) while
+requests or queued jobs are pending. Invalid configurations return HTTP 400
+(`invalid_model_config`) before unloading the current context. An initialization
+failure returns HTTP 422 (`model_load_failed`) and leaves no active context.
+The load HTTP request waits for completion; a separate client can poll status
+while it runs. Refreshing the catalog never modifies an already loaded context.
+
+Status uses `state: empty | loading | ready | error`, `recipe` (ID or null),
+`models` (slot-to-file-ID mapping), and `error` (string or null). A monotonically
+increasing `revision` identifies runtime transitions within this server process.
+Command-line preloading keeps `origin: command_line` and `recipe: null` for
+compatibility, and adds `runtime_recipe`: a read-only `@command-line` recipe
+with all feature declarations enabled. It is only available while that context
+is loaded and cannot be submitted to `models/load`. The UI selects it by default
+and uses command-line generation defaults, not defaults from a catalog recipe.
+Capabilities also include this status as an additive `runtime` field.
+
+`load_elapsed_seconds` is the server-measured duration in seconds (a number or
+null). It updates while a model is loading and freezes on success or failure.
+Timing starts after selection validation and includes releasing the previous
+model. It resets for each load and is null before any load and during/after
+unloading. Command-line preloading reports its completed initialization time.
+Poll `/sdcpp/v1/models/status` to display elapsed time, including while loading;
+timer updates alone do not change `revision`.
+
+### Recipe feature declarations
+
+Recipes may declare `features` and `disabled_features` arrays. The server starts
+with shared defaults, enables features declared by component slots and `features`,
+then applies `disabled_features` last. A disabled feature always wins. Unknown
+names, non-array values and duplicate names within an array are rejected.
+Both fields are optional; existing recipes remain valid.
+
+```json
+{
+ "features": ["flow_shift", "distilled_guidance"],
+ "disabled_features": ["clip_skip", "lora"]
+}
+```
+
+Default-enabled features are `img2img`, `mask_image`, `lora`, `vae_tiling`,
+`cache`, `cache_dit` and `preview`. All other features below default to false:
+
+| Feature | Controls |
+| --- | --- |
+| `flow_shift` | Flow shift, where used by the selected scheduler |
+| `distilled_guidance` | Distilled guidance embedding |
+| `shifted_timestep` | SDXL timestep shifting |
+| `img_cfg` | Image CFG |
+| `slg` | Skip-layer guidance fields |
+| `clip_skip` | CLIP Skip |
+| `img2img`, `mask_image` | Image initialization/strength and masks |
+| `ref_images` | Reference image inputs |
+| `controlnet` | Control image and ControlNet strength |
+| `control_image` | Control image without a separate ControlNet (FLUX Canny/Depth) |
+| `ip_adapter` | IP-Adapter image and strength |
+| `init_image`, `end_image` | Video start/end frames |
+| `control_frames`, `vace` | Video control frames and VACE strength |
+| `high_noise` | High-noise pass, MoE boundary and high-noise LoRAs |
+| `lora`, `vae_tiling` | LoRA overrides and VAE tiling |
+| `cache`, `cache_unet`, `cache_dit` | Cache panel and architecture-specific algorithms |
+| `preview`, `preview_tae` | Preview panel and TAE preview option |
+
+A `control_net` slot enables `controlnet`, `ip_adapter` enables `ip_adapter`,
+`high_noise_diffusion_model` enables `high_noise`, and `tae`/`taesd` enables `preview_tae`, unless explicitly disabled.
+ControlNet controls are shown when previewing a supporting recipe, even before
+a model file is selected. High-noise and TAE controls additionally require their
+component to be selected. Runtime capabilities require the component to be loaded.
+PuLID disables SLG while selected.
+Image-only and video-only inputs are filtered by the active generation mode.
+Eta depends on the selected sampler; SCM fields only apply to DBCache,
+TaylorSeer and Cache-DIT. Empty panels and advanced sections are hidden.
+
+`GET /sdcpp/v1/recipes` adds a resolved boolean `effective_features` object to
+each recipe. This is response-only metadata, like `source` and slot `categories`;
+omit it when writing a user recipe file. The defaults are maintained by the
+server, not duplicated by the frontend.
+
+`GET /sdcpp/v1/capabilities` adds `generation_features` for the loaded context,
+derived from the active recipe and component availability without inferring
+features from model-family names. When no model is loaded, every feature is
+present and false. The UI can still preview a selected recipe using its
+`effective_features` before loading.
+Existing `features` / `features_by_mode` keys are retained and their model-specific
+values reflect these declarations. A loaded recipe is snapshotted: refreshing
+recipe files does not change the active context's capabilities until reloading.
+Command-line preloading uses the runtime-only all-features recipe instead of model-family inference
+and component paths; variant-specific recipe declarations are more precise.
+
+These declarations govern the Web UI and advertised capabilities, not API
+request validation. Existing generation APIs continue accepting their existing
+fields. The Web UI omits unsupported/hidden fields and ignores hidden image
+inputs when building requests, so switching recipes cannot submit stale options.
+
+### User recipe format
+
+Contributions to the built-in catalog must follow the
+[built-in recipe policy](recipes/README.md). Personal user recipes may define
+workflow-specific presets without adding them to the built-in catalog.
+
+Each UTF-8 JSON file in `/recipes/` defines one complete recipe.
+The maximum recipe file size is 1 MiB. Unknown fields, invalid types and
+duplicate slots are errors. Built-ins in `examples/server/recipes/*.json` use
+the same one-recipe-per-file schema:
+
+```json
+{
+ "schema_version": 1,
+ "id": "my-flux",
+ "name": "My FLUX",
+ "description": "FLUX.1 dev",
+ "model_url": "https://huggingface.co/black-forest-labs/FLUX.1-dev",
+ "slots": [
+ {"id": "diffusion_model", "required": true},
+ {"id": "clip_l", "required": true},
+ {"id": "t5xxl", "required": true},
+ {"id": "vae", "required": false},
+ {"id": "tae", "required": false}
+ ],
+ "required_any": [["vae", "tae"]],
+ "defaults": {"width": 1024, "height": 1024, "cfg_scale": 1.0, "distilled_guidance": 3.5, "sampling_method": "euler", "steps": 20}
+}
+```
+
+IDs contain 1β128 ASCII letters, digits, dots, underscores or hyphens.
+`name`, `schema_version: 1` and `slots` are required; `description` and
+`model_url`, `required_any`, `defaults`, `context`, `features` and `disabled_features` are optional. Each slot requires only `id` and boolean `required`.
+The server supplies the default label and search directories for each slot.
+An optional nonempty `label` overrides the display name, for example
+`{"id": "diffusion_model", "label": "Low-noise diffusion model", "required": true}`.
+Legacy `category` fields remain accepted, but must match one of the slot's
+supported directories; they cannot redefine or restrict its search directories.
+At least one required `model` or `diffusion_model` slot is necessary.
+
+`model_url` is an optional HTTPS Hugging Face model repository link, shown beside
+the recipe description. Built-ins link to the model's author/publisher, not repacks.
+`required_any` contains groups of declared slot IDs. At least one model in each
+group must be selected. For example, `"required_any": [["vae", "tae"]]` with both
+slots marked `required: false` allows either the full VAE or a compatible TAE.
+Selecting both is allowed; the current server uses TAE for encoding/decoding when
+TAE is selected. Checkpoint recipes with a bundled VAE do not need this group.
+Use complete, model-compatible TAE weights; decoder-only preview weights cannot
+replace a full VAE. Pixel-space models and unsupported latent formats have no
+TAE slot.
+
+| Slot IDs | Search directories |
+| --- | --- |
+| `model` | `checkpoints` |
+| `diffusion_model`, `high_noise_diffusion_model`, `uncond_diffusion_model` | `diffusion_models` |
+| `clip_l`, `clip_g`, `t5xxl`, `llm`, `audio_encoder`, `embeddings_connectors` | `text_encoders` |
+| `clip_vision`, `llm_vision` | `clip_vision` |
+| `vae`, `audio_vae` | `vae` |
+| `tae` (legacy alias: `taesd`) | `vae` or `vae_approx` |
+| `tokenizer` | `tokenizers` |
+| `control_net` | `controlnet` |
+| `ip_adapter` | `ipadapter` |
+| `motion_module` | `motion_modules` |
+| `photo_maker` | `photomaker` |
+| `pulid_weights` | `pulid` |
+
+Built-in recipes use `tae` for the Tiny AutoEncoder slot. TAE selections can use files from either `vae/` or the legacy
+`vae_approx/` directory. Existing recipes and load requests using `taesd`
+remain supported. Do not specify both `tae` and `taesd` in the same recipe
+or load request.
+
+Model files can use `.gguf`, `.safetensors`, `.sft`, `.ckpt`, `.pt`, `.pth`
+or `.bin` extensions. The tokenizer category lists `.json` files only.
+Custom recipes compose components supported by the backend; they do not add
+new model architectures.
+
+Optional `context` settings apply to each model load and override the matching
+startup setting. Supported fields are `vae_format` (`auto`, `flux`, `sd3`,
+`flux2`, or `wan`) and `model_args` (a string in the same format as
+`--model-args`; replaces the startup string). Other startup settings remain
+unchanged. Switching to a recipe that omits these fields restores the startup
+values. For example, PiD uses `{"vae_format": "flux"}` and Qwen Image Edit
+2511 uses `{"model_args": "qwen_image_zero_cond_t=true"}`.
+
+Supported defaults:
+
+- Integer `width`/`height` (64β4096), `steps` (1β1000), `video_frames`
+ (1β4096), `fps` (1β240), `shifted_timestep` (0β1000), and
+ `high_noise_steps` (1β1000 or -1 for automatic).
+- Numeric `cfg_scale`, `img_cfg_scale`, `distilled_guidance`, `flow_shift`,
+ `high_noise_cfg_scale`, and `high_noise_flow_shift` (0β1000).
+- Numeric `strength` (0β1), used for image-to-image denoising, and
+ `moe_boundary` (0β1), used for automatic Wan MoE expert switching.
+- `custom_sigmas`: an empty array or 2β1001 finite, nonnegative numbers.
+ A nonempty array overrides the generated schedule; the sigma count minus one
+ determines the actual step count. Include the final zero. An empty array uses
+ the generated schedule. Loaded recipe sigmas apply to generation requests
+ unless `sample_params.custom_sigmas` overrides them; switching recipes restores
+ that recipe's schedule or the startup default.
+- Supported `sampling_method`/`scheduler` and
+ `high_noise_sampling_method`/`high_noise_scheduler` names.
+
+Other generation parameters continue to use startup defaults unless explicitly
+supplied by the generation request. Built-in Wan recipes provide video defaults;
+use `/sdcpp/v1/vid_gen` after loading a video model.
+LoRAs are merged from `loras/` and `Lora/` in every model root unless
+`--lora-model-dir` is supplied.
+
+Recipe responses fill in each slot's `label`, `category` (the legacy value,
+if supplied, or the first search directory), and `categories` (the complete
+server-defined list of search directories). Clients should filter model files
+using `categories`; `category` remains available for older clients.
+For example, `{"id": "tae", "required": false}` expands to
+`{"id": "tae", "required": false, "label": "TAE", "category": "vae", "categories": ["vae", "vae_approx"]}`.
+
+Recipe responses also add `source: builtin | user` and, for user recipes, `file`.
+Do not include `source`, `file`, or slot `categories` in a recipe file. A valid user recipe
+replaces a built-in with the same ID in full; inheritance/partial merging is
+not supported. Invalid recipes are skipped. Multiple valid user files sharing
+an ID are all skipped, preserving any built-in with that ID. Catalog errors
+are returned as `{file, message}` entries.
+
## `sd_cpp_extra_args`
`sd_cpp_extra_args` is an extension mechanism for the compatibility APIs.
@@ -155,6 +442,10 @@ image establishes the generation dimensions if `size` is omitted. Input
geometry follows `image_preprocess`: references preserve their dimensions by
default, while init and mask use the generation canvas preset.
+When the request starts without preconfigured reference images, the first uploaded image becomes the img2img init image if the loaded model does not support reference-image conditioning, the final reference list is empty, a mask is present, or an explicit numeric `strength` override is provided. Otherwise, only the final reference list is used for image conditioning, without initializing the img2img latent. Requests with preconfigured references retain their existing init-image selection.
+
+Routing uses the reference list and mask after applying `sd_cpp_extra_args`. Both `ref_images: []` and `ref_images: null` clear the reference list; `mask_image: null` clears an uploaded mask. An explicit `init_image` in `sd_cpp_extra_args`, including `null`, takes precedence over the implicit init-image selection.
+
Reference encoding then follows model presets and `ref_image_args`. To skip
input geometry for references and disable resizing before VAE encoding, include
this in `prompt`:
@@ -409,7 +700,7 @@ Common job shape:
"id": "job_01HTXYZABC",
"kind": "img_gen",
"status": "queued",
- "created": 1775401200,
+ "created": 1775401200000,
"started": null,
"completed": null,
"queue_position": 2,
@@ -433,6 +724,11 @@ Field types:
| `error` | `object \| null` |
| `preview` | `object \| null` |
+`created`, `started`, and `completed` are integer Unix timestamps in
+**milliseconds**. Subtract `started` from `completed` and divide by 1000 to get
+the elapsed generation time in seconds, including output encoding and excluding
+queue time.
+
`preview` sub-fields:
| Field | Type | Notes |
@@ -508,6 +804,7 @@ Shared nested fields:
| --- | --- |
| `loras[].name` | `string` |
| `loras[].path` | `string` |
+| `loras[].display_path` | `string` (display only) |
`upscalers`
@@ -662,7 +959,7 @@ Example response:
"id": "job_01HTXYZABC",
"kind": "img_gen",
"status": "queued",
- "created": 1775401200,
+ "created": 1775401200000,
"poll_url": "/sdcpp/v1/jobs/job_01HTXYZABC"
}
```
@@ -990,9 +1287,9 @@ Example completed job:
"id": "job_01HTXYZABC",
"kind": "img_gen",
"status": "completed",
- "created": 1775401200,
- "started": 1775401203,
- "completed": 1775401215,
+ "created": 1775401200000,
+ "started": 1775401203000,
+ "completed": 1775401215237,
"queue_position": 0,
"result": {
"output_format": "png",
@@ -1016,9 +1313,9 @@ Example failed job:
"id": "job_01HTXYZABC",
"kind": "img_gen",
"status": "failed",
- "created": 1775401200,
- "started": 1775401203,
- "completed": 1775401204,
+ "created": 1775401200000,
+ "started": 1775401203000,
+ "completed": 1775401204000,
"queue_position": 0,
"result": null,
"error": {
@@ -1037,9 +1334,9 @@ Example cancelled job:
"id": "job_01HTXYZABC",
"kind": "img_gen",
"status": "cancelled",
- "created": 1775401200,
+ "created": 1775401200000,
"started": null,
- "completed": 1775401202,
+ "completed": 1775401202000,
"queue_position": 0,
"result": null,
"error": {
@@ -1075,7 +1372,7 @@ Example response:
"id": "job_01HTXYZVID",
"kind": "vid_gen",
"status": "queued",
- "created": 1775401200,
+ "created": 1775401200000,
"poll_url": "/sdcpp/v1/jobs/job_01HTXYZVID"
}
```
@@ -1403,9 +1700,9 @@ Example completed job:
"id": "job_01HTXYZVID",
"kind": "vid_gen",
"status": "completed",
- "created": 1775401200,
- "started": 1775401203,
- "completed": 1775401215,
+ "created": 1775401200000,
+ "started": 1775401203000,
+ "completed": 1775401215237,
"queue_position": 0,
"result": {
"output_format": "webm",
@@ -1429,9 +1726,9 @@ Example failed job:
"id": "job_01HTXYZVID",
"kind": "vid_gen",
"status": "failed",
- "created": 1775401200,
- "started": 1775401203,
- "completed": 1775401204,
+ "created": 1775401200000,
+ "started": 1775401203000,
+ "completed": 1775401204000,
"queue_position": 0,
"result": null,
"error": {
@@ -1450,9 +1747,9 @@ Example cancelled job:
"id": "job_01HTXYZVID",
"kind": "vid_gen",
"status": "cancelled",
- "created": 1775401200,
+ "created": 1775401200000,
"started": null,
- "completed": 1775401202,
+ "completed": 1775401202000,
"queue_position": 0,
"result": null,
"error": {
diff --git a/examples/server/async_jobs.cpp b/examples/server/async_jobs.cpp
index 88408088e..7f3c8a1bf 100644
--- a/examples/server/async_jobs.cpp
+++ b/examples/server/async_jobs.cpp
@@ -88,7 +88,7 @@ const char* async_job_status_name(AsyncJobStatus status) {
}
void purge_expired_jobs(AsyncJobManager& manager) {
- const int64_t now = unix_timestamp_now();
+ const int64_t now = unix_timestamp_now_ms();
for (auto it = manager.expired_jobs.begin(); it != manager.expired_jobs.end();) {
if (it->second <= now) {
@@ -108,8 +108,8 @@ void purge_expired_jobs(AsyncJobManager& manager) {
int64_t ttl_seconds = job->status == AsyncJobStatus::Completed
? manager.completed_ttl_seconds
: manager.failed_ttl_seconds;
- if (now - job->completed_at >= ttl_seconds) {
- manager.expired_jobs[job->id] = now + std::max(ttl_seconds, 60);
+ if (now - job->completed_at >= ttl_seconds * 1000) {
+ manager.expired_jobs[job->id] = now + std::max(ttl_seconds, 60) * 1000;
it = manager.jobs.erase(it);
} else {
++it;
@@ -130,7 +130,7 @@ size_t count_pending_jobs(const AsyncJobManager& manager) {
std::string make_async_job_id(AsyncJobManager& manager) {
std::ostringstream oss;
- oss << "job_" << std::hex << unix_timestamp_now() << "_" << std::setw(8)
+ oss << "job_" << std::hex << unix_timestamp_now_ms() << "_" << std::setw(8)
<< std::setfill('0') << manager.next_id++;
return oss.str();
}
@@ -143,7 +143,7 @@ bool cancel_queued_job(AsyncJobManager& manager, AsyncGenerationJob& job) {
manager.queue.erase(new_end, manager.queue.end());
job.status = AsyncJobStatus::Cancelled;
- job.completed_at = unix_timestamp_now();
+ job.completed_at = unix_timestamp_now_ms();
job.result_images_b64.clear();
job.result_media_b64.clear();
job.result_media_mime_type.clear();
@@ -373,7 +373,7 @@ void async_job_worker(ServerRuntime& runtime) {
job = it->second;
job->status = AsyncJobStatus::Generating;
- job->started_at = unix_timestamp_now();
+ job->started_at = unix_timestamp_now_ms();
}
std::vector output_images;
@@ -405,7 +405,7 @@ void async_job_worker(ServerRuntime& runtime) {
continue;
}
- job->completed_at = unix_timestamp_now();
+ job->completed_at = unix_timestamp_now_ms();
if (ok) {
job->status = AsyncJobStatus::Completed;
job->result_images_b64 = std::move(output_images);
diff --git a/examples/server/async_jobs.h b/examples/server/async_jobs.h
index 2fe79bb24..049a9d73b 100644
--- a/examples/server/async_jobs.h
+++ b/examples/server/async_jobs.h
@@ -32,7 +32,7 @@ struct AsyncGenerationJob {
std::string id;
AsyncJobKind kind = AsyncJobKind::ImgGen;
AsyncJobStatus status = AsyncJobStatus::Queued;
- int64_t created_at = unix_timestamp_now();
+ int64_t created_at = unix_timestamp_now_ms();
int64_t started_at = 0;
int64_t completed_at = 0;
ImgGenJobRequest img_gen;
diff --git a/examples/server/frontend b/examples/server/frontend
index 8e7c11505..e36e34a2e 160000
--- a/examples/server/frontend
+++ b/examples/server/frontend
@@ -1 +1 @@
-Subproject commit 8e7c11505925c0dbf9aaea56a3a5058b8690d66b
+Subproject commit e36e34a2e2204e3df654b16cadc112d8ce8f3174
diff --git a/examples/server/main.cpp b/examples/server/main.cpp
index ddfd7334e..170877686 100644
--- a/examples/server/main.cpp
+++ b/examples/server/main.cpp
@@ -10,6 +10,7 @@
#include "async_jobs.h"
#include "common/common.h"
#include "common/resource_owners.hpp"
+#include "model_catalog.h"
#include "routes.h"
#include "runtime.h"
@@ -50,7 +51,7 @@ static void parse_args(int argc,
const bool random_seed_requested = default_gen_params.seed < 0;
if (!svr_params.resolve_and_validate() ||
- !ctx_params.resolve_and_validate(IMG_GEN) ||
+ !ctx_params.resolve_and_validate(ctx_params.model_path.empty() && ctx_params.diffusion_model_path.empty() ? METADATA : IMG_GEN) ||
!default_gen_params.resolve_and_validate(IMG_GEN,
ctx_params.lora_model_dir,
ctx_params.hires_upscalers_dir)) {
@@ -84,14 +85,19 @@ int main(int argc, const char** argv) {
LOG_VERBOSE("version: %s", version_string().c_str());
LOG_VERBOSE("%s", sd_get_system_info());
- LOG_VERBOSE("%s", svr_params.to_string().c_str());
+ LOG_DEBUG("%s", svr_params.to_string().c_str());
LOG_DEBUG("%s", ctx_params.to_string().c_str());
LOG_DEBUG("%s", default_gen_params.to_string().c_str());
sd_ctx_params_t sd_ctx_params = ctx_params.to_sd_ctx_params_t(false);
- SDCtxPtr sd_ctx(new_sd_ctx(&sd_ctx_params));
+ ServerModels models;
+ if (!ctx_params.model_path.empty() || !ctx_params.diffusion_model_path.empty()) {
+ const auto started = std::chrono::steady_clock::now();
+ models.context.reset(new_sd_ctx(&sd_ctx_params));
+ models.status["load_elapsed_seconds"] = std::chrono::duration(std::chrono::steady_clock::now() - started).count();
+ }
- if (sd_ctx == nullptr) {
+ if ((!ctx_params.model_path.empty() || !ctx_params.diffusion_model_path.empty()) && !models.context) {
LOG_ERROR("new_sd_ctx_t failed");
return 1;
}
@@ -104,7 +110,7 @@ int main(int argc, const char** argv) {
std::mutex upscaler_mutex;
AsyncJobManager async_job_manager;
ServerRuntime runtime = {
- sd_ctx.get(),
+ models.context.get(),
&sd_ctx_mutex,
&svr_params,
&ctx_params,
@@ -114,8 +120,16 @@ int main(int argc, const char** argv) {
&upscaler_cache,
&upscaler_mutex,
&async_job_manager,
+ &models,
};
+ try {
+ initialize_server_models(runtime);
+ } catch (const std::exception& e) {
+ LOG_ERROR("model catalog initialization failed: %s", e.what());
+ return 1;
+ }
+
std::thread async_worker(async_job_worker, std::ref(runtime));
httplib::Server svr;
@@ -147,6 +161,7 @@ int main(int argc, const char** argv) {
register_openai_api_endpoints(svr, runtime);
register_sdapi_endpoints(svr, runtime);
register_sdcpp_api_endpoints(svr, runtime);
+ register_model_endpoints(svr, runtime);
LOG_INFO("listening on: http://%s:%d\n", svr_params.listen_ip.c_str(), svr_params.listen_port);
svr.listen(svr_params.listen_ip, svr_params.listen_port);
diff --git a/examples/server/model_catalog.cpp b/examples/server/model_catalog.cpp
new file mode 100644
index 000000000..a3c59206d
--- /dev/null
+++ b/examples/server/model_catalog.cpp
@@ -0,0 +1,664 @@
+#include "model_catalog.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "common/log.h"
+#include "recipes_builtin.h"
+
+namespace fs = std::filesystem;
+
+struct ModelSlot {
+ const char* id;
+ const char* label;
+ std::vector categories;
+ std::string SDContextParams::*field;
+};
+
+static const ModelSlot k_model_slots[] = {
+ {"model", "Checkpoint", {"checkpoints"}, &SDContextParams::model_path},
+ {"diffusion_model", "Diffusion model", {"diffusion_models"}, &SDContextParams::diffusion_model_path},
+ {"high_noise_diffusion_model", "High-noise diffusion model", {"diffusion_models"}, &SDContextParams::high_noise_diffusion_model_path},
+ {"uncond_diffusion_model", "Unconditional diffusion model", {"diffusion_models"}, &SDContextParams::uncond_diffusion_model_path},
+ {"clip_l", "CLIP-L", {"text_encoders"}, &SDContextParams::clip_l_path},
+ {"clip_g", "CLIP-G", {"text_encoders"}, &SDContextParams::clip_g_path},
+ {"clip_vision", "CLIP Vision", {"clip_vision"}, &SDContextParams::clip_vision_path},
+ {"t5xxl", "T5", {"text_encoders"}, &SDContextParams::t5xxl_path},
+ {"llm", "LLM Text Encoder", {"text_encoders"}, &SDContextParams::llm_path},
+ {"llm_vision", "LLM Vision Encoder", {"clip_vision"}, &SDContextParams::llm_vision_path},
+ {"tokenizer", "Tokenizer", {"tokenizers"}, &SDContextParams::tokenizer},
+ {"vae", "VAE", {"vae"}, &SDContextParams::vae_path},
+ {"audio_vae", "Audio VAE", {"vae"}, &SDContextParams::audio_vae_path},
+ {"audio_encoder", "Audio Encoder", {"text_encoders"}, &SDContextParams::audio_encoder_path},
+ {"embeddings_connectors", "Embeddings Connectors", {"text_encoders"}, &SDContextParams::embeddings_connectors_path},
+ {"tae", "TAE", {"vae", "vae_approx"}, &SDContextParams::taesd_path},
+ {"taesd", "TAE", {"vae_approx", "vae"}, &SDContextParams::taesd_path},
+ {"control_net", "ControlNet", {"controlnet"}, &SDContextParams::control_net_path},
+ {"ip_adapter", "IP-Adapter", {"ipadapter"}, &SDContextParams::ip_adapter_path},
+ {"motion_module", "Motion Module", {"motion_modules"}, &SDContextParams::motion_module_path},
+ {"photo_maker", "PhotoMaker", {"photomaker"}, &SDContextParams::photo_maker_path},
+ {"pulid_weights", "PuLID", {"pulid"}, &SDContextParams::pulid_weights_path},
+};
+
+static const ModelSlot* find_model_slot(const std::string& id) {
+ for (const auto& slot : k_model_slots) {
+ if (id == slot.id) {
+ return &slot;
+ }
+ }
+ return nullptr;
+}
+
+static bool matches_model_category(const ModelSlot& slot, const std::string& category) {
+ return std::find(slot.categories.begin(), slot.categories.end(), category) != slot.categories.end();
+}
+
+static void clear_model_paths(SDContextParams& params) {
+ for (const auto& slot : k_model_slots) {
+ (params.*slot.field).clear();
+ }
+}
+
+std::string model_path_key(const fs::path& path) {
+#ifdef _WIN32
+ const auto value = path.generic_wstring();
+ const int length = LCMapStringEx(LOCALE_NAME_INVARIANT, LCMAP_LOWERCASE, value.data(),
+ static_cast(value.size()), nullptr, 0, nullptr, nullptr, 0);
+ if (length > 0) {
+ std::wstring folded(length, L'\0');
+ LCMapStringEx(LOCALE_NAME_INVARIANT, LCMAP_LOWERCASE, value.data(),
+ static_cast(value.size()), &folded[0], length, nullptr, nullptr, 0);
+ return fs::path(folded).generic_u8string();
+ }
+#endif
+ return path.generic_u8string();
+}
+
+bool model_path_inside(const fs::path& root, const fs::path& path) {
+ auto parent = root.begin();
+ auto child = path.begin();
+ for (; parent != root.end(); ++parent, ++child) {
+ if (child == path.end() || model_path_key(*parent) != model_path_key(*child)) {
+ return false;
+ }
+ }
+ return child != path.end();
+}
+
+ModelRoot make_model_root(const fs::path& path, bool primary) {
+ ModelRoot root;
+ const auto absolute = fs::absolute(path);
+ std::error_code ec;
+ root.path = fs::weakly_canonical(absolute, ec);
+ if (ec)
+ root.path = absolute.lexically_normal();
+ root.primary = primary;
+ uint64_t hash = 14695981039346656037ULL;
+ for (unsigned char c : model_path_key(root.path)) {
+ hash ^= c;
+ hash *= 1099511628211ULL;
+ }
+ std::ostringstream id;
+ id << '@' << std::hex << std::setfill('0') << std::setw(16) << hash;
+ root.id = id.str();
+ root.label = root.path.filename().u8string() + " [" + root.id.substr(1, 8) + "]";
+ return root;
+}
+
+std::vector get_model_roots(const SDSvrParams& params) {
+ const auto directories = params.model_dirs.empty() ? std::vector{"models"} : params.model_dirs;
+ std::vector roots;
+ std::set seen;
+ for (size_t i = 0; i < directories.size(); ++i) {
+ auto root = make_model_root(fs::u8path(directories[i]), i == 0);
+ if (seen.insert(model_path_key(root.path)).second)
+ roots.push_back(std::move(root));
+ }
+ return roots;
+}
+
+std::vector model_category_directories(const ModelRoot& root, const std::string& category) {
+ std::vector directories;
+ if (!fs::exists(root.path))
+ return directories;
+ for (const auto& entry : fs::directory_iterator(root.path)) {
+ auto name = entry.path().filename().u8string();
+ std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); });
+ if (name == category || (category == "checkpoints" && name == "stable-diffusion") ||
+ (category == "loras" && name == "lora")) {
+ directories.push_back(entry.path());
+ }
+ }
+ std::sort(directories.begin(), directories.end(), [&](const fs::path& a, const fs::path& b) {
+ const bool a_standard = a.filename().u8string() == category;
+ const bool b_standard = b.filename().u8string() == category;
+ return a_standard != b_standard ? a_standard : a < b;
+ });
+ return directories;
+}
+
+static bool valid_identifier(const std::string& id) {
+ return !id.empty() && id.size() <= 128 &&
+ std::all_of(id.begin(), id.end(), [](unsigned char c) {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.';
+ });
+}
+
+static void check_keys(const json& object, const std::set& keys) {
+ if (!object.is_object()) {
+ throw std::runtime_error("expected an object");
+ }
+ for (auto it = object.begin(); it != object.end(); ++it) {
+ if (!keys.count(it.key())) {
+ throw std::runtime_error("unknown field: " + it.key());
+ }
+ }
+}
+
+static void apply_recipe_defaults(const json& defaults, SDGenerationParams& params) {
+ check_keys(defaults, {"width", "height", "steps", "cfg_scale", "strength", "distilled_guidance", "sampling_method", "scheduler", "custom_sigmas",
+ "video_frames", "fps", "flow_shift", "moe_boundary", "high_noise_steps", "high_noise_cfg_scale",
+ "high_noise_flow_shift", "high_noise_sampling_method", "high_noise_scheduler", "img_cfg_scale", "shifted_timestep"});
+ for (const char* key : {"width", "height", "steps", "video_frames", "fps", "high_noise_steps", "shifted_timestep"}) {
+ if (!defaults.contains(key)) {
+ continue;
+ }
+ if (!defaults[key].is_number_integer()) {
+ throw std::runtime_error(std::string(key) + " must be an integer");
+ }
+ const int64_t value = defaults[key].get();
+ const std::string field = key;
+ const bool dimension = field == "width" || field == "height";
+ const int64_t maximum = dimension || field == "video_frames" ? 4096 : field == "fps" ? 240
+ : 1000;
+ const int64_t minimum = dimension ? 64 : field == "shifted_timestep" ? 0
+ : 1;
+ if ((value < minimum || value > maximum) && !(field == "high_noise_steps" && value == -1)) {
+ throw std::runtime_error(std::string(key) + " is out of range");
+ }
+ if (std::string(key) == "width")
+ params.width = static_cast(value);
+ if (std::string(key) == "height")
+ params.height = static_cast(value);
+ if (std::string(key) == "steps")
+ params.sample_params.sample_steps = static_cast(value);
+ if (field == "shifted_timestep")
+ params.sample_params.shifted_timestep = static_cast(value);
+ if (field == "video_frames")
+ params.video_frames = static_cast(value);
+ if (field == "fps")
+ params.fps = static_cast(value);
+ if (field == "high_noise_steps")
+ params.high_noise_sample_params.sample_steps = static_cast(value);
+ }
+ for (const char* key : {"cfg_scale", "strength", "moe_boundary", "img_cfg_scale", "distilled_guidance", "flow_shift", "high_noise_cfg_scale", "high_noise_flow_shift"}) {
+ if (!defaults.contains(key)) {
+ continue;
+ }
+ if (!defaults[key].is_number()) {
+ throw std::runtime_error(std::string(key) + " must be a number");
+ }
+ const float value = defaults[key].get();
+ if (!std::isfinite(value) || value < 0 || value > ((std::string(key) == "strength" || std::string(key) == "moe_boundary") ? 1.f : 1000.f)) {
+ throw std::runtime_error(std::string(key) + " is out of range");
+ }
+ if (std::string(key) == "strength")
+ params.strength = value;
+ if (std::string(key) == "moe_boundary")
+ params.moe_boundary = value;
+ if (std::string(key) == "cfg_scale")
+ params.sample_params.guidance.txt_cfg = value;
+ if (std::string(key) == "img_cfg_scale")
+ params.sample_params.guidance.img_cfg = value;
+ if (std::string(key) == "distilled_guidance")
+ params.sample_params.guidance.distilled_guidance = value;
+ if (std::string(key) == "flow_shift")
+ params.sample_params.flow_shift = value;
+ if (std::string(key) == "high_noise_cfg_scale")
+ params.high_noise_sample_params.guidance.txt_cfg = value;
+ if (std::string(key) == "high_noise_flow_shift")
+ params.high_noise_sample_params.flow_shift = value;
+ }
+ if (defaults.contains("custom_sigmas")) {
+ const auto& sigmas = defaults.at("custom_sigmas");
+ if (!sigmas.is_array() || sigmas.size() == 1 || sigmas.size() > 1001)
+ throw std::runtime_error("custom_sigmas must be empty or contain 2-1001 values");
+ for (const auto& sigma : sigmas) {
+ if (!sigma.is_number() || !std::isfinite(sigma.get()) || sigma.get() < 0)
+ throw std::runtime_error("custom_sigmas must contain finite nonnegative numbers");
+ }
+ params.custom_sigmas = sigmas.get>();
+ }
+ for (const char* key : {"sampling_method", "high_noise_sampling_method"}) {
+ if (!defaults.contains(key))
+ continue;
+ auto& sample = std::string(key) == "sampling_method" ? params.sample_params : params.high_noise_sample_params;
+ const auto name = defaults.at(key).get();
+ bool found = false;
+ for (int i = 0; i < SAMPLE_METHOD_COUNT; ++i) {
+ if (name == sd_sample_method_name(static_cast(i))) {
+ sample.sample_method = static_cast(i);
+ found = true;
+ break;
+ }
+ }
+ if (!found)
+ throw std::runtime_error(std::string("unknown ") + key);
+ }
+ for (const char* key : {"scheduler", "high_noise_scheduler"}) {
+ if (!defaults.contains(key))
+ continue;
+ auto& sample = std::string(key) == "scheduler" ? params.sample_params : params.high_noise_sample_params;
+ const auto name = defaults.at(key).get();
+ bool found = false;
+ for (int i = 0; i < SCHEDULER_COUNT; ++i) {
+ if (name == sd_scheduler_name(static_cast(i)) ||
+ (name == "normal" && i == DISCRETE_SCHEDULER)) {
+ sample.scheduler = static_cast(i);
+ found = true;
+ break;
+ }
+ }
+ if (!found)
+ throw std::runtime_error(std::string("unknown ") + key);
+ }
+}
+
+static void apply_recipe_context(const json& settings, SDContextParams& params) {
+ check_keys(settings, {"vae_format", "model_args"});
+ if (settings.contains("vae_format")) {
+ const auto format = settings.at("vae_format").get();
+ if (format != "auto" && format != "flux" && format != "sd3" && format != "flux2" && format != "wan")
+ throw std::runtime_error("unknown vae_format");
+ params.vae_format = format;
+ }
+ if (settings.contains("model_args")) {
+ params.model_args = settings.at("model_args").get();
+ }
+}
+
+static const std::map k_recipe_features = {
+ {"img2img", true},
+ {"mask_image", true},
+ {"lora", true},
+ {"vae_tiling", true},
+ {"cache", true},
+ {"cache_dit", true},
+ {"preview", true},
+ {"flow_shift", false},
+ {"distilled_guidance", false},
+ {"shifted_timestep", false},
+ {"img_cfg", false},
+ {"slg", false},
+ {"clip_skip", false},
+ {"ref_images", false},
+ {"controlnet", false},
+ {"control_image", false},
+ {"ip_adapter", false},
+ {"init_image", false},
+ {"end_image", false},
+ {"control_frames", false},
+ {"vace", false},
+ {"high_noise", false},
+ {"cache_unet", false},
+ {"preview_tae", false},
+};
+
+static void validate_recipe_features(const json& recipe) {
+ for (const char* key : {"features", "disabled_features"}) {
+ if (!recipe.contains(key))
+ continue;
+ const auto& values = recipe.at(key);
+ if (!values.is_array() || values.size() > k_recipe_features.size())
+ throw std::runtime_error(std::string(key) + " must be an array of feature names");
+ std::set seen;
+ for (const auto& value : values) {
+ const auto name = value.get();
+ if (!k_recipe_features.count(name) || !seen.insert(name).second)
+ throw std::runtime_error("unknown or duplicate feature: " + name);
+ }
+ }
+}
+
+json resolve_recipe_features(const json& recipe) {
+ json features = k_recipe_features;
+ for (const auto& slot : recipe.at("slots")) {
+ const auto id = slot.at("id").get();
+ if (id == "ip_adapter")
+ features["ip_adapter"] = true;
+ if (id == "control_net")
+ features["controlnet"] = true;
+ if (id == "high_noise_diffusion_model")
+ features["high_noise"] = true;
+ if (id == "tae" || id == "taesd")
+ features["preview_tae"] = true;
+ }
+ for (const auto& name : recipe.value("features", json::array()))
+ features[name.get()] = true;
+ for (const auto& name : recipe.value("disabled_features", json::array()))
+ features[name.get()] = false;
+ return features;
+}
+
+json model_generation_features(const ServerRuntime& runtime) {
+ if (!runtime.sd_ctx || runtime.models->active_recipe.is_null()) {
+ json features = k_recipe_features;
+ for (auto& feature : features)
+ feature = false;
+ return features;
+ }
+ json features = resolve_recipe_features(runtime.models->active_recipe);
+ if (runtime.models->active_recipe.value("source", "") == "command_line")
+ return features;
+ // Optional components only become usable once selected and loaded.
+ features["ip_adapter"] = features.value("ip_adapter", false) && !runtime.ctx_params->ip_adapter_path.empty();
+ features["controlnet"] = features.value("controlnet", false) && !runtime.ctx_params->control_net_path.empty();
+ features["high_noise"] = features.value("high_noise", false) && !runtime.ctx_params->high_noise_diffusion_model_path.empty();
+ features["preview_tae"] = features.value("preview_tae", false) && !runtime.ctx_params->taesd_path.empty();
+ if (!runtime.ctx_params->pulid_weights_path.empty())
+ features["slg"] = false;
+ return features;
+}
+
+static void validate_recipe(const json& recipe) {
+ check_keys(recipe, {"schema_version", "id", "name", "description", "model_url", "slots", "required_any", "defaults", "context", "features", "disabled_features"});
+ if (!recipe.at("schema_version").is_number_integer() || recipe.at("schema_version") != 1 ||
+ !valid_identifier(recipe.at("id").get()) || recipe.at("name").get().empty()) {
+ throw std::runtime_error("invalid schema_version, id or name");
+ }
+ if (recipe.contains("description") && !recipe["description"].is_string()) {
+ throw std::runtime_error("description must be a string");
+ }
+ if (recipe.contains("model_url")) {
+ const auto url = recipe.at("model_url").get();
+ const std::string prefix = "https://huggingface.co/";
+ if (url.size() <= prefix.size() || url.size() > 2048 || url.rfind(prefix, 0) != 0 ||
+ std::any_of(url.begin(), url.end(), [](unsigned char c) { return std::isspace(c) || std::iscntrl(c); })) {
+ throw std::runtime_error("model_url must be a Hugging Face HTTPS URL");
+ }
+ }
+ const auto& slots = recipe.at("slots");
+ if (!slots.is_array() || slots.empty() || slots.size() > 32) {
+ throw std::runtime_error("slots must be a nonempty array of at most 32 entries");
+ }
+ std::set ids;
+ bool has_required_model = false;
+ for (const auto& slot : slots) {
+ check_keys(slot, {"id", "label", "category", "required"});
+ const auto id = slot.at("id").get();
+ const auto* spec = find_model_slot(id);
+ if (!spec || !ids.insert(id == "taesd" ? "tae" : id).second ||
+ (slot.contains("category") && !matches_model_category(*spec, slot.at("category").get())) ||
+ (slot.contains("label") && slot.at("label").get().empty()) || !slot.at("required").is_boolean()) {
+ throw std::runtime_error("invalid or duplicate model slot: " + id);
+ }
+ has_required_model |= (id == "model" || id == "diffusion_model") && slot["required"].get();
+ }
+ if (!has_required_model) {
+ throw std::runtime_error("a required model or diffusion_model slot is needed");
+ }
+ if (recipe.contains("required_any")) {
+ const auto& groups = recipe.at("required_any");
+ if (!groups.is_array() || groups.size() > 32)
+ throw std::runtime_error("required_any must be an array of at most 32 slot groups");
+ for (const auto& group : groups) {
+ if (!group.is_array() || group.size() < 2 || group.size() > slots.size())
+ throw std::runtime_error("required_any groups need at least two declared slots");
+ std::set seen;
+ for (const auto& value : group) {
+ const auto id = value.get();
+ if (!seen.insert(id).second || std::none_of(slots.begin(), slots.end(), [&](const json& slot) { return slot.at("id") == id; }))
+ throw std::runtime_error("unknown or duplicate required_any slot: " + id);
+ }
+ }
+ }
+ validate_recipe_features(recipe);
+ SDContextParams context;
+ apply_recipe_context(recipe.value("context", json::object()), context);
+ SDGenerationParams defaults;
+ apply_recipe_defaults(recipe.value("defaults", json::object()), defaults);
+}
+
+static json normalize_recipe(json recipe) {
+ validate_recipe(recipe);
+ recipe["effective_features"] = resolve_recipe_features(recipe);
+ for (auto& slot : recipe.at("slots")) {
+ const auto& spec = *find_model_slot(slot.at("id").get());
+ slot["label"] = slot.value("label", std::string(spec.label));
+ slot["category"] = slot.value("category", spec.categories.front());
+ slot["categories"] = spec.categories;
+ }
+ return recipe;
+}
+
+static bool supported_model_file(const fs::path& path, const std::string& category) {
+ std::string ext = path.extension().u8string();
+ std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); });
+ if (category == "tokenizers") {
+ return ext == ".json";
+ }
+ return ext == ".gguf" || ext == ".safetensors" || ext == ".sft" ||
+ ext == ".ckpt" || ext == ".pt" || ext == ".pth" || ext == ".bin";
+}
+
+void refresh_model_catalog(ServerRuntime& runtime) {
+ auto& models = *runtime.models;
+ std::lock_guard lock(models.catalog_mutex);
+ std::map recipes;
+ std::map files;
+ json errors = json::array();
+ auto report = [&](const std::string& file, const std::string& message) {
+ errors.push_back({{"file", file}, {"message", message}});
+ };
+ for (const char* builtin : k_builtin_recipes) {
+ auto recipe = normalize_recipe(json::parse(builtin));
+ const auto id = recipe.at("id").get();
+ recipe["source"] = "builtin";
+ recipes[id] = std::move(recipe);
+ }
+
+ std::set seen_files;
+ for (const auto& source : get_model_roots(*runtime.svr_params)) {
+ const auto& root = source.path;
+ auto display_path = [&](const fs::path& path) {
+ return source.id + "/" + path.lexically_relative(root).generic_u8string();
+ };
+ const fs::path recipe_dir = root / "recipes";
+ std::map> user_recipes;
+ try {
+ if (fs::exists(recipe_dir)) {
+ if (!model_path_inside(root, fs::canonical(recipe_dir)))
+ throw std::runtime_error("recipes directory escapes model directory");
+ for (const auto& entry : fs::directory_iterator(recipe_dir)) {
+ const std::string display = display_path(entry.path());
+ if (entry.path().extension() != ".json")
+ continue;
+ try {
+ if (!entry.is_regular_file() || !model_path_inside(root, fs::canonical(entry.path())))
+ throw std::runtime_error("recipe must be a file inside the model directory");
+ if (entry.file_size() > 1024 * 1024)
+ throw std::runtime_error("recipe exceeds 1 MiB");
+ std::ifstream input(entry.path(), std::ios::binary);
+ json recipe;
+ input >> recipe;
+ recipe = normalize_recipe(std::move(recipe));
+ recipe["source"] = "user";
+ recipe["file"] = entry.path().filename().u8string();
+ recipe["root"] = source.id;
+ const auto id = recipe.at("id").get();
+ user_recipes[id].push_back(std::move(recipe));
+ } catch (const std::exception& e) {
+ report(display, e.what());
+ }
+ }
+ }
+ } catch (const std::exception& e) {
+ report(display_path(recipe_dir), e.what());
+ }
+ for (auto& item : user_recipes) {
+ if (item.second.size() != 1) {
+ for (const auto& recipe : item.second)
+ report(source.id + "/recipes/" + recipe["file"].get(), "duplicate user recipe id: " + item.first);
+ continue;
+ }
+ const auto existing = recipes.find(item.first);
+ if (existing != recipes.end()) {
+ if (existing->second["source"] == "user") {
+ LOG_WARN("recipe '%s' from %s is shadowed by an earlier model directory", item.first.c_str(), source.label.c_str());
+ continue;
+ }
+ LOG_INFO("user recipe '%s' from %s overrides the builtin", item.first.c_str(), source.label.c_str());
+ }
+ recipes[item.first] = std::move(item.second.front());
+ }
+
+ std::set categories;
+ for (const auto& slot : k_model_slots)
+ categories.insert(slot.categories.begin(), slot.categories.end());
+ for (const auto& category : categories) {
+ try {
+ for (const auto& directory : model_category_directories(source, category)) {
+ if (!model_path_inside(root, fs::canonical(directory))) {
+ report(display_path(directory), "category directory escapes model directory");
+ continue;
+ }
+ for (fs::recursive_directory_iterator it(directory), end; it != end; ++it) {
+ std::error_code path_error;
+ const fs::path canonical = fs::canonical(it->path(), path_error);
+ if (path_error || !model_path_inside(root, canonical)) {
+ it.disable_recursion_pending();
+ report(display_path(it->path()), path_error ? path_error.message() : "path escapes model directory");
+ continue;
+ }
+ if (!it->is_regular_file() || !supported_model_file(it->path(), category) ||
+ !seen_files.insert(model_path_key(canonical)).second)
+ continue;
+ const auto relative = it->path().lexically_relative(root).generic_u8string();
+ const auto id = source.primary ? relative : source.id + "/" + relative;
+ files[id] = {category, it->path().lexically_relative(directory).generic_u8string(), relative, source};
+ }
+ }
+ } catch (const std::exception& e) {
+ report(source.id + "/" + category, e.what());
+ }
+ }
+ }
+ std::map, size_t> names;
+ for (const auto& entry : files)
+ ++names[{entry.second.category, entry.second.name}];
+ for (auto& entry : files) {
+ auto& file = entry.second;
+ if (names[{file.category, file.name}] > 1)
+ file.name = (file.root.path / fs::u8path(file.relative_path)).generic_u8string();
+ }
+ models.recipes = std::move(recipes);
+ models.files = std::move(files);
+ models.catalog_errors = std::move(errors);
+}
+
+json model_status(ServerRuntime& runtime) {
+ std::lock_guard lock(runtime.models->status_mutex);
+ auto& models = *runtime.models;
+ json status = models.status;
+ if (status["state"] == "loading" && !status["load_elapsed_seconds"].is_null()) {
+ status["load_elapsed_seconds"] = std::chrono::duration(std::chrono::steady_clock::now() - models.load_started_at).count();
+ }
+ return status;
+}
+
+void initialize_server_models(ServerRuntime& runtime) {
+ auto& models = *runtime.models;
+ models.base_context = *runtime.ctx_params;
+ clear_model_paths(models.base_context);
+ models.base_defaults = *runtime.default_gen_params;
+ if (runtime.sd_ctx) {
+ models.status["state"] = "ready";
+ models.status["origin"] = "command_line";
+ json features = json::array();
+ for (const auto& feature : k_recipe_features)
+ features.push_back(feature.first);
+ models.active_recipe = {
+ {"id", "@command-line"},
+ {"name", "Command-line model (all features)"},
+ {"source", "command_line"},
+ {"description", "Uses the loaded command-line model and its generation defaults."},
+ {"slots", json::array()},
+ {"defaults", json::object()},
+ {"features", features},
+ };
+ models.active_recipe["effective_features"] = resolve_recipe_features(models.active_recipe);
+ models.status["runtime_recipe"] = models.active_recipe;
+ }
+ refresh_model_catalog(runtime);
+}
+
+void prepare_model_load(ServerRuntime& runtime, const json& body, SDContextParams& context, SDGenerationParams& defaults, json* loaded_recipe) {
+ check_keys(body, {"recipe", "models"});
+ const auto recipe_id = body.at("recipe").get();
+ auto selection = body.at("models");
+ if (!selection.is_object())
+ throw std::runtime_error("models must be an object");
+ auto& models = *runtime.models;
+ std::lock_guard lock(models.catalog_mutex);
+ const auto found = models.recipes.find(recipe_id);
+ if (found == models.recipes.end())
+ throw std::runtime_error("unknown recipe");
+ const auto& recipe = found->second;
+ if (loaded_recipe)
+ *loaded_recipe = recipe;
+ context = models.base_context;
+ defaults = models.base_defaults;
+ apply_recipe_context(recipe.value("context", json::object()), context);
+ apply_recipe_defaults(recipe.value("defaults", json::object()), defaults);
+ std::set allowed;
+ for (const auto& slot : recipe.at("slots")) {
+ const auto id = slot.at("id").get();
+ allowed.insert(id);
+ if (id == "tae" || id == "taesd") {
+ const std::string alias = id == "tae" ? "taesd" : "tae";
+ allowed.insert(alias);
+ if (selection.contains(alias)) {
+ if (selection.contains(id))
+ throw std::runtime_error("specify only one of tae and taesd");
+ selection[id] = selection.at(alias);
+ }
+ }
+ const std::string file_id = selection.value(id, std::string());
+ if (file_id.empty()) {
+ if (slot.at("required").get())
+ throw std::runtime_error("missing required model: " + id);
+ continue;
+ }
+ const auto file = models.files.find(file_id);
+ if (file == models.files.end() || !matches_model_category(*find_model_slot(id), file->second.category)) {
+ throw std::runtime_error("unknown model or incorrect category: " + id);
+ }
+ const auto& root = file->second.root.path;
+ const fs::path path = fs::canonical(root / fs::u8path(file->second.relative_path));
+ if (!model_path_inside(root, path) || !fs::is_regular_file(path)) {
+ throw std::runtime_error("model file must remain inside the model directory: " + id);
+ }
+ context.*find_model_slot(id)->field = path.u8string();
+ }
+ for (auto it = selection.begin(); it != selection.end(); ++it) {
+ if (!allowed.count(it.key()))
+ throw std::runtime_error("unknown model slot: " + it.key());
+ }
+ for (const auto& group : recipe.value("required_any", json::array())) {
+ if (std::none_of(group.begin(), group.end(), [&](const json& value) {
+ return !selection.value(value.get(), std::string()).empty();
+ })) {
+ throw std::runtime_error("select at least one model from: " + group.dump());
+ }
+ }
+ if (!context.resolve_and_validate(IMG_GEN))
+ throw std::runtime_error("invalid model context parameters");
+}
diff --git a/examples/server/model_catalog.h b/examples/server/model_catalog.h
new file mode 100644
index 000000000..7d49a1924
--- /dev/null
+++ b/examples/server/model_catalog.h
@@ -0,0 +1,56 @@
+#ifndef __SD_EXAMPLES_SERVER_MODEL_CATALOG_H__
+#define __SD_EXAMPLES_SERVER_MODEL_CATALOG_H__
+
+#include
+#include