Initial release
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
include_directories(${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
add_subdirectory(cli)
|
||||
add_subdirectory(server)
|
||||
@@ -0,0 +1,27 @@
|
||||
set(TARGET sd-cli)
|
||||
|
||||
add_executable(${TARGET}
|
||||
../common/common.cpp
|
||||
../common/log.cpp
|
||||
../common/media_io.cpp
|
||||
image_metadata.cpp
|
||||
main.cpp
|
||||
)
|
||||
if(APPLE)
|
||||
sd_set_macos_rpaths(${TARGET})
|
||||
endif()
|
||||
target_include_directories(${TARGET} PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/.."
|
||||
"${PROJECT_SOURCE_DIR}/src"
|
||||
)
|
||||
install(TARGETS ${TARGET} RUNTIME)
|
||||
target_link_libraries(${TARGET} PRIVATE stable-diffusion zip ${CMAKE_THREAD_LIBS_INIT})
|
||||
if(SD_WEBP)
|
||||
target_compile_definitions(${TARGET} PRIVATE SD_USE_WEBP)
|
||||
target_link_libraries(${TARGET} PRIVATE webp libwebpmux)
|
||||
endif()
|
||||
if(SD_WEBM)
|
||||
target_compile_definitions(${TARGET} PRIVATE SD_USE_WEBM)
|
||||
target_link_libraries(${TARGET} PRIVATE webm)
|
||||
endif()
|
||||
target_compile_features(${TARGET} PUBLIC c_std_11 cxx_std_17)
|
||||
@@ -0,0 +1,19 @@
|
||||
# Usage
|
||||
|
||||
For detailed command-line arguments, run:
|
||||
|
||||
```bash
|
||||
./bin/sd-cli -h
|
||||
```
|
||||
|
||||
For direct image repair or automatic post-generation YOLOv8 detection followed by cropped inpainting, see
|
||||
[ADetailer](../../docs/adetailer.md).
|
||||
|
||||
Metadata mode inspects PNG/JPEG container metadata without loading any model:
|
||||
|
||||
```bash
|
||||
./bin/sd-cli -M metadata --image ./output.png
|
||||
./bin/sd-cli -M metadata --image ./output.jpg --metadata-format json
|
||||
./bin/sd-cli -M metadata --image ./output.png --metadata-raw
|
||||
./bin/sd-cli -M metadata --image ./output.png --metadata-all
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <iosfwd>
|
||||
#include <string>
|
||||
|
||||
enum class MetadataOutputFormat {
|
||||
TEXT,
|
||||
JSON,
|
||||
};
|
||||
|
||||
struct MetadataReadOptions {
|
||||
MetadataOutputFormat output_format = MetadataOutputFormat::TEXT;
|
||||
bool include_raw = false;
|
||||
bool brief = false;
|
||||
bool include_structural = false;
|
||||
};
|
||||
|
||||
bool print_image_metadata(const std::string& image_path,
|
||||
const MetadataReadOptions& options,
|
||||
std::ostream& out,
|
||||
std::string& error);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,333 @@
|
||||
#ifndef __EXAMPLES_COMMON_COMMON_H__
|
||||
#define __EXAMPLES_COMMON_COMMON_H__
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "log.h"
|
||||
#include "resource_owners.hpp"
|
||||
#include "stable-diffusion.h"
|
||||
|
||||
#define SAFE_STR(s) ((s) ? (s) : "")
|
||||
#define BOOL_STR(b) ((b) ? "true" : "false")
|
||||
|
||||
extern const char* const modes_str[];
|
||||
#define SD_ALL_MODES_STR "img_gen, adetailer, vid_gen, convert, upscale, metadata"
|
||||
|
||||
enum SDMode {
|
||||
IMG_GEN,
|
||||
ADETAILER,
|
||||
VID_GEN,
|
||||
CONVERT,
|
||||
UPSCALE,
|
||||
METADATA,
|
||||
MODE_COUNT
|
||||
};
|
||||
|
||||
struct StringOption {
|
||||
std::string short_name;
|
||||
std::string long_name;
|
||||
std::string desc;
|
||||
int concat;
|
||||
std::string* target;
|
||||
};
|
||||
|
||||
struct IntOption {
|
||||
std::string short_name;
|
||||
std::string long_name;
|
||||
std::string desc;
|
||||
int* target;
|
||||
};
|
||||
|
||||
struct FloatOption {
|
||||
std::string short_name;
|
||||
std::string long_name;
|
||||
std::string desc;
|
||||
float* target;
|
||||
};
|
||||
|
||||
struct BoolOption {
|
||||
std::string short_name;
|
||||
std::string long_name;
|
||||
std::string desc;
|
||||
bool keep_true;
|
||||
bool* target;
|
||||
};
|
||||
|
||||
struct ManualFunction {
|
||||
std::function<int(int, const char**, int, bool&)> _func;
|
||||
|
||||
ManualFunction() = default;
|
||||
|
||||
ManualFunction(std::function<int(int argc, const char** argv, int index, bool& valid)> func)
|
||||
: _func(std::move(func)) {
|
||||
}
|
||||
|
||||
template <typename F>
|
||||
ManualFunction(F func)
|
||||
: _func(make_function(func)) {
|
||||
}
|
||||
|
||||
int operator()(int argc, const char** argv, int index, bool& valid) const {
|
||||
return _func(argc, argv, index, valid);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename F>
|
||||
static std::function<int(int, const char**, int, bool&)> make_function(F func) {
|
||||
if constexpr (std::is_invocable_v<F, int, const char**, int, bool&>) {
|
||||
return func;
|
||||
} else {
|
||||
return [func](int argc, const char** argv, int index, bool&) {
|
||||
return func(argc, argv, index);
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct ManualOption {
|
||||
std::string short_name;
|
||||
std::string long_name;
|
||||
std::string desc;
|
||||
ManualFunction cb;
|
||||
};
|
||||
|
||||
struct ArgOptions {
|
||||
std::vector<StringOption> string_options;
|
||||
std::vector<IntOption> int_options;
|
||||
std::vector<FloatOption> float_options;
|
||||
std::vector<BoolOption> bool_options;
|
||||
std::vector<ManualOption> manual_options;
|
||||
|
||||
static std::string wrap_text(const std::string& text, size_t width, size_t indent);
|
||||
void print() const;
|
||||
};
|
||||
|
||||
bool parse_options(int argc, const char** argv, const std::vector<ArgOptions>& options_list);
|
||||
bool decode_base64_image(const std::string& encoded_input,
|
||||
int target_channels,
|
||||
int expected_width,
|
||||
int expected_height,
|
||||
SDImageOwner& out_image);
|
||||
|
||||
struct SDContextParams {
|
||||
int n_threads = -1;
|
||||
std::string model_path;
|
||||
std::string clip_l_path;
|
||||
std::string clip_g_path;
|
||||
std::string clip_vision_path;
|
||||
std::string t5xxl_path;
|
||||
std::string llm_path;
|
||||
std::string llm_vision_path;
|
||||
std::string diffusion_model_path;
|
||||
std::string high_noise_diffusion_model_path;
|
||||
std::string uncond_diffusion_model_path;
|
||||
std::string embeddings_connectors_path;
|
||||
std::string vae_path;
|
||||
std::string vae_format = "auto";
|
||||
std::string audio_vae_path;
|
||||
std::string taesd_path;
|
||||
std::string esrgan_path;
|
||||
std::string control_net_path;
|
||||
std::string ip_adapter_path;
|
||||
std::string motion_module_path;
|
||||
std::string embedding_dir;
|
||||
std::string photo_maker_path;
|
||||
std::string pulid_weights_path;
|
||||
sd_type_t wtype = SD_TYPE_COUNT;
|
||||
std::string tensor_type_rules;
|
||||
std::string lora_model_dir = ".";
|
||||
std::string hires_upscalers_dir;
|
||||
|
||||
std::map<std::string, std::string> embedding_map;
|
||||
std::vector<sd_embedding_t> embedding_vec;
|
||||
|
||||
rng_type_t rng_type = CUDA_RNG;
|
||||
rng_type_t sampler_rng_type = RNG_TYPE_COUNT;
|
||||
bool offload_params_to_cpu = false;
|
||||
std::string max_vram = "0";
|
||||
bool stream_layers = false;
|
||||
bool eager_load = false;
|
||||
std::string backend;
|
||||
std::string params_backend;
|
||||
std::string split_mode;
|
||||
std::string model_args;
|
||||
bool auto_fit = false;
|
||||
std::string rpc_servers;
|
||||
std::string effective_backend;
|
||||
std::string effective_params_backend;
|
||||
bool enable_mmap = false;
|
||||
bool control_net_cpu = false;
|
||||
bool clip_on_cpu = false;
|
||||
bool vae_on_cpu = false;
|
||||
bool flash_attn = false;
|
||||
bool diffusion_flash_attn = false;
|
||||
bool diffusion_conv_direct = false;
|
||||
bool vae_conv_direct = false;
|
||||
|
||||
prediction_t prediction = PREDICTION_COUNT;
|
||||
lora_apply_mode_t lora_apply_mode = LORA_APPLY_AUTO;
|
||||
|
||||
bool force_sdxl_vae_conv_scale = false;
|
||||
|
||||
float flow_shift = INFINITY;
|
||||
ArgOptions get_options();
|
||||
void build_embedding_map();
|
||||
void prepare_backend_assignments();
|
||||
bool resolve(SDMode mode);
|
||||
bool validate(SDMode mode);
|
||||
bool resolve_and_validate(SDMode mode);
|
||||
std::string to_string() const;
|
||||
sd_ctx_params_t to_sd_ctx_params_t(bool taesd_preview);
|
||||
};
|
||||
|
||||
struct SDGenerationParams {
|
||||
// User-facing input fields.
|
||||
std::string prompt;
|
||||
std::string negative_prompt;
|
||||
std::string ad_model_path;
|
||||
std::string ad_prompt;
|
||||
std::string ad_negative_prompt;
|
||||
std::string extra_ad_args;
|
||||
int clip_skip = -1; // <= 0 represents unspecified
|
||||
int width = -1;
|
||||
int height = -1;
|
||||
int batch_count = 1;
|
||||
int qwen_image_layers = 3;
|
||||
int64_t seed = 42;
|
||||
float strength = 0.75f;
|
||||
float control_strength = 0.9f;
|
||||
float ip_adapter_strength = 1.0f;
|
||||
bool auto_resize_ref_image = true;
|
||||
bool increase_ref_index = false;
|
||||
bool embed_image_metadata = true;
|
||||
|
||||
std::string init_image_path;
|
||||
std::string end_image_path;
|
||||
std::string mask_image_path;
|
||||
std::string control_image_path;
|
||||
std::string ip_adapter_image_path;
|
||||
std::vector<std::string> ref_image_paths;
|
||||
std::vector<std::string> ref_video_paths;
|
||||
std::vector<std::string> ref_video_audio_paths;
|
||||
std::vector<std::string> ref_audio_paths;
|
||||
std::string control_video_path;
|
||||
|
||||
sd_sample_params_t sample_params;
|
||||
sd_sample_params_t high_noise_sample_params;
|
||||
std::string extra_sample_args;
|
||||
std::string high_noise_extra_sample_args;
|
||||
std::vector<int> skip_layers = {7, 8, 9};
|
||||
std::vector<int> high_noise_skip_layers = {7, 8, 9};
|
||||
|
||||
std::vector<float> custom_sigmas;
|
||||
|
||||
std::string cache_mode;
|
||||
std::string cache_option;
|
||||
std::string scm_mask;
|
||||
bool scm_policy_dynamic = true;
|
||||
sd_cache_params_t cache_params{};
|
||||
|
||||
float moe_boundary = 0.875f;
|
||||
int video_frames = 1;
|
||||
int fps = 16;
|
||||
float vace_strength = 1.f;
|
||||
sd_tiling_params_t vae_tiling_params = {false, false, 0, 0, 0.5f, 0.0f, 0.0f, nullptr};
|
||||
std::string extra_tiling_args;
|
||||
|
||||
std::string ref_image_args;
|
||||
|
||||
std::string pm_id_images_dir;
|
||||
std::string pm_id_embed_path;
|
||||
float pm_style_strength = 20.f;
|
||||
|
||||
std::string pulid_id_embedding_path;
|
||||
float pulid_id_weight = 1.0f;
|
||||
|
||||
int upscale_repeats = 1;
|
||||
int upscale_tile_size = 128;
|
||||
|
||||
bool circular = false;
|
||||
bool circular_x = false;
|
||||
bool circular_y = false;
|
||||
|
||||
bool hires_enabled = false;
|
||||
std::string hires_upscaler = "Latent";
|
||||
std::string hires_upscaler_model_path;
|
||||
float hires_scale = 2.f;
|
||||
int hires_width = 0;
|
||||
int hires_height = 0;
|
||||
int hires_steps = 0;
|
||||
float hires_denoising_strength = 0.7f;
|
||||
int hires_upscale_tile_size = 128;
|
||||
std::vector<float> hires_custom_sigmas;
|
||||
|
||||
std::map<std::string, float> lora_map;
|
||||
std::map<std::string, float> high_noise_lora_map;
|
||||
|
||||
// Derived and normalized fields.
|
||||
std::string prompt_with_lora; // for metadata record only
|
||||
std::vector<sd_lora_t> lora_vec;
|
||||
sd_hires_upscaler_t resolved_hires_upscaler;
|
||||
|
||||
// Owned execution payload.
|
||||
SDImageOwner init_image;
|
||||
SDImageOwner end_image;
|
||||
std::vector<SDImageOwner> ref_images;
|
||||
std::vector<std::vector<SDImageOwner>> ref_videos;
|
||||
std::vector<SDAudioOwner> ref_video_audios;
|
||||
std::vector<SDAudioOwner> ref_audios;
|
||||
SDImageOwner mask_image;
|
||||
SDImageOwner control_image;
|
||||
SDImageOwner ip_adapter_image;
|
||||
std::vector<SDImageOwner> pm_id_images;
|
||||
std::vector<SDImageOwner> control_frames;
|
||||
|
||||
// Backing storage for sd_img_gen_params_t view fields.
|
||||
std::vector<sd_image_t> ref_image_views;
|
||||
std::vector<std::vector<sd_image_t>> ref_video_frame_views;
|
||||
std::vector<sd_ref_video_t> ref_video_views;
|
||||
std::vector<sd_audio_t> ref_audio_views;
|
||||
std::vector<sd_image_t> pm_id_image_views;
|
||||
std::vector<sd_image_t> control_frame_views;
|
||||
|
||||
SDGenerationParams();
|
||||
SDGenerationParams(const SDGenerationParams& other) = default;
|
||||
SDGenerationParams& operator=(const SDGenerationParams& other) = default;
|
||||
SDGenerationParams(SDGenerationParams&& other) noexcept = default;
|
||||
SDGenerationParams& operator=(SDGenerationParams&& other) noexcept = default;
|
||||
ArgOptions get_options();
|
||||
bool from_json_str(const std::string& json_str,
|
||||
const std::function<std::string(const std::string&)>& lora_path_resolver = {});
|
||||
bool initialize_cache_params();
|
||||
void extract_and_remove_lora(const std::string& lora_model_dir);
|
||||
bool width_and_height_are_set() const;
|
||||
void set_width_and_height_if_unset(int w, int h);
|
||||
int get_resolved_width() const;
|
||||
int get_resolved_height() const;
|
||||
bool resolve(const std::string& lora_model_dir, const std::string& hires_upscalers_dir, bool strict = false);
|
||||
bool validate(SDMode mode);
|
||||
bool resolve_and_validate(SDMode mode,
|
||||
const std::string& lora_model_dir,
|
||||
const std::string& hires_upscalers_dir,
|
||||
bool strict = false);
|
||||
sd_img_gen_params_t to_sd_img_gen_params_t();
|
||||
sd_vid_gen_params_t to_sd_vid_gen_params_t();
|
||||
std::string to_string() const;
|
||||
};
|
||||
|
||||
std::string version_string();
|
||||
std::string build_sdcpp_image_metadata_json(const SDContextParams& ctx_params,
|
||||
const SDGenerationParams& gen_params,
|
||||
int64_t seed,
|
||||
SDMode mode = IMG_GEN);
|
||||
std::string get_image_params(const SDContextParams& ctx_params,
|
||||
const SDGenerationParams& gen_params,
|
||||
int64_t seed,
|
||||
SDMode mode = IMG_GEN);
|
||||
|
||||
#endif // __EXAMPLES_COMMON_COMMON_H__
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "log.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
bool log_verbose = false;
|
||||
bool log_color = false;
|
||||
|
||||
std::string sd_basename(const std::string& path) {
|
||||
size_t pos = path.find_last_of('/');
|
||||
if (pos != std::string::npos) {
|
||||
return path.substr(pos + 1);
|
||||
}
|
||||
pos = path.find_last_of('\\');
|
||||
if (pos != std::string::npos) {
|
||||
return path.substr(pos + 1);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
void print_utf8(FILE* stream, const char* utf8) {
|
||||
if (!utf8) {
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
HANDLE h = (stream == stderr)
|
||||
? GetStdHandle(STD_ERROR_HANDLE)
|
||||
: GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
|
||||
DWORD mode;
|
||||
BOOL is_console = GetConsoleMode(h, &mode);
|
||||
|
||||
if (is_console) {
|
||||
int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, NULL, 0);
|
||||
if (wlen <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<wchar_t> wbuf(static_cast<size_t>(wlen));
|
||||
|
||||
MultiByteToWideChar(CP_UTF8, 0, utf8, -1, wbuf.data(), wlen);
|
||||
|
||||
DWORD written;
|
||||
WriteConsoleW(h, wbuf.data(), wlen - 1, &written, NULL);
|
||||
} else {
|
||||
DWORD written;
|
||||
WriteFile(h, utf8, (DWORD)strlen(utf8), &written, NULL);
|
||||
}
|
||||
#else
|
||||
fputs(utf8, stream);
|
||||
#endif
|
||||
}
|
||||
|
||||
void log_print(enum sd_log_level_t level, const char* log, bool verbose, bool color) {
|
||||
int tag_color;
|
||||
const char* level_str;
|
||||
FILE* out_stream = (level == SD_LOG_ERROR) ? stderr : stdout;
|
||||
|
||||
if (!log || (!verbose && level <= SD_LOG_DEBUG)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (level) {
|
||||
case SD_LOG_DEBUG:
|
||||
tag_color = 37;
|
||||
level_str = "DEBUG";
|
||||
break;
|
||||
case SD_LOG_INFO:
|
||||
tag_color = 34;
|
||||
level_str = "INFO";
|
||||
break;
|
||||
case SD_LOG_WARN:
|
||||
tag_color = 35;
|
||||
level_str = "WARN";
|
||||
break;
|
||||
case SD_LOG_ERROR:
|
||||
tag_color = 31;
|
||||
level_str = "ERROR";
|
||||
break;
|
||||
default:
|
||||
tag_color = 33;
|
||||
level_str = "?????";
|
||||
break;
|
||||
}
|
||||
|
||||
if (color) {
|
||||
fprintf(out_stream, "\033[%d;1m[%-5s]\033[0m ", tag_color, level_str);
|
||||
} else {
|
||||
fprintf(out_stream, "[%-5s] ", level_str);
|
||||
}
|
||||
print_utf8(out_stream, log);
|
||||
fflush(out_stream);
|
||||
}
|
||||
|
||||
void example_log_printf(sd_log_level_t level, const char* file, int line, const char* format, ...) {
|
||||
constexpr size_t LOG_BUFFER_SIZE = 4096;
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
|
||||
static char log_buffer[LOG_BUFFER_SIZE + 1];
|
||||
int written = snprintf(log_buffer, LOG_BUFFER_SIZE, "%s:%-4d - ", sd_basename(file).c_str(), line);
|
||||
|
||||
if (written >= 0 && written < static_cast<int>(LOG_BUFFER_SIZE)) {
|
||||
vsnprintf(log_buffer + written, LOG_BUFFER_SIZE - written, format, args);
|
||||
}
|
||||
size_t len = strlen(log_buffer);
|
||||
if (len == 0 || log_buffer[len - 1] != '\n') {
|
||||
strncat(log_buffer, "\n", LOG_BUFFER_SIZE - len);
|
||||
}
|
||||
|
||||
log_print(level, log_buffer, log_verbose, log_color);
|
||||
|
||||
va_end(args);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#ifndef __EXAMPLE_LOG_H__
|
||||
#define __EXAMPLE_LOG_H__
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#endif // _WIN32
|
||||
|
||||
#include "stable-diffusion.h"
|
||||
|
||||
extern bool log_verbose;
|
||||
extern bool log_color;
|
||||
|
||||
std::string sd_basename(const std::string& path);
|
||||
void print_utf8(FILE* stream, const char* utf8);
|
||||
void log_print(sd_log_level_t level, const char* log, bool verbose, bool color);
|
||||
void example_log_printf(sd_log_level_t level, const char* file, int line, const char* format, ...);
|
||||
|
||||
#define LOG_DEBUG(format, ...) example_log_printf(SD_LOG_DEBUG, __FILE__, __LINE__, format, ##__VA_ARGS__)
|
||||
#define LOG_INFO(format, ...) example_log_printf(SD_LOG_INFO, __FILE__, __LINE__, format, ##__VA_ARGS__)
|
||||
#define LOG_WARN(format, ...) example_log_printf(SD_LOG_WARN, __FILE__, __LINE__, format, ##__VA_ARGS__)
|
||||
#define LOG_ERROR(format, ...) example_log_printf(SD_LOG_ERROR, __FILE__, __LINE__, format, ##__VA_ARGS__)
|
||||
|
||||
#endif // __EXAMPLE_LOG_H__
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
#ifndef __MEDIA_IO_H__
|
||||
#define __MEDIA_IO_H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "stable-diffusion.h"
|
||||
|
||||
enum class EncodedImageFormat {
|
||||
JPEG,
|
||||
PNG,
|
||||
WEBP,
|
||||
UNKNOWN,
|
||||
};
|
||||
|
||||
EncodedImageFormat encoded_image_format_from_path(const std::string& path);
|
||||
|
||||
std::vector<uint8_t> encode_image_to_vector(EncodedImageFormat format,
|
||||
const uint8_t* image,
|
||||
int width,
|
||||
int height,
|
||||
int channels,
|
||||
const std::string& parameters = "",
|
||||
int quality = 90);
|
||||
|
||||
bool write_image_to_file(const std::string& path,
|
||||
const uint8_t* image,
|
||||
int width,
|
||||
int height,
|
||||
int channels,
|
||||
const std::string& parameters = "",
|
||||
int quality = 90);
|
||||
|
||||
uint8_t* load_image_from_file(const char* image_path,
|
||||
int& width,
|
||||
int& height,
|
||||
int expected_width = 0,
|
||||
int expected_height = 0,
|
||||
int expected_channel = 3);
|
||||
|
||||
bool load_sd_image_from_file(sd_image_t* image,
|
||||
const char* image_path,
|
||||
int expected_width = 0,
|
||||
int expected_height = 0,
|
||||
int expected_channel = 3);
|
||||
|
||||
uint8_t* load_image_from_memory(const char* image_bytes,
|
||||
int len,
|
||||
int& width,
|
||||
int& height,
|
||||
int expected_width = 0,
|
||||
int expected_height = 0,
|
||||
int expected_channel = 3);
|
||||
|
||||
int create_mjpg_avi_from_sd_images(const char* filename,
|
||||
sd_image_t* images,
|
||||
int num_images,
|
||||
int fps,
|
||||
int quality = 90,
|
||||
const sd_audio_t* audio = nullptr);
|
||||
std::vector<uint8_t> create_mjpg_avi_from_sd_images_to_vector(sd_image_t* images,
|
||||
int num_images,
|
||||
int fps,
|
||||
int quality = 90,
|
||||
const sd_audio_t* audio = nullptr);
|
||||
|
||||
#ifdef SD_USE_WEBP
|
||||
int create_animated_webp_from_sd_images(const char* filename,
|
||||
sd_image_t* images,
|
||||
int num_images,
|
||||
int fps,
|
||||
int quality = 90);
|
||||
std::vector<uint8_t> create_animated_webp_from_sd_images_to_vector(sd_image_t* images,
|
||||
int num_images,
|
||||
int fps,
|
||||
int quality = 90);
|
||||
#endif
|
||||
|
||||
#ifdef SD_USE_WEBM
|
||||
int create_webm_from_sd_images(const char* filename,
|
||||
sd_image_t* images,
|
||||
int num_images,
|
||||
int fps,
|
||||
int quality = 90,
|
||||
const sd_audio_t* audio = nullptr);
|
||||
std::vector<uint8_t> create_webm_from_sd_images_to_vector(sd_image_t* images,
|
||||
int num_images,
|
||||
int fps,
|
||||
int quality = 90,
|
||||
const sd_audio_t* audio = nullptr);
|
||||
#endif
|
||||
|
||||
int create_video_from_sd_images(const char* filename,
|
||||
sd_image_t* images,
|
||||
int num_images,
|
||||
int fps,
|
||||
int quality = 90,
|
||||
const sd_audio_t* audio = nullptr);
|
||||
std::vector<uint8_t> create_video_from_sd_images_to_vector(const std::string& output_format,
|
||||
sd_image_t* images,
|
||||
int num_images,
|
||||
int fps,
|
||||
int quality = 90,
|
||||
const sd_audio_t* audio = nullptr);
|
||||
|
||||
bool write_wav_to_file(const std::string& path,
|
||||
const float* interleaved_samples,
|
||||
uint64_t sample_count,
|
||||
uint32_t channels,
|
||||
uint32_t sample_rate);
|
||||
|
||||
bool load_wav_from_file(const std::string& path,
|
||||
std::vector<float>& interleaved_samples,
|
||||
uint32_t& sample_rate,
|
||||
uint32_t& channels);
|
||||
|
||||
#endif // __MEDIA_IO_H__
|
||||
@@ -0,0 +1,276 @@
|
||||
#ifndef __EXAMPLE_RESOURCE_OWNERS_H__
|
||||
#define __EXAMPLE_RESOURCE_OWNERS_H__
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "stable-diffusion.h"
|
||||
|
||||
struct FreeDeleter {
|
||||
void operator()(void* ptr) const {
|
||||
free(ptr);
|
||||
}
|
||||
};
|
||||
|
||||
struct FileCloser {
|
||||
void operator()(FILE* file) const {
|
||||
if (file != nullptr) {
|
||||
fclose(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct SDCtxDeleter {
|
||||
void operator()(sd_ctx_t* ctx) const {
|
||||
if (ctx != nullptr) {
|
||||
free_sd_ctx(ctx);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct UpscalerCtxDeleter {
|
||||
void operator()(upscaler_ctx_t* ctx) const {
|
||||
if (ctx != nullptr) {
|
||||
free_upscaler_ctx(ctx);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct ADetailerCtxDeleter {
|
||||
void operator()(adetailer_ctx_t* ctx) const {
|
||||
if (ctx != nullptr) {
|
||||
free_adetailer_ctx(ctx);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
using FreeUniquePtr = std::unique_ptr<T, FreeDeleter>;
|
||||
|
||||
using FilePtr = std::unique_ptr<FILE, FileCloser>;
|
||||
using SDCtxPtr = std::unique_ptr<sd_ctx_t, SDCtxDeleter>;
|
||||
using UpscalerCtxPtr = std::unique_ptr<upscaler_ctx_t, UpscalerCtxDeleter>;
|
||||
using ADetailerCtxPtr = std::unique_ptr<adetailer_ctx_t, ADetailerCtxDeleter>;
|
||||
|
||||
class SDImageOwner {
|
||||
private:
|
||||
static sd_image_t copy_image(const sd_image_t& image) {
|
||||
if (image.data == nullptr) {
|
||||
return {image.width, image.height, image.channel, nullptr};
|
||||
}
|
||||
|
||||
const size_t byte_count = static_cast<size_t>(image.width) * image.height * image.channel;
|
||||
uint8_t* raw_copy = static_cast<uint8_t*>(malloc(byte_count));
|
||||
if (raw_copy == nullptr) {
|
||||
return {0, 0, 0, nullptr};
|
||||
}
|
||||
|
||||
std::memcpy(raw_copy, image.data, byte_count);
|
||||
return {image.width, image.height, image.channel, raw_copy};
|
||||
}
|
||||
|
||||
sd_image_t image_ = {0, 0, 0, nullptr};
|
||||
|
||||
public:
|
||||
SDImageOwner() = default;
|
||||
explicit SDImageOwner(sd_image_t image)
|
||||
: image_(image) {
|
||||
}
|
||||
|
||||
SDImageOwner(const SDImageOwner& other)
|
||||
: image_(copy_image(other.image_)) {
|
||||
}
|
||||
|
||||
SDImageOwner& operator=(const SDImageOwner& other) {
|
||||
if (this != &other) {
|
||||
reset(copy_image(other.image_));
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
SDImageOwner(SDImageOwner&& other) noexcept
|
||||
: image_(other.release()) {
|
||||
}
|
||||
|
||||
SDImageOwner& operator=(SDImageOwner&& other) noexcept {
|
||||
if (this != &other) {
|
||||
reset();
|
||||
image_ = other.release();
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
~SDImageOwner() {
|
||||
reset();
|
||||
}
|
||||
|
||||
sd_image_t* put() {
|
||||
if (image_.data != nullptr) {
|
||||
free(image_.data);
|
||||
image_.data = nullptr;
|
||||
}
|
||||
image_.width = 0;
|
||||
image_.height = 0;
|
||||
image_.channel = 0;
|
||||
return &image_;
|
||||
}
|
||||
|
||||
sd_image_t& get() {
|
||||
return image_;
|
||||
}
|
||||
|
||||
const sd_image_t& get() const {
|
||||
return image_;
|
||||
}
|
||||
|
||||
sd_image_t release() {
|
||||
sd_image_t image = image_;
|
||||
image_ = {0, 0, 0, nullptr};
|
||||
return image;
|
||||
}
|
||||
|
||||
void reset(sd_image_t image = {0, 0, 0, nullptr}) {
|
||||
if (image_.data != nullptr) {
|
||||
free(image_.data);
|
||||
}
|
||||
image_ = image;
|
||||
}
|
||||
};
|
||||
|
||||
class SDAudioOwner {
|
||||
private:
|
||||
uint32_t sample_rate_ = 0;
|
||||
uint32_t channels_ = 0;
|
||||
std::vector<float> samples_;
|
||||
|
||||
public:
|
||||
SDAudioOwner() = default;
|
||||
|
||||
void reset(std::vector<float> samples = {}, uint32_t sample_rate = 0, uint32_t channels = 0) {
|
||||
samples_ = std::move(samples);
|
||||
sample_rate_ = sample_rate;
|
||||
channels_ = channels;
|
||||
}
|
||||
|
||||
bool empty() const {
|
||||
return samples_.empty();
|
||||
}
|
||||
|
||||
sd_audio_t get() {
|
||||
return {sample_rate_,
|
||||
channels_,
|
||||
channels_ == 0 ? 0 : static_cast<uint64_t>(samples_.size() / channels_),
|
||||
samples_.empty() ? nullptr : samples_.data()};
|
||||
}
|
||||
|
||||
const std::vector<float>& samples() const {
|
||||
return samples_;
|
||||
}
|
||||
};
|
||||
|
||||
class SDImageVec {
|
||||
private:
|
||||
std::vector<sd_image_t> images_;
|
||||
|
||||
public:
|
||||
SDImageVec() = default;
|
||||
|
||||
SDImageVec(const SDImageVec&) = delete;
|
||||
SDImageVec& operator=(const SDImageVec&) = delete;
|
||||
|
||||
SDImageVec(SDImageVec&& other) noexcept
|
||||
: images_(std::move(other.images_)) {
|
||||
}
|
||||
|
||||
SDImageVec& operator=(SDImageVec&& other) noexcept {
|
||||
if (this != &other) {
|
||||
clear();
|
||||
images_ = std::move(other.images_);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
~SDImageVec() {
|
||||
clear();
|
||||
}
|
||||
|
||||
void push_back(sd_image_t image) {
|
||||
images_.push_back(image);
|
||||
}
|
||||
|
||||
void push_back(SDImageOwner&& image) {
|
||||
images_.push_back(image.release());
|
||||
}
|
||||
|
||||
void reserve(size_t count) {
|
||||
images_.reserve(count);
|
||||
}
|
||||
|
||||
void adopt(sd_image_t* images, int count) {
|
||||
clear();
|
||||
if (images == nullptr || count <= 0) {
|
||||
free(images);
|
||||
return;
|
||||
}
|
||||
|
||||
images_.reserve(static_cast<size_t>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
images_.push_back(images[i]);
|
||||
}
|
||||
free(images);
|
||||
}
|
||||
|
||||
size_t size() const {
|
||||
return images_.size();
|
||||
}
|
||||
|
||||
bool empty() const {
|
||||
return images_.empty();
|
||||
}
|
||||
|
||||
int count() const {
|
||||
return static_cast<int>(images_.size());
|
||||
}
|
||||
|
||||
explicit operator bool() const {
|
||||
return !images_.empty();
|
||||
}
|
||||
|
||||
sd_image_t* data() {
|
||||
return images_.data();
|
||||
}
|
||||
|
||||
const sd_image_t* data() const {
|
||||
return images_.data();
|
||||
}
|
||||
|
||||
sd_image_t& operator[](size_t index) {
|
||||
return images_[index];
|
||||
}
|
||||
|
||||
const sd_image_t& operator[](size_t index) const {
|
||||
return images_[index];
|
||||
}
|
||||
|
||||
std::vector<sd_image_t>& raw() {
|
||||
return images_;
|
||||
}
|
||||
|
||||
const std::vector<sd_image_t>& raw() const {
|
||||
return images_;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
for (sd_image_t& image : images_) {
|
||||
free(image.data);
|
||||
image.data = nullptr;
|
||||
}
|
||||
images_.clear();
|
||||
}
|
||||
};
|
||||
|
||||
#endif // __EXAMPLE_RESOURCE_OWNERS_H__
|
||||
@@ -0,0 +1,107 @@
|
||||
set(TARGET sd-server)
|
||||
|
||||
option(SD_SERVER_BUILD_FRONTEND "Build server frontend with pnpm" ON)
|
||||
|
||||
set(FRONTEND_DIR "${CMAKE_CURRENT_SOURCE_DIR}/frontend")
|
||||
set(GENERATED_HTML_HEADER "${FRONTEND_DIR}/dist/gen_index_html.h")
|
||||
|
||||
set(HAVE_FRONTEND_BUILD OFF)
|
||||
|
||||
if(SD_SERVER_BUILD_FRONTEND AND EXISTS "${FRONTEND_DIR}")
|
||||
if(WIN32)
|
||||
find_program(PNPM_EXECUTABLE NAMES pnpm.cmd pnpm)
|
||||
else()
|
||||
find_program(PNPM_EXECUTABLE NAMES pnpm)
|
||||
endif()
|
||||
|
||||
if(PNPM_EXECUTABLE)
|
||||
message(STATUS "Frontend dir found: ${FRONTEND_DIR}")
|
||||
message(STATUS "pnpm found: ${PNPM_EXECUTABLE}")
|
||||
|
||||
set(HAVE_FRONTEND_BUILD ON)
|
||||
|
||||
add_custom_target(${TARGET}_frontend_install
|
||||
COMMAND "${PNPM_EXECUTABLE}" -C "${FRONTEND_DIR}" install
|
||||
WORKING_DIRECTORY "${FRONTEND_DIR}"
|
||||
COMMENT "Installing frontend dependencies"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_custom_target(${TARGET}_frontend_build
|
||||
COMMAND "${PNPM_EXECUTABLE}" -C "${FRONTEND_DIR}" run build
|
||||
WORKING_DIRECTORY "${FRONTEND_DIR}"
|
||||
COMMENT "Building frontend"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_custom_target(${TARGET}_frontend_header
|
||||
COMMAND "${PNPM_EXECUTABLE}" -C "${FRONTEND_DIR}" run build:header
|
||||
WORKING_DIRECTORY "${FRONTEND_DIR}"
|
||||
COMMENT "Generating gen_index_html.h"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_dependencies(${TARGET}_frontend_build ${TARGET}_frontend_install)
|
||||
add_dependencies(${TARGET}_frontend_header ${TARGET}_frontend_build)
|
||||
|
||||
add_custom_target(${TARGET}_frontend
|
||||
DEPENDS ${TARGET}_frontend_header
|
||||
)
|
||||
|
||||
set_source_files_properties("${GENERATED_HTML_HEADER}" PROPERTIES GENERATED TRUE)
|
||||
else()
|
||||
if(EXISTS "${GENERATED_HTML_HEADER}")
|
||||
message(STATUS "pnpm not found; using pre-built frontend header detected at ${GENERATED_HTML_HEADER}")
|
||||
set(HAVE_FRONTEND_BUILD ON)
|
||||
add_custom_target(${TARGET}_frontend)
|
||||
else()
|
||||
message(WARNING "pnpm not found; frontend build disabled.")
|
||||
endif()
|
||||
endif()
|
||||
else()
|
||||
message(STATUS "Frontend disabled or directory not found: ${FRONTEND_DIR}")
|
||||
endif()
|
||||
|
||||
add_executable(${TARGET}
|
||||
../common/common.cpp
|
||||
../common/log.cpp
|
||||
../common/media_io.cpp
|
||||
main.cpp
|
||||
runtime.cpp
|
||||
async_jobs.cpp
|
||||
routes_index.cpp
|
||||
routes_openai.cpp
|
||||
routes_sdapi.cpp
|
||||
routes_sdcpp.cpp
|
||||
)
|
||||
if(APPLE)
|
||||
sd_set_macos_rpaths(${TARGET})
|
||||
endif()
|
||||
|
||||
if(HAVE_FRONTEND_BUILD)
|
||||
add_dependencies(${TARGET} ${TARGET}_frontend)
|
||||
target_sources(${TARGET} PRIVATE "${GENERATED_HTML_HEADER}")
|
||||
target_include_directories(${TARGET} PRIVATE "${FRONTEND_DIR}/dist")
|
||||
target_compile_definitions(${TARGET} PRIVATE HAVE_INDEX_HTML)
|
||||
message(STATUS "HAVE_INDEX_HTML enabled")
|
||||
else()
|
||||
message(STATUS "HAVE_INDEX_HTML disabled")
|
||||
endif()
|
||||
|
||||
install(TARGETS ${TARGET} RUNTIME)
|
||||
target_link_libraries(${TARGET} PRIVATE stable-diffusion ${CMAKE_THREAD_LIBS_INIT})
|
||||
if(SD_WEBP)
|
||||
target_compile_definitions(${TARGET} PRIVATE SD_USE_WEBP)
|
||||
target_link_libraries(${TARGET} PRIVATE webp libwebpmux)
|
||||
endif()
|
||||
if(SD_WEBM)
|
||||
target_compile_definitions(${TARGET} PRIVATE SD_USE_WEBM)
|
||||
target_link_libraries(${TARGET} PRIVATE webm)
|
||||
endif()
|
||||
|
||||
# due to httplib; it contains a pragma for MSVC, but other things need explicit flags
|
||||
if(WIN32 AND NOT MSVC)
|
||||
target_link_libraries(${TARGET} PRIVATE ws2_32)
|
||||
endif()
|
||||
|
||||
target_compile_features(${TARGET} PUBLIC c_std_11 cxx_std_17)
|
||||
@@ -0,0 +1,126 @@
|
||||
# Example
|
||||
|
||||
The following example starts `sd-server` with a standalone diffusion model, VAE, and LLM text encoder:
|
||||
|
||||
```
|
||||
.\bin\Release\sd-server.exe --diffusion-model ..\models\diffusion_models\z_image_turbo_bf16.safetensors --vae ..\models\vae\ae.sft --llm ..\models\text_encoders\qwen_3_4b.safetensors --diffusion-fa --offload-to-cpu -v --cfg-scale 1.0
|
||||
```
|
||||
|
||||
What this example does:
|
||||
|
||||
* `--diffusion-model` selects the standalone diffusion model
|
||||
* `--vae` selects the VAE decoder
|
||||
* `--llm` selects the text encoder / language model used by this pipeline
|
||||
* `--diffusion-fa` enables flash attention in the diffusion model
|
||||
* `--offload-to-cpu` reduces VRAM pressure by keeping weights in RAM when possible
|
||||
* `-v` enables verbose logging
|
||||
* `--cfg-scale 1.0` sets the default CFG scale for generation
|
||||
|
||||
After the server starts successfully:
|
||||
|
||||
* the web UI is available at `http://127.0.0.1:1234/`
|
||||
* the native async API is available under `/sdcpp/v1/...`
|
||||
* the compatibility APIs are available under `/v1/...` and `/sdapi/v1/...`
|
||||
|
||||
If you want to use a different host or port, pass:
|
||||
|
||||
```bash
|
||||
--listen-ip <ip> --listen-port <port>
|
||||
```
|
||||
|
||||
# Frontend
|
||||
|
||||
## Build with Frontend
|
||||
|
||||
The server can optionally build the web frontend and embed it into the binary as `gen_index_html.h`.
|
||||
|
||||
### Requirements
|
||||
|
||||
Install the following tools:
|
||||
|
||||
* **Node.js** ≥ 20
|
||||
https://nodejs.org/
|
||||
|
||||
* **pnpm** ≥ 10
|
||||
Install via npm:
|
||||
|
||||
```bash
|
||||
npm install -g pnpm
|
||||
```
|
||||
|
||||
Verify installation:
|
||||
|
||||
```bash
|
||||
node -v
|
||||
pnpm -v
|
||||
```
|
||||
|
||||
### Install frontend dependencies
|
||||
|
||||
Go to the frontend directory and install dependencies:
|
||||
|
||||
```bash
|
||||
cd examples/server/frontend
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Build the server with CMake
|
||||
|
||||
Enable the frontend build option when configuring CMake:
|
||||
|
||||
```bash
|
||||
cmake -B build -DSD_SERVER_BUILD_FRONTEND=ON
|
||||
cmake --build build --config Release
|
||||
```
|
||||
|
||||
If `pnpm` is available, the build system will automatically run:
|
||||
|
||||
```
|
||||
pnpm run build
|
||||
pnpm run build:header
|
||||
```
|
||||
|
||||
and embed the generated frontend into the server binary.
|
||||
|
||||
## Frontend Repository
|
||||
|
||||
The web frontend is maintained in a **separate repository**, https://github.com/leejet/sdcpp-webui.
|
||||
|
||||
If you want to modify the UI or frontend logic, please submit pull requests to the **frontend repository**.
|
||||
|
||||
This repository (`stable-diffusion.cpp`) only vendors the frontend periodically. Changes from the frontend repo are synchronized:
|
||||
|
||||
* approximately **every 1–2 weeks**, or
|
||||
* when there are **major frontend updates**
|
||||
|
||||
Because of this, frontend changes will **not appear here immediately** after being merged upstream.
|
||||
|
||||
## Using an external frontend
|
||||
|
||||
By default, the server uses the **embedded frontend** generated during the build (`gen_index_html.h`).
|
||||
|
||||
You can also serve a custom frontend file instead of the embedded one by using:
|
||||
|
||||
```bash
|
||||
--serve-html-path <path-to-index.html>
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```bash
|
||||
sd-server --serve-html-path ./index.html
|
||||
```
|
||||
|
||||
In this case, the server will load and serve the specified `index.html` file instead of the embedded frontend. This is useful when:
|
||||
|
||||
* developing or testing frontend changes
|
||||
* using a custom UI
|
||||
* avoiding rebuilding the binary after frontend modifications
|
||||
|
||||
# Usage
|
||||
|
||||
For detailed command-line arguments, run:
|
||||
|
||||
```bash
|
||||
./bin/sd-server -h
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,364 @@
|
||||
// Extracted from main.cpp during server refactor.
|
||||
|
||||
#include "async_jobs.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
#include "common/log.h"
|
||||
#include "common/media_io.h"
|
||||
#include "common/resource_owners.hpp"
|
||||
|
||||
const char* async_job_kind_name(AsyncJobKind kind) {
|
||||
switch (kind) {
|
||||
case AsyncJobKind::ImgGen:
|
||||
return "img_gen";
|
||||
case AsyncJobKind::VidGen:
|
||||
return "vid_gen";
|
||||
default:
|
||||
return "img_gen";
|
||||
}
|
||||
}
|
||||
|
||||
const char* async_job_status_name(AsyncJobStatus status) {
|
||||
switch (status) {
|
||||
case AsyncJobStatus::Queued:
|
||||
return "queued";
|
||||
case AsyncJobStatus::Generating:
|
||||
return "generating";
|
||||
case AsyncJobStatus::Completed:
|
||||
return "completed";
|
||||
case AsyncJobStatus::Failed:
|
||||
return "failed";
|
||||
case AsyncJobStatus::Cancelled:
|
||||
return "cancelled";
|
||||
default:
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
void purge_expired_jobs(AsyncJobManager& manager) {
|
||||
const int64_t now = unix_timestamp_now();
|
||||
|
||||
for (auto it = manager.expired_jobs.begin(); it != manager.expired_jobs.end();) {
|
||||
if (it->second <= now) {
|
||||
it = manager.expired_jobs.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
for (auto it = manager.jobs.begin(); it != manager.jobs.end();) {
|
||||
const auto& job = it->second;
|
||||
if (job->completed_at == 0) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
|
||||
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<int64_t>(ttl_seconds, 60);
|
||||
it = manager.jobs.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size_t count_pending_jobs(const AsyncJobManager& manager) {
|
||||
size_t pending = 0;
|
||||
for (const auto& entry : manager.jobs) {
|
||||
if (entry.second->status == AsyncJobStatus::Queued ||
|
||||
entry.second->status == AsyncJobStatus::Generating) {
|
||||
++pending;
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
std::string make_async_job_id(AsyncJobManager& manager) {
|
||||
std::ostringstream oss;
|
||||
oss << "job_" << std::hex << unix_timestamp_now() << "_" << std::setw(8)
|
||||
<< std::setfill('0') << manager.next_id++;
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
bool cancel_queued_job(AsyncJobManager& manager, AsyncGenerationJob& job) {
|
||||
auto new_end = std::remove(manager.queue.begin(), manager.queue.end(), job.id);
|
||||
if (new_end == manager.queue.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
manager.queue.erase(new_end, manager.queue.end());
|
||||
job.status = AsyncJobStatus::Cancelled;
|
||||
job.completed_at = unix_timestamp_now();
|
||||
job.result_images_b64.clear();
|
||||
job.result_media_b64.clear();
|
||||
job.result_media_mime_type.clear();
|
||||
job.result_frame_count = 0;
|
||||
job.result_fps = 0;
|
||||
job.error_code = "cancelled";
|
||||
job.error_message = "job cancelled by client";
|
||||
return true;
|
||||
}
|
||||
|
||||
json make_async_job_json(const AsyncJobManager& manager, const AsyncGenerationJob& job) {
|
||||
json result;
|
||||
result["id"] = job.id;
|
||||
result["kind"] = async_job_kind_name(job.kind);
|
||||
result["status"] = async_job_status_name(job.status);
|
||||
result["created"] = job.created_at;
|
||||
result["started"] = job.started_at == 0 ? json(nullptr) : json(job.started_at);
|
||||
result["completed"] = job.completed_at == 0 ? json(nullptr) : json(job.completed_at);
|
||||
result["queue_position"] = 0;
|
||||
|
||||
if (job.status == AsyncJobStatus::Queued) {
|
||||
size_t position = 1;
|
||||
for (const auto& queued_id : manager.queue) {
|
||||
if (queued_id == job.id) {
|
||||
result["queue_position"] = position;
|
||||
break;
|
||||
}
|
||||
++position;
|
||||
}
|
||||
}
|
||||
|
||||
if (job.status == AsyncJobStatus::Completed) {
|
||||
if (job.kind == AsyncJobKind::VidGen) {
|
||||
result["result"] = {
|
||||
{"output_format", job.vid_gen.output_format},
|
||||
{"mime_type", job.result_media_mime_type},
|
||||
{"fps", job.result_fps},
|
||||
{"frame_count", job.result_frame_count},
|
||||
{"b64_json", job.result_media_b64},
|
||||
};
|
||||
} else {
|
||||
json images = json::array();
|
||||
for (size_t i = 0; i < job.result_images_b64.size(); ++i) {
|
||||
images.push_back({{"index", i}, {"b64_json", job.result_images_b64[i]}});
|
||||
}
|
||||
result["result"] = {
|
||||
{"output_format", job.img_gen.output_format},
|
||||
{"images", images},
|
||||
};
|
||||
}
|
||||
result["error"] = nullptr;
|
||||
} else if (job.status == AsyncJobStatus::Failed ||
|
||||
job.status == AsyncJobStatus::Cancelled) {
|
||||
result["result"] = nullptr;
|
||||
result["error"] = {
|
||||
{"code",
|
||||
job.error_code.empty()
|
||||
? (job.status == AsyncJobStatus::Cancelled ? "cancelled" : "generation_failed")
|
||||
: job.error_code},
|
||||
{"message", job.error_message},
|
||||
};
|
||||
} else {
|
||||
result["result"] = nullptr;
|
||||
result["error"] = nullptr;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool execute_img_gen_job(ServerRuntime& runtime,
|
||||
AsyncGenerationJob& job,
|
||||
std::vector<std::string>& output_images,
|
||||
std::string& error_message) {
|
||||
sd_img_gen_params_t params = job.img_gen.to_sd_img_gen_params_t();
|
||||
|
||||
SDImageVec results;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*runtime.sd_ctx_mutex);
|
||||
sd_image_t* raw_results = nullptr;
|
||||
int num_results = 0;
|
||||
if (!generate_image(runtime.sd_ctx, ¶ms, &raw_results, &num_results)) {
|
||||
raw_results = nullptr;
|
||||
num_results = 0;
|
||||
}
|
||||
results.adopt(raw_results, num_results);
|
||||
}
|
||||
|
||||
const int num_results = results.count();
|
||||
if (num_results <= 0) {
|
||||
error_message = "generate_image returned no results";
|
||||
return false;
|
||||
}
|
||||
|
||||
EncodedImageFormat encoded_format = EncodedImageFormat::PNG;
|
||||
if (job.img_gen.output_format == "jpeg") {
|
||||
encoded_format = EncodedImageFormat::JPEG;
|
||||
} else if (job.img_gen.output_format == "webp") {
|
||||
encoded_format = EncodedImageFormat::WEBP;
|
||||
}
|
||||
|
||||
int batch_count = job.img_gen.gen_params.batch_count;
|
||||
int images_per_batch = batch_count > 0 ? std::max(1, num_results / batch_count) : 1;
|
||||
for (int i = 0; i < num_results; ++i) {
|
||||
if (results[i].data == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::string metadata = job.img_gen.gen_params.embed_image_metadata
|
||||
? get_image_params(*runtime.ctx_params,
|
||||
job.img_gen.gen_params,
|
||||
job.img_gen.gen_params.seed + i / images_per_batch)
|
||||
: "";
|
||||
auto image_bytes = encode_image_to_vector(encoded_format,
|
||||
results[i].data,
|
||||
results[i].width,
|
||||
results[i].height,
|
||||
results[i].channel,
|
||||
metadata,
|
||||
job.img_gen.output_compression);
|
||||
if (image_bytes.empty()) {
|
||||
continue;
|
||||
}
|
||||
output_images.push_back(base64_encode(image_bytes));
|
||||
}
|
||||
|
||||
if (output_images.empty()) {
|
||||
error_message = "generate_image returned empty encoded outputs";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool execute_vid_gen_job(ServerRuntime& runtime,
|
||||
AsyncGenerationJob& job,
|
||||
std::string& output_media_b64,
|
||||
std::string& output_media_mime_type,
|
||||
int& output_frame_count,
|
||||
int& output_fps,
|
||||
std::string& error_message) {
|
||||
sd_vid_gen_params_t params = job.vid_gen.to_sd_vid_gen_params_t();
|
||||
|
||||
SDImageVec results;
|
||||
int num_results = 0;
|
||||
sd_audio_t* generated_audio = nullptr;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*runtime.sd_ctx_mutex);
|
||||
sd_image_t* raw_results = nullptr;
|
||||
if (!generate_video(runtime.sd_ctx, ¶ms, &raw_results, &num_results, &generated_audio)) {
|
||||
raw_results = nullptr;
|
||||
}
|
||||
results.adopt(raw_results, num_results);
|
||||
}
|
||||
|
||||
num_results = results.count();
|
||||
if (num_results <= 0) {
|
||||
free_sd_audio(generated_audio);
|
||||
error_message = "generate_video returned no results";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> video_bytes = create_video_from_sd_images_to_vector(job.vid_gen.output_format,
|
||||
results.data(),
|
||||
num_results,
|
||||
job.vid_gen.gen_params.fps,
|
||||
job.vid_gen.output_compression,
|
||||
generated_audio);
|
||||
free_sd_audio(generated_audio);
|
||||
if (video_bytes.empty()) {
|
||||
error_message = "failed to encode generated video container";
|
||||
return false;
|
||||
}
|
||||
|
||||
output_media_b64 = base64_encode(video_bytes);
|
||||
output_media_mime_type = video_mime_type(job.vid_gen.output_format);
|
||||
output_frame_count = num_results;
|
||||
output_fps = job.vid_gen.gen_params.fps;
|
||||
return true;
|
||||
}
|
||||
|
||||
void async_job_worker(ServerRuntime& runtime) {
|
||||
AsyncJobManager& manager = *runtime.async_job_manager;
|
||||
|
||||
while (true) {
|
||||
std::shared_ptr<AsyncGenerationJob> job;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(manager.mutex);
|
||||
manager.cv.wait(lock, [&]() { return manager.stop || !manager.queue.empty(); });
|
||||
|
||||
if (manager.stop && manager.queue.empty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
purge_expired_jobs(manager);
|
||||
if (manager.queue.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::string job_id = manager.queue.front();
|
||||
manager.queue.pop_front();
|
||||
|
||||
auto it = manager.jobs.find(job_id);
|
||||
if (it == manager.jobs.end()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
job = it->second;
|
||||
job->status = AsyncJobStatus::Generating;
|
||||
job->started_at = unix_timestamp_now();
|
||||
}
|
||||
|
||||
std::vector<std::string> output_images;
|
||||
std::string output_media_b64;
|
||||
std::string output_media_mime_type;
|
||||
int output_frame_count = 0;
|
||||
int output_fps = 0;
|
||||
std::string error_message;
|
||||
bool ok = false;
|
||||
|
||||
if (job->kind == AsyncJobKind::ImgGen) {
|
||||
ok = execute_img_gen_job(runtime, *job, output_images, error_message);
|
||||
} else if (job->kind == AsyncJobKind::VidGen) {
|
||||
ok = execute_vid_gen_job(runtime,
|
||||
*job,
|
||||
output_media_b64,
|
||||
output_media_mime_type,
|
||||
output_frame_count,
|
||||
output_fps,
|
||||
error_message);
|
||||
} else {
|
||||
error_message = "unsupported job kind";
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(manager.mutex);
|
||||
auto it = manager.jobs.find(job->id);
|
||||
if (it == manager.jobs.end()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
job->completed_at = unix_timestamp_now();
|
||||
if (ok) {
|
||||
job->status = AsyncJobStatus::Completed;
|
||||
job->result_images_b64 = std::move(output_images);
|
||||
job->result_media_b64 = std::move(output_media_b64);
|
||||
job->result_media_mime_type = std::move(output_media_mime_type);
|
||||
job->result_frame_count = output_frame_count;
|
||||
job->result_fps = output_fps;
|
||||
job->error_code.clear();
|
||||
job->error_message.clear();
|
||||
} else {
|
||||
job->status = AsyncJobStatus::Failed;
|
||||
job->error_code = "generation_failed";
|
||||
job->error_message = error_message.empty() ? "unknown generation error" : error_message;
|
||||
job->result_images_b64.clear();
|
||||
job->result_media_b64.clear();
|
||||
job->result_media_mime_type.clear();
|
||||
job->result_frame_count = 0;
|
||||
job->result_fps = 0;
|
||||
}
|
||||
|
||||
purge_expired_jobs(manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
|
||||
#include "runtime.h"
|
||||
|
||||
enum class AsyncJobKind {
|
||||
ImgGen,
|
||||
VidGen,
|
||||
};
|
||||
|
||||
enum class AsyncJobStatus {
|
||||
Queued,
|
||||
Generating,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
};
|
||||
|
||||
const char* async_job_kind_name(AsyncJobKind kind);
|
||||
const char* async_job_status_name(AsyncJobStatus status);
|
||||
|
||||
struct AsyncGenerationJob {
|
||||
std::string id;
|
||||
AsyncJobKind kind = AsyncJobKind::ImgGen;
|
||||
AsyncJobStatus status = AsyncJobStatus::Queued;
|
||||
int64_t created_at = unix_timestamp_now();
|
||||
int64_t started_at = 0;
|
||||
int64_t completed_at = 0;
|
||||
ImgGenJobRequest img_gen;
|
||||
VidGenJobRequest vid_gen;
|
||||
std::vector<std::string> result_images_b64;
|
||||
std::string result_media_b64;
|
||||
std::string result_media_mime_type;
|
||||
int result_frame_count = 0;
|
||||
int result_fps = 0;
|
||||
std::string error_code;
|
||||
std::string error_message;
|
||||
};
|
||||
|
||||
struct AsyncJobManager {
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::unordered_map<std::string, std::shared_ptr<AsyncGenerationJob>> jobs;
|
||||
std::unordered_map<std::string, int64_t> expired_jobs;
|
||||
std::deque<std::string> queue;
|
||||
uint64_t next_id = 0;
|
||||
bool stop = false;
|
||||
size_t max_pending_jobs = 64;
|
||||
int64_t completed_ttl_seconds = 600;
|
||||
int64_t failed_ttl_seconds = 600;
|
||||
};
|
||||
|
||||
void purge_expired_jobs(AsyncJobManager& manager);
|
||||
size_t count_pending_jobs(const AsyncJobManager& manager);
|
||||
std::string make_async_job_id(AsyncJobManager& manager);
|
||||
bool cancel_queued_job(AsyncJobManager& manager, AsyncGenerationJob& job);
|
||||
json make_async_job_json(const AsyncJobManager& manager, const AsyncGenerationJob& job);
|
||||
bool execute_img_gen_job(ServerRuntime& runtime,
|
||||
AsyncGenerationJob& job,
|
||||
std::vector<std::string>& output_images,
|
||||
std::string& error_message);
|
||||
bool execute_vid_gen_job(ServerRuntime& runtime,
|
||||
AsyncGenerationJob& job,
|
||||
std::string& output_media_b64,
|
||||
std::string& output_media_mime_type,
|
||||
int& output_frame_count,
|
||||
int& output_fps,
|
||||
std::string& error_message);
|
||||
void async_job_worker(ServerRuntime& runtime);
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 leejet
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,126 @@
|
||||
# sdcpp-webui
|
||||
|
||||
A lightweight Vue + Vite web UI for `stable-diffusion.cpp` servers that expose the native `sdcpp` API.
|
||||
|
||||
It is designed for two use cases:
|
||||
|
||||
- run as a standalone frontend during local development
|
||||
- build into a single HTML file that can be embedded into `sd-server`
|
||||
|
||||
## What It Does
|
||||
|
||||
`sdcpp-webui` talks directly to the native server endpoints:
|
||||
|
||||
- `GET /sdcpp/v1/capabilities`
|
||||
- `POST /sdcpp/v1/img_gen`
|
||||
- `POST /sdcpp/v1/vid_gen`
|
||||
- `GET /sdcpp/v1/jobs/:id`
|
||||
- `POST /sdcpp/v1/jobs/:id/cancel`
|
||||
|
||||
The current UI supports:
|
||||
|
||||
- image generation and video generation mode switching
|
||||
- prompt and negative prompt editing
|
||||
- width, height, seed, batch count, video frames, and fps
|
||||
- sampler and scheduler selection
|
||||
- guidance controls, including the video high-noise stage
|
||||
- conditioning controls such as `clip_skip`, `strength`, `control_strength`, `moe_boundary`, and `vace_strength`
|
||||
- LoRA selection from server capabilities
|
||||
- init image, end image, mask image, control image, control frames, and reference images
|
||||
- VAE tiling controls
|
||||
- cache controls
|
||||
- job polling, cancellation, image preview, and video or animated WebP preview
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js `>= 20`
|
||||
- `pnpm` `>= 10`
|
||||
- a running `stable-diffusion.cpp` server with the `sdcpp` API enabled
|
||||
|
||||
## Development
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Then open the Vite URL shown in the terminal.
|
||||
|
||||
The UI lets you set the backend base URL in the Settings tab.
|
||||
If left empty, requests go to the current origin.
|
||||
|
||||
## Production Build
|
||||
|
||||
Build a production bundle:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
This project uses `vite-plugin-singlefile`, so the output is emitted as a self-contained `dist/index.html`.
|
||||
|
||||
Preview the production build locally:
|
||||
|
||||
```bash
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## Embedding Into `sd-server`
|
||||
|
||||
If you want to ship the UI inside `stable-diffusion.cpp`, first build the frontend:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Then generate the C header:
|
||||
|
||||
```bash
|
||||
pnpm build:header
|
||||
```
|
||||
|
||||
That produces:
|
||||
|
||||
```text
|
||||
dist/gen_index_html.h
|
||||
```
|
||||
|
||||
The generated header contains the built HTML as a byte array, which can be compiled into the server binary.
|
||||
|
||||
## Type Checking
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
## Project Layout
|
||||
|
||||
```text
|
||||
src/
|
||||
components/ reusable UI pieces
|
||||
lib/ API, form mapping, image helpers, settings helpers
|
||||
App.vue main application shell
|
||||
main.ts app entry
|
||||
styles.css global styles
|
||||
scripts/
|
||||
build_gen_index_html.js
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This UI is intentionally thin. Most selectable options come from the server's `capabilities` response.
|
||||
- It assumes the backend handles CORS correctly if the frontend is served from a different origin.
|
||||
- It is scoped to the native `sdcpp` API, not the OpenAI-compatible routes and not the A1111-compatible `sdapi` routes.
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect x="4" y="4" width="56" height="56" rx="16" fill="#111827"/>
|
||||
<text
|
||||
x="32"
|
||||
y="39"
|
||||
fill="#F9FAFB"
|
||||
font-family="Segoe UI, Arial, sans-serif"
|
||||
font-size="28"
|
||||
font-weight="700"
|
||||
letter-spacing="-1"
|
||||
text-anchor="middle"
|
||||
>SD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='none'%3E%3Crect x='4' y='4' width='56' height='56' rx='16' fill='%23111827'/%3E%3Ctext x='32' y='39' fill='%23F9FAFB' font-family='Segoe UI, Arial, sans-serif' font-size='28' font-weight='700' letter-spacing='-1' text-anchor='middle'%3ESD%3C/text%3E%3C/svg%3E" />
|
||||
<title>stable-diffusion.cpp WebUI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "sdcpp-webui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.15.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 4174",
|
||||
"build:header": "node scripts/build_gen_index_html.js",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.2.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^3.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^3.2.10",
|
||||
"vite-plugin-singlefile": "^1.0.0",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
||||
Generated
+732
@@ -0,0 +1,732 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
vue:
|
||||
specifier: ^3.2.38
|
||||
version: 3.5.32(typescript@5.9.3)
|
||||
devDependencies:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^3.0.3
|
||||
version: 3.2.0(vite@3.2.11)(vue@3.5.32(typescript@5.9.3))
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^3.2.10
|
||||
version: 3.2.11
|
||||
vite-plugin-singlefile:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(rollup@2.80.0)(vite@3.2.11)
|
||||
vue-tsc:
|
||||
specifier: ^2.2.8
|
||||
version: 2.2.12(typescript@5.9.3)
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@esbuild/android-arm@0.15.18':
|
||||
resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/linux-loong64@0.15.18':
|
||||
resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@vitejs/plugin-vue@3.2.0':
|
||||
resolution: {integrity: sha512-E0tnaL4fr+qkdCNxJ+Xd0yM31UwMkQje76fsDVBBUCoGOUPexu2VDUYHL8P4CwV+zMvWw6nlRw19OnRKmYAJpw==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
vite: ^3.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@volar/language-core@2.4.15':
|
||||
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
||||
|
||||
'@volar/source-map@2.4.15':
|
||||
resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
|
||||
|
||||
'@volar/typescript@2.4.15':
|
||||
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
|
||||
|
||||
'@vue/compiler-core@3.5.32':
|
||||
resolution: {integrity: sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==}
|
||||
|
||||
'@vue/compiler-dom@3.5.32':
|
||||
resolution: {integrity: sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.32':
|
||||
resolution: {integrity: sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.32':
|
||||
resolution: {integrity: sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==}
|
||||
|
||||
'@vue/compiler-vue2@2.7.16':
|
||||
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
|
||||
|
||||
'@vue/language-core@2.2.12':
|
||||
resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@vue/reactivity@3.5.32':
|
||||
resolution: {integrity: sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==}
|
||||
|
||||
'@vue/runtime-core@3.5.32':
|
||||
resolution: {integrity: sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==}
|
||||
|
||||
'@vue/runtime-dom@3.5.32':
|
||||
resolution: {integrity: sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==}
|
||||
|
||||
'@vue/server-renderer@3.5.32':
|
||||
resolution: {integrity: sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==}
|
||||
peerDependencies:
|
||||
vue: 3.5.32
|
||||
|
||||
'@vue/shared@3.5.32':
|
||||
resolution: {integrity: sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==}
|
||||
|
||||
alien-signals@1.0.13:
|
||||
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
brace-expansion@2.0.3:
|
||||
resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
de-indent@1.0.2:
|
||||
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
esbuild-android-64@0.15.18:
|
||||
resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
esbuild-android-arm64@0.15.18:
|
||||
resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
esbuild-darwin-64@0.15.18:
|
||||
resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
esbuild-darwin-arm64@0.15.18:
|
||||
resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
esbuild-freebsd-64@0.15.18:
|
||||
resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
esbuild-freebsd-arm64@0.15.18:
|
||||
resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
esbuild-linux-32@0.15.18:
|
||||
resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
esbuild-linux-64@0.15.18:
|
||||
resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
esbuild-linux-arm64@0.15.18:
|
||||
resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
esbuild-linux-arm@0.15.18:
|
||||
resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
esbuild-linux-mips64le@0.15.18:
|
||||
resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
esbuild-linux-ppc64le@0.15.18:
|
||||
resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
esbuild-linux-riscv64@0.15.18:
|
||||
resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
esbuild-linux-s390x@0.15.18:
|
||||
resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
esbuild-netbsd-64@0.15.18:
|
||||
resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
esbuild-openbsd-64@0.15.18:
|
||||
resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
esbuild-sunos-64@0.15.18:
|
||||
resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
esbuild-windows-32@0.15.18:
|
||||
resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
esbuild-windows-64@0.15.18:
|
||||
resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
esbuild-windows-arm64@0.15.18:
|
||||
resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
esbuild@0.15.18:
|
||||
resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
he@1.2.0:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
is-core-module@2.16.1:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-number@7.0.0:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@2.3.2:
|
||||
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
postcss@8.5.8:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
resolve@1.22.11:
|
||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
rollup@2.80.0:
|
||||
resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
vite-plugin-singlefile@1.0.0:
|
||||
resolution: {integrity: sha512-iVAxl0t7gndQrznI4kI14hqAGxhVL7FQndeTdvTIH5/CbwpR2asgD3XSTsd7iWC83YTCmwAmsHIQvKdIdnHu7w==}
|
||||
engines: {node: '>18.0.0'}
|
||||
peerDependencies:
|
||||
rollup: ^4.9.5
|
||||
vite: ^5.0.11
|
||||
|
||||
vite@3.2.11:
|
||||
resolution: {integrity: sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': '>= 14'
|
||||
less: '*'
|
||||
sass: '*'
|
||||
stylus: '*'
|
||||
sugarss: '*'
|
||||
terser: ^5.4.0
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-tsc@2.2.12:
|
||||
resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
vue@3.5.32:
|
||||
resolution: {integrity: sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@esbuild/android-arm@0.15.18':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.15.18':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@vitejs/plugin-vue@3.2.0(vite@3.2.11)(vue@3.5.32(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vite: 3.2.11
|
||||
vue: 3.5.32(typescript@5.9.3)
|
||||
|
||||
'@volar/language-core@2.4.15':
|
||||
dependencies:
|
||||
'@volar/source-map': 2.4.15
|
||||
|
||||
'@volar/source-map@2.4.15': {}
|
||||
|
||||
'@volar/typescript@2.4.15':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.15
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.1.0
|
||||
|
||||
'@vue/compiler-core@3.5.32':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@vue/shared': 3.5.32
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-dom@3.5.32':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.32
|
||||
'@vue/shared': 3.5.32
|
||||
|
||||
'@vue/compiler-sfc@3.5.32':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@vue/compiler-core': 3.5.32
|
||||
'@vue/compiler-dom': 3.5.32
|
||||
'@vue/compiler-ssr': 3.5.32
|
||||
'@vue/shared': 3.5.32
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.8
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-ssr@3.5.32':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.32
|
||||
'@vue/shared': 3.5.32
|
||||
|
||||
'@vue/compiler-vue2@2.7.16':
|
||||
dependencies:
|
||||
de-indent: 1.0.2
|
||||
he: 1.2.0
|
||||
|
||||
'@vue/language-core@2.2.12(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.15
|
||||
'@vue/compiler-dom': 3.5.32
|
||||
'@vue/compiler-vue2': 2.7.16
|
||||
'@vue/shared': 3.5.32
|
||||
alien-signals: 1.0.13
|
||||
minimatch: 9.0.9
|
||||
muggle-string: 0.4.1
|
||||
path-browserify: 1.0.1
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@vue/reactivity@3.5.32':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.32
|
||||
|
||||
'@vue/runtime-core@3.5.32':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.32
|
||||
'@vue/shared': 3.5.32
|
||||
|
||||
'@vue/runtime-dom@3.5.32':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.32
|
||||
'@vue/runtime-core': 3.5.32
|
||||
'@vue/shared': 3.5.32
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vue/server-renderer@3.5.32(vue@3.5.32(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.5.32
|
||||
'@vue/shared': 3.5.32
|
||||
vue: 3.5.32(typescript@5.9.3)
|
||||
|
||||
'@vue/shared@3.5.32': {}
|
||||
|
||||
alien-signals@1.0.13: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
brace-expansion@2.0.3:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
de-indent@1.0.2: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
esbuild-android-64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-android-arm64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-darwin-64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-darwin-arm64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-freebsd-64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-freebsd-arm64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-linux-32@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-linux-64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-linux-arm64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-linux-arm@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-linux-mips64le@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-linux-ppc64le@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-linux-riscv64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-linux-s390x@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-netbsd-64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-openbsd-64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-sunos-64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-windows-32@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-windows-64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild-windows-arm64@0.15.18:
|
||||
optional: true
|
||||
|
||||
esbuild@0.15.18:
|
||||
optionalDependencies:
|
||||
'@esbuild/android-arm': 0.15.18
|
||||
'@esbuild/linux-loong64': 0.15.18
|
||||
esbuild-android-64: 0.15.18
|
||||
esbuild-android-arm64: 0.15.18
|
||||
esbuild-darwin-64: 0.15.18
|
||||
esbuild-darwin-arm64: 0.15.18
|
||||
esbuild-freebsd-64: 0.15.18
|
||||
esbuild-freebsd-arm64: 0.15.18
|
||||
esbuild-linux-32: 0.15.18
|
||||
esbuild-linux-64: 0.15.18
|
||||
esbuild-linux-arm: 0.15.18
|
||||
esbuild-linux-arm64: 0.15.18
|
||||
esbuild-linux-mips64le: 0.15.18
|
||||
esbuild-linux-ppc64le: 0.15.18
|
||||
esbuild-linux-riscv64: 0.15.18
|
||||
esbuild-linux-s390x: 0.15.18
|
||||
esbuild-netbsd-64: 0.15.18
|
||||
esbuild-openbsd-64: 0.15.18
|
||||
esbuild-sunos-64: 0.15.18
|
||||
esbuild-windows-32: 0.15.18
|
||||
esbuild-windows-64: 0.15.18
|
||||
esbuild-windows-arm64: 0.15.18
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.2
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.3
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
postcss@8.5.8:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
resolve@1.22.11:
|
||||
dependencies:
|
||||
is-core-module: 2.16.1
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
rollup@2.80.0:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
vite-plugin-singlefile@1.0.0(rollup@2.80.0)(vite@3.2.11):
|
||||
dependencies:
|
||||
micromatch: 4.0.8
|
||||
rollup: 2.80.0
|
||||
vite: 3.2.11
|
||||
|
||||
vite@3.2.11:
|
||||
dependencies:
|
||||
esbuild: 0.15.18
|
||||
postcss: 8.5.8
|
||||
resolve: 1.22.11
|
||||
rollup: 2.80.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-tsc@2.2.12(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.15
|
||||
'@vue/language-core': 2.2.12(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
vue@3.5.32(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.32
|
||||
'@vue/compiler-sfc': 3.5.32
|
||||
'@vue/runtime-dom': 3.5.32
|
||||
'@vue/server-renderer': 3.5.32(vue@3.5.32(typescript@5.9.3))
|
||||
'@vue/shared': 3.5.32
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -0,0 +1,24 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const distHtml = path.join(__dirname, "../dist/index.html");
|
||||
const outHeader = path.join(__dirname, "../dist/gen_index_html.h");
|
||||
|
||||
if (!fs.existsSync(distHtml)) {
|
||||
console.error("index.html not found. Please run pnpm build first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(distHtml);
|
||||
const bytes = Array.from(html).map((b) => `0x${b.toString(16).padStart(2, "0")},`);
|
||||
const lines = [];
|
||||
for (let i = 0; i < bytes.length; i += 12) {
|
||||
lines.push(" " + bytes.slice(i, i + 12).join(" "));
|
||||
}
|
||||
|
||||
const headerContent =
|
||||
`static const unsigned char index_html_bytes[] = {\n${lines.join("\n")}\n};\n` +
|
||||
"static const size_t index_html_size = sizeof(index_html_bytes);\n";
|
||||
|
||||
fs.writeFileSync(outHeader, headerContent);
|
||||
console.log(`Generated ${outHeader}`);
|
||||
@@ -0,0 +1,923 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
|
||||
import CollapsibleSection from "./components/CollapsibleSection.vue";
|
||||
import ImageDropzone from "./components/ImageDropzone.vue";
|
||||
import StatusChip from "./components/StatusChip.vue";
|
||||
import { cancelJob, getCapabilities, getJob, submitImageJob, submitVideoJob } from "./lib/api";
|
||||
import { buildRequestBodyForMode, CACHE_MODES, createBlankForm, formFromCapabilities } from "./lib/form";
|
||||
import { IMAGE_INPUTS, VIDEO_IMAGE_INPUTS } from "./lib/image-inputs";
|
||||
import { assignImageEntries, clearImageEntries, filesToImageEntries, removeImageEntry } from "./lib/images";
|
||||
import { createStoredRef, normalizePollIntervalMs } from "./lib/settings";
|
||||
import type {
|
||||
Capabilities,
|
||||
GenerationForm,
|
||||
GenerationMode,
|
||||
ImageEntry,
|
||||
ImageTarget,
|
||||
Job,
|
||||
SampleParams,
|
||||
} from "./lib/types";
|
||||
|
||||
const baseUrl = createStoredRef<string>("sdcpp-webui-base-url", "", (value: unknown) => String(value || ""));
|
||||
const pollIntervalMs = createStoredRef<number>("sdcpp-webui-poll-interval-ms", 100, normalizePollIntervalMs);
|
||||
const activeTab = ref<"image" | "video" | "settings">("image");
|
||||
const selectedGenerationTab = ref<"image" | "video">("image");
|
||||
const generationMode = computed<GenerationMode>(() => selectedGenerationTab.value === "video" ? "video" : "image");
|
||||
const lightboxOpen = ref(false);
|
||||
const lightboxImageSrc = ref("");
|
||||
const lightboxImageAlt = ref("");
|
||||
const sectionState = reactive<Record<string, boolean>>({
|
||||
sample: true,
|
||||
sampleAdvanced: false,
|
||||
guidance: true,
|
||||
guidanceAdvanced: false,
|
||||
highNoise: false,
|
||||
highNoiseSample: true,
|
||||
highNoiseGuidance: true,
|
||||
conditioning: false,
|
||||
auxiliaryImages: false,
|
||||
lora: false,
|
||||
vaeTiling: false,
|
||||
cache: false,
|
||||
});
|
||||
const loadingCapabilities = ref(false);
|
||||
const capabilitiesError = ref("");
|
||||
const serviceOnline = ref(false);
|
||||
const capabilities = ref<Capabilities | null>(null);
|
||||
const currentJob = ref<Job | null>(null);
|
||||
const selectedOutputIndex = ref(0);
|
||||
const statusMessage = ref("");
|
||||
const statusTone = ref("");
|
||||
const form = reactive<GenerationForm>(createBlankForm());
|
||||
|
||||
let pollTimer = 0;
|
||||
let elapsedTimer = 0;
|
||||
const nowSeconds = ref(Date.now() / 1000);
|
||||
|
||||
const modelName = computed(() => {
|
||||
const model = capabilities.value?.model;
|
||||
return model?.stem || model?.name || "No model info";
|
||||
});
|
||||
|
||||
const supportedModes = computed(() => capabilities.value?.supported_modes || []);
|
||||
const supportsImageMode = computed(() => {
|
||||
return !supportedModes.value.length || supportedModes.value.includes("img_gen");
|
||||
});
|
||||
const supportsVideoMode = computed(() => {
|
||||
return !supportedModes.value.length || supportedModes.value.includes("vid_gen");
|
||||
});
|
||||
const selectedModeKey = computed<"img_gen" | "vid_gen">(() => generationMode.value === "video" ? "vid_gen" : "img_gen");
|
||||
const currentJobModeKey = computed<"img_gen" | "vid_gen">(() => currentJob.value?.kind || selectedModeKey.value);
|
||||
const selectedModeFeatures = computed(() => {
|
||||
return capabilities.value?.features_by_mode?.[selectedModeKey.value] || {};
|
||||
});
|
||||
const currentJobFeatures = computed(() => {
|
||||
return capabilities.value?.features_by_mode?.[currentJobModeKey.value] || selectedModeFeatures.value;
|
||||
});
|
||||
const imageOutputFormats = computed(() => {
|
||||
return capabilities.value?.output_formats_by_mode?.img_gen || ["png", "jpeg"];
|
||||
});
|
||||
const videoOutputFormats = computed(() => {
|
||||
return capabilities.value?.output_formats_by_mode?.vid_gen || ["webm", "avi"];
|
||||
});
|
||||
const outputFormats = computed(() => generationMode.value === "video" ? videoOutputFormats.value : imageOutputFormats.value);
|
||||
const samplers = computed(() => capabilities.value?.samplers || ["default"]);
|
||||
const schedulers = computed(() => capabilities.value?.schedulers || ["default"]);
|
||||
const availableLoras = computed(() => capabilities.value?.loras || []);
|
||||
const currentImageInputs = computed(() => generationMode.value === "video" ? VIDEO_IMAGE_INPUTS : IMAGE_INPUTS);
|
||||
const gridImageInputs = computed(() => currentImageInputs.value.filter((input) => input.layout === "grid"));
|
||||
const fullImageInputs = computed(() => currentImageInputs.value.filter((input) => input.layout === "full"));
|
||||
const queueLimit = computed(() => capabilities.value?.limits?.max_queue_size ?? "unknown");
|
||||
const canCancelQueued = computed(() => Boolean(currentJobFeatures.value.cancel_queued));
|
||||
const canCancelGenerating = computed(() => Boolean(currentJobFeatures.value.cancel_generating));
|
||||
|
||||
const currentStatus = computed(() => currentJob.value?.status || "idle");
|
||||
const currentJobKind = computed(() => currentJob.value?.kind || null);
|
||||
const currentImages = computed(() => currentJobKind.value === "img_gen" ? currentJob.value?.result?.images || [] : []);
|
||||
const selectedImage = computed(() => {
|
||||
if (!currentImages.value.length) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.min(selectedOutputIndex.value, currentImages.value.length - 1);
|
||||
const image = currentImages.value[index];
|
||||
const format = currentJob.value?.result?.output_format || form.output_format || "png";
|
||||
return `data:image/${format};base64,${image.b64_json}`;
|
||||
});
|
||||
const videoMimeType = computed(() => currentJobKind.value === "vid_gen" ? currentJob.value?.result?.mime_type || "" : "");
|
||||
const videoFrameCount = computed(() => currentJobKind.value === "vid_gen" ? currentJob.value?.result?.frame_count || 0 : 0);
|
||||
const videoFps = computed(() => currentJobKind.value === "vid_gen" ? currentJob.value?.result?.fps || 0 : 0);
|
||||
const videoPreviewSrc = computed(() => {
|
||||
if (currentJobKind.value !== "vid_gen" || !currentJob.value?.result?.b64_json) {
|
||||
return null;
|
||||
}
|
||||
if (currentJob.value?.result?.output_format === "avi") {
|
||||
return null;
|
||||
}
|
||||
if (!videoMimeType.value.startsWith("video/")) {
|
||||
return null;
|
||||
}
|
||||
return `data:${videoMimeType.value};base64,${currentJob.value.result.b64_json}`;
|
||||
});
|
||||
const animatedVideoImageSrc = computed(() => {
|
||||
if (currentJobKind.value !== "vid_gen" || !currentJob.value?.result?.b64_json) {
|
||||
return null;
|
||||
}
|
||||
if (videoMimeType.value !== "image/webp") {
|
||||
return null;
|
||||
}
|
||||
return `data:image/webp;base64,${currentJob.value.result.b64_json}`;
|
||||
});
|
||||
const previewImageSrc = computed(() => animatedVideoImageSrc.value || selectedImage.value);
|
||||
const downloadableSrc = computed(() => {
|
||||
const result = currentJob.value?.result;
|
||||
if (currentJobKind.value === "vid_gen" && result?.b64_json && videoMimeType.value) {
|
||||
return `data:${videoMimeType.value};base64,${result.b64_json}`;
|
||||
}
|
||||
return selectedImage.value;
|
||||
});
|
||||
|
||||
const canCancelCurrentJob = computed(() => {
|
||||
if (!currentJob.value) {
|
||||
return false;
|
||||
}
|
||||
if (currentStatus.value === "queued") {
|
||||
return canCancelQueued.value;
|
||||
}
|
||||
if (currentStatus.value === "generating") {
|
||||
return canCancelGenerating.value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isJobRunning = computed(() => {
|
||||
return currentStatus.value === "queued" || currentStatus.value === "generating";
|
||||
});
|
||||
|
||||
function buildSampleSummary(sample: SampleParams): string {
|
||||
const scheduler = sample.scheduler || "default";
|
||||
const method = sample.sample_method || "default";
|
||||
const steps = sample.sample_steps || 0;
|
||||
const flowShift = sample.flow_shift === "" || sample.flow_shift == null
|
||||
? "flow auto"
|
||||
: `flow ${sample.flow_shift}`;
|
||||
return `${scheduler} · ${flowShift} · ${method} · ${steps} steps`;
|
||||
}
|
||||
|
||||
function buildGuidanceSummary(sample: SampleParams): string {
|
||||
const cfg = sample.guidance.txt_cfg;
|
||||
const distilled = sample.guidance.distilled_guidance;
|
||||
return `cfg ${cfg} · distilled ${distilled}`;
|
||||
}
|
||||
|
||||
const sampleSummary = computed(() => buildSampleSummary(form.sample_params));
|
||||
const guidanceSummary = computed(() => buildGuidanceSummary(form.sample_params));
|
||||
const highNoiseSummary = computed(() => {
|
||||
return `moe ${formatSummaryNumber(form.moe_boundary)} · ${buildSampleSummary(form.high_noise_sample_params)} · ${buildGuidanceSummary(form.high_noise_sample_params)}`;
|
||||
});
|
||||
const loraSummary = computed(() => {
|
||||
if (!form.lora.length) {
|
||||
return "No LoRA";
|
||||
}
|
||||
return `${form.lora.length} configured`;
|
||||
});
|
||||
const imageInputsSummary = computed(() => {
|
||||
const parts: string[] = [];
|
||||
if (form.init_image) parts.push(generationMode.value === "video" ? "start" : "init");
|
||||
if (generationMode.value === "image") {
|
||||
if (form.mask_image) parts.push("mask");
|
||||
if (form.control_image) parts.push("control");
|
||||
if (form.ref_images.length) parts.push(`${form.ref_images.length} refs`);
|
||||
} else {
|
||||
if (form.end_image) parts.push("end");
|
||||
if (form.control_frames.length) parts.push(`${form.control_frames.length} frames`);
|
||||
}
|
||||
return parts.length ? parts.join(" · ") : "No images";
|
||||
});
|
||||
const vaeTilingSummary = computed(() => {
|
||||
if (!form.vae_tiling_params.enabled) {
|
||||
return "Disabled";
|
||||
}
|
||||
return `${form.vae_tiling_params.tile_size_x}×${form.vae_tiling_params.tile_size_y} · overlap ${form.vae_tiling_params.target_overlap}`;
|
||||
});
|
||||
const cacheSummary = computed(() => {
|
||||
const mode = form.cache.mode || "disabled";
|
||||
if (mode === "disabled") {
|
||||
return "Disabled";
|
||||
}
|
||||
const option = String(form.cache.option || "").trim();
|
||||
return option ? `${mode} · ${option}` : mode;
|
||||
});
|
||||
const conditioningSummary = computed(() => {
|
||||
const clipSkip = formatSummaryNumber(form.clip_skip, 0);
|
||||
const strength = formatSummaryNumber(form.strength);
|
||||
if (generationMode.value === "video") {
|
||||
return `clip_skip ${clipSkip} · strength ${strength} · vace ${formatSummaryNumber(form.vace_strength)}`;
|
||||
}
|
||||
const controlStrength = formatSummaryNumber(form.control_strength);
|
||||
return `clip_skip ${clipSkip} · img ${strength} · control ${controlStrength}`;
|
||||
});
|
||||
|
||||
function defaultOutputFormatForMode(mode: GenerationMode): string {
|
||||
if (mode === "video") {
|
||||
return videoOutputFormats.value[0] || "webm";
|
||||
}
|
||||
return imageOutputFormats.value[0] || "png";
|
||||
}
|
||||
|
||||
function ensureOutputFormatForMode(mode = generationMode.value): void {
|
||||
const validFormats = mode === "video" ? videoOutputFormats.value : imageOutputFormats.value;
|
||||
if (!validFormats.length) {
|
||||
return;
|
||||
}
|
||||
if (!validFormats.includes(form.output_format)) {
|
||||
form.output_format = defaultOutputFormatForMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
function setMessage(message: string, tone = ""): void {
|
||||
statusMessage.value = message;
|
||||
statusTone.value = tone;
|
||||
}
|
||||
|
||||
function clearMessage(): void {
|
||||
setMessage("", "");
|
||||
}
|
||||
|
||||
function deepAssign(target: Record<string, any>, ...sources: Record<string, any>[]): Record<string, any> {
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
for (const key of Object.keys(source)) {
|
||||
const sv = source[key];
|
||||
if (sv !== null && typeof sv === "object" && !Array.isArray(sv) &&
|
||||
target[key] !== null && typeof target[key] === "object" && !Array.isArray(target[key])) {
|
||||
deepAssign(target[key], sv);
|
||||
} else {
|
||||
target[key] = sv;
|
||||
}
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function applyForm(nextForm: GenerationForm): void {
|
||||
deepAssign(form, createBlankForm(), nextForm);
|
||||
}
|
||||
|
||||
async function refreshCapabilities(): Promise<void> {
|
||||
loadingCapabilities.value = true;
|
||||
capabilitiesError.value = "";
|
||||
try {
|
||||
const response = await getCapabilities(baseUrl.value);
|
||||
capabilities.value = response;
|
||||
serviceOnline.value = true;
|
||||
applyForm(formFromCapabilities(response));
|
||||
if (selectedGenerationTab.value === "image" && !supportsImageMode.value && supportsVideoMode.value) {
|
||||
selectedGenerationTab.value = "video";
|
||||
} else if (selectedGenerationTab.value === "video" && !supportsVideoMode.value && supportsImageMode.value) {
|
||||
selectedGenerationTab.value = "image";
|
||||
}
|
||||
if (activeTab.value === "image" && !supportsImageMode.value && supportsVideoMode.value) {
|
||||
activeTab.value = "video";
|
||||
} else if (activeTab.value === "video" && !supportsVideoMode.value && supportsImageMode.value) {
|
||||
activeTab.value = "image";
|
||||
}
|
||||
ensureOutputFormatForMode();
|
||||
clearMessage();
|
||||
} catch (error) {
|
||||
capabilitiesError.value = error instanceof Error ? error.message : String(error);
|
||||
serviceOnline.value = false;
|
||||
setMessage(capabilitiesError.value, "error");
|
||||
} finally {
|
||||
loadingCapabilities.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSection(section: string): void {
|
||||
sectionState[section] = !sectionState[section];
|
||||
}
|
||||
|
||||
function selectGenerationMode(mode: GenerationMode): void {
|
||||
if (mode === "image" && !supportsImageMode.value) {
|
||||
setMessage("Current model only supports video generation.", "error");
|
||||
return;
|
||||
}
|
||||
if (mode === "video" && !supportsVideoMode.value) {
|
||||
setMessage("Current model only supports image generation.", "error");
|
||||
return;
|
||||
}
|
||||
selectedGenerationTab.value = mode;
|
||||
activeTab.value = mode;
|
||||
ensureOutputFormatForMode(mode);
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
window.clearTimeout(pollTimer);
|
||||
pollTimer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function stopElapsedTimer(): void {
|
||||
if (elapsedTimer) {
|
||||
window.clearInterval(elapsedTimer);
|
||||
elapsedTimer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function startElapsedTimer(): void {
|
||||
stopElapsedTimer();
|
||||
nowSeconds.value = Date.now() / 1000;
|
||||
elapsedTimer = window.setInterval(() => {
|
||||
nowSeconds.value = Date.now() / 1000;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async function pollJob(id: string): Promise<void> {
|
||||
stopPolling();
|
||||
try {
|
||||
currentJob.value = await getJob(baseUrl.value, id);
|
||||
serviceOnline.value = true;
|
||||
if (currentStatus.value === "queued" || currentStatus.value === "generating") {
|
||||
pollTimer = window.setTimeout(() => pollJob(id), normalizePollIntervalMs(pollIntervalMs.value));
|
||||
clearMessage();
|
||||
return;
|
||||
}
|
||||
stopElapsedTimer();
|
||||
if (currentStatus.value === "completed") {
|
||||
setMessage(currentJob.value?.kind === "vid_gen" ? "Video generation completed." : "Image generation completed.", "success");
|
||||
return;
|
||||
}
|
||||
if (currentStatus.value === "cancelled") {
|
||||
setMessage("Job cancelled.", "error");
|
||||
return;
|
||||
}
|
||||
if (currentStatus.value === "failed") {
|
||||
setMessage(currentJob.value?.error?.message || "Generation failed.", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
stopElapsedTimer();
|
||||
serviceOnline.value = false;
|
||||
setMessage(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
try {
|
||||
const request = buildRequestBodyForMode(generationMode.value, form);
|
||||
clearMessage();
|
||||
selectedOutputIndex.value = 0;
|
||||
startElapsedTimer();
|
||||
currentJob.value = generationMode.value === "video"
|
||||
? await submitVideoJob(baseUrl.value, request)
|
||||
: await submitImageJob(baseUrl.value, request);
|
||||
await pollJob(currentJob.value.id);
|
||||
} catch (error) {
|
||||
stopElapsedTimer();
|
||||
setMessage(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelCurrentJob(): Promise<void> {
|
||||
if (!currentJob.value?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
currentJob.value = await cancelJob(baseUrl.value, currentJob.value.id);
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
setMessage("Job cancelled.", "error");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
}
|
||||
|
||||
function addLora(): void {
|
||||
form.lora.push({
|
||||
path: availableLoras.value[0]?.path || "",
|
||||
multiplier: 1,
|
||||
is_high_noise: false,
|
||||
});
|
||||
}
|
||||
|
||||
function removeLora(index: number): void {
|
||||
form.lora.splice(index, 1);
|
||||
}
|
||||
|
||||
async function assignImages(target: ImageTarget, files: FileList): Promise<void> {
|
||||
const images = await filesToImageEntries(files);
|
||||
if (!images.length) {
|
||||
return;
|
||||
}
|
||||
assignImageEntries(form, target, images);
|
||||
}
|
||||
|
||||
function clearImage(target: ImageTarget): void {
|
||||
clearImageEntries(form, target);
|
||||
}
|
||||
|
||||
function getFormImage(target: ImageTarget): ImageEntry | null {
|
||||
if (target === "ref_images" || target === "control_frames") return null;
|
||||
return form[target];
|
||||
}
|
||||
|
||||
function openImageEntry(image: ImageEntry | null): void {
|
||||
openLightbox(image?.dataUrl, image?.name);
|
||||
}
|
||||
|
||||
function removeCollectionImage(target: "ref_images" | "control_frames", index: number): void {
|
||||
removeImageEntry(form, target, index);
|
||||
}
|
||||
|
||||
function selectOutput(index: number): void {
|
||||
selectedOutputIndex.value = index;
|
||||
}
|
||||
|
||||
function formatUnixTime(seconds: number | undefined): string {
|
||||
if (!seconds) {
|
||||
return "No job";
|
||||
}
|
||||
return new Date(seconds * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatElapsed(started: number | undefined, completed: number | undefined): string {
|
||||
if (!started) {
|
||||
return "Idle";
|
||||
}
|
||||
const end = completed || nowSeconds.value;
|
||||
const total = Math.max(0, end - started);
|
||||
if (total < 60) {
|
||||
return `${total.toFixed(1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total - minutes * 60;
|
||||
return `${minutes}m ${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatSummaryNumber(value: number, digits = 3): string {
|
||||
const numeric = Number(value ?? 0);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return "0";
|
||||
}
|
||||
return Number(numeric.toFixed(digits)).toString();
|
||||
}
|
||||
|
||||
function downloadSelected(): void {
|
||||
if (!downloadableSrc.value) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadableSrc.value;
|
||||
link.download = `${currentJob.value?.id || "output"}.${currentJob.value?.result?.output_format || form.output_format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function openLightbox(src: string | null | undefined = previewImageSrc.value, alt = "Expanded image"): void {
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
lightboxImageSrc.value = src;
|
||||
lightboxImageAlt.value = alt;
|
||||
lightboxOpen.value = true;
|
||||
}
|
||||
|
||||
function closeLightbox(): void {
|
||||
lightboxOpen.value = false;
|
||||
lightboxImageSrc.value = "";
|
||||
lightboxImageAlt.value = "";
|
||||
}
|
||||
|
||||
async function onPaste(event: ClipboardEvent): Promise<void> {
|
||||
if (!event.clipboardData?.files?.length) {
|
||||
return;
|
||||
}
|
||||
await assignImages("init_image", event.clipboardData.files);
|
||||
setMessage("Pasted image into init_image.", "success");
|
||||
}
|
||||
|
||||
watch(generationMode, (mode) => {
|
||||
ensureOutputFormatForMode(mode);
|
||||
});
|
||||
|
||||
watch(imageOutputFormats, () => {
|
||||
ensureOutputFormatForMode();
|
||||
});
|
||||
|
||||
watch(videoOutputFormats, () => {
|
||||
ensureOutputFormatForMode();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("paste", onPaste);
|
||||
refreshCapabilities();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
stopElapsedTimer();
|
||||
window.removeEventListener("paste", onPaste);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="page-header panel">
|
||||
<div class="page-header__top">
|
||||
<div class="page-header__copy">
|
||||
<div class="breadcrumb">
|
||||
<span class="breadcrumb__org">stable-diffusion.cpp</span>
|
||||
<span class="breadcrumb__slash">/</span>
|
||||
<span>{{ modelName }}</span>
|
||||
</div>
|
||||
<h1 class="page-title">{{ modelName }}</h1>
|
||||
<p class="page-description">
|
||||
Native async image and video generation interface for the local `stable-diffusion.cpp` server.
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-header__meta">
|
||||
<StatusChip :status="serviceOnline ? 'online' : 'offline'" :label="serviceOnline ? 'service online' : 'service unavailable'" />
|
||||
<StatusChip :label="`queue ${queueLimit}`" />
|
||||
<StatusChip :status="currentStatus" :label="currentStatus" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-tabs">
|
||||
<div class="page-tabs__list">
|
||||
<button class="page-tab" :class="{ 'page-tab--active': activeTab === 'image' }" type="button" @click="selectGenerationMode('image')" :disabled="!supportsImageMode">Image Generation</button>
|
||||
<button class="page-tab" :class="{ 'page-tab--active': activeTab === 'video' }" type="button" @click="selectGenerationMode('video')" :disabled="!supportsVideoMode">Video Generation</button>
|
||||
<button class="page-tab" :class="{ 'page-tab--active': activeTab === 'settings' }" type="button" @click="activeTab = 'settings'">Settings</button>
|
||||
</div>
|
||||
<div class="page-tabs__actions">
|
||||
<button class="btn-secondary" type="button" @click="refreshCapabilities" :disabled="loadingCapabilities">{{ loadingCapabilities ? "Refreshing..." : "Refresh Server Info" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeTab === 'settings'" class="settings">
|
||||
<div class="settings__grid">
|
||||
<div class="field">
|
||||
<label>Base URL</label>
|
||||
<input v-model="baseUrl" placeholder="Leave blank to use same origin" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Queue Limit</label>
|
||||
<input :value="queueLimit" readonly />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Output Formats ({{ generationMode === "video" ? "video" : "image" }})</label>
|
||||
<input :value="outputFormats.join(', ')" readonly />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Output Format</label>
|
||||
<select v-model="form.output_format">
|
||||
<option v-for="format in outputFormats" :key="format" :value="format">{{ format }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Output Compression</label>
|
||||
<input v-model.number="form.output_compression" type="number" min="0" max="100" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Job Poll Interval (ms)</label>
|
||||
<input v-model.number="pollIntervalMs" type="number" min="1" step="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="capabilitiesError" class="status-message status-message--error">{{ capabilitiesError }}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="activeTab !== 'settings'" class="layout">
|
||||
<section class="panel control-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">Input</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prompt-card">
|
||||
<div class="field--full">
|
||||
<label>Prompt</label>
|
||||
<textarea v-model="form.prompt" :placeholder="generationMode === 'video' ? 'Describe the motion, scene, and framing you want to generate' : 'Describe the image you want to generate'" />
|
||||
</div>
|
||||
<div class="field--full stack-top">
|
||||
<label>Negative Prompt</label>
|
||||
<textarea v-model="form.negative_prompt" placeholder="What should be excluded?" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fields stack-top">
|
||||
<div class="field"><label>Width</label><input v-model.number="form.width" type="number" min="64" /></div>
|
||||
<div class="field"><label>Height</label><input v-model.number="form.height" type="number" min="64" /></div>
|
||||
</div>
|
||||
|
||||
<div v-if="generationMode === 'image'" class="fields stack-top">
|
||||
<div class="field"><label>Batch Count</label><input v-model.number="form.batch_count" type="number" min="1" /></div>
|
||||
<div class="field"><label>Seed</label><input v-model.number="form.seed" type="number" /></div>
|
||||
</div>
|
||||
<div v-else class="fields stack-top">
|
||||
<div class="field"><label>Video Frames</label><input v-model.number="form.video_frames" type="number" min="1" /></div>
|
||||
<div class="field"><label>FPS</label><input v-model.number="form.fps" type="number" min="1" /></div>
|
||||
</div>
|
||||
<div v-if="generationMode === 'video'" class="field stack-top">
|
||||
<label>Seed</label>
|
||||
<input v-model.number="form.seed" type="number" />
|
||||
</div>
|
||||
|
||||
<CollapsibleSection class="stack-top" eyebrow="Sample" :summary="sampleSummary" :open="sectionState.sample" variant="module" @toggle="toggleSection('sample')">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label>Scheduler</label>
|
||||
<select v-model="form.sample_params.scheduler">
|
||||
<option value="default">default</option>
|
||||
<option v-for="scheduler in schedulers" :key="scheduler" :value="scheduler">{{ scheduler }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Flow Shift</label><input v-model="form.sample_params.flow_shift" type="number" step="0.01" placeholder="blank = default" /></div>
|
||||
<div class="field">
|
||||
<label>Method</label>
|
||||
<select v-model="form.sample_params.sample_method">
|
||||
<option value="default">default</option>
|
||||
<option v-for="sampler in samplers" :key="sampler" :value="sampler">{{ sampler }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Steps</label><input v-model.number="form.sample_params.sample_steps" type="number" /></div>
|
||||
</div>
|
||||
<div class="sample-panel__extras">
|
||||
<button class="module-card__link" type="button" @click="toggleSection('sampleAdvanced')">
|
||||
{{ sectionState.sampleAdvanced ? "Hide extras" : "Show extras" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="sectionState.sampleAdvanced" class="fields">
|
||||
<div class="field"><label>Eta</label><input v-model="form.sample_params.eta" type="number" step="0.01" placeholder="blank = default" /></div>
|
||||
<div class="field"><label>Shifted Timestep</label><input v-model.number="form.sample_params.shifted_timestep" type="number" /></div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection class="stack-top" eyebrow="Guidance" :summary="guidanceSummary" :open="sectionState.guidance" variant="module" @toggle="toggleSection('guidance')">
|
||||
<div class="fields">
|
||||
<div class="field"><label>CFG Scale</label><input v-model.number="form.sample_params.guidance.txt_cfg" type="number" step="0.1" /></div>
|
||||
<div class="field"><label>Distilled Guidance</label><input v-model.number="form.sample_params.guidance.distilled_guidance" type="number" step="0.1" /></div>
|
||||
</div>
|
||||
<div class="sample-panel__extras">
|
||||
<button class="module-card__link" type="button" @click="toggleSection('guidanceAdvanced')">
|
||||
{{ sectionState.guidanceAdvanced ? "Hide extras" : "Show extras" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="sectionState.guidanceAdvanced" class="fields">
|
||||
<div class="field"><label>Image CFG</label><input v-model="form.sample_params.guidance.img_cfg" type="number" step="0.1" placeholder="blank = follow text cfg" /></div>
|
||||
<div class="field"><label>SLG Layers</label><input v-model="form.sample_params.guidance.slg_layers" placeholder="7,8,9" /></div>
|
||||
<div class="field"><label>SLG Layer Start</label><input v-model.number="form.sample_params.guidance.layer_start" type="number" step="0.01" /></div>
|
||||
<div class="field"><label>SLG Layer End</label><input v-model.number="form.sample_params.guidance.layer_end" type="number" step="0.01" /></div>
|
||||
<div class="field"><label>SLG Scale</label><input v-model.number="form.sample_params.guidance.scale" type="number" step="0.01" /></div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection v-if="generationMode === 'video'" class="stack-top" eyebrow="High Noise Pass" :summary="highNoiseSummary" :open="sectionState.highNoise" variant="module" @toggle="toggleSection('highNoise')">
|
||||
<div class="field">
|
||||
<label>MoE Boundary</label>
|
||||
<input v-model.number="form.moe_boundary" type="number" step="0.001" />
|
||||
</div>
|
||||
<CollapsibleSection eyebrow="Sample" :summary="buildSampleSummary(form.high_noise_sample_params)" :open="sectionState.highNoiseSample" variant="plain" @toggle="toggleSection('highNoiseSample')">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label>Scheduler</label>
|
||||
<select v-model="form.high_noise_sample_params.scheduler">
|
||||
<option value="default">default</option>
|
||||
<option v-for="scheduler in schedulers" :key="`high-noise-${scheduler}`" :value="scheduler">{{ scheduler }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Flow Shift</label><input v-model="form.high_noise_sample_params.flow_shift" type="number" step="0.01" placeholder="blank = default" /></div>
|
||||
<div class="field">
|
||||
<label>Method</label>
|
||||
<select v-model="form.high_noise_sample_params.sample_method">
|
||||
<option value="default">default</option>
|
||||
<option v-for="sampler in samplers" :key="`high-noise-${sampler}`" :value="sampler">{{ sampler }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Steps</label><input v-model.number="form.high_noise_sample_params.sample_steps" type="number" /></div>
|
||||
<div class="field"><label>Eta</label><input v-model="form.high_noise_sample_params.eta" type="number" step="0.01" placeholder="blank = auto" /></div>
|
||||
<div class="field"><label>Shifted Timestep</label><input v-model.number="form.high_noise_sample_params.shifted_timestep" type="number" /></div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection class="stack-top" eyebrow="Guidance" :summary="buildGuidanceSummary(form.high_noise_sample_params)" :open="sectionState.highNoiseGuidance" variant="plain" @toggle="toggleSection('highNoiseGuidance')">
|
||||
<div class="fields">
|
||||
<div class="field"><label>CFG Scale</label><input v-model.number="form.high_noise_sample_params.guidance.txt_cfg" type="number" step="0.1" /></div>
|
||||
<div class="field"><label>Distilled Guidance</label><input v-model.number="form.high_noise_sample_params.guidance.distilled_guidance" type="number" step="0.1" /></div>
|
||||
<div class="field"><label>Image CFG</label><input v-model="form.high_noise_sample_params.guidance.img_cfg" type="number" step="0.1" placeholder="blank = follow text cfg" /></div>
|
||||
<div class="field"><label>SLG Layers</label><input v-model="form.high_noise_sample_params.guidance.slg_layers" placeholder="7,8,9" /></div>
|
||||
<div class="field"><label>SLG Layer Start</label><input v-model.number="form.high_noise_sample_params.guidance.layer_start" type="number" step="0.01" /></div>
|
||||
<div class="field"><label>SLG Layer End</label><input v-model.number="form.high_noise_sample_params.guidance.layer_end" type="number" step="0.01" /></div>
|
||||
<div class="field"><label>SLG Scale</label><input v-model.number="form.high_noise_sample_params.guidance.scale" type="number" step="0.01" /></div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection class="stack-top" eyebrow="Conditioning" :summary="conditioningSummary" :open="sectionState.conditioning" @toggle="toggleSection('conditioning')">
|
||||
<div class="fields">
|
||||
<div class="field"><label>CLIP Skip</label><input v-model.number="form.clip_skip" type="number" /></div>
|
||||
<div class="field"><label>Strength</label><input v-model.number="form.strength" type="number" step="0.01" /></div>
|
||||
<div v-if="generationMode === 'image'" class="field"><label>Control Strength</label><input v-model.number="form.control_strength" type="number" step="0.01" /></div>
|
||||
<div v-if="generationMode === 'video'" class="field"><label>VACE Strength</label><input v-model.number="form.vace_strength" type="number" step="0.01" /></div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection class="stack-top" eyebrow="LoRA" :summary="loraSummary" :open="sectionState.lora" @toggle="toggleSection('lora')">
|
||||
<div v-if="!availableLoras.length" class="hint">No LoRA entries were returned by capabilities.</div>
|
||||
<div v-else-if="!form.lora.length" class="hint">No LoRA overrides configured.</div>
|
||||
<div v-else class="list-editor">
|
||||
<div class="list-row list-row--header">
|
||||
<div>LoRA</div>
|
||||
<div>Multiplier</div>
|
||||
<div>High Noise</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div v-for="(item, index) in form.lora" :key="index" class="list-row">
|
||||
<select v-model="item.path">
|
||||
<option v-for="lora in availableLoras" :key="lora.path" :value="lora.path">
|
||||
{{ lora.name }} ({{ lora.path }})
|
||||
</option>
|
||||
</select>
|
||||
<input v-model.number="item.multiplier" type="number" step="0.1" />
|
||||
<label class="checkbox list-row__checkbox">
|
||||
<input v-model="item.is_high_noise" type="checkbox" />
|
||||
<span>{{ item.is_high_noise ? "On" : "Off" }}</span>
|
||||
</label>
|
||||
<button class="btn-ghost" type="button" @click="removeLora(index)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<div><button class="btn-ghost" type="button" @click="addLora" :disabled="!availableLoras.length">Add LoRA</button></div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection class="stack-top" eyebrow="Image Inputs" :summary="imageInputsSummary" :open="sectionState.auxiliaryImages" @toggle="toggleSection('auxiliaryImages')">
|
||||
<div class="upload-grid">
|
||||
<ImageDropzone
|
||||
v-for="input in gridImageInputs"
|
||||
:key="input.target"
|
||||
:label="input.label"
|
||||
:description="input.description"
|
||||
:preview="getFormImage(input.target)"
|
||||
@select="assignImages(input.target, $event)"
|
||||
@clear="clearImage(input.target)"
|
||||
@preview="openImageEntry($event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-for="input in fullImageInputs" :key="input.target">
|
||||
<ImageDropzone
|
||||
:label="input.label"
|
||||
:description="input.description"
|
||||
:preview="getFormImage(input.target)"
|
||||
@select="assignImages(input.target, $event)"
|
||||
@clear="clearImage(input.target)"
|
||||
@preview="openImageEntry($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="generationMode === 'image'" class="group">
|
||||
<label>Reference Images</label>
|
||||
<ImageDropzone
|
||||
label="Reference Images"
|
||||
description="Multiple reference images supported."
|
||||
:items="form.ref_images"
|
||||
multiple
|
||||
@select="assignImages('ref_images', $event)"
|
||||
@clear="clearImage('ref_images')"
|
||||
/>
|
||||
<div v-if="!form.ref_images.length" class="hint">No files selected.</div>
|
||||
<div v-else class="file-list">
|
||||
<div v-for="(item, index) in form.ref_images" :key="item.name + index" class="file-chip file-chip--preview">
|
||||
<button class="file-chip__thumb-button" type="button" @click="openLightbox(item.dataUrl, item.name)">
|
||||
<img class="file-chip__thumb" :src="item.dataUrl" :alt="item.name" />
|
||||
</button>
|
||||
<span class="file-chip__name">{{ item.name }}</span>
|
||||
<button class="icon-button" type="button" @click="removeCollectionImage('ref_images', index)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="group">
|
||||
<label>Control Frames</label>
|
||||
<ImageDropzone
|
||||
label="Control Frames"
|
||||
description="Upload ordered conditioning frames. The server preserves the array order."
|
||||
:items="form.control_frames"
|
||||
multiple
|
||||
@select="assignImages('control_frames', $event)"
|
||||
@clear="clearImage('control_frames')"
|
||||
/>
|
||||
<div v-if="!form.control_frames.length" class="hint">No files selected.</div>
|
||||
<div v-else class="file-list">
|
||||
<div v-for="(item, index) in form.control_frames" :key="item.name + index" class="file-chip file-chip--preview">
|
||||
<button class="file-chip__thumb-button" type="button" @click="openLightbox(item.dataUrl, item.name)">
|
||||
<img class="file-chip__thumb" :src="item.dataUrl" :alt="item.name" />
|
||||
</button>
|
||||
<span class="file-chip__name">{{ item.name }}</span>
|
||||
<button class="icon-button" type="button" @click="removeCollectionImage('control_frames', index)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection class="stack-top" eyebrow="VAE Tiling" :summary="vaeTilingSummary" :open="sectionState.vaeTiling" @toggle="toggleSection('vaeTiling')">
|
||||
<label class="checkbox"><input v-model="form.vae_tiling_params.enabled" type="checkbox" /><span>Enabled</span></label>
|
||||
<div class="fields">
|
||||
<div class="field"><label>Tile Size X</label><input v-model.number="form.vae_tiling_params.tile_size_x" type="number" /></div>
|
||||
<div class="field"><label>Tile Size Y</label><input v-model.number="form.vae_tiling_params.tile_size_y" type="number" /></div>
|
||||
</div>
|
||||
<div class="field"><label>Target Overlap</label><input v-model.number="form.vae_tiling_params.target_overlap" type="number" step="0.01" /></div>
|
||||
<div class="fields">
|
||||
<div class="field"><label>Relative Size X</label><input v-model.number="form.vae_tiling_params.rel_size_x" type="number" step="0.01" /></div>
|
||||
<div class="field"><label>Relative Size Y</label><input v-model.number="form.vae_tiling_params.rel_size_y" type="number" step="0.01" /></div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection class="stack-top" eyebrow="Cache" :summary="cacheSummary" :open="sectionState.cache" @toggle="toggleSection('cache')">
|
||||
<div class="field">
|
||||
<label>Mode</label>
|
||||
<select v-model="form.cache.mode">
|
||||
<option v-for="mode in CACHE_MODES" :key="mode" :value="mode">{{ mode }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field--full"><label>Cache Option</label><input v-model="form.cache.option" placeholder="threshold=0.25,start=0.15,end=0.95" /></div>
|
||||
<div class="field"><label>SCM Mask</label><input v-model="form.cache.scm_mask" /></div>
|
||||
<label class="checkbox"><input v-model="form.cache.scm_policy_dynamic" type="checkbox" /><span>Dynamic SCM Policy</span></label>
|
||||
</CollapsibleSection>
|
||||
</section>
|
||||
|
||||
<section class="panel output-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">Output</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="videoPreviewSrc" class="hero-frame hero-frame--media">
|
||||
<video class="hero-frame__video" :src="videoPreviewSrc" controls autoplay loop muted playsinline />
|
||||
</div>
|
||||
<button v-else class="hero-frame hero-frame--button" type="button" :disabled="!previewImageSrc" @click="openLightbox(previewImageSrc, 'Generated output')">
|
||||
<img v-if="previewImageSrc" :src="previewImageSrc" alt="Generated output" />
|
||||
<div v-else class="hero-placeholder">
|
||||
<h2>{{ generationMode === "video" ? "Generate Video" : "Generate Images" }}</h2>
|
||||
<p>
|
||||
{{ generationMode === "video"
|
||||
? "Generated video or animated WebP output will appear here once the current job finishes."
|
||||
: "Generated images will appear here once the current job finishes." }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="currentJobKind === 'vid_gen' && currentJob?.result?.output_format === 'avi'" class="hint stack-top">
|
||||
Browser playback for AVI depends on codec support. Download the file if the preview cannot play.
|
||||
</div>
|
||||
|
||||
<div class="metrics output-metrics">
|
||||
<div class="metric">
|
||||
<div class="metric__label">Status</div>
|
||||
<div class="metric__value">{{ currentStatus }}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric__label">Queue</div>
|
||||
<div class="metric__value">{{ currentJob?.queue_position ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric__label">Created</div>
|
||||
<div class="metric__value mono">{{ formatUnixTime(currentJob?.created) }}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric__label">Elapsed</div>
|
||||
<div class="metric__value">{{ formatElapsed(currentJob?.started, currentJob?.completed) }}</div>
|
||||
</div>
|
||||
<div v-if="currentJobKind === 'vid_gen'" class="metric">
|
||||
<div class="metric__label">FPS</div>
|
||||
<div class="metric__value">{{ videoFps || "-" }}</div>
|
||||
</div>
|
||||
<div v-if="currentJobKind === 'vid_gen'" class="metric">
|
||||
<div class="metric__label">Frames</div>
|
||||
<div class="metric__value">{{ videoFrameCount || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="statusMessage" class="status-message" :class="statusTone === 'error' ? 'status-message--error' : 'status-message--success'">{{ statusMessage }}</div>
|
||||
<div v-else-if="currentJob?.error?.message" class="status-message status-message--error">{{ currentJob.error.message }}</div>
|
||||
|
||||
<div class="output-controls">
|
||||
<button class="btn output-controls__primary" type="button" :disabled="isJobRunning" @click="generate">
|
||||
{{ generationMode === "video" ? "Generate Video" : "Generate Image" }}
|
||||
</button>
|
||||
<div class="actions output-controls__secondary">
|
||||
<button class="btn-secondary" type="button" :disabled="!downloadableSrc" @click="downloadSelected">Download</button>
|
||||
<button class="btn-danger" type="button" :disabled="!canCancelCurrentJob" @click="cancelCurrentJob">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentImages.length > 1" class="thumb-row">
|
||||
<button v-for="(image, index) in currentImages" :key="image.index" class="thumb" :class="{ 'thumb--active': index === selectedOutputIndex }" type="button" @click="selectOutput(index)">
|
||||
<img :src="`data:image/${currentJob?.result?.output_format || 'png'};base64,${image.b64_json}`" :alt="`Output ${index + 1}`" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="lightboxOpen && lightboxImageSrc" class="lightbox" @click.self="closeLightbox">
|
||||
<button class="lightbox__close" type="button" @click="closeLightbox">Close</button>
|
||||
<img class="lightbox__image" :src="lightboxImageSrc" :alt="lightboxImageAlt" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
type Variant = "module" | "section" | "plain";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
open: boolean;
|
||||
variant?: Variant;
|
||||
}>(), {
|
||||
eyebrow: "",
|
||||
title: "",
|
||||
summary: "",
|
||||
variant: "section",
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle"): void;
|
||||
}>();
|
||||
|
||||
const rootClass: Record<Variant, string> = {
|
||||
module: "advanced-panel module-card",
|
||||
section: "advanced-group module-card",
|
||||
plain: "advanced-group",
|
||||
};
|
||||
|
||||
const buttonClass: Record<Variant, string> = {
|
||||
module: "advanced-group__toggle",
|
||||
section: "advanced-group__toggle",
|
||||
plain: "advanced-group__toggle",
|
||||
};
|
||||
|
||||
const bodyClass: Record<Variant, string> = {
|
||||
module: "advanced-group__content",
|
||||
section: "advanced-group__content",
|
||||
plain: "advanced-group__content",
|
||||
};
|
||||
|
||||
function onToggle(): void {
|
||||
emit("toggle");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="rootClass[variant] || rootClass.section">
|
||||
<button :class="buttonClass[variant] || buttonClass.section" type="button" @click="onToggle">
|
||||
<span v-if="$slots.header" class="module-card__copy">
|
||||
<slot name="header" />
|
||||
</span>
|
||||
<span v-else class="module-card__copy">
|
||||
<span v-if="eyebrow" class="module-card__eyebrow">{{ eyebrow }}</span>
|
||||
<span v-if="title && variant === 'plain'" class="advanced-group__title">{{ title }}</span>
|
||||
<span v-else-if="title" class="module-card__summary">{{ title }}</span>
|
||||
<span v-if="summary && !open" class="module-card__summary">{{ summary }}</span>
|
||||
</span>
|
||||
<span class="module-card__action">{{ open ? "Hide" : "Show" }}</span>
|
||||
</button>
|
||||
<div v-if="open" :class="bodyClass[variant] || bodyClass.section">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import type { ImageEntry } from "../lib/types";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: ImageEntry | null;
|
||||
items?: ImageEntry[];
|
||||
multiple?: boolean;
|
||||
}>(), {
|
||||
description: "",
|
||||
preview: null,
|
||||
items: () => [],
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select", files: FileList): void;
|
||||
(e: "clear"): void;
|
||||
(e: "preview", entry: ImageEntry): void;
|
||||
}>();
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const dragging = ref(false);
|
||||
const hasSelection = computed(() => Boolean(props.preview) || props.items.length > 0);
|
||||
|
||||
const summary = computed(() => {
|
||||
return props.multiple
|
||||
? (props.items.length ? `${props.items.length} file(s)` : "No files selected")
|
||||
: (props.preview?.name || "No file selected");
|
||||
});
|
||||
|
||||
function emitSelection(files: FileList | undefined | null): void {
|
||||
if (!files || !files.length) {
|
||||
return;
|
||||
}
|
||||
emit("select", files);
|
||||
}
|
||||
|
||||
function onPick(event: Event): void {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emitSelection(target.files);
|
||||
target.value = "";
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): void {
|
||||
dragging.value = false;
|
||||
emitSelection(event.dataTransfer?.files);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-card" :class="{ 'upload-card--active': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<button
|
||||
v-if="preview"
|
||||
class="upload-card__preview-button"
|
||||
type="button"
|
||||
@click="$emit('preview', preview)"
|
||||
>
|
||||
<img
|
||||
class="upload-card__preview"
|
||||
:src="preview.dataUrl"
|
||||
:alt="preview.name"
|
||||
/>
|
||||
</button>
|
||||
<div v-else class="upload-card__drop">
|
||||
<div class="upload-card__label">{{ label }}</div>
|
||||
<div>{{ description }}</div>
|
||||
<div class="hint">{{ summary }}</div>
|
||||
</div>
|
||||
<div class="upload-card__actions">
|
||||
<button class="btn-ghost" type="button" @click="inputRef?.click()">Select</button>
|
||||
<button
|
||||
v-if="hasSelection"
|
||||
class="btn-ghost"
|
||||
type="button"
|
||||
@click="$emit('clear')"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
:multiple="multiple"
|
||||
@change="onPick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const STATUS_TONES: Record<string, string> = {
|
||||
online: "online",
|
||||
completed: "online",
|
||||
queued: "queued",
|
||||
generating: "generating",
|
||||
failed: "failed",
|
||||
cancelled: "cancelled",
|
||||
offline: "offline",
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
status?: string;
|
||||
label?: string;
|
||||
}>(), {
|
||||
status: "",
|
||||
label: "",
|
||||
});
|
||||
|
||||
const tone = computed(() => STATUS_TONES[props.status] || "");
|
||||
|
||||
const classes = computed(() => ["chip", tone.value && `chip--${tone.value}`].filter(Boolean));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="classes">{{ label || status || "idle" }}</span>
|
||||
</template>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Capabilities, Job } from "./types";
|
||||
|
||||
function withBase(baseUrl: string, path: string): string {
|
||||
const base = String(baseUrl || window.location.pathname).trim().replace(/\/+$/, "");
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
(payload && (payload.error || payload.message)) || `HTTP ${response.status}`
|
||||
);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export function getCapabilities(baseUrl: string): Promise<Capabilities> {
|
||||
return fetchJson<Capabilities>(withBase(baseUrl, "/sdcpp/v1/capabilities"));
|
||||
}
|
||||
|
||||
export function submitImageJob(baseUrl: string, body: unknown): Promise<Job> {
|
||||
return fetchJson<Job>(withBase(baseUrl, "/sdcpp/v1/img_gen"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function submitVideoJob(baseUrl: string, body: unknown): Promise<Job> {
|
||||
return fetchJson<Job>(withBase(baseUrl, "/sdcpp/v1/vid_gen"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function getJob(baseUrl: string, id: string): Promise<Job> {
|
||||
return fetchJson<Job>(withBase(baseUrl, `/sdcpp/v1/jobs/${id}`));
|
||||
}
|
||||
|
||||
export function cancelJob(baseUrl: string, id: string): Promise<Job> {
|
||||
return fetchJson<Job>(withBase(baseUrl, `/sdcpp/v1/jobs/${id}/cancel`), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createBlankForm } from "./form-defaults";
|
||||
import type { Capabilities, GenerationForm, SampleParams } from "./types";
|
||||
|
||||
function finiteOrFallback(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function assignSampleParams(
|
||||
target: SampleParams,
|
||||
sample: Record<string, any>,
|
||||
fallbackSteps: number,
|
||||
): void {
|
||||
const guidance = sample.guidance || {};
|
||||
const slg = guidance.slg || {};
|
||||
|
||||
target.scheduler = sample.scheduler || "default";
|
||||
target.sample_method = sample.sample_method || "default";
|
||||
target.sample_steps = finiteOrFallback(sample.sample_steps, fallbackSteps);
|
||||
target.eta = sample.eta == null ? "" : sample.eta;
|
||||
target.shifted_timestep = finiteOrFallback(sample.shifted_timestep, 0);
|
||||
target.flow_shift = sample.flow_shift == null ? "" : sample.flow_shift;
|
||||
|
||||
target.guidance.txt_cfg = finiteOrFallback(guidance.txt_cfg, 7);
|
||||
target.guidance.img_cfg = guidance.img_cfg == null ? "" : guidance.img_cfg;
|
||||
target.guidance.distilled_guidance = finiteOrFallback(guidance.distilled_guidance, 3.5);
|
||||
target.guidance.slg_layers = Array.isArray(slg.layers) ? slg.layers.join(",") : "7,8,9";
|
||||
target.guidance.layer_start = finiteOrFallback(slg.layer_start, 0.01);
|
||||
target.guidance.layer_end = finiteOrFallback(slg.layer_end, 0.2);
|
||||
target.guidance.scale = finiteOrFallback(slg.scale, 0);
|
||||
}
|
||||
|
||||
export function formFromCapabilities(capabilities: Capabilities): GenerationForm {
|
||||
const currentMode = capabilities?.current_mode;
|
||||
const defaultsByMode = capabilities?.defaults_by_mode || {};
|
||||
const defaults: Record<string, any> = (currentMode && defaultsByMode[currentMode]) || {};
|
||||
const sample = defaults.sample_params || {};
|
||||
const highNoiseSample = defaults.high_noise_sample_params || {};
|
||||
const tiling = defaults.vae_tiling_params || {};
|
||||
const form = createBlankForm();
|
||||
|
||||
form.prompt = defaults.prompt || "";
|
||||
form.negative_prompt = defaults.negative_prompt || "";
|
||||
form.width = finiteOrFallback(defaults.width, 512);
|
||||
form.height = finiteOrFallback(defaults.height, 512);
|
||||
form.batch_count = finiteOrFallback(defaults.batch_count, 1);
|
||||
form.video_frames = finiteOrFallback(defaults.video_frames, 33);
|
||||
form.fps = finiteOrFallback(defaults.fps, 16);
|
||||
form.seed = typeof defaults.seed === "number" ? defaults.seed : -1;
|
||||
form.clip_skip = typeof defaults.clip_skip === "number" ? defaults.clip_skip : -1;
|
||||
form.strength = finiteOrFallback(defaults.strength, 0.75);
|
||||
form.control_strength = finiteOrFallback(defaults.control_strength, 0.9);
|
||||
form.moe_boundary = finiteOrFallback(defaults.moe_boundary, 0.875);
|
||||
form.vace_strength = finiteOrFallback(defaults.vace_strength, 1.0);
|
||||
form.output_format = defaults.output_format || "png";
|
||||
form.output_compression = finiteOrFallback(defaults.output_compression, 100);
|
||||
|
||||
assignSampleParams(form.sample_params, sample, 20);
|
||||
assignSampleParams(form.high_noise_sample_params, highNoiseSample, -1);
|
||||
|
||||
form.vae_tiling_params.enabled = Boolean(tiling.enabled);
|
||||
form.vae_tiling_params.tile_size_x = finiteOrFallback(tiling.tile_size_x, 0);
|
||||
form.vae_tiling_params.tile_size_y = finiteOrFallback(tiling.tile_size_y, 0);
|
||||
form.vae_tiling_params.target_overlap = finiteOrFallback(tiling.target_overlap, 0.5);
|
||||
form.vae_tiling_params.rel_size_x = finiteOrFallback(tiling.rel_size_x, 0);
|
||||
form.vae_tiling_params.rel_size_y = finiteOrFallback(tiling.rel_size_y, 0);
|
||||
|
||||
form.cache.mode = defaults.cache_mode || "disabled";
|
||||
form.cache.option = defaults.cache_option || "";
|
||||
form.cache.scm_mask = defaults.scm_mask || "";
|
||||
form.cache.scm_policy_dynamic =
|
||||
typeof defaults.scm_policy_dynamic === "boolean"
|
||||
? defaults.scm_policy_dynamic
|
||||
: true;
|
||||
|
||||
return form;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { GenerationForm } from "./types";
|
||||
|
||||
export const CACHE_MODES = [
|
||||
"disabled",
|
||||
"easycache",
|
||||
"ucache",
|
||||
"dbcache",
|
||||
"taylorseer",
|
||||
"cache-dit",
|
||||
"spectrum",
|
||||
] as const;
|
||||
|
||||
export function createBlankForm(): GenerationForm {
|
||||
return {
|
||||
prompt: "",
|
||||
negative_prompt: "",
|
||||
width: 512,
|
||||
height: 512,
|
||||
batch_count: 1,
|
||||
video_frames: 33,
|
||||
fps: 16,
|
||||
seed: -1,
|
||||
clip_skip: -1,
|
||||
strength: 0.75,
|
||||
control_strength: 0.9,
|
||||
moe_boundary: 0.875,
|
||||
vace_strength: 1.0,
|
||||
output_format: "png",
|
||||
output_compression: 100,
|
||||
sample_params: {
|
||||
scheduler: "default",
|
||||
sample_method: "default",
|
||||
sample_steps: 20,
|
||||
eta: "",
|
||||
shifted_timestep: 0,
|
||||
flow_shift: "",
|
||||
guidance: {
|
||||
txt_cfg: 7,
|
||||
img_cfg: "",
|
||||
distilled_guidance: 3.5,
|
||||
slg_layers: "7,8,9",
|
||||
layer_start: 0.01,
|
||||
layer_end: 0.2,
|
||||
scale: 0,
|
||||
},
|
||||
},
|
||||
high_noise_sample_params: {
|
||||
scheduler: "default",
|
||||
sample_method: "default",
|
||||
sample_steps: -1,
|
||||
eta: "",
|
||||
shifted_timestep: 0,
|
||||
flow_shift: "",
|
||||
guidance: {
|
||||
txt_cfg: 7,
|
||||
img_cfg: "",
|
||||
distilled_guidance: 3.5,
|
||||
slg_layers: "7,8,9",
|
||||
layer_start: 0.01,
|
||||
layer_end: 0.2,
|
||||
scale: 0,
|
||||
},
|
||||
},
|
||||
init_image: null,
|
||||
end_image: null,
|
||||
ref_images: [],
|
||||
control_frames: [],
|
||||
mask_image: null,
|
||||
control_image: null,
|
||||
lora: [],
|
||||
vae_tiling_params: {
|
||||
enabled: false,
|
||||
tile_size_x: 0,
|
||||
tile_size_y: 0,
|
||||
target_overlap: 0.5,
|
||||
rel_size_x: 0,
|
||||
rel_size_y: 0,
|
||||
},
|
||||
cache: {
|
||||
mode: "disabled",
|
||||
option: "",
|
||||
scm_mask: "",
|
||||
scm_policy_dynamic: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { GenerationForm, GenerationMode, SampleParams } from "./types";
|
||||
|
||||
function parseNumber(value: unknown, fallback: number): number {
|
||||
if (value === "" || value == null) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function nullable(value: unknown): unknown {
|
||||
return value === "" || value == null ? null : value;
|
||||
}
|
||||
|
||||
interface SampleParamsRequest {
|
||||
sample_steps: number;
|
||||
shifted_timestep: number;
|
||||
custom_sigmas: number[];
|
||||
guidance: {
|
||||
txt_cfg: number;
|
||||
distilled_guidance: number;
|
||||
slg: {
|
||||
layers: number[];
|
||||
layer_start: number;
|
||||
layer_end: number;
|
||||
scale: number;
|
||||
};
|
||||
img_cfg?: number;
|
||||
};
|
||||
eta?: number;
|
||||
flow_shift?: number;
|
||||
scheduler?: string;
|
||||
sample_method?: string;
|
||||
}
|
||||
|
||||
function buildSampleParams(sample: SampleParams, defaultSteps: number): SampleParamsRequest {
|
||||
const eta = nullable(sample.eta);
|
||||
const flowShift = nullable(sample.flow_shift);
|
||||
const imgCfg = nullable(sample.guidance.img_cfg);
|
||||
|
||||
const sampleParams: SampleParamsRequest = {
|
||||
sample_steps: parseNumber(sample.sample_steps, defaultSteps),
|
||||
shifted_timestep: parseNumber(sample.shifted_timestep, 0),
|
||||
custom_sigmas: [],
|
||||
guidance: {
|
||||
txt_cfg: parseNumber(sample.guidance.txt_cfg, 7),
|
||||
distilled_guidance: parseNumber(sample.guidance.distilled_guidance, 3.5),
|
||||
slg: {
|
||||
layers: String(sample.guidance.slg_layers || "")
|
||||
.split(",")
|
||||
.map((item) => Number(item.trim()))
|
||||
.filter((value) => Number.isInteger(value)),
|
||||
layer_start: parseNumber(sample.guidance.layer_start, 0.01),
|
||||
layer_end: parseNumber(sample.guidance.layer_end, 0.2),
|
||||
scale: parseNumber(sample.guidance.scale, 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (eta != null) {
|
||||
sampleParams.eta = Number(eta);
|
||||
}
|
||||
if (flowShift != null) {
|
||||
sampleParams.flow_shift = Number(flowShift);
|
||||
}
|
||||
if (imgCfg != null) {
|
||||
sampleParams.guidance.img_cfg = Number(imgCfg);
|
||||
}
|
||||
|
||||
const scheduler =
|
||||
sample.scheduler && sample.scheduler !== "default"
|
||||
? sample.scheduler
|
||||
: undefined;
|
||||
const sampleMethod =
|
||||
sample.sample_method && sample.sample_method !== "default"
|
||||
? sample.sample_method
|
||||
: undefined;
|
||||
|
||||
if (scheduler) {
|
||||
sampleParams.scheduler = scheduler;
|
||||
}
|
||||
if (sampleMethod) {
|
||||
sampleParams.sample_method = sampleMethod;
|
||||
}
|
||||
|
||||
return sampleParams;
|
||||
}
|
||||
|
||||
function buildLoraRequest(form: GenerationForm) {
|
||||
return form.lora
|
||||
.filter((item) => String(item.path || "").trim())
|
||||
.map((item) => ({
|
||||
path: String(item.path).trim(),
|
||||
multiplier: parseNumber(item.multiplier, 1.0),
|
||||
is_high_noise: Boolean(item.is_high_noise),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildTilingRequest(form: GenerationForm) {
|
||||
return {
|
||||
enabled: Boolean(form.vae_tiling_params.enabled),
|
||||
tile_size_x: parseNumber(form.vae_tiling_params.tile_size_x, 0),
|
||||
tile_size_y: parseNumber(form.vae_tiling_params.tile_size_y, 0),
|
||||
target_overlap: parseNumber(form.vae_tiling_params.target_overlap, 0.5),
|
||||
rel_size_x: parseNumber(form.vae_tiling_params.rel_size_x, 0),
|
||||
rel_size_y: parseNumber(form.vae_tiling_params.rel_size_y, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRequestBody(form: GenerationForm) {
|
||||
const request = {
|
||||
prompt: String(form.prompt || "").trim(),
|
||||
negative_prompt: form.negative_prompt,
|
||||
clip_skip: parseNumber(form.clip_skip, -1),
|
||||
width: parseNumber(form.width, 512),
|
||||
height: parseNumber(form.height, 512),
|
||||
strength: parseNumber(form.strength, 0.75),
|
||||
seed: parseNumber(form.seed, -1),
|
||||
sample_params: buildSampleParams(form.sample_params, 20),
|
||||
lora: buildLoraRequest(form),
|
||||
vae_tiling_params: buildTilingRequest(form),
|
||||
cache_mode: form.cache.mode || "disabled",
|
||||
cache_option: String(form.cache.option || ""),
|
||||
scm_mask: String(form.cache.scm_mask || ""),
|
||||
scm_policy_dynamic: Boolean(form.cache.scm_policy_dynamic),
|
||||
output_format: form.output_format,
|
||||
output_compression: parseNumber(form.output_compression, 100),
|
||||
};
|
||||
|
||||
if (!request.prompt) {
|
||||
throw new Error("prompt is required");
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
export function buildImageRequestBody(form: GenerationForm) {
|
||||
return {
|
||||
...buildRequestBody(form),
|
||||
batch_count: parseNumber(form.batch_count, 1),
|
||||
auto_resize_ref_image: true,
|
||||
increase_ref_index: false,
|
||||
control_strength: parseNumber(form.control_strength, 0.9),
|
||||
init_image: form.init_image ? form.init_image.dataUrl : null,
|
||||
ref_images: form.ref_images.map((item) => item.dataUrl),
|
||||
mask_image: form.mask_image ? form.mask_image.dataUrl : null,
|
||||
control_image: form.control_image ? form.control_image.dataUrl : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVideoRequestBody(form: GenerationForm) {
|
||||
return {
|
||||
...buildRequestBody(form),
|
||||
video_frames: parseNumber(form.video_frames, 33),
|
||||
fps: parseNumber(form.fps, 16),
|
||||
moe_boundary: parseNumber(form.moe_boundary, 0.875),
|
||||
vace_strength: parseNumber(form.vace_strength, 1.0),
|
||||
init_image: form.init_image ? form.init_image.dataUrl : null,
|
||||
end_image: form.end_image ? form.end_image.dataUrl : null,
|
||||
control_frames: form.control_frames.map((item) => item.dataUrl),
|
||||
high_noise_sample_params: buildSampleParams(form.high_noise_sample_params, -1),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRequestBodyForMode(mode: GenerationMode, form: GenerationForm) {
|
||||
return mode === "video" ? buildVideoRequestBody(form) : buildImageRequestBody(form);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CACHE_MODES, createBlankForm } from "./form-defaults";
|
||||
export { formFromCapabilities } from "./form-capabilities";
|
||||
export { buildImageRequestBody, buildRequestBody, buildRequestBodyForMode, buildVideoRequestBody } from "./form-request";
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ImageInputConfig } from "./types";
|
||||
|
||||
export const IMAGE_INPUTS: readonly ImageInputConfig[] = [
|
||||
{
|
||||
target: "init_image",
|
||||
label: "Init Image",
|
||||
description: "Drop, paste, or browse an image to seed generation.",
|
||||
layout: "grid",
|
||||
},
|
||||
{
|
||||
target: "mask_image",
|
||||
label: "Mask Image",
|
||||
description: "One-channel mask image.",
|
||||
layout: "grid",
|
||||
},
|
||||
{
|
||||
target: "control_image",
|
||||
label: "Control Image",
|
||||
description: "ControlNet-style guidance image.",
|
||||
layout: "full",
|
||||
},
|
||||
];
|
||||
|
||||
export const VIDEO_IMAGE_INPUTS: readonly ImageInputConfig[] = [
|
||||
{
|
||||
target: "init_image",
|
||||
label: "Start Frame",
|
||||
description: "Optional first frame or seed image for the sequence.",
|
||||
layout: "grid",
|
||||
},
|
||||
{
|
||||
target: "end_image",
|
||||
label: "End Frame",
|
||||
description: "Optional end frame for interpolation or FLF2V-style runs.",
|
||||
layout: "grid",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { GenerationForm, ImageEntry, ImageTarget } from "./types";
|
||||
|
||||
export function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error(`failed to read ${file.name}`));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export async function filesToImageEntries(fileList: FileList | File[]): Promise<ImageEntry[]> {
|
||||
const files = Array.from(fileList || []);
|
||||
return Promise.all(files.map(async (file) => ({
|
||||
name: file.name,
|
||||
type: file.type || "image/png",
|
||||
dataUrl: await readFileAsDataUrl(file),
|
||||
})));
|
||||
}
|
||||
|
||||
export function assignImageEntries(form: GenerationForm, target: ImageTarget, images: ImageEntry[]): void {
|
||||
if (target === "init_image" || target === "mask_image" || target === "control_image" || target === "end_image") {
|
||||
form[target] = images[0] || null;
|
||||
return;
|
||||
}
|
||||
if (target === "ref_images" || target === "control_frames") {
|
||||
form[target].push(...images);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearImageEntries(form: GenerationForm, target: ImageTarget): void {
|
||||
if (target === "init_image" || target === "mask_image" || target === "control_image" || target === "end_image") {
|
||||
form[target] = null;
|
||||
return;
|
||||
}
|
||||
if (target === "ref_images" || target === "control_frames") {
|
||||
form[target].splice(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeImageEntry(form: GenerationForm, target: ImageTarget, index: number): void {
|
||||
if (target === "ref_images" || target === "control_frames") {
|
||||
form[target].splice(index, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { type Ref, ref, watch } from "vue";
|
||||
|
||||
export function normalizePollIntervalMs(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 100;
|
||||
}
|
||||
return Math.max(1, Math.round(numeric));
|
||||
}
|
||||
|
||||
export function createStoredRef<T>(key: string, fallbackValue: T, normalize: (value: any) => T = (value) => value): Ref<T> {
|
||||
const state = ref(readStoredValue(key, fallbackValue, normalize)) as Ref<T>;
|
||||
|
||||
watch(state, (value) => {
|
||||
const normalized = normalize(value);
|
||||
if (normalized !== value) {
|
||||
state.value = normalized;
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify(normalized));
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function readStoredValue<T>(key: string, fallbackValue: T, normalize: (value: any) => T): T {
|
||||
try {
|
||||
const storedValue = window.localStorage.getItem(key);
|
||||
if (storedValue == null) {
|
||||
return normalize(fallbackValue);
|
||||
}
|
||||
return normalize(JSON.parse(storedValue));
|
||||
} catch {
|
||||
return normalize(fallbackValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { CACHE_MODES } from "./form-defaults";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ImageEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
export type ImageTarget =
|
||||
| "init_image"
|
||||
| "mask_image"
|
||||
| "control_image"
|
||||
| "end_image"
|
||||
| "ref_images"
|
||||
| "control_frames";
|
||||
|
||||
export type GenerationMode = "image" | "video";
|
||||
|
||||
export interface ImageInputConfig {
|
||||
target: ImageTarget;
|
||||
label: string;
|
||||
description: string;
|
||||
layout: "grid" | "full";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GuidanceParams {
|
||||
txt_cfg: number;
|
||||
img_cfg: string | number;
|
||||
distilled_guidance: number;
|
||||
slg_layers: string;
|
||||
layer_start: number;
|
||||
layer_end: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface SampleParams {
|
||||
scheduler: string;
|
||||
sample_method: string;
|
||||
sample_steps: number;
|
||||
eta: string | number;
|
||||
shifted_timestep: number;
|
||||
flow_shift: string | number;
|
||||
guidance: GuidanceParams;
|
||||
}
|
||||
|
||||
export interface VaeTilingParams {
|
||||
enabled: boolean;
|
||||
tile_size_x: number;
|
||||
tile_size_y: number;
|
||||
target_overlap: number;
|
||||
rel_size_x: number;
|
||||
rel_size_y: number;
|
||||
}
|
||||
|
||||
export interface CacheParams {
|
||||
mode: string;
|
||||
option: string;
|
||||
scm_mask: string;
|
||||
scm_policy_dynamic: boolean;
|
||||
}
|
||||
|
||||
export interface FormLoraEntry {
|
||||
path: string;
|
||||
multiplier: number;
|
||||
is_high_noise: boolean;
|
||||
}
|
||||
|
||||
export interface GenerationForm {
|
||||
prompt: string;
|
||||
negative_prompt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
batch_count: number;
|
||||
video_frames: number;
|
||||
fps: number;
|
||||
seed: number;
|
||||
clip_skip: number;
|
||||
strength: number;
|
||||
control_strength: number;
|
||||
moe_boundary: number;
|
||||
vace_strength: number;
|
||||
output_format: string;
|
||||
output_compression: number;
|
||||
sample_params: SampleParams;
|
||||
high_noise_sample_params: SampleParams;
|
||||
init_image: ImageEntry | null;
|
||||
end_image: ImageEntry | null;
|
||||
ref_images: ImageEntry[];
|
||||
control_frames: ImageEntry[];
|
||||
mask_image: ImageEntry | null;
|
||||
control_image: ImageEntry | null;
|
||||
lora: FormLoraEntry[];
|
||||
vae_tiling_params: VaeTilingParams;
|
||||
cache: CacheParams;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LoRA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AvailableLora {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capabilities (API response)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Capabilities {
|
||||
model?: {
|
||||
stem?: string;
|
||||
name?: string;
|
||||
};
|
||||
current_mode?: "img_gen" | "vid_gen";
|
||||
supported_modes?: Array<"img_gen" | "vid_gen">;
|
||||
output_formats?: string[];
|
||||
output_formats_by_mode?: Partial<Record<"img_gen" | "vid_gen", string[]>>;
|
||||
samplers?: string[];
|
||||
schedulers?: string[];
|
||||
loras?: AvailableLora[];
|
||||
limits?: {
|
||||
max_queue_size?: number;
|
||||
};
|
||||
features?: {
|
||||
cancel_queued?: boolean;
|
||||
cancel_generating?: boolean;
|
||||
};
|
||||
features_by_mode?: Partial<Record<"img_gen" | "vid_gen", Record<string, any>>>;
|
||||
defaults?: Record<string, any>;
|
||||
defaults_by_mode?: Partial<Record<"img_gen" | "vid_gen", Record<string, any>>>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job (API response)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ImageOutput {
|
||||
index: number;
|
||||
b64_json: string;
|
||||
}
|
||||
|
||||
export type JobKind = "img_gen" | "vid_gen";
|
||||
|
||||
export interface JobResult {
|
||||
images?: ImageOutput[];
|
||||
output_format?: string;
|
||||
b64_json?: string;
|
||||
mime_type?: string;
|
||||
fps?: number;
|
||||
frame_count?: number;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
kind?: JobKind;
|
||||
status: string;
|
||||
queue_position?: number;
|
||||
created?: number;
|
||||
started?: number;
|
||||
completed?: number;
|
||||
result?: JobResult | null;
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache mode literal union
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CacheMode = (typeof CACHE_MODES)[number];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,927 @@
|
||||
:root {
|
||||
--bg: #f6f7f8;
|
||||
--panel: #ffffff;
|
||||
--panel-subtle: #fafafa;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--border-strong: #d1d5db;
|
||||
--shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
--chip-bg: #f3f4f6;
|
||||
--primary: #111827;
|
||||
--primary-soft: #f3f4f6;
|
||||
--success: #0f766e;
|
||||
--warning: #9a6700;
|
||||
--danger: #b42318;
|
||||
--radius-lg: 16px;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 10px;
|
||||
--font-ui: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--checker-bg:
|
||||
linear-gradient(45deg, #f3f4f6 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #f3f4f6 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #f3f4f6 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #f3f4f6 75%),
|
||||
#ffffff;
|
||||
--checker-size-sm: 12px 12px;
|
||||
--checker-position-sm:
|
||||
0 0,
|
||||
0 6px,
|
||||
6px -6px,
|
||||
-6px 0;
|
||||
--checker-size-md: 16px 16px;
|
||||
--checker-position-md:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
--checker-size-lg: 20px 20px;
|
||||
--checker-position-lg:
|
||||
0 0,
|
||||
0 10px,
|
||||
10px -10px,
|
||||
-10px 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: min(1440px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 24px 0 40px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 20px 24px 0;
|
||||
}
|
||||
|
||||
.page-header__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-header__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.breadcrumb__org {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.breadcrumb__slash {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
line-height: 1.1;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin: 10px 0 0;
|
||||
max-width: 680px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-header__meta,
|
||||
.actions,
|
||||
.page-tabs,
|
||||
.page-tabs__list,
|
||||
.page-tabs__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-header__meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--chip-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.chip--online,
|
||||
.chip--completed {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.chip--queued,
|
||||
.chip--generating {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.chip--offline,
|
||||
.chip--failed,
|
||||
.chip--cancelled {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger,
|
||||
.page-tab {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
transition: background-color 120ms ease, border-color 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
|
||||
.btn:hover,
|
||||
.btn-secondary:hover,
|
||||
.btn-ghost:hover,
|
||||
.btn-danger:hover,
|
||||
.page-tab:hover {
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.btn-danger:disabled,
|
||||
.page-tab:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--primary-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fff;
|
||||
border-color: #f0c7c3;
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-tabs {
|
||||
margin-top: 18px;
|
||||
padding: 0 0 16px;
|
||||
border-top: 1px solid transparent;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-tabs__list,
|
||||
.page-tabs__actions {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-tab {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.page-tab--active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page-tab--active:hover,
|
||||
.page-tab--active:focus {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings {
|
||||
padding: 0 0 20px;
|
||||
}
|
||||
|
||||
.settings__grid,
|
||||
.metrics,
|
||||
.fields,
|
||||
.upload-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings__grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(420px, 520px);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.control-panel,
|
||||
.output-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
order: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.output-panel {
|
||||
order: 2;
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.prompt-card,
|
||||
.advanced-panel,
|
||||
.metric,
|
||||
.upload-card,
|
||||
.status-message {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.prompt-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.prompt-card textarea {
|
||||
border: 0;
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.fields {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.field,
|
||||
.field--full,
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
:is(.field, .field--full, .group) label,
|
||||
.upload-card__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:is(.field, .field--full, .group) :is(input, select, textarea),
|
||||
.settings input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:is(.field, .field--full, .group) textarea {
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
:is(.field, .field--full, .group) :is(input, select, textarea):focus,
|
||||
.settings input:focus {
|
||||
border-color: #9ca3af;
|
||||
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.06);
|
||||
}
|
||||
|
||||
.stack-top {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sample-panel__extras {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.module-card {
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
}
|
||||
|
||||
.module-card__copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.module-card__eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.module-card__summary {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.module-card__action {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.module-card__link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.module-card__link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.advanced-group {
|
||||
display: grid;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.advanced-group.module-card {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.advanced-group__title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.advanced-group__toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.advanced-group__content {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.module-card > .advanced-group__toggle {
|
||||
padding: 16px;
|
||||
align-items: flex-start;
|
||||
background: linear-gradient(180deg, rgba(249, 250, 251, 0.9), rgba(255, 255, 255, 1));
|
||||
}
|
||||
|
||||
.module-card > .advanced-group__content {
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.upload-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-card__drop {
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
||||
.upload-card__preview-button {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
max-height: 220px;
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-card__preview {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.upload-card__actions input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-card--active {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.upload-card__preview-button,
|
||||
.thumb {
|
||||
background: var(--checker-bg);
|
||||
background-size: var(--checker-size-md);
|
||||
background-position: var(--checker-position-md);
|
||||
}
|
||||
|
||||
.hero-frame {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
min-height: 360px;
|
||||
max-height: 520px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-frame,
|
||||
.lightbox__image {
|
||||
background: var(--checker-bg);
|
||||
background-size: var(--checker-size-lg);
|
||||
background-position: var(--checker-position-lg);
|
||||
}
|
||||
|
||||
.hero-frame--button {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hero-frame--media {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.hero-frame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 520px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero-frame__video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 520px;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.hero-placeholder {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.hero-placeholder h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero-placeholder p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.output-metrics {
|
||||
margin-top: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.metric__label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric__value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.thumb-row {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 88px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb--active {
|
||||
border-color: #111827;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-message--error {
|
||||
color: var(--danger);
|
||||
border-color: #f0c7c3;
|
||||
background: #fff7f6;
|
||||
}
|
||||
|
||||
.status-message--success {
|
||||
color: var(--success);
|
||||
border-color: #b8e0db;
|
||||
background: #f4fbfa;
|
||||
}
|
||||
|
||||
.output-controls {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.output-controls__primary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.output-controls__secondary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.output-controls__secondary > * {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-chip--preview {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.file-chip__thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--checker-bg);
|
||||
background-size: var(--checker-size-sm);
|
||||
background-position: var(--checker-position-sm);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.file-chip__thumb-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.file-chip__name {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(96px, 0.8fr) auto auto;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-row--header {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.list-row > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-row__checkbox {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(17, 24, 39, 0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.lightbox__image {
|
||||
max-width: min(92vw, 1600px);
|
||||
max-height: 88vh;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.lightbox__close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(17, 24, 39, 0.56);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.list-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.output-panel {
|
||||
position: static;
|
||||
top: auto;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.settings__grid,
|
||||
.fields,
|
||||
.output-metrics,
|
||||
.list-row,
|
||||
.output-controls__secondary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#app {
|
||||
width: calc(100vw - 16px);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.control-panel,
|
||||
.output-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header__top,
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-header__meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.page-tabs {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.hero-frame {
|
||||
min-height: 280px;
|
||||
max-height: 420px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "src/env.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
const { defineConfig } = require("vite");
|
||||
const vue = require("@vitejs/plugin-vue");
|
||||
const { viteSingleFile } = require("vite-plugin-singlefile");
|
||||
|
||||
module.exports = defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
viteSingleFile(),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "httplib.h"
|
||||
|
||||
#include "async_jobs.h"
|
||||
#include "common/common.h"
|
||||
#include "common/resource_owners.hpp"
|
||||
#include "routes.h"
|
||||
#include "runtime.h"
|
||||
|
||||
#ifdef HAVE_INDEX_HTML
|
||||
#include "frontend/dist/gen_index_html.h"
|
||||
#endif
|
||||
|
||||
static void print_usage(const char* argv0, const std::vector<ArgOptions>& options_list) {
|
||||
std::cout << version_string() << "\n";
|
||||
std::cout << "Usage: " << argv0 << " [options]\n\n";
|
||||
std::cout << "Svr Options:\n";
|
||||
options_list[0].print();
|
||||
std::cout << "\nContext Options:\n";
|
||||
options_list[1].print();
|
||||
std::cout << "\nDefault Generation Options:\n";
|
||||
options_list[2].print();
|
||||
}
|
||||
|
||||
static void parse_args(int argc,
|
||||
const char** argv,
|
||||
SDSvrParams& svr_params,
|
||||
SDContextParams& ctx_params,
|
||||
SDGenerationParams& default_gen_params) {
|
||||
std::vector<ArgOptions> options_vec = {
|
||||
svr_params.get_options(),
|
||||
ctx_params.get_options(),
|
||||
default_gen_params.get_options(),
|
||||
};
|
||||
|
||||
if (!parse_options(argc, argv, options_vec)) {
|
||||
print_usage(argv[0], options_vec);
|
||||
exit(svr_params.normal_exit ? 0 : 1);
|
||||
}
|
||||
|
||||
const bool random_seed_requested = default_gen_params.seed < 0;
|
||||
|
||||
if (!svr_params.resolve_and_validate() ||
|
||||
!ctx_params.resolve_and_validate(IMG_GEN) ||
|
||||
!default_gen_params.resolve_and_validate(IMG_GEN,
|
||||
ctx_params.lora_model_dir,
|
||||
ctx_params.hires_upscalers_dir)) {
|
||||
print_usage(argv[0], options_vec);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (random_seed_requested) {
|
||||
default_gen_params.seed = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void sd_log_cb(enum sd_log_level_t level, const char* log, void* data) {
|
||||
SDSvrParams* svr_params = (SDSvrParams*)data;
|
||||
log_print(level, log, svr_params->verbose, svr_params->color);
|
||||
}
|
||||
|
||||
int main(int argc, const char** argv) {
|
||||
if (argc > 1 && std::string(argv[1]) == "--version") {
|
||||
std::cout << version_string() << "\n";
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
SDSvrParams svr_params;
|
||||
SDContextParams ctx_params;
|
||||
SDGenerationParams default_gen_params;
|
||||
parse_args(argc, argv, svr_params, ctx_params, default_gen_params);
|
||||
|
||||
sd_set_log_callback(sd_log_cb, (void*)&svr_params);
|
||||
log_verbose = svr_params.verbose;
|
||||
log_color = svr_params.color;
|
||||
|
||||
LOG_DEBUG("version: %s", version_string().c_str());
|
||||
LOG_DEBUG("%s", sd_get_system_info());
|
||||
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));
|
||||
|
||||
if (sd_ctx == nullptr) {
|
||||
LOG_ERROR("new_sd_ctx_t failed");
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::mutex sd_ctx_mutex;
|
||||
|
||||
std::vector<LoraEntry> lora_cache;
|
||||
std::mutex lora_mutex;
|
||||
std::vector<UpscalerEntry> upscaler_cache;
|
||||
std::mutex upscaler_mutex;
|
||||
AsyncJobManager async_job_manager;
|
||||
ServerRuntime runtime = {
|
||||
sd_ctx.get(),
|
||||
&sd_ctx_mutex,
|
||||
&svr_params,
|
||||
&ctx_params,
|
||||
&default_gen_params,
|
||||
&lora_cache,
|
||||
&lora_mutex,
|
||||
&upscaler_cache,
|
||||
&upscaler_mutex,
|
||||
&async_job_manager,
|
||||
};
|
||||
|
||||
std::thread async_worker(async_job_worker, std::ref(runtime));
|
||||
|
||||
httplib::Server svr;
|
||||
|
||||
svr.set_pre_routing_handler([](const httplib::Request& req, httplib::Response& res) {
|
||||
std::string origin = req.get_header_value("Origin");
|
||||
if (origin.empty()) {
|
||||
origin = "*";
|
||||
}
|
||||
res.set_header("Access-Control-Allow-Origin", origin);
|
||||
res.set_header("Access-Control-Allow-Credentials", "true");
|
||||
res.set_header("Access-Control-Allow-Methods", "*");
|
||||
res.set_header("Access-Control-Allow-Headers", "*");
|
||||
|
||||
if (req.method == "OPTIONS") {
|
||||
res.status = 204;
|
||||
return httplib::Server::HandlerResponse::Handled;
|
||||
}
|
||||
return httplib::Server::HandlerResponse::Unhandled;
|
||||
});
|
||||
|
||||
std::string index_html;
|
||||
#ifdef HAVE_INDEX_HTML
|
||||
index_html.assign(reinterpret_cast<const char*>(index_html_bytes), index_html_size);
|
||||
#else
|
||||
index_html = "Stable Diffusion Server is running";
|
||||
#endif
|
||||
register_index_endpoints(svr, svr_params, index_html);
|
||||
register_openai_api_endpoints(svr, runtime);
|
||||
register_sdapi_endpoints(svr, runtime);
|
||||
register_sdcpp_api_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);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(async_job_manager.mutex);
|
||||
async_job_manager.stop = true;
|
||||
}
|
||||
async_job_manager.cv.notify_all();
|
||||
async_worker.join();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "httplib.h"
|
||||
#include "runtime.h"
|
||||
|
||||
void register_index_endpoints(httplib::Server& svr, const SDSvrParams& svr_params, const std::string& index_html);
|
||||
void register_openai_api_endpoints(httplib::Server& svr, ServerRuntime& rt);
|
||||
void register_sdapi_endpoints(httplib::Server& svr, ServerRuntime& rt);
|
||||
void register_sdcpp_api_endpoints(httplib::Server& svr, ServerRuntime& rt);
|
||||
@@ -0,0 +1,22 @@
|
||||
#include "routes.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
|
||||
void register_index_endpoints(httplib::Server& svr, const SDSvrParams& svr_params, const std::string& index_html) {
|
||||
const std::string serve_html_path = svr_params.serve_html_path;
|
||||
svr.Get("/", [serve_html_path, index_html](const httplib::Request&, httplib::Response& res) {
|
||||
if (!serve_html_path.empty()) {
|
||||
std::ifstream file(serve_html_path);
|
||||
if (file) {
|
||||
std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
|
||||
res.set_content(content, "text/html");
|
||||
} else {
|
||||
res.status = 500;
|
||||
res.set_content("Error: Unable to read HTML file", "text/plain");
|
||||
}
|
||||
} else {
|
||||
res.set_content(index_html, "text/html");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
#include "routes.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <regex>
|
||||
|
||||
#include "common/common.h"
|
||||
#include "common/media_io.h"
|
||||
#include "common/resource_owners.hpp"
|
||||
|
||||
static std::string extract_and_remove_sd_cpp_extra_args(std::string& text) {
|
||||
std::regex re("<sd_cpp_extra_args>(.*?)</sd_cpp_extra_args>");
|
||||
std::smatch match;
|
||||
|
||||
std::string extracted;
|
||||
if (std::regex_search(text, match, re)) {
|
||||
extracted = match[1].str();
|
||||
text = std::regex_replace(text, re, "");
|
||||
}
|
||||
return extracted;
|
||||
}
|
||||
|
||||
static bool build_openai_generation_request(const httplib::Request& req,
|
||||
ServerRuntime& runtime,
|
||||
ImgGenJobRequest& request,
|
||||
std::string& error_message) {
|
||||
if (req.body.empty()) {
|
||||
error_message = "empty body";
|
||||
return false;
|
||||
}
|
||||
|
||||
json j = json::parse(req.body);
|
||||
std::string prompt = j.value("prompt", "");
|
||||
int n = std::max(1, j.value("n", 1));
|
||||
std::string size = j.value("size", "");
|
||||
std::string output_format = j.value("output_format", "png");
|
||||
int output_compression = j.value("output_compression", 100);
|
||||
int width = runtime.default_gen_params->width > 0 ? runtime.default_gen_params->width : 512;
|
||||
int height = runtime.default_gen_params->width > 0 ? runtime.default_gen_params->height : 512;
|
||||
if (!size.empty()) {
|
||||
auto pos = size.find('x');
|
||||
if (pos != std::string::npos) {
|
||||
try {
|
||||
width = std::stoi(size.substr(0, pos));
|
||||
height = std::stoi(size.substr(pos + 1));
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (prompt.empty()) {
|
||||
error_message = "prompt required";
|
||||
return false;
|
||||
}
|
||||
|
||||
request.gen_params = *runtime.default_gen_params;
|
||||
if (!assign_output_options(request, output_format, output_compression, true, error_message)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
request.gen_params.prompt = prompt;
|
||||
request.gen_params.width = width;
|
||||
request.gen_params.height = height;
|
||||
request.gen_params.batch_count = n;
|
||||
|
||||
std::string sd_cpp_extra_args_str = extract_and_remove_sd_cpp_extra_args(request.gen_params.prompt);
|
||||
if (!sd_cpp_extra_args_str.empty() && !request.gen_params.from_json_str(sd_cpp_extra_args_str)) {
|
||||
error_message = "invalid sd_cpp_extra_args";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Intentionally disable prompt-embedded LoRA tag parsing for server APIs.
|
||||
if (!request.gen_params.resolve_and_validate(IMG_GEN, "", runtime.ctx_params->hires_upscalers_dir, true)) {
|
||||
error_message = "invalid params";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool build_openai_edit_request(const httplib::Request& req,
|
||||
ServerRuntime& runtime,
|
||||
ImgGenJobRequest& request,
|
||||
std::string& error_message) {
|
||||
if (!req.is_multipart_form_data()) {
|
||||
error_message = "Content-Type must be multipart/form-data";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string prompt = req.form.get_field("prompt");
|
||||
if (prompt.empty()) {
|
||||
error_message = "prompt required";
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t image_count = req.form.get_file_count("image[]");
|
||||
bool has_legacy_image = req.form.has_file("image");
|
||||
if (image_count == 0 && !has_legacy_image) {
|
||||
error_message = "at least one image[] required";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::vector<uint8_t>> images_bytes;
|
||||
for (size_t i = 0; i < image_count; ++i) {
|
||||
auto file = req.form.get_file("image[]", i);
|
||||
images_bytes.emplace_back(file.content.begin(), file.content.end());
|
||||
}
|
||||
if (image_count == 0 && has_legacy_image) {
|
||||
auto file = req.form.get_file("image");
|
||||
images_bytes.emplace_back(file.content.begin(), file.content.end());
|
||||
}
|
||||
|
||||
std::vector<uint8_t> mask_bytes;
|
||||
if (req.form.has_file("mask")) {
|
||||
auto file = req.form.get_file("mask");
|
||||
mask_bytes.assign(file.content.begin(), file.content.end());
|
||||
}
|
||||
|
||||
int n = 1;
|
||||
if (req.form.has_field("n")) {
|
||||
try {
|
||||
n = std::stoi(req.form.get_field("n"));
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string size = req.form.get_field("size");
|
||||
int width = -1;
|
||||
int height = -1;
|
||||
if (!size.empty()) {
|
||||
auto pos = size.find('x');
|
||||
if (pos != std::string::npos) {
|
||||
try {
|
||||
width = std::stoi(size.substr(0, pos));
|
||||
height = std::stoi(size.substr(pos + 1));
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string output_format = req.form.has_field("output_format")
|
||||
? req.form.get_field("output_format")
|
||||
: "png";
|
||||
|
||||
int output_compression = 100;
|
||||
try {
|
||||
output_compression = std::stoi(req.form.get_field("output_compression"));
|
||||
} catch (...) {
|
||||
}
|
||||
|
||||
request.gen_params = *runtime.default_gen_params;
|
||||
if (!assign_output_options(request, output_format, output_compression, false, error_message)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
request.gen_params.prompt = prompt;
|
||||
request.gen_params.width = width;
|
||||
request.gen_params.height = height;
|
||||
request.gen_params.batch_count = n;
|
||||
|
||||
for (auto& bytes : images_bytes) {
|
||||
int img_w = 0;
|
||||
int img_h = 0;
|
||||
uint8_t* raw_pixels = load_image_from_memory(
|
||||
reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<int>(bytes.size()),
|
||||
img_w, img_h,
|
||||
width, height, 3);
|
||||
if (raw_pixels == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SDImageOwner image_owner({(uint32_t)img_w, (uint32_t)img_h, 3, raw_pixels});
|
||||
request.gen_params.set_width_and_height_if_unset(image_owner.get().width, image_owner.get().height);
|
||||
request.gen_params.ref_images.push_back(std::move(image_owner));
|
||||
}
|
||||
|
||||
if (!request.gen_params.ref_images.empty()) {
|
||||
request.gen_params.init_image = request.gen_params.ref_images.front();
|
||||
}
|
||||
|
||||
if (!mask_bytes.empty()) {
|
||||
int expected_width = 0;
|
||||
int expected_height = 0;
|
||||
if (request.gen_params.width_and_height_are_set()) {
|
||||
expected_width = request.gen_params.width;
|
||||
expected_height = request.gen_params.height;
|
||||
}
|
||||
int mask_w = 0;
|
||||
int mask_h = 0;
|
||||
|
||||
uint8_t* mask_raw = load_image_from_memory(
|
||||
reinterpret_cast<const char*>(mask_bytes.data()),
|
||||
static_cast<int>(mask_bytes.size()),
|
||||
mask_w, mask_h,
|
||||
expected_width, expected_height, 1);
|
||||
request.gen_params.mask_image.reset({(uint32_t)mask_w, (uint32_t)mask_h, 1, mask_raw});
|
||||
const sd_image_t& mask_image = request.gen_params.mask_image.get();
|
||||
request.gen_params.set_width_and_height_if_unset(mask_image.width, mask_image.height);
|
||||
} else {
|
||||
request.gen_params.mask_image.reset({
|
||||
(uint32_t)request.gen_params.get_resolved_width(),
|
||||
(uint32_t)request.gen_params.get_resolved_height(),
|
||||
1,
|
||||
nullptr,
|
||||
});
|
||||
}
|
||||
|
||||
std::string sd_cpp_extra_args_str = extract_and_remove_sd_cpp_extra_args(request.gen_params.prompt);
|
||||
if (!sd_cpp_extra_args_str.empty() && !request.gen_params.from_json_str(sd_cpp_extra_args_str)) {
|
||||
error_message = "invalid sd_cpp_extra_args";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Intentionally disable prompt-embedded LoRA tag parsing for server APIs.
|
||||
if (!request.gen_params.resolve_and_validate(IMG_GEN, "", runtime.ctx_params->hires_upscalers_dir, true)) {
|
||||
error_message = "invalid params";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool execute_sync_img_gen_request(ServerRuntime& runtime,
|
||||
ImgGenJobRequest& request,
|
||||
SDImageVec& results,
|
||||
std::string& error_message) {
|
||||
sd_img_gen_params_t img_gen_params = request.to_sd_img_gen_params_t();
|
||||
int num_results = 0;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*runtime.sd_ctx_mutex);
|
||||
sd_image_t* raw_results = nullptr;
|
||||
if (!generate_image(runtime.sd_ctx, &img_gen_params, &raw_results, &num_results)) {
|
||||
raw_results = nullptr;
|
||||
num_results = 0;
|
||||
}
|
||||
results.adopt(raw_results, num_results);
|
||||
}
|
||||
|
||||
if (results.empty()) {
|
||||
error_message = "generate_image returned no results";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void register_openai_api_endpoints(httplib::Server& svr, ServerRuntime& rt) {
|
||||
ServerRuntime* runtime = &rt;
|
||||
|
||||
svr.Get("/v1/models", [runtime](const httplib::Request&, httplib::Response& res) {
|
||||
json r;
|
||||
r["data"] = json::array();
|
||||
r["data"].push_back({{"id", "sd-cpp-local"}, {"object", "model"}, {"owned_by", "local"}});
|
||||
res.set_content(r.dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Post("/v1/images/generations", [runtime](const httplib::Request& req, httplib::Response& res) {
|
||||
try {
|
||||
if (!runtime_supports_generation_mode(*runtime, IMG_GEN)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", unsupported_generation_mode_error(IMG_GEN)}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
ImgGenJobRequest request;
|
||||
std::string error_message;
|
||||
if (!build_openai_generation_request(req, *runtime, request, error_message)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", error_message}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DEBUG("%s\n", request.gen_params.to_string().c_str());
|
||||
|
||||
SDImageVec results;
|
||||
if (!execute_sync_img_gen_request(*runtime, request, results, error_message)) {
|
||||
res.status = 500;
|
||||
res.set_content(json({{"error", error_message}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
json out;
|
||||
out["created"] = static_cast<long long>(std::time(nullptr));
|
||||
out["data"] = json::array();
|
||||
out["output_format"] = request.output_format;
|
||||
|
||||
int result_count = results.count();
|
||||
int images_per_batch = request.gen_params.batch_count > 0 ? std::max(1, result_count / request.gen_params.batch_count) : 1;
|
||||
for (int i = 0; i < result_count; ++i) {
|
||||
if (results[i].data == nullptr) {
|
||||
continue;
|
||||
}
|
||||
std::string params = request.gen_params.embed_image_metadata
|
||||
? get_image_params(*runtime->ctx_params,
|
||||
request.gen_params,
|
||||
request.gen_params.seed + i / images_per_batch)
|
||||
: "";
|
||||
auto image_bytes = encode_image_to_vector(request.output_format == "jpeg"
|
||||
? EncodedImageFormat::JPEG
|
||||
: request.output_format == "webp"
|
||||
? EncodedImageFormat::WEBP
|
||||
: EncodedImageFormat::PNG,
|
||||
results[i].data,
|
||||
results[i].width,
|
||||
results[i].height,
|
||||
results[i].channel,
|
||||
params,
|
||||
request.output_compression);
|
||||
if (image_bytes.empty()) {
|
||||
LOG_ERROR("write image to mem failed");
|
||||
continue;
|
||||
}
|
||||
|
||||
json item;
|
||||
item["b64_json"] = base64_encode(image_bytes);
|
||||
out["data"].push_back(item);
|
||||
}
|
||||
|
||||
res.set_content(out.dump(), "application/json");
|
||||
res.status = 200;
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
res.status = 500;
|
||||
json err;
|
||||
err["error"] = "server_error";
|
||||
err["message"] = e.what();
|
||||
res.set_content(err.dump(), "application/json");
|
||||
}
|
||||
});
|
||||
|
||||
svr.Post("/v1/images/edits", [runtime](const httplib::Request& req, httplib::Response& res) {
|
||||
try {
|
||||
if (!runtime_supports_generation_mode(*runtime, IMG_GEN)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", unsupported_generation_mode_error(IMG_GEN)}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
ImgGenJobRequest request;
|
||||
std::string error_message;
|
||||
if (!build_openai_edit_request(req, *runtime, request, error_message)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", error_message}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DEBUG("%s\n", request.gen_params.to_string().c_str());
|
||||
|
||||
SDImageVec results;
|
||||
if (!execute_sync_img_gen_request(*runtime, request, results, error_message)) {
|
||||
res.status = 500;
|
||||
res.set_content(json({{"error", error_message}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
json out;
|
||||
out["created"] = static_cast<long long>(std::time(nullptr));
|
||||
out["data"] = json::array();
|
||||
out["output_format"] = request.output_format;
|
||||
|
||||
int result_count = results.count();
|
||||
int images_per_batch = request.gen_params.batch_count > 0 ? std::max(1, result_count / request.gen_params.batch_count) : 1;
|
||||
for (int i = 0; i < result_count; ++i) {
|
||||
if (results[i].data == nullptr) {
|
||||
continue;
|
||||
}
|
||||
std::string params = request.gen_params.embed_image_metadata
|
||||
? get_image_params(*runtime->ctx_params,
|
||||
request.gen_params,
|
||||
request.gen_params.seed + i / images_per_batch)
|
||||
: "";
|
||||
auto image_bytes = encode_image_to_vector(request.output_format == "jpeg" ? EncodedImageFormat::JPEG : EncodedImageFormat::PNG,
|
||||
results[i].data,
|
||||
results[i].width,
|
||||
results[i].height,
|
||||
results[i].channel,
|
||||
params,
|
||||
request.output_compression);
|
||||
json item;
|
||||
item["b64_json"] = base64_encode(image_bytes);
|
||||
out["data"].push_back(item);
|
||||
}
|
||||
|
||||
res.set_content(out.dump(), "application/json");
|
||||
res.status = 200;
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
res.status = 500;
|
||||
json err;
|
||||
err["error"] = "server_error";
|
||||
err["message"] = e.what();
|
||||
res.set_content(err.dump(), "application/json");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
#include "routes.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <regex>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "common/common.h"
|
||||
#include "common/media_io.h"
|
||||
#include "common/resource_owners.hpp"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static std::string extract_and_remove_sd_cpp_extra_args(std::string& text) {
|
||||
std::regex re("<sd_cpp_extra_args>(.*?)</sd_cpp_extra_args>");
|
||||
std::smatch match;
|
||||
|
||||
std::string extracted;
|
||||
if (std::regex_search(text, match, re)) {
|
||||
extracted = match[1].str();
|
||||
text = std::regex_replace(text, re, "");
|
||||
}
|
||||
return extracted;
|
||||
}
|
||||
|
||||
static fs::path resolve_display_model_path(const ServerRuntime& runtime) {
|
||||
const auto& ctx = *runtime.ctx_params;
|
||||
if (!ctx.model_path.empty()) {
|
||||
return fs::path(ctx.model_path);
|
||||
}
|
||||
if (!ctx.diffusion_model_path.empty()) {
|
||||
return fs::path(ctx.diffusion_model_path);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static std::string lower_ascii(std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
static enum sample_method_t get_sdapi_sample_method(std::string name) {
|
||||
enum sample_method_t result = str_to_sample_method(name.c_str());
|
||||
if (result != SAMPLE_METHOD_COUNT) {
|
||||
return result;
|
||||
}
|
||||
|
||||
name = lower_ascii(name);
|
||||
static const std::unordered_map<std::string_view, sample_method_t> hardcoded{
|
||||
{"euler a", EULER_A_SAMPLE_METHOD},
|
||||
{"k_euler_a", EULER_A_SAMPLE_METHOD},
|
||||
{"euler", EULER_SAMPLE_METHOD},
|
||||
{"k_euler", EULER_SAMPLE_METHOD},
|
||||
{"heun", HEUN_SAMPLE_METHOD},
|
||||
{"k_heun", HEUN_SAMPLE_METHOD},
|
||||
{"dpm2", DPM2_SAMPLE_METHOD},
|
||||
{"k_dpm_2", DPM2_SAMPLE_METHOD},
|
||||
{"lcm", LCM_SAMPLE_METHOD},
|
||||
{"ddim", DDIM_TRAILING_SAMPLE_METHOD},
|
||||
{"dpm++ 2m", DPMPP2M_SAMPLE_METHOD},
|
||||
{"k_dpmpp_2m", DPMPP2M_SAMPLE_METHOD},
|
||||
{"dpm++ 2m sde", DPMPP2M_SDE_SAMPLE_METHOD},
|
||||
{"k_dpmpp_2m_sde", DPMPP2M_SDE_SAMPLE_METHOD},
|
||||
{"dpm++ 2m sde gpu", DPMPP2M_SDE_BT_SAMPLE_METHOD},
|
||||
{"k_dpmpp_2m_sde_gpu", DPMPP2M_SDE_BT_SAMPLE_METHOD},
|
||||
{"res multistep", RES_MULTISTEP_SAMPLE_METHOD},
|
||||
{"k_res_multistep", RES_MULTISTEP_SAMPLE_METHOD},
|
||||
{"res 2s", RES_2S_SAMPLE_METHOD},
|
||||
{"k_res_2s", RES_2S_SAMPLE_METHOD},
|
||||
{"euler_cfg_pp", EULER_CFG_PP_SAMPLE_METHOD},
|
||||
{"k_euler_cfg_pp", EULER_CFG_PP_SAMPLE_METHOD},
|
||||
{"euler_a_cfg_pp", EULER_CFG_PP_SAMPLE_METHOD},
|
||||
{"k_euler_a_cfg_pp", EULER_CFG_PP_SAMPLE_METHOD},
|
||||
};
|
||||
auto it = hardcoded.find(name);
|
||||
return it != hardcoded.end() ? it->second : SAMPLE_METHOD_COUNT;
|
||||
}
|
||||
|
||||
static void assign_solid_mask(SDImageOwner& mask_owner, int width, int height) {
|
||||
const size_t pixel_count = static_cast<size_t>(width) * static_cast<size_t>(height);
|
||||
uint8_t* raw_mask = static_cast<uint8_t*>(malloc(pixel_count));
|
||||
if (raw_mask == nullptr) {
|
||||
mask_owner.reset({0, 0, 1, nullptr});
|
||||
return;
|
||||
}
|
||||
std::memset(raw_mask, 255, pixel_count);
|
||||
mask_owner.reset({(uint32_t)width, (uint32_t)height, 1, raw_mask});
|
||||
}
|
||||
|
||||
static bool build_sdapi_img_gen_request(const json& j,
|
||||
ServerRuntime& runtime,
|
||||
bool img2img,
|
||||
ImgGenJobRequest& request,
|
||||
std::string& error_message) {
|
||||
std::string prompt = j.value("prompt", "");
|
||||
std::string negative_prompt = j.value("negative_prompt", "");
|
||||
int width = j.value("width", 512);
|
||||
int height = j.value("height", 512);
|
||||
int steps = j.value("steps", runtime.default_gen_params->sample_params.sample_steps);
|
||||
float cfg_scale = j.value("cfg_scale", runtime.default_gen_params->sample_params.guidance.txt_cfg);
|
||||
int64_t seed = j.value("seed", -1);
|
||||
int batch_size = j.value("batch_size", 1);
|
||||
int clip_skip = j.value("clip_skip", -1);
|
||||
std::string sampler_name = j.value("sampler_name", "");
|
||||
std::string scheduler_name = j.value("scheduler", "");
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
error_message = "width and height must be positive";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prompt.empty()) {
|
||||
error_message = "prompt required";
|
||||
return false;
|
||||
}
|
||||
|
||||
request.gen_params = *runtime.default_gen_params;
|
||||
|
||||
request.gen_params.prompt = prompt;
|
||||
request.gen_params.negative_prompt = negative_prompt;
|
||||
request.gen_params.seed = seed;
|
||||
request.gen_params.sample_params.sample_steps = steps;
|
||||
request.gen_params.batch_count = batch_size;
|
||||
request.gen_params.sample_params.guidance.txt_cfg = cfg_scale;
|
||||
request.gen_params.width = j.value("width", -1);
|
||||
request.gen_params.height = j.value("height", -1);
|
||||
|
||||
if (!img2img && j.value("enable_hr", false)) {
|
||||
request.gen_params.hires_enabled = true;
|
||||
request.gen_params.hires_scale = j.value("hr_scale", request.gen_params.hires_scale);
|
||||
request.gen_params.hires_width = j.value("hr_resize_x", request.gen_params.hires_width);
|
||||
request.gen_params.hires_height = j.value("hr_resize_y", request.gen_params.hires_height);
|
||||
request.gen_params.hires_steps = j.value("hr_steps", request.gen_params.hires_steps);
|
||||
request.gen_params.hires_denoising_strength =
|
||||
j.value("denoising_strength", request.gen_params.hires_denoising_strength);
|
||||
|
||||
request.gen_params.hires_upscaler = j.value("hr_upscaler", request.gen_params.hires_upscaler);
|
||||
}
|
||||
|
||||
std::string sd_cpp_extra_args_str = extract_and_remove_sd_cpp_extra_args(request.gen_params.prompt);
|
||||
if (!sd_cpp_extra_args_str.empty() && !request.gen_params.from_json_str(sd_cpp_extra_args_str)) {
|
||||
error_message = "invalid sd_cpp_extra_args";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (clip_skip > 0) {
|
||||
request.gen_params.clip_skip = clip_skip;
|
||||
}
|
||||
|
||||
enum sample_method_t sample_method = get_sdapi_sample_method(sampler_name);
|
||||
if (sample_method != SAMPLE_METHOD_COUNT) {
|
||||
request.gen_params.sample_params.sample_method = sample_method;
|
||||
}
|
||||
|
||||
enum scheduler_t scheduler = str_to_scheduler(scheduler_name.c_str());
|
||||
if (scheduler != SCHEDULER_COUNT) {
|
||||
request.gen_params.sample_params.scheduler = scheduler;
|
||||
}
|
||||
|
||||
if (j.contains("lora") && j["lora"].is_array()) {
|
||||
request.gen_params.lora_map.clear();
|
||||
request.gen_params.high_noise_lora_map.clear();
|
||||
|
||||
for (const auto& item : j["lora"]) {
|
||||
if (!item.is_object()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string path = item.value("path", "");
|
||||
float multiplier = item.value("multiplier", 1.0f);
|
||||
bool is_high_noise = item.value("is_high_noise", false);
|
||||
|
||||
if (path.empty()) {
|
||||
error_message = "lora.path required";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string fullpath = get_lora_full_path(runtime, path);
|
||||
if (fullpath.empty()) {
|
||||
error_message = "invalid lora path: " + path;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_high_noise) {
|
||||
request.gen_params.high_noise_lora_map[fullpath] += multiplier;
|
||||
} else {
|
||||
request.gen_params.lora_map[fullpath] += multiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (img2img) {
|
||||
const int expected_width = request.gen_params.width_and_height_are_set() ? request.gen_params.width : 0;
|
||||
const int expected_height = request.gen_params.width_and_height_are_set() ? request.gen_params.height : 0;
|
||||
|
||||
if (j.contains("init_images") && j["init_images"].is_array() && !j["init_images"].empty()) {
|
||||
if (decode_base64_image(j["init_images"][0].get<std::string>(),
|
||||
3,
|
||||
expected_width,
|
||||
expected_height,
|
||||
request.gen_params.init_image)) {
|
||||
const sd_image_t& image = request.gen_params.init_image.get();
|
||||
request.gen_params.set_width_and_height_if_unset(image.width, image.height);
|
||||
}
|
||||
}
|
||||
|
||||
if (j.contains("mask") && j["mask"].is_string()) {
|
||||
if (decode_base64_image(j["mask"].get<std::string>(),
|
||||
1,
|
||||
expected_width,
|
||||
expected_height,
|
||||
request.gen_params.mask_image)) {
|
||||
const sd_image_t& image = request.gen_params.mask_image.get();
|
||||
request.gen_params.set_width_and_height_if_unset(image.width, image.height);
|
||||
}
|
||||
sd_image_t& mask_image = request.gen_params.mask_image.get();
|
||||
bool inpainting_mask_invert = j.value("inpainting_mask_invert", 0) != 0;
|
||||
if (inpainting_mask_invert && mask_image.data != nullptr) {
|
||||
for (uint32_t i = 0; i < mask_image.width * mask_image.height; ++i) {
|
||||
mask_image.data[i] = 255 - mask_image.data[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const int resolved_width = request.gen_params.get_resolved_width();
|
||||
const int resolved_height = request.gen_params.get_resolved_height();
|
||||
assign_solid_mask(request.gen_params.mask_image, resolved_width, resolved_height);
|
||||
}
|
||||
|
||||
float denoising_strength = j.value("denoising_strength", -1.f);
|
||||
if (denoising_strength >= 0.f) {
|
||||
request.gen_params.strength = std::min(denoising_strength, 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
if (j.contains("extra_images") && j["extra_images"].is_array()) {
|
||||
for (const auto& extra_image : j["extra_images"]) {
|
||||
if (!extra_image.is_string()) {
|
||||
continue;
|
||||
}
|
||||
SDImageOwner image_owner;
|
||||
if (decode_base64_image(extra_image.get<std::string>(),
|
||||
3,
|
||||
request.gen_params.width_and_height_are_set() ? request.gen_params.width : 0,
|
||||
request.gen_params.width_and_height_are_set() ? request.gen_params.height : 0,
|
||||
image_owner)) {
|
||||
const sd_image_t& image = image_owner.get();
|
||||
request.gen_params.set_width_and_height_if_unset(image.width, image.height);
|
||||
request.gen_params.ref_images.push_back(std::move(image_owner));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally disable prompt-embedded LoRA tag parsing for server APIs.
|
||||
if (!request.gen_params.resolve_and_validate(IMG_GEN, "", runtime.ctx_params->hires_upscalers_dir, true)) {
|
||||
error_message = "invalid params";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static nlohmann::json prepare_info_field(const SDContextParams& ctx_params,
|
||||
const SDGenerationParams& gen_params,
|
||||
bool img2img) {
|
||||
nlohmann::json jsoninfo = nlohmann::json::object();
|
||||
jsoninfo["prompt"] = gen_params.prompt;
|
||||
if (!gen_params.negative_prompt.empty()) {
|
||||
jsoninfo["negative_prompt"] = gen_params.negative_prompt;
|
||||
}
|
||||
jsoninfo["seed"] = gen_params.seed;
|
||||
jsoninfo["cfg_scale"] = gen_params.sample_params.guidance.txt_cfg;
|
||||
jsoninfo["width"] = gen_params.get_resolved_width();
|
||||
jsoninfo["height"] = gen_params.get_resolved_height();
|
||||
jsoninfo["steps"] = gen_params.sample_params.sample_steps;
|
||||
jsoninfo["sampler_name"] = sd_sample_method_name(gen_params.sample_params.sample_method);
|
||||
if (gen_params.clip_skip != -1) {
|
||||
jsoninfo["clip_skip"] = gen_params.clip_skip;
|
||||
}
|
||||
if (gen_params.sample_params.scheduler != scheduler_t::SCHEDULER_COUNT) {
|
||||
jsoninfo["extra_generation_params"] = nlohmann::json::object();
|
||||
jsoninfo["extra_generation_params"]["Schedule type"] = sd_scheduler_name(gen_params.sample_params.scheduler);
|
||||
}
|
||||
if (img2img) {
|
||||
jsoninfo["denoising_strength"] = gen_params.strength;
|
||||
}
|
||||
// not clear what should happen if we have both model and diffusion_model
|
||||
if (!ctx_params.diffusion_model_path.empty()) {
|
||||
jsoninfo["sd_model_name"] = sd_basename(ctx_params.diffusion_model_path);
|
||||
} else if (!ctx_params.model_path.empty()) {
|
||||
jsoninfo["sd_model_name"] = sd_basename(ctx_params.model_path);
|
||||
}
|
||||
if (!ctx_params.vae_path.empty()) {
|
||||
jsoninfo["sd_vae_name"] = sd_basename(ctx_params.vae_path);
|
||||
}
|
||||
jsoninfo["version"] = "stable-diffusion.cpp";
|
||||
|
||||
jsoninfo["infotexts"] = nlohmann::json::array();
|
||||
jsoninfo["all_prompts"] = nlohmann::json::array();
|
||||
jsoninfo["all_negative_prompts"] = nlohmann::json::array();
|
||||
jsoninfo["all_seeds"] = nlohmann::json::array();
|
||||
return jsoninfo;
|
||||
}
|
||||
|
||||
void register_sdapi_endpoints(httplib::Server& svr, ServerRuntime& rt) {
|
||||
ServerRuntime* runtime = &rt;
|
||||
|
||||
auto sdapi_any2img = [runtime](const httplib::Request& req, httplib::Response& res, bool img2img) {
|
||||
try {
|
||||
if (req.body.empty()) {
|
||||
res.status = 400;
|
||||
res.set_content(R"({"error":"empty body"})", "application/json");
|
||||
return;
|
||||
}
|
||||
if (!runtime_supports_generation_mode(*runtime, IMG_GEN)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", unsupported_generation_mode_error(IMG_GEN)}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
json j = json::parse(req.body);
|
||||
ImgGenJobRequest request;
|
||||
std::string error_message;
|
||||
if (!build_sdapi_img_gen_request(j, *runtime, img2img, request, error_message)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", error_message}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DEBUG("%s\n", request.gen_params.to_string().c_str());
|
||||
|
||||
sd_img_gen_params_t img_gen_params = request.to_sd_img_gen_params_t();
|
||||
SDImageVec results;
|
||||
int num_results = 0;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*runtime->sd_ctx_mutex);
|
||||
sd_image_t* raw_results = nullptr;
|
||||
if (!generate_image(runtime->sd_ctx, &img_gen_params, &raw_results, &num_results)) {
|
||||
raw_results = nullptr;
|
||||
num_results = 0;
|
||||
}
|
||||
results.adopt(raw_results, num_results);
|
||||
}
|
||||
|
||||
if (results.empty()) {
|
||||
res.status = 500;
|
||||
res.set_content(R"({"error":"generate_image returned no results"})", "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
json out;
|
||||
out["images"] = json::array();
|
||||
out["parameters"] = j;
|
||||
json jsoninfo = prepare_info_field(*runtime->ctx_params, request.gen_params, img2img);
|
||||
|
||||
int images_per_batch = request.gen_params.batch_count > 0 ? std::max(1, num_results / request.gen_params.batch_count) : 1;
|
||||
for (int i = 0; i < num_results; ++i) {
|
||||
if (results[i].data == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bool embed_meta = request.gen_params.embed_image_metadata;
|
||||
|
||||
std::string params = get_image_params(*runtime->ctx_params,
|
||||
request.gen_params,
|
||||
request.gen_params.seed + i / images_per_batch);
|
||||
|
||||
auto image_bytes = encode_image_to_vector(EncodedImageFormat::PNG,
|
||||
results[i].data,
|
||||
results[i].width,
|
||||
results[i].height,
|
||||
results[i].channel,
|
||||
embed_meta ? params : "");
|
||||
|
||||
if (image_bytes.empty()) {
|
||||
LOG_ERROR("write image to mem failed");
|
||||
continue;
|
||||
}
|
||||
|
||||
out["images"].push_back(base64_encode(image_bytes));
|
||||
|
||||
jsoninfo["infotexts"][i] = params;
|
||||
jsoninfo["all_seeds"][i] = request.gen_params.seed + i;
|
||||
jsoninfo["all_prompts"][i] = request.gen_params.prompt;
|
||||
jsoninfo["all_negative_prompts"][i] = request.gen_params.negative_prompt;
|
||||
}
|
||||
|
||||
// not a mistake: it is supposed to be a string in json format
|
||||
out["info"] = jsoninfo.dump();
|
||||
|
||||
res.set_content(out.dump(), "application/json");
|
||||
res.status = 200;
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
res.status = 500;
|
||||
json err;
|
||||
err["error"] = "server_error";
|
||||
err["message"] = e.what();
|
||||
res.set_content(err.dump(), "application/json");
|
||||
}
|
||||
};
|
||||
|
||||
svr.Post("/sdapi/v1/txt2img", [sdapi_any2img](const httplib::Request& req, httplib::Response& res) {
|
||||
sdapi_any2img(req, res, false);
|
||||
});
|
||||
|
||||
svr.Post("/sdapi/v1/img2img", [sdapi_any2img](const httplib::Request& req, httplib::Response& res) {
|
||||
sdapi_any2img(req, res, true);
|
||||
});
|
||||
|
||||
svr.Get("/sdapi/v1/loras", [runtime](const httplib::Request&, httplib::Response& res) {
|
||||
refresh_lora_cache(*runtime);
|
||||
|
||||
json result = json::array();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*runtime->lora_mutex);
|
||||
for (const auto& e : *runtime->lora_cache) {
|
||||
json item;
|
||||
item["name"] = e.name;
|
||||
item["path"] = e.path;
|
||||
result.push_back(item);
|
||||
}
|
||||
}
|
||||
|
||||
res.set_content(result.dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Get("/sdapi/v1/upscalers", [runtime](const httplib::Request&, httplib::Response& res) {
|
||||
refresh_upscaler_cache(*runtime);
|
||||
|
||||
auto make_builtin = [](const char* name) {
|
||||
json item;
|
||||
item["name"] = name;
|
||||
item["model_name"] = nullptr;
|
||||
item["model_path"] = nullptr;
|
||||
item["model_url"] = nullptr;
|
||||
item["scale"] = 4;
|
||||
return item;
|
||||
};
|
||||
|
||||
json result = json::array();
|
||||
result.push_back(make_builtin("None"));
|
||||
result.push_back(make_builtin("Lanczos"));
|
||||
result.push_back(make_builtin("Nearest"));
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*runtime->upscaler_mutex);
|
||||
for (const auto& e : *runtime->upscaler_cache) {
|
||||
json item;
|
||||
item["name"] = e.name;
|
||||
item["model_name"] = e.model_name;
|
||||
item["model_path"] = e.fullpath;
|
||||
item["model_url"] = nullptr;
|
||||
item["scale"] = e.scale;
|
||||
result.push_back(item);
|
||||
}
|
||||
}
|
||||
|
||||
res.set_content(result.dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Get("/sdapi/v1/latent-upscale-modes", [](const httplib::Request&, httplib::Response& res) {
|
||||
json result = json::array({
|
||||
{{"name", "Latent"}},
|
||||
{{"name", "Latent (nearest)"}},
|
||||
{{"name", "Latent (nearest-exact)"}},
|
||||
{{"name", "Latent (antialiased)"}},
|
||||
{{"name", "Latent (bicubic)"}},
|
||||
{{"name", "Latent (bicubic antialiased)"}},
|
||||
});
|
||||
res.set_content(result.dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Get("/sdapi/v1/samplers", [runtime](const httplib::Request&, httplib::Response& res) {
|
||||
std::vector<std::string> sampler_names;
|
||||
sampler_names.push_back("default");
|
||||
for (int i = 0; i < SAMPLE_METHOD_COUNT; i++) {
|
||||
sampler_names.push_back(sd_sample_method_name((sample_method_t)i));
|
||||
}
|
||||
json r = json::array();
|
||||
for (auto name : sampler_names) {
|
||||
json entry;
|
||||
entry["name"] = name;
|
||||
entry["aliases"] = json::array({name});
|
||||
entry["options"] = json::object();
|
||||
r.push_back(entry);
|
||||
}
|
||||
res.set_content(r.dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Get("/sdapi/v1/schedulers", [runtime](const httplib::Request&, httplib::Response& res) {
|
||||
std::vector<std::string> scheduler_names;
|
||||
scheduler_names.push_back("default");
|
||||
for (int i = 0; i < SCHEDULER_COUNT; i++) {
|
||||
scheduler_names.push_back(sd_scheduler_name((scheduler_t)i));
|
||||
if (i == DISCRETE_SCHEDULER) {
|
||||
scheduler_names.push_back("normal");
|
||||
}
|
||||
}
|
||||
json r = json::array();
|
||||
for (auto name : scheduler_names) {
|
||||
json entry;
|
||||
entry["name"] = name;
|
||||
entry["label"] = name;
|
||||
r.push_back(entry);
|
||||
}
|
||||
res.set_content(r.dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Get("/sdapi/v1/sd-models", [runtime](const httplib::Request&, httplib::Response& res) {
|
||||
fs::path model_path = resolve_display_model_path(*runtime);
|
||||
json entry;
|
||||
entry["title"] = model_path.stem();
|
||||
entry["model_name"] = model_path.stem();
|
||||
entry["filename"] = model_path.filename();
|
||||
entry["hash"] = "8888888888";
|
||||
entry["sha256"] = "8888888888888888888888888888888888888888888888888888888888888888";
|
||||
entry["config"] = nullptr;
|
||||
json r = json::array();
|
||||
r.push_back(entry);
|
||||
res.set_content(r.dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Get("/sdapi/v1/options", [runtime](const httplib::Request&, httplib::Response& res) {
|
||||
fs::path model_path = resolve_display_model_path(*runtime);
|
||||
json r;
|
||||
r["samples_format"] = "png";
|
||||
r["sd_model_checkpoint"] = model_path.stem();
|
||||
res.set_content(r.dump(), "application/json");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
#include "routes.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
|
||||
#include "async_jobs.h"
|
||||
#include "common/common.h"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static bool parse_cache_mode(const std::string& mode_str, sd_cache_mode_t& mode_out) {
|
||||
if (mode_str == "disabled") {
|
||||
mode_out = SD_CACHE_DISABLED;
|
||||
return true;
|
||||
}
|
||||
if (mode_str == "easycache") {
|
||||
mode_out = SD_CACHE_EASYCACHE;
|
||||
return true;
|
||||
}
|
||||
if (mode_str == "ucache") {
|
||||
mode_out = SD_CACHE_UCACHE;
|
||||
return true;
|
||||
}
|
||||
if (mode_str == "dbcache") {
|
||||
mode_out = SD_CACHE_DBCACHE;
|
||||
return true;
|
||||
}
|
||||
if (mode_str == "taylorseer") {
|
||||
mode_out = SD_CACHE_TAYLORSEER;
|
||||
return true;
|
||||
}
|
||||
if (mode_str == "cache-dit") {
|
||||
mode_out = SD_CACHE_CACHE_DIT;
|
||||
return true;
|
||||
}
|
||||
if (mode_str == "spectrum") {
|
||||
mode_out = SD_CACHE_SPECTRUM;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static json finite_number_or_null(float value) {
|
||||
return std::isfinite(value) ? json(value) : json(nullptr);
|
||||
}
|
||||
|
||||
static const char* capability_scheduler_name(enum scheduler_t scheduler) {
|
||||
return scheduler < SCHEDULER_COUNT ? sd_scheduler_name(scheduler) : "default";
|
||||
}
|
||||
|
||||
static const char* capability_sample_method_name(enum sample_method_t sample_method) {
|
||||
return sample_method < SAMPLE_METHOD_COUNT ? sd_sample_method_name(sample_method) : "default";
|
||||
}
|
||||
|
||||
static json make_vae_tiling_json(const sd_tiling_params_t& params) {
|
||||
return {
|
||||
{"enabled", params.enabled},
|
||||
{"temporal_tiling", params.temporal_tiling},
|
||||
{"tile_size_x", params.tile_size_x},
|
||||
{"tile_size_y", params.tile_size_y},
|
||||
{"target_overlap", params.target_overlap},
|
||||
{"rel_size_x", params.rel_size_x},
|
||||
{"rel_size_y", params.rel_size_y},
|
||||
{"extra_tiling_args", params.extra_tiling_args ? params.extra_tiling_args : ""},
|
||||
};
|
||||
}
|
||||
|
||||
static fs::path resolve_display_model_path(const ServerRuntime& runtime) {
|
||||
const auto& ctx = *runtime.ctx_params;
|
||||
if (!ctx.model_path.empty()) {
|
||||
return fs::path(ctx.model_path);
|
||||
}
|
||||
if (!ctx.diffusion_model_path.empty()) {
|
||||
return fs::path(ctx.diffusion_model_path);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static json make_sample_params_json(const sd_sample_params_t& sample_params, const std::vector<int>& skip_layers) {
|
||||
const auto& guidance = sample_params.guidance;
|
||||
return {
|
||||
{"scheduler", capability_scheduler_name(sample_params.scheduler)},
|
||||
{"sample_method", capability_sample_method_name(sample_params.sample_method)},
|
||||
{"sample_steps", sample_params.sample_steps},
|
||||
{"eta", finite_number_or_null(sample_params.eta)},
|
||||
{"shifted_timestep", sample_params.shifted_timestep},
|
||||
{"flow_shift", finite_number_or_null(sample_params.flow_shift)},
|
||||
{"guidance",
|
||||
{
|
||||
{"txt_cfg", guidance.txt_cfg},
|
||||
{"img_cfg", finite_number_or_null(guidance.img_cfg)},
|
||||
{"distilled_guidance", guidance.distilled_guidance},
|
||||
{"slg",
|
||||
{
|
||||
{"layers", skip_layers},
|
||||
{"layer_start", guidance.slg.layer_start},
|
||||
{"layer_end", guidance.slg.layer_end},
|
||||
{"scale", guidance.slg.scale},
|
||||
}},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
static json make_hires_json(const SDGenerationParams& defaults) {
|
||||
return {
|
||||
{"enabled", defaults.hires_enabled},
|
||||
{"upscaler", defaults.hires_upscaler},
|
||||
{"scale", defaults.hires_scale},
|
||||
{"target_width", defaults.hires_width},
|
||||
{"target_height", defaults.hires_height},
|
||||
{"steps", defaults.hires_steps},
|
||||
{"denoising_strength", defaults.hires_denoising_strength},
|
||||
{"custom_sigmas", defaults.hires_custom_sigmas},
|
||||
{"upscale_tile_size", defaults.hires_upscale_tile_size},
|
||||
};
|
||||
}
|
||||
|
||||
static json make_img_gen_defaults_json(const SDGenerationParams& defaults, const std::string& output_format) {
|
||||
return {
|
||||
{"prompt", defaults.prompt},
|
||||
{"negative_prompt", defaults.negative_prompt},
|
||||
{"clip_skip", defaults.clip_skip},
|
||||
{"width", defaults.width > 0 ? defaults.width : 512},
|
||||
{"height", defaults.height > 0 ? defaults.height : 512},
|
||||
{"strength", defaults.strength},
|
||||
{"seed", defaults.seed},
|
||||
{"batch_count", defaults.batch_count},
|
||||
{"qwen_image_layers", defaults.qwen_image_layers},
|
||||
{"auto_resize_ref_image", defaults.auto_resize_ref_image},
|
||||
{"increase_ref_index", defaults.increase_ref_index},
|
||||
{"control_strength", defaults.control_strength},
|
||||
{"ip_adapter_strength", defaults.ip_adapter_strength},
|
||||
{"sample_params", make_sample_params_json(defaults.sample_params, defaults.skip_layers)},
|
||||
{"hires", make_hires_json(defaults)},
|
||||
{"vae_tiling_params", make_vae_tiling_json(defaults.vae_tiling_params)},
|
||||
{"cache_mode", defaults.cache_mode},
|
||||
{"cache_option", defaults.cache_option},
|
||||
{"scm_mask", defaults.scm_mask},
|
||||
{"scm_policy_dynamic", defaults.scm_policy_dynamic},
|
||||
{"output_format", output_format},
|
||||
{"output_compression", 100},
|
||||
};
|
||||
}
|
||||
|
||||
static json make_vid_gen_defaults_json(const SDGenerationParams& defaults, const std::string& output_format) {
|
||||
return {
|
||||
{"prompt", defaults.prompt},
|
||||
{"negative_prompt", defaults.negative_prompt},
|
||||
{"clip_skip", defaults.clip_skip},
|
||||
{"width", defaults.width > 0 ? defaults.width : 512},
|
||||
{"height", defaults.height > 0 ? defaults.height : 512},
|
||||
{"strength", defaults.strength},
|
||||
{"seed", defaults.seed},
|
||||
{"video_frames", defaults.video_frames},
|
||||
{"fps", defaults.fps},
|
||||
{"moe_boundary", defaults.moe_boundary},
|
||||
{"vace_strength", defaults.vace_strength},
|
||||
{"sample_params", make_sample_params_json(defaults.sample_params, defaults.skip_layers)},
|
||||
{"high_noise_sample_params", make_sample_params_json(defaults.high_noise_sample_params, defaults.high_noise_skip_layers)},
|
||||
{"hires", make_hires_json(defaults)},
|
||||
{"vae_tiling_params", make_vae_tiling_json(defaults.vae_tiling_params)},
|
||||
{"cache_mode", defaults.cache_mode},
|
||||
{"cache_option", defaults.cache_option},
|
||||
{"scm_mask", defaults.scm_mask},
|
||||
{"scm_policy_dynamic", defaults.scm_policy_dynamic},
|
||||
{"output_format", output_format},
|
||||
{"output_compression", 100},
|
||||
};
|
||||
}
|
||||
|
||||
static json make_img_gen_features_json() {
|
||||
return {
|
||||
{"init_image", true},
|
||||
{"mask_image", true},
|
||||
{"control_image", true},
|
||||
{"ip_adapter_image", true},
|
||||
{"ref_images", true},
|
||||
{"lora", true},
|
||||
{"vae_tiling", true},
|
||||
{"hires", true},
|
||||
{"cache", true},
|
||||
{"cancel_queued", true},
|
||||
{"cancel_generating", false},
|
||||
};
|
||||
}
|
||||
|
||||
static json make_vid_gen_features_json() {
|
||||
return {
|
||||
{"init_image", true},
|
||||
{"end_image", true},
|
||||
{"control_frames", true},
|
||||
{"high_noise_sample_params", true},
|
||||
{"lora", true},
|
||||
{"vae_tiling", true},
|
||||
{"cache", true},
|
||||
{"cancel_queued", true},
|
||||
{"cancel_generating", false},
|
||||
};
|
||||
}
|
||||
|
||||
static json make_capabilities_json(ServerRuntime& runtime) {
|
||||
refresh_lora_cache(runtime);
|
||||
refresh_upscaler_cache(runtime);
|
||||
|
||||
AsyncJobManager& manager = *runtime.async_job_manager;
|
||||
const auto& defaults = *runtime.default_gen_params;
|
||||
const fs::path model_path = resolve_display_model_path(runtime);
|
||||
const bool supports_img = runtime_supports_generation_mode(runtime, IMG_GEN);
|
||||
const bool supports_vid = runtime_supports_generation_mode(runtime, VID_GEN);
|
||||
json samplers = json::array();
|
||||
json schedulers = json::array();
|
||||
json image_output_formats = supported_img_output_formats();
|
||||
json video_output_formats = supported_vid_output_formats();
|
||||
json available_loras = json::array();
|
||||
json available_upscalers = json::array();
|
||||
json supported_modes = json::array();
|
||||
|
||||
for (int i = 0; i < SAMPLE_METHOD_COUNT; ++i) {
|
||||
samplers.push_back(sd_sample_method_name((sample_method_t)i));
|
||||
}
|
||||
|
||||
for (int i = 0; i < SCHEDULER_COUNT; ++i) {
|
||||
schedulers.push_back(sd_scheduler_name((scheduler_t)i));
|
||||
if (i == DISCRETE_SCHEDULER) {
|
||||
schedulers.push_back("normal");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*runtime.lora_mutex);
|
||||
for (const auto& entry : *runtime.lora_cache) {
|
||||
available_loras.push_back({
|
||||
{"name", entry.name},
|
||||
{"path", entry.path},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
available_upscalers.push_back({
|
||||
{"name", "None"},
|
||||
});
|
||||
available_upscalers.push_back({
|
||||
{"name", "Lanczos"},
|
||||
});
|
||||
available_upscalers.push_back({
|
||||
{"name", "Nearest"},
|
||||
});
|
||||
available_upscalers.push_back({
|
||||
{"name", "Latent"},
|
||||
});
|
||||
available_upscalers.push_back({
|
||||
{"name", "Latent (nearest)"},
|
||||
});
|
||||
available_upscalers.push_back({
|
||||
{"name", "Latent (nearest-exact)"},
|
||||
});
|
||||
available_upscalers.push_back({
|
||||
{"name", "Latent (antialiased)"},
|
||||
});
|
||||
available_upscalers.push_back({
|
||||
{"name", "Latent (bicubic)"},
|
||||
});
|
||||
available_upscalers.push_back({
|
||||
{"name", "Latent (bicubic antialiased)"},
|
||||
});
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*runtime.upscaler_mutex);
|
||||
for (const auto& entry : *runtime.upscaler_cache) {
|
||||
available_upscalers.push_back({
|
||||
{"name", entry.name},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (supports_img) {
|
||||
supported_modes.push_back("img_gen");
|
||||
}
|
||||
if (supports_vid) {
|
||||
supported_modes.push_back("vid_gen");
|
||||
}
|
||||
|
||||
std::string default_img_output_format = "png";
|
||||
std::string default_vid_output_format = "avi";
|
||||
if (!image_output_formats.empty()) {
|
||||
default_img_output_format = image_output_formats[0].get<std::string>();
|
||||
}
|
||||
if (!video_output_formats.empty()) {
|
||||
default_vid_output_format = video_output_formats[0].get<std::string>();
|
||||
}
|
||||
|
||||
json defaults_by_mode = json::object();
|
||||
json output_formats_by_mode = json::object();
|
||||
json features_by_mode = json::object();
|
||||
if (supports_img) {
|
||||
defaults_by_mode["img_gen"] = make_img_gen_defaults_json(defaults, default_img_output_format);
|
||||
output_formats_by_mode["img_gen"] = image_output_formats;
|
||||
features_by_mode["img_gen"] = make_img_gen_features_json();
|
||||
}
|
||||
if (supports_vid) {
|
||||
defaults_by_mode["vid_gen"] = make_vid_gen_defaults_json(defaults, default_vid_output_format);
|
||||
output_formats_by_mode["vid_gen"] = video_output_formats;
|
||||
features_by_mode["vid_gen"] = make_vid_gen_features_json();
|
||||
}
|
||||
|
||||
json top_level_defaults = json::object();
|
||||
json top_level_output_formats = json::array();
|
||||
json top_level_features = {
|
||||
{"cancel_queued", true},
|
||||
{"cancel_generating", false},
|
||||
};
|
||||
std::string current_mode = "";
|
||||
if (supports_img) {
|
||||
current_mode = "img_gen";
|
||||
top_level_defaults = defaults_by_mode["img_gen"];
|
||||
top_level_output_formats = output_formats_by_mode["img_gen"];
|
||||
top_level_features = features_by_mode["img_gen"];
|
||||
} else if (supports_vid) {
|
||||
current_mode = "vid_gen";
|
||||
top_level_defaults = defaults_by_mode["vid_gen"];
|
||||
top_level_output_formats = output_formats_by_mode["vid_gen"];
|
||||
top_level_features = features_by_mode["vid_gen"];
|
||||
}
|
||||
|
||||
json result;
|
||||
result["model"] = {
|
||||
{"name", model_path.filename().u8string()},
|
||||
{"stem", model_path.stem().u8string()},
|
||||
{"path", model_path.u8string()},
|
||||
};
|
||||
result["current_mode"] = current_mode;
|
||||
result["supported_modes"] = supported_modes;
|
||||
result["defaults"] = top_level_defaults;
|
||||
result["defaults_by_mode"] = defaults_by_mode;
|
||||
result["limits"] = {
|
||||
{"min_width", 64},
|
||||
{"max_width", 4096},
|
||||
{"min_height", 64},
|
||||
{"max_height", 4096},
|
||||
{"max_batch_count", 8},
|
||||
{"max_queue_size", manager.max_pending_jobs},
|
||||
};
|
||||
result["samplers"] = samplers;
|
||||
result["schedulers"] = schedulers;
|
||||
result["output_formats"] = top_level_output_formats;
|
||||
result["output_formats_by_mode"] = output_formats_by_mode;
|
||||
result["features"] = top_level_features;
|
||||
result["features_by_mode"] = features_by_mode;
|
||||
result["loras"] = available_loras;
|
||||
result["upscalers"] = available_upscalers;
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool parse_img_gen_request(const json& body,
|
||||
ServerRuntime& runtime,
|
||||
ImgGenJobRequest& request,
|
||||
std::string& error_message) {
|
||||
request.gen_params = *runtime.default_gen_params;
|
||||
|
||||
refresh_lora_cache(runtime);
|
||||
if (!request.gen_params.from_json_str(body.dump(), [&](const std::string& path) {
|
||||
return get_lora_full_path(runtime, path);
|
||||
})) {
|
||||
error_message = "invalid generation parameters";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string output_format = body.value("output_format", "png");
|
||||
int output_compression = body.value("output_compression", 100);
|
||||
if (!assign_output_options(request, output_format, output_compression, true, error_message)) {
|
||||
return false;
|
||||
}
|
||||
// Intentionally disable prompt-embedded LoRA tag parsing for server APIs.
|
||||
if (!request.gen_params.resolve_and_validate(IMG_GEN, "", runtime.ctx_params->hires_upscalers_dir, true)) {
|
||||
error_message = "invalid generation parameters";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parse_vid_gen_request(const json& body,
|
||||
ServerRuntime& runtime,
|
||||
VidGenJobRequest& request,
|
||||
std::string& error_message) {
|
||||
request.gen_params = *runtime.default_gen_params;
|
||||
|
||||
refresh_lora_cache(runtime);
|
||||
if (!request.gen_params.from_json_str(body.dump(), [&](const std::string& path) {
|
||||
return get_lora_full_path(runtime, path);
|
||||
})) {
|
||||
error_message = "invalid generation parameters";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string output_format = body.value("output_format", "webm");
|
||||
int output_compression = body.value("output_compression", 100);
|
||||
if (!assign_output_options(request, output_format, output_compression, error_message)) {
|
||||
return false;
|
||||
}
|
||||
// Intentionally disable prompt-embedded LoRA tag parsing for server APIs.
|
||||
if (!request.gen_params.resolve_and_validate(VID_GEN, "", runtime.ctx_params->hires_upscalers_dir, true)) {
|
||||
error_message = "invalid generation parameters";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void register_sdcpp_api_endpoints(httplib::Server& svr, ServerRuntime& rt) {
|
||||
ServerRuntime* runtime = &rt;
|
||||
|
||||
svr.Get("/sdcpp/v1/capabilities", [runtime](const httplib::Request&, httplib::Response& res) {
|
||||
res.status = 200;
|
||||
res.set_content(make_capabilities_json(*runtime).dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Post("/sdcpp/v1/img_gen", [runtime](const httplib::Request& req, httplib::Response& res) {
|
||||
try {
|
||||
if (req.body.empty()) {
|
||||
res.status = 400;
|
||||
res.set_content(R"({"error":"empty body"})", "application/json");
|
||||
return;
|
||||
}
|
||||
if (!runtime_supports_generation_mode(*runtime, IMG_GEN)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", unsupported_generation_mode_error(IMG_GEN)}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
json body = json::parse(req.body);
|
||||
ImgGenJobRequest request;
|
||||
std::string error_message;
|
||||
if (!parse_img_gen_request(body, *runtime, request, error_message)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", error_message}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
AsyncJobManager& manager = *runtime->async_job_manager;
|
||||
std::shared_ptr<AsyncGenerationJob> job = std::make_shared<AsyncGenerationJob>();
|
||||
job->kind = AsyncJobKind::ImgGen;
|
||||
job->status = AsyncJobStatus::Queued;
|
||||
job->created_at = unix_timestamp_now();
|
||||
job->img_gen = std::move(request);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(manager.mutex);
|
||||
purge_expired_jobs(manager);
|
||||
if (count_pending_jobs(manager) >= manager.max_pending_jobs) {
|
||||
res.status = 429;
|
||||
res.set_content(R"({"error":"job queue is full"})", "application/json");
|
||||
return;
|
||||
}
|
||||
job->id = make_async_job_id(manager);
|
||||
manager.jobs[job->id] = job;
|
||||
manager.queue.push_back(job->id);
|
||||
}
|
||||
|
||||
manager.cv.notify_one();
|
||||
|
||||
json out;
|
||||
out["id"] = job->id;
|
||||
out["kind"] = async_job_kind_name(job->kind);
|
||||
out["status"] = async_job_status_name(job->status);
|
||||
out["created"] = job->created_at;
|
||||
out["poll_url"] = "/sdcpp/v1/jobs/" + job->id;
|
||||
|
||||
res.status = 202;
|
||||
res.set_content(out.dump(), "application/json");
|
||||
} catch (const json::parse_error& e) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", "invalid json"}, {"message", e.what()}}).dump(), "application/json");
|
||||
} catch (const std::exception& e) {
|
||||
res.status = 500;
|
||||
res.set_content(json({{"error", "server_error"}, {"message", e.what()}}).dump(), "application/json");
|
||||
}
|
||||
});
|
||||
|
||||
svr.Post("/sdcpp/v1/vid_gen", [runtime](const httplib::Request& req, httplib::Response& res) {
|
||||
try {
|
||||
if (req.body.empty()) {
|
||||
res.status = 400;
|
||||
res.set_content(R"({"error":"empty body"})", "application/json");
|
||||
return;
|
||||
}
|
||||
if (!runtime_supports_generation_mode(*runtime, VID_GEN)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", unsupported_generation_mode_error(VID_GEN)}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
json body = json::parse(req.body);
|
||||
VidGenJobRequest request;
|
||||
std::string error_message;
|
||||
if (!parse_vid_gen_request(body, *runtime, request, error_message)) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", error_message}}).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
AsyncJobManager& manager = *runtime->async_job_manager;
|
||||
std::shared_ptr<AsyncGenerationJob> job = std::make_shared<AsyncGenerationJob>();
|
||||
job->kind = AsyncJobKind::VidGen;
|
||||
job->status = AsyncJobStatus::Queued;
|
||||
job->created_at = unix_timestamp_now();
|
||||
job->vid_gen = std::move(request);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(manager.mutex);
|
||||
purge_expired_jobs(manager);
|
||||
if (count_pending_jobs(manager) >= manager.max_pending_jobs) {
|
||||
res.status = 429;
|
||||
res.set_content(R"({"error":"job queue is full"})", "application/json");
|
||||
return;
|
||||
}
|
||||
job->id = make_async_job_id(manager);
|
||||
manager.jobs[job->id] = job;
|
||||
manager.queue.push_back(job->id);
|
||||
}
|
||||
|
||||
manager.cv.notify_one();
|
||||
|
||||
json out;
|
||||
out["id"] = job->id;
|
||||
out["kind"] = async_job_kind_name(job->kind);
|
||||
out["status"] = async_job_status_name(job->status);
|
||||
out["created"] = job->created_at;
|
||||
out["poll_url"] = "/sdcpp/v1/jobs/" + job->id;
|
||||
|
||||
res.status = 202;
|
||||
res.set_content(out.dump(), "application/json");
|
||||
} catch (const json::parse_error& e) {
|
||||
res.status = 400;
|
||||
res.set_content(json({{"error", "invalid json"}, {"message", e.what()}}).dump(), "application/json");
|
||||
} catch (const std::exception& e) {
|
||||
res.status = 500;
|
||||
res.set_content(json({{"error", "server_error"}, {"message", e.what()}}).dump(), "application/json");
|
||||
}
|
||||
});
|
||||
|
||||
svr.Get(R"(/sdcpp/v1/jobs/([A-Za-z0-9_\-]+))", [runtime](const httplib::Request& req, httplib::Response& res) {
|
||||
AsyncJobManager& manager = *runtime->async_job_manager;
|
||||
std::lock_guard<std::mutex> lock(manager.mutex);
|
||||
purge_expired_jobs(manager);
|
||||
|
||||
std::string job_id = req.matches[1];
|
||||
auto it = manager.jobs.find(job_id);
|
||||
if (it == manager.jobs.end()) {
|
||||
if (manager.expired_jobs.find(job_id) != manager.expired_jobs.end()) {
|
||||
res.status = 410;
|
||||
res.set_content(R"({"error":"job expired"})", "application/json");
|
||||
} else {
|
||||
res.status = 404;
|
||||
res.set_content(R"({"error":"job not found"})", "application/json");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.status = 200;
|
||||
res.set_content(make_async_job_json(manager, *it->second).dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Post(R"(/sdcpp/v1/jobs/([A-Za-z0-9_\-]+)/cancel)", [runtime](const httplib::Request& req, httplib::Response& res) {
|
||||
AsyncJobManager& manager = *runtime->async_job_manager;
|
||||
std::lock_guard<std::mutex> lock(manager.mutex);
|
||||
purge_expired_jobs(manager);
|
||||
|
||||
std::string job_id = req.matches[1];
|
||||
auto it = manager.jobs.find(job_id);
|
||||
if (it == manager.jobs.end()) {
|
||||
if (manager.expired_jobs.find(job_id) != manager.expired_jobs.end()) {
|
||||
res.status = 410;
|
||||
res.set_content(R"({"error":"job expired"})", "application/json");
|
||||
} else {
|
||||
res.status = 404;
|
||||
res.set_content(R"({"error":"job not found"})", "application/json");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
auto& job = *it->second;
|
||||
if (job.status == AsyncJobStatus::Queued) {
|
||||
if (!cancel_queued_job(manager, job)) {
|
||||
res.status = 409;
|
||||
res.set_content(R"({"error":"job queue state changed before cancellation"})", "application/json");
|
||||
return;
|
||||
}
|
||||
res.status = 200;
|
||||
res.set_content(make_async_job_json(manager, job).dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.status == AsyncJobStatus::Generating) {
|
||||
res.status = 409;
|
||||
res.set_content(R"({"error":"job is currently generating and cannot be interrupted yet"})", "application/json");
|
||||
return;
|
||||
}
|
||||
|
||||
res.status = 200;
|
||||
res.set_content(make_async_job_json(manager, job).dump(), "application/json");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
#include "runtime.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <regex>
|
||||
#include <sstream>
|
||||
|
||||
#include "common/common.h"
|
||||
#include "common/log.h"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static std::string lower_ascii(std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool is_supported_model_ext(const fs::path& p) {
|
||||
auto ext = lower_ascii(p.extension().string());
|
||||
return ext == ".gguf" || ext == ".pt" || ext == ".pth" || ext == ".safetensors";
|
||||
}
|
||||
|
||||
static const std::string k_base64_chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
"0123456789+/";
|
||||
|
||||
std::string base64_encode(const std::vector<uint8_t>& bytes) {
|
||||
std::string ret;
|
||||
int val = 0;
|
||||
int valb = -6;
|
||||
for (uint8_t c : bytes) {
|
||||
val = (val << 8) + c;
|
||||
valb += 8;
|
||||
while (valb >= 0) {
|
||||
ret.push_back(k_base64_chars[(val >> valb) & 0x3F]);
|
||||
valb -= 6;
|
||||
}
|
||||
}
|
||||
if (valb > -6) {
|
||||
ret.push_back(k_base64_chars[((val << 8) >> (valb + 8)) & 0x3F]);
|
||||
}
|
||||
while (ret.size() % 4) {
|
||||
ret.push_back('=');
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
std::string normalize_output_format(std::string output_format) {
|
||||
std::transform(output_format.begin(), output_format.end(), output_format.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return output_format;
|
||||
}
|
||||
|
||||
std::vector<std::string> supported_img_output_formats(bool allow_webp) {
|
||||
std::vector<std::string> formats = {"png", "jpeg"};
|
||||
#ifdef SD_USE_WEBP
|
||||
if (allow_webp) {
|
||||
formats.push_back("webp");
|
||||
}
|
||||
#else
|
||||
(void)allow_webp;
|
||||
#endif
|
||||
return formats;
|
||||
}
|
||||
|
||||
std::vector<std::string> supported_vid_output_formats() {
|
||||
std::vector<std::string> formats;
|
||||
#ifdef SD_USE_WEBM
|
||||
formats.push_back("webm");
|
||||
#endif
|
||||
#ifdef SD_USE_WEBP
|
||||
formats.push_back("webp");
|
||||
#endif
|
||||
formats.push_back("avi");
|
||||
return formats;
|
||||
}
|
||||
|
||||
static std::string valid_vid_output_formats_message() {
|
||||
const std::vector<std::string> formats = supported_vid_output_formats();
|
||||
|
||||
std::string message = "invalid output_format, must be one of [";
|
||||
for (size_t i = 0; i < formats.size(); ++i) {
|
||||
if (i > 0) {
|
||||
message += ", ";
|
||||
}
|
||||
message += formats[i];
|
||||
}
|
||||
message += "]";
|
||||
return message;
|
||||
}
|
||||
|
||||
bool assign_output_options(ImgGenJobRequest& request,
|
||||
std::string output_format,
|
||||
int output_compression,
|
||||
bool allow_webp,
|
||||
std::string& error_message) {
|
||||
request.output_format = normalize_output_format(std::move(output_format));
|
||||
request.output_compression = std::clamp(output_compression, 0, 100);
|
||||
|
||||
const std::vector<std::string> valid_formats = supported_img_output_formats(allow_webp);
|
||||
const bool valid_format = std::find(valid_formats.begin(),
|
||||
valid_formats.end(),
|
||||
request.output_format) != valid_formats.end();
|
||||
if (!valid_format) {
|
||||
error_message = "invalid output_format, must be one of [";
|
||||
for (size_t i = 0; i < valid_formats.size(); ++i) {
|
||||
if (i > 0) {
|
||||
error_message += ", ";
|
||||
}
|
||||
error_message += valid_formats[i];
|
||||
}
|
||||
error_message += "]";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool assign_output_options(VidGenJobRequest& request,
|
||||
std::string output_format,
|
||||
int output_compression,
|
||||
std::string& error_message) {
|
||||
request.output_format = normalize_output_format(std::move(output_format));
|
||||
request.output_compression = std::clamp(output_compression, 0, 100);
|
||||
|
||||
if (request.output_format == "avi") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.output_format == "webm") {
|
||||
#ifdef SD_USE_WEBM
|
||||
return true;
|
||||
#else
|
||||
error_message = valid_vid_output_formats_message();
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (request.output_format == "webp") {
|
||||
#ifdef SD_USE_WEBP
|
||||
return true;
|
||||
#else
|
||||
error_message = valid_vid_output_formats_message();
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
error_message = valid_vid_output_formats_message();
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string video_mime_type(const std::string& output_format) {
|
||||
if (output_format == "webm") {
|
||||
return "video/webm";
|
||||
}
|
||||
if (output_format == "webp") {
|
||||
return "image/webp";
|
||||
}
|
||||
return "video/x-msvideo";
|
||||
}
|
||||
|
||||
bool runtime_supports_generation_mode(const ServerRuntime& runtime, SDMode mode) {
|
||||
if (mode == VID_GEN) {
|
||||
return sd_ctx_supports_video_generation(runtime.sd_ctx);
|
||||
}
|
||||
if (mode == IMG_GEN) {
|
||||
return sd_ctx_supports_image_generation(runtime.sd_ctx);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string unsupported_generation_mode_error(SDMode mode) {
|
||||
if (mode == VID_GEN) {
|
||||
return "loaded model does not support vid_gen";
|
||||
}
|
||||
if (mode == IMG_GEN) {
|
||||
return "loaded model does not support img_gen";
|
||||
}
|
||||
return "loaded model does not support requested mode";
|
||||
}
|
||||
|
||||
ArgOptions SDSvrParams::get_options() {
|
||||
ArgOptions options;
|
||||
|
||||
options.string_options = {
|
||||
{"-l", "--listen-ip", "server listen ip (default: 127.0.0.1)", 0, &listen_ip},
|
||||
{"", "--serve-html-path", "path to HTML file to serve at root (optional)", 0, &serve_html_path},
|
||||
};
|
||||
|
||||
options.int_options = {
|
||||
{"", "--listen-port", "server listen port (default: 1234)", &listen_port},
|
||||
};
|
||||
|
||||
options.bool_options = {
|
||||
{"-v", "--verbose", "print extra info", true, &verbose},
|
||||
{"", "--color", "colors the logging tags according to level", true, &color},
|
||||
};
|
||||
|
||||
auto on_help_arg = [&](int, const char**, int, bool& valid) {
|
||||
normal_exit = true;
|
||||
valid = true;
|
||||
return -1;
|
||||
};
|
||||
|
||||
options.manual_options = {
|
||||
{"-h", "--help", "show this help message and exit", on_help_arg},
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
bool SDSvrParams::validate() {
|
||||
if (listen_ip.empty()) {
|
||||
LOG_ERROR("error: the following arguments are required: listen_ip");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listen_port < 0 || listen_port > 65535) {
|
||||
LOG_ERROR("error: listen_port should be in the range [0, 65535]");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!serve_html_path.empty() && !fs::exists(serve_html_path)) {
|
||||
LOG_ERROR("error: serve_html_path file does not exist: %s", serve_html_path.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SDSvrParams::resolve_and_validate() {
|
||||
if (!validate()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string SDSvrParams::to_string() const {
|
||||
std::ostringstream oss;
|
||||
oss << "SDSvrParams {\n"
|
||||
<< " listen_ip: " << listen_ip << ",\n"
|
||||
<< " listen_port: \"" << listen_port << "\",\n"
|
||||
<< " serve_html_path: \"" << serve_html_path << "\",\n"
|
||||
<< "}";
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
void refresh_lora_cache(ServerRuntime& rt) {
|
||||
std::vector<LoraEntry> new_cache;
|
||||
|
||||
fs::path lora_dir = rt.ctx_params->lora_model_dir;
|
||||
if (fs::exists(lora_dir) && fs::is_directory(lora_dir)) {
|
||||
for (auto& entry : fs::recursive_directory_iterator(lora_dir, fs::directory_options::skip_permission_denied)) {
|
||||
if (!entry.is_regular_file()) {
|
||||
continue;
|
||||
}
|
||||
const fs::path& p = entry.path();
|
||||
if (!is_supported_model_ext(p)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
LoraEntry lora_entry;
|
||||
lora_entry.name = p.stem().u8string();
|
||||
lora_entry.fullpath = p.u8string();
|
||||
std::string rel = p.lexically_relative(lora_dir).u8string();
|
||||
std::replace(rel.begin(), rel.end(), '\\', '/');
|
||||
lora_entry.path = rel;
|
||||
|
||||
new_cache.push_back(std::move(lora_entry));
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(new_cache.begin(), new_cache.end(), [](const LoraEntry& a, const LoraEntry& b) {
|
||||
return a.path < b.path;
|
||||
});
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*rt.lora_mutex);
|
||||
*rt.lora_cache = std::move(new_cache);
|
||||
}
|
||||
}
|
||||
|
||||
std::string get_lora_full_path(ServerRuntime& rt, const std::string& path) {
|
||||
std::lock_guard<std::mutex> lock(*rt.lora_mutex);
|
||||
auto it = std::find_if(rt.lora_cache->begin(), rt.lora_cache->end(),
|
||||
[&](const LoraEntry& entry) { return entry.path == path; });
|
||||
return it != rt.lora_cache->end() ? it->fullpath : "";
|
||||
}
|
||||
|
||||
void refresh_upscaler_cache(ServerRuntime& rt) {
|
||||
std::vector<UpscalerEntry> new_cache;
|
||||
|
||||
fs::path upscaler_dir = rt.ctx_params->hires_upscalers_dir;
|
||||
if (fs::exists(upscaler_dir) && fs::is_directory(upscaler_dir)) {
|
||||
for (auto& entry : fs::directory_iterator(upscaler_dir)) {
|
||||
if (!entry.is_regular_file()) {
|
||||
continue;
|
||||
}
|
||||
const fs::path& p = entry.path();
|
||||
if (!is_supported_model_ext(p)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
UpscalerEntry upscaler_entry;
|
||||
upscaler_entry.name = p.stem().u8string();
|
||||
upscaler_entry.fullpath = fs::absolute(p).lexically_normal().u8string();
|
||||
upscaler_entry.model_name = "ESRGAN_4x";
|
||||
upscaler_entry.path = p.filename().u8string();
|
||||
|
||||
new_cache.push_back(std::move(upscaler_entry));
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(new_cache.begin(), new_cache.end(), [](const UpscalerEntry& a, const UpscalerEntry& b) {
|
||||
return a.name < b.name;
|
||||
});
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*rt.upscaler_mutex);
|
||||
*rt.upscaler_cache = std::move(new_cache);
|
||||
}
|
||||
}
|
||||
|
||||
int64_t unix_timestamp_now() {
|
||||
return std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <json.hpp>
|
||||
#include "common/common.h"
|
||||
#include "common/resource_owners.hpp"
|
||||
#include "stable-diffusion.h"
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
struct ArgOptions;
|
||||
struct SDContextParams;
|
||||
struct AsyncJobManager;
|
||||
|
||||
struct SDSvrParams {
|
||||
std::string listen_ip = "127.0.0.1";
|
||||
int listen_port = 1234;
|
||||
std::string serve_html_path;
|
||||
bool normal_exit = false;
|
||||
bool verbose = false;
|
||||
bool color = false;
|
||||
|
||||
ArgOptions get_options();
|
||||
bool validate();
|
||||
bool resolve_and_validate();
|
||||
std::string to_string() const;
|
||||
};
|
||||
|
||||
struct LoraEntry {
|
||||
std::string name;
|
||||
std::string path;
|
||||
std::string fullpath;
|
||||
};
|
||||
|
||||
struct UpscalerEntry {
|
||||
std::string name;
|
||||
std::string path;
|
||||
std::string fullpath;
|
||||
std::string model_name;
|
||||
int scale = 4;
|
||||
};
|
||||
|
||||
struct ServerRuntime {
|
||||
sd_ctx_t* sd_ctx;
|
||||
std::mutex* sd_ctx_mutex;
|
||||
const SDSvrParams* svr_params;
|
||||
const SDContextParams* ctx_params;
|
||||
const SDGenerationParams* default_gen_params;
|
||||
std::vector<LoraEntry>* lora_cache;
|
||||
std::mutex* lora_mutex;
|
||||
std::vector<UpscalerEntry>* upscaler_cache;
|
||||
std::mutex* upscaler_mutex;
|
||||
AsyncJobManager* async_job_manager;
|
||||
};
|
||||
|
||||
struct ImgGenJobRequest {
|
||||
SDGenerationParams gen_params;
|
||||
std::string output_format = "png";
|
||||
int output_compression = 100;
|
||||
|
||||
sd_img_gen_params_t to_sd_img_gen_params_t() {
|
||||
return gen_params.to_sd_img_gen_params_t();
|
||||
}
|
||||
};
|
||||
|
||||
struct VidGenJobRequest {
|
||||
SDGenerationParams gen_params;
|
||||
std::string output_format = "webm";
|
||||
int output_compression = 100;
|
||||
|
||||
sd_vid_gen_params_t to_sd_vid_gen_params_t() {
|
||||
return gen_params.to_sd_vid_gen_params_t();
|
||||
}
|
||||
};
|
||||
|
||||
std::string base64_encode(const std::vector<uint8_t>& bytes);
|
||||
std::string normalize_output_format(std::string output_format);
|
||||
std::vector<std::string> supported_img_output_formats(bool allow_webp = true);
|
||||
std::vector<std::string> supported_vid_output_formats();
|
||||
bool assign_output_options(ImgGenJobRequest& request,
|
||||
std::string output_format,
|
||||
int output_compression,
|
||||
bool allow_webp,
|
||||
std::string& error_message);
|
||||
bool assign_output_options(VidGenJobRequest& request,
|
||||
std::string output_format,
|
||||
int output_compression,
|
||||
std::string& error_message);
|
||||
std::string video_mime_type(const std::string& output_format);
|
||||
bool runtime_supports_generation_mode(const ServerRuntime& runtime, SDMode mode);
|
||||
std::string unsupported_generation_mode_error(SDMode mode);
|
||||
void refresh_lora_cache(ServerRuntime& rt);
|
||||
std::string get_lora_full_path(ServerRuntime& rt, const std::string& path);
|
||||
void refresh_upscaler_cache(ServerRuntime& rt);
|
||||
int64_t unix_timestamp_now();
|
||||
Reference in New Issue
Block a user