feat(server): report generation stages independently of previews (#2303)

Co-authored-by: Maxime Dréan <maximedrean@proton.me>
This commit is contained in:
leejetandMaxime Dréan authored and GitHub committed 2026-11-09 02:32:27 +08:00
1 parent b43ce4badd
commit fc350be2ce
12 files changed
+259 -11

No files matched your search

+10
View File
@@ -76,6 +76,16 @@ Recipes declaring `video_frames` also switch the form to Video Generation;
other recipes switch to Image Generation. Generation is enabled after the
selected recipe is loaded. Changing a selection does not reload the model
until you click Load.
The generation status bar shows the current stage and elapsed time even when
Preview Mode is `none`. During sampling it shows completed steps for the current
pass; prompt encoding and VAE decoding show an activity indicator when their
total work is unknown. Sampling reaching 100% does not mean the job is finished:
decoding, ADetailer, and output encoding may still follow.
The canvas keeps the submitted output aspect ratio, and stage changes keep the
same layout. Editing the next request's resolution does not resize the current
preview or result.
The server can also start with the existing model arguments shown below;
existing API clients then work without selecting a recipe. The UI automatically
selects **Command-line model (all features)** and uses the startup generation
+52
View File
@@ -775,6 +775,7 @@ Field types:
| `queue_position` | `integer` |
| `result` | `object \| null` |
| `error` | `object \| null` |
| `progress` | `object` |
| `preview` | `object \| null` |
`created`, `started`, and `completed` are integer Unix timestamps in
@@ -782,6 +783,57 @@ Field types:
the elapsed generation time in seconds, including output encoding and excluding
queue time.
`progress` is available independently of the preview setting, for both image
and video jobs. Continue polling until `status` is terminal: a sampling pass
can finish before decoding, ADetailer, or output encoding finishes.
| Field | Type | Notes |
| --- | --- | --- |
| `progress.stage` | `string` | Current execution stage; see below |
| `progress.detail` | `string` | Stage detail, or an empty string |
| `progress.pass` | `integer` | Sampling pass number within the job; zero before sampling |
| `progress.completed` | `integer \| null` | Completed units in the current stage/pass |
| `progress.total` | `integer \| null` | Actual total units, or null when unknown |
| `progress.unit` | `string \| null` | `steps` for sampling, `images` for image output encoding; null when uncounted |
| `progress.stage_elapsed_ms` | `integer` | Elapsed time in the current stage/detail/pass |
| `progress.elapsed_ms` | `integer` | Job execution time, excluding queue time; zero before execution |
Stages include `queued`, `waiting` (waiting for the execution lock), `preparing`,
`conditioning`, `sampling`, `vae_encode`, `vae_decode`, `audio_decode`,
`upscaling`, `adetailer`, and `encoding`. Stages may repeat or be skipped.
Details include `positive_prompt` / `negative_prompt` / `image_condition` for
conditioning, `high_noise` / `low_noise` for two-stage sampling, and `image` /
`video` for output encoding. During ADetailer, `detail` identifies the inner
stage, such as `conditioning` or `sampling`. Clients should tolerate unknown
stage, detail, and unit values.
Sampling counts completed logical steps using the actual schedule, including
strength adjustments. Each new sampling pass increments `pass` and starts at
zero, including batch items, highres passes, high/low-noise passes, and ADetailer
passes. The ratio is **current-pass progress**, not overall job completion.
The progress snapshot is published atomically. Preview metadata still describes
the preview frame, which may be older than the current progress.
Unknown totals are represented by null counts, not an estimated percentage.
For example, prompt encoding can report:
```json
"progress": {
"stage": "conditioning",
"detail": "positive_prompt",
"pass": 0,
"completed": null,
"total": null,
"unit": null,
"stage_elapsed_ms": 3200,
"elapsed_ms": 4100
}
```
Elapsed times freeze when a job completes, fails, or is cancelled; the last
stage is retained for inspection. Use `status` to determine the final outcome.
Elapsed time alone does not indicate that computation is advancing.
`preview` sub-fields:
| Field | Type | Notes |
+89 -7
View File
@@ -92,6 +92,63 @@ static void set_preview_callback(AsyncGenerationJob& job) {
sd_set_preview_callback(preview_callback, mode, interval, true, false, &job);
}
static void update_job_progress(AsyncGenerationJob& job, const char* stage, const char* detail = "", int sample_pass = -1, int64_t completed = 0, int64_t total = 0, const char* unit = "") {
std::lock_guard<std::mutex> lock(job.progress_mutex);
auto& progress = job.progress;
if (sample_pass < 0) {
sample_pass = progress.sample_pass;
}
if (progress.stage != stage || progress.detail != detail || progress.sample_pass != sample_pass) {
progress.stage_started = std::chrono::steady_clock::now();
}
progress.stage = stage;
progress.detail = detail;
progress.sample_pass = sample_pass;
progress.completed = completed;
progress.total = total;
progress.unit = unit;
}
static void finish_job_progress(AsyncGenerationJob& job) {
std::lock_guard<std::mutex> lock(job.progress_mutex);
job.progress.finished = std::chrono::steady_clock::now();
}
class JobCallbacksGuard {
sd_ctx_t* ctx_;
AsyncGenerationJob& job_;
bool detailing_ = false;
static void progress_callback(const sd_generation_progress_t* progress, void* data) {
auto& guard = *static_cast<JobCallbacksGuard*>(data);
update_job_progress(guard.job_, guard.detailing_ ? "adetailer" : progress->stage,
guard.detailing_ ? progress->stage : progress->detail,
progress->sample_pass, progress->completed, progress->total, progress->unit);
}
public:
JobCallbacksGuard(sd_ctx_t* ctx, AsyncGenerationJob& job)
: ctx_(ctx), job_(job) {
clear_preview_callback();
set_preview_callback(job);
sd_set_generation_progress_callback(ctx_, progress_callback, this);
}
~JobCallbacksGuard() {
sd_set_generation_progress_callback(ctx_, nullptr, nullptr);
clear_preview_callback();
}
void begin_detailing() {
clear_preview_callback();
detailing_ = true;
update_job_progress(job_, "adetailer");
}
JobCallbacksGuard(const JobCallbacksGuard&) = delete;
JobCallbacksGuard& operator=(const JobCallbacksGuard&) = delete;
};
const char* async_job_kind_name(AsyncJobKind kind) {
switch (kind) {
case AsyncJobKind::ImgGen:
@@ -186,6 +243,7 @@ bool cancel_queued_job(AsyncJobManager& manager, AsyncGenerationJob& job) {
job.result_fps = 0;
job.error_code = "cancelled";
job.error_message = "job cancelled by client";
finish_job_progress(job);
return true;
}
@@ -200,6 +258,24 @@ json make_async_job_json(const AsyncJobManager& manager, const AsyncGenerationJo
result["completed"] = job.completed_at == 0 ? json(nullptr) : json(job.completed_at);
result["queue_position"] = 0;
{
std::lock_guard<std::mutex> lock(job.progress_mutex);
const auto& progress = job.progress;
const auto end = progress.finished == std::chrono::steady_clock::time_point{}
? std::chrono::steady_clock::now()
: progress.finished;
result["progress"] = {
{"stage", progress.stage},
{"detail", progress.detail},
{"pass", progress.sample_pass},
{"completed", progress.total > 0 ? json(progress.completed) : json(nullptr)},
{"total", progress.total > 0 ? json(progress.total) : json(nullptr)},
{"unit", progress.unit.empty() ? json(nullptr) : json(progress.unit)},
{"stage_elapsed_ms", std::chrono::duration_cast<std::chrono::milliseconds>(end - progress.stage_started).count()},
{"elapsed_ms", job.started_at == 0 ? 0 : std::max<int64_t>(0, (job.completed_at == 0 ? unix_timestamp_now_ms() : job.completed_at) - job.started_at)},
};
}
if (job.status == AsyncJobStatus::Queued) {
size_t position = 1;
for (const auto& queued_id : manager.queue) {
@@ -278,17 +354,19 @@ bool execute_img_gen_job(ServerRuntime& runtime,
return false;
}
JobCancellationMonitor cancellation(runtime.sd_ctx, job);
set_preview_callback(job);
JobCallbacksGuard callbacks(runtime.sd_ctx, job);
sd_image_generation_result_t* generated = nullptr;
generate_image(runtime.sd_ctx, &params, &generated);
results.adopt(generated, runtime.ctx_params->lora_model_dir);
clear_preview_callback();
if (job.cancel_requested.load()) {
return false;
}
if (!results.empty() && !apply_server_adetailer(runtime, job.img_gen, params, results)) {
error_message = "ADetailer failed";
return false;
if (!results.empty() && job.img_gen.adetailer_enabled && !runtime.default_gen_params->ad_model_path.empty()) {
callbacks.begin_detailing();
if (!apply_server_adetailer(runtime, job.img_gen, params, results)) {
error_message = "ADetailer failed";
return false;
}
}
}
@@ -305,6 +383,7 @@ bool execute_img_gen_job(ServerRuntime& runtime,
encoded_format = EncodedImageFormat::WEBP;
}
update_job_progress(job, "encoding", "image", -1, 0, num_results, "images");
for (int i = 0; i < num_results; ++i) {
if (results[i].data == nullptr) {
continue;
@@ -325,6 +404,7 @@ bool execute_img_gen_job(ServerRuntime& runtime,
}
output_images.push_back(base64_encode(image_bytes));
output_info.push_back(json::parse(results.generation_info(i)));
update_job_progress(job, "encoding", "image", -1, i + 1, num_results, "images");
}
if (output_images.empty()) {
@@ -356,7 +436,7 @@ bool execute_vid_gen_job(ServerRuntime& runtime,
return false;
}
JobCancellationMonitor cancellation(runtime.sd_ctx, job);
set_preview_callback(job);
JobCallbacksGuard callbacks(runtime.sd_ctx, job);
sd_video_generation_result_t* generated = nullptr;
if (generate_video(runtime.sd_ctx, &params, &generated)) {
output_fps = generated->fps;
@@ -367,7 +447,6 @@ bool execute_vid_gen_job(ServerRuntime& runtime,
if (job.vid_gen.gen_params.embed_image_metadata)
str_params = results.generation_info(0);
}
clear_preview_callback();
}
num_results = results.count();
@@ -377,6 +456,7 @@ bool execute_vid_gen_job(ServerRuntime& runtime,
return false;
}
update_job_progress(job, "encoding", "video");
std::vector<uint8_t> video_bytes = create_video_from_sd_images_to_vector(job.vid_gen.output_format,
results.data(),
num_results,
@@ -425,6 +505,7 @@ void async_job_worker(ServerRuntime& runtime) {
job = it->second;
job->status = AsyncJobStatus::Generating;
job->started_at = unix_timestamp_now_ms();
update_job_progress(*job, "waiting");
}
std::vector<std::string> output_images;
@@ -459,6 +540,7 @@ void async_job_worker(ServerRuntime& runtime) {
}
job->completed_at = unix_timestamp_now_ms();
finish_job_progress(*job);
if (job->cancel_requested.load()) {
job->status = AsyncJobStatus::Cancelled;
job->error_code = "cancelled";
+14
View File
@@ -1,6 +1,7 @@
#pragma once
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <deque>
@@ -29,6 +30,17 @@ enum class AsyncJobStatus {
const char* async_job_kind_name(AsyncJobKind kind);
const char* async_job_status_name(AsyncJobStatus status);
struct AsyncJobProgress {
std::string stage = "queued";
std::string detail;
std::string unit;
int sample_pass = 0;
int64_t completed = 0;
int64_t total = 0;
std::chrono::steady_clock::time_point stage_started = std::chrono::steady_clock::now();
std::chrono::steady_clock::time_point finished{};
};
struct AsyncGenerationJob {
std::string id;
AsyncJobKind kind = AsyncJobKind::ImgGen;
@@ -47,6 +59,8 @@ struct AsyncGenerationJob {
int result_fps = 0;
std::string error_code;
std::string error_message;
mutable std::mutex progress_mutex;
AsyncJobProgress progress;
mutable std::mutex preview_mutex;
std::string preview_b64;
int preview_pass = 0;
+14
View File
@@ -500,6 +500,15 @@ struct ggml_tensor;
typedef void (*sd_log_cb_t)(enum sd_log_level_t level, const char* text, void* data);
typedef void (*sd_progress_cb_t)(int step, int steps, float time, void* data);
typedef struct {
const char* stage;
const char* detail;
const char* unit;
int sample_pass;
int64_t completed;
int64_t total;
} sd_generation_progress_t;
typedef void (*sd_generation_progress_cb_t)(const sd_generation_progress_t* progress, void* data);
typedef void (*sd_preview_cb_t)(int step, int frame_count, sd_image_t* frames, bool is_noisy, void* data);
typedef struct {
@@ -510,6 +519,11 @@ typedef bool (*sd_graph_eval_callback_t)(struct ggml_tensor* t, bool ask, void*
SD_API void sd_set_log_callback(sd_log_cb_t sd_log_cb, void* data);
SD_API void sd_set_progress_callback(sd_progress_cb_t cb, void* data);
// Context-local generation events, independent of preview and the legacy progress callback.
// Strings and the event are valid only during the synchronous callback. Callbacks must not throw.
// total == 0 means unknown; sample_pass counts from 1 since callback registration.
// Register/clear only while the context is idle. Passing nullptr clears the callback.
SD_API void sd_set_generation_progress_callback(sd_ctx_t* ctx, sd_generation_progress_cb_t cb, void* data);
// In each sampling pass, a positive interval previews every Nth denoiser step, while a
// negative interval previews only completed logical step -interval. Zero previews the final
// completed step of the first sampling pass (base-resolution or high-noise).
+8
View File
@@ -1807,6 +1807,7 @@ void StableDiffusionGGML::lora_stat() {
}
bool StableDiffusionGGML::apply_loras(const sd_lora_t* loras, uint32_t lora_count) {
generation_progress.report("preparing", "loras");
std::vector<ModelManager::LoraSpec> all_loras;
all_loras.reserve(lora_count);
for (uint32_t i = 0; i < lora_count; i++) {
@@ -2243,6 +2244,7 @@ void StableDiffusionGGML::report_sample_progress(int step,
? (now - *last_progress_us) / 1000000.f
: 0.f;
pretty_progress(showstep, (int)total_steps, step_seconds);
generation_progress.report_sample(showstep, static_cast<int64_t>(total_steps));
if (last_progress_us != nullptr) {
*last_progress_us = now;
}
@@ -2424,6 +2426,9 @@ sd::Tensor<float> StableDiffusionGGML::sample(const std::shared_ptr<DiffusionMod
int64_t last_progress_us = ggml_time_us();
sd_begin_preview_pass(static_cast<int>(steps));
generation_progress.begin_sample(static_cast<int64_t>(steps),
work_diffusion_model == high_noise_diffusion_model ? "high_noise" : high_noise_diffusion_model != nullptr ? "low_noise"
: "");
SamplePreviewContext preview = prepare_sample_preview_context();
sd::Tensor<float> processed_init_latent = denoiser->process_latent_in(init_latent);
@@ -2995,6 +3000,7 @@ int StableDiffusionGGML::align_video_frames(int frames) {
}
sd::Tensor<float> StableDiffusionGGML::encode_to_vae_latents(const sd::Tensor<float>& x) {
generation_progress.report("vae_encode");
auto latents = first_stage_model->encode(n_threads, x, vae_tiling_params, circular_x, circular_y);
if (latents.empty()) {
return {};
@@ -3015,6 +3021,7 @@ sd::Tensor<float> StableDiffusionGGML::encode_first_stage(const sd::Tensor<float
}
sd::Tensor<float> StableDiffusionGGML::decode_first_stage(const sd::Tensor<float>& x, bool decode_video) {
generation_progress.report("vae_decode");
if (sd_version_is_pid(version) || sd_version_is_minit2i(version) || version == VERSION_IRIS) {
return sd::ops::clamp((x + 1.f) * 0.5f, 0.0f, 1.0f);
}
@@ -3069,6 +3076,7 @@ sd::Tensor<float> StableDiffusionGGML::decode_ltx_audio_latent(const sd::Tensor<
if (audio_vae_model == nullptr || audio_latent.empty()) {
return {};
}
generation_progress.report("audio_decode");
auto waveform = audio_vae_model->decode(n_threads, audio_latent);
return waveform;
}
+5
View File
@@ -21,6 +21,7 @@
#include "model/adapter/lora.hpp"
#include "model_builders.h"
#include "model_manager.h"
#include "runtime/generation_progress.h"
#include "stable-diffusion.h"
class RNG;
@@ -199,6 +200,7 @@ public:
std::unique_ptr<ConditioningCache> conditioning_cache_;
std::vector<ModelManager::LoraSpec> conditioning_loras_;
bool executing_ = false;
sd::GenerationProgress generation_progress;
std::shared_ptr<Denoiser> denoiser;
std::vector<float> file_alphas_cumprod;
@@ -254,6 +256,9 @@ public:
explicit ExecutionScope(StableDiffusionGGML& sd)
: operation(sd) {
if (operation.acquired) {
sd.generation_progress.report("preparing");
}
ready = operation.acquired && sd.refresh_model_sources();
}
+8 -1
View File
@@ -531,6 +531,7 @@ namespace sd::pipeline {
condition_params.ref_image_params = ref_image_params;
sd->generation_progress.report("conditioning", "extensions");
sd->prepare_generation_extensions(request->pm_params,
request->pulid_params,
condition_params,
@@ -538,7 +539,8 @@ namespace sd::pipeline {
sd->compute_ip_adapter_tokens(sd_img_gen_params->ip_adapter_image, sd_img_gen_params->ip_adapter_strength);
int64_t prepare_start_ms = ggml_time_ms();
condition_params.zero_out_masked = false;
auto cond = sd->get_learned_condition(condition_params);
sd->generation_progress.report("conditioning", "positive_prompt");
auto cond = sd->get_learned_condition(condition_params);
if (cond.empty()) {
LOG_ERROR("failed to encode prompt");
return std::nullopt;
@@ -584,6 +586,7 @@ namespace sd::pipeline {
// Keep source latents in CFG, but omit source images from the text encoder.
condition_params.ref_images = nullptr;
}
sd->generation_progress.report("conditioning", "negative_prompt");
uncond = sd->get_learned_condition(condition_params);
if (uncond.empty()) {
LOG_ERROR("failed to encode negative prompt");
@@ -615,6 +618,7 @@ namespace sd::pipeline {
if (use_ref_latent_img_cfg) {
condition_params.ref_images = &empty_ref_images;
}
sd->generation_progress.report("conditioning", "image_condition");
img_uncond = sd->get_learned_condition(condition_params);
if (img_uncond.empty()) {
LOG_ERROR("failed to encode image guidance prompt");
@@ -830,6 +834,7 @@ namespace sd::pipeline {
return {};
}
sd->generation_progress.report("upscaling", "hires");
sd::Tensor<float> upscaled_tensor;
if (request.hires.upscaler == SD_HIRES_UPSCALER_MODEL) {
upscaled_tensor = upscaler->upscale_tensor(decoded);
@@ -930,6 +935,7 @@ namespace sd::pipeline {
ImageVaeAxesGuard axes_guard(sd, sd_img_gen_params, request);
SamplePlan plan(sd, sd_img_gen_params, request);
sd->generation_progress.report("preparing", "inputs");
auto latents_opt = prepare_image_generation_latents(sd,
sd_img_gen_params,
&request,
@@ -1027,6 +1033,7 @@ namespace sd::pipeline {
}
LOG_INFO("hires fix: upscaling to %dx%d", request.hires.target_width, request.hires.target_height);
sd->generation_progress.report("upscaling", "hires");
std::unique_ptr<UpscalerGGML> hires_upscaler;
if (request.hires.upscaler == SD_HIRES_UPSCALER_MODEL) {
if (sd->get_cancel_flag() == SD_CANCEL_ALL) {
+6 -2
View File
@@ -1210,7 +1210,8 @@ namespace sd::pipeline {
condition_params.ref_image_params = ref_image_params;
int64_t prepare_start_ms = ggml_time_ms();
embeds.cond = sd->get_learned_condition(condition_params);
sd->generation_progress.report("conditioning", "positive_prompt");
embeds.cond = sd->get_learned_condition(condition_params);
if (embeds.cond.empty()) {
LOG_ERROR("failed to encode video prompt");
return std::nullopt;
@@ -1235,7 +1236,8 @@ namespace sd::pipeline {
}
if (request.use_uncond) {
condition_params.text = request.negative_prompt;
embeds.uncond = sd->get_learned_condition(condition_params);
sd->generation_progress.report("conditioning", "negative_prompt");
embeds.uncond = sd->get_learned_condition(condition_params);
if (embeds.uncond.empty()) {
LOG_ERROR("failed to encode negative video prompt");
return std::nullopt;
@@ -1901,6 +1903,7 @@ namespace sd::pipeline {
sd->reset_generation_extensions();
SamplePlan plan(sd, sd_vid_gen_params, request);
sd->generation_progress.report("preparing", "inputs");
auto latent_inputs_opt = prepare_video_generation_latents(sd, sd_vid_gen_params, &request, ref_image_params);
if (!latent_inputs_opt.has_value()) {
return false;
@@ -2029,6 +2032,7 @@ namespace sd::pipeline {
LOG_ERROR("cancelling generation before latent upscale");
return false;
}
sd->generation_progress.report("upscaling", "hires");
int64_t upscale_start = ggml_time_ms();
sd::Tensor<float> upscaled_latent;
if (sd_version_is_ltxav(sd->version)) {
+42
View File
@@ -0,0 +1,42 @@
#ifndef __SD_RUNTIME_GENERATION_PROGRESS_H__
#define __SD_RUNTIME_GENERATION_PROGRESS_H__
#include "stable-diffusion.h"
namespace sd {
class GenerationProgress {
sd_generation_progress_cb_t callback_ = nullptr;
void* data_ = nullptr;
int sample_pass_ = 0;
const char* sample_detail_ = "";
public:
void set_callback(sd_generation_progress_cb_t callback, void* data) {
callback_ = callback;
data_ = data;
sample_pass_ = 0;
sample_detail_ = "";
}
void report(const char* stage, const char* detail = "", int64_t completed = 0, int64_t total = 0, const char* unit = "") const {
if (callback_ != nullptr) {
const sd_generation_progress_t progress{stage, detail, unit, sample_pass_, completed, total};
callback_(&progress, data_);
}
}
void begin_sample(int64_t total, const char* detail) {
++sample_pass_;
sample_detail_ = detail;
report_sample(0, total);
}
void report_sample(int64_t completed, int64_t total) const {
report("sampling", sample_detail_, completed, total, "steps");
}
};
} // namespace sd
#endif // __SD_RUNTIME_GENERATION_PROGRESS_H__
+10
View File
@@ -698,6 +698,16 @@ struct sd_ctx_t {
StableDiffusionGGML* sd = nullptr;
};
void sd_set_generation_progress_callback(sd_ctx_t* ctx, sd_generation_progress_cb_t cb, void* data) {
if (ctx == nullptr || ctx->sd == nullptr) {
return;
}
std::lock_guard<std::recursive_mutex> lock(ctx->sd->execution_mutex);
if (!ctx->sd->executing_) {
ctx->sd->generation_progress.set_callback(cb, data);
}
}
sd_ctx_t* new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) {
sd_ctx_t* sd_ctx = (sd_ctx_t*)malloc(sizeof(sd_ctx_t));
if (sd_ctx == nullptr) {