Initial release

This commit is contained in:
civ
2026-08-16 18:27:57 +07:00
commit 8ff9ca0fc0
3800 changed files with 848933 additions and 0 deletions
+107
View File
@@ -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)
+126
View File
@@ -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 12 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
+364
View File
@@ -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, &params, &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, &params, &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);
}
}
}
+78
View File
@@ -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);
+3
View File
@@ -0,0 +1,3 @@
node_modules
.DS_Store
dist
+21
View File
@@ -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.
+126
View File
@@ -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
+13
View File
@@ -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

+13
View File
@@ -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>
+26
View File
@@ -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"
}
}
+732
View File
@@ -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}`);
+923
View File
@@ -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
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+56
View File
@@ -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);
}
+3
View File
@@ -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);
}
}
+181
View File
@@ -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];
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
createApp(App).mount("#app");
+927
View File
@@ -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;
}
}
+18
View File
@@ -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"]
}
+10
View File
@@ -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(),
],
});
+158
View File
@@ -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;
}
+11
View File
@@ -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);
+22
View File
@@ -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");
}
});
}
+395
View File
@@ -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");
}
});
}
+535
View File
@@ -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");
});
}
+601
View File
@@ -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");
});
}
+333
View File
@@ -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();
}
+100
View File
@@ -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();