From 921871c454f9ac8ecd7baf3b7711458d94d54224 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 9 Apr 2026 16:19:12 +0100 Subject: [PATCH 01/30] chore[bc]: remove BaseInference inheritance from diffusion addon Replace class inheritance with composable utilities from @qvac/infer-base@0.4.0: - createJobHandler() for single-job lifecycle management - exclusiveRunQueue() for run serialization Constructor now takes { files: { model, clipL?, clipG?, t5Xxl?, llm?, vae? }, config, logger, opts } instead of { diskPath, modelName, clipLModel, ... } + config. All examples and tests updated to new constructor shape. --- .../examples/generate-image-sd2.js | 19 +- .../examples/generate-image-sd3.js | 19 +- .../examples/generate-image-sdxl.js | 19 +- .../examples/generate-image.js | 19 +- .../examples/load-model.js | 19 +- .../examples/quickstart.js | 17 +- .../examples/runtime-stats-sd2.js | 17 +- packages/lib-infer-diffusion/index.d.ts | 117 ++------ packages/lib-infer-diffusion/index.js | 258 ++++++------------ packages/lib-infer-diffusion/package.json | 3 +- .../test/integration/api-behavior.test.js | 16 +- .../integration/generate-image-flux2.test.js | 19 +- .../integration/generate-image-sd3.test.js | 15 +- .../integration/generate-image-sdxl.test.js | 16 +- .../test/integration/generate-image.test.js | 15 +- .../test/integration/model-loading.test.js | 9 +- 16 files changed, 220 insertions(+), 377 deletions(-) diff --git a/packages/lib-infer-diffusion/examples/generate-image-sd2.js b/packages/lib-infer-diffusion/examples/generate-image-sd2.js index c0ddb790bd..ea0eb80ae5 100644 --- a/packages/lib-infer-diffusion/examples/generate-image-sd2.js +++ b/packages/lib-infer-diffusion/examples/generate-image-sd2.js @@ -49,21 +49,20 @@ async function main () { console.log('Seed :', SEED) console.log() - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: MODELS_DIR, - modelName: MODEL_NAME - // No llmModel — SD2.1 uses the CLIP text encoder baked into the checkpoint. - // No vaeModel — the VAE is baked into the checkpoint. + const model = new ImgStableDiffusion({ + files: { + model: path.join(MODELS_DIR, MODEL_NAME) + // No llm — SD2.1 uses the CLIP text encoder baked into the checkpoint. + // No vae — the VAE is baked into the checkpoint. }, - { + config: { threads: 8, // SD2.1 uses v-prediction. This safetensors file has no GGUF metadata so // auto-detection cannot determine the prediction type; set it explicitly. prediction: 'v' - } - ) + }, + logger: console + }) try { // ── 1. Load weights ─────────────────────────────────────────────────────── diff --git a/packages/lib-infer-diffusion/examples/generate-image-sd3.js b/packages/lib-infer-diffusion/examples/generate-image-sd3.js index fcd6a8788f..a71c5cc927 100644 --- a/packages/lib-infer-diffusion/examples/generate-image-sd3.js +++ b/packages/lib-infer-diffusion/examples/generate-image-sd3.js @@ -54,24 +54,23 @@ async function main () { console.log('Seed :', SEED) console.log() - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: MODELS_DIR, - modelName: MODEL_NAME - // All-in-one safetensors: no clipLModel, clipGModel, t5XxlModel, or vaeModel. + const model = new ImgStableDiffusion({ + files: { + model: path.join(MODELS_DIR, MODEL_NAME) + // All-in-one safetensors: no clipL, clipG, t5Xxl, or vae. // // To add T5-XXL (better text following) without redownloading the main file: - // t5XxlModel: 't5xxl_fp8_e4m3fn.safetensors' // download via download-model-sd3.sh + // t5Xxl: path.join(MODELS_DIR, 't5xxl_fp8_e4m3fn.safetensors') // download via download-model-sd3.sh }, - { + config: { threads: 4, // SD3 uses flow-matching. The safetensors metadata allows auto-detection, // but we set these explicitly as safety overrides. prediction: 'flow', // FLOW_PRED — SD3 flow-matching flow_shift: '3.0' // SD3 Medium default; overrides INFINITY sentinel - } - ) + }, + logger: console + }) try { // ── 1. Load weights ─────────────────────────────────────────────────────── diff --git a/packages/lib-infer-diffusion/examples/generate-image-sdxl.js b/packages/lib-infer-diffusion/examples/generate-image-sdxl.js index ea730dd00f..bea89f5b6a 100644 --- a/packages/lib-infer-diffusion/examples/generate-image-sdxl.js +++ b/packages/lib-infer-diffusion/examples/generate-image-sdxl.js @@ -51,20 +51,19 @@ async function main () { console.log('Seed :', SEED) console.log() - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: MODELS_DIR, - modelName: MODEL_NAME - // No llmModel — SDXL uses CLIP-L + CLIP-G baked into the checkpoint. - // No vaeModel — the VAE is baked into the checkpoint. + const model = new ImgStableDiffusion({ + files: { + model: path.join(MODELS_DIR, MODEL_NAME) + // No llm — SDXL uses CLIP-L + CLIP-G baked into the checkpoint. + // No vae — the VAE is baked into the checkpoint. }, - { + config: { threads: 4 // No prediction override — SDXL uses eps-prediction and the GGUF // has the correct metadata for auto-detection. - } - ) + }, + logger: console + }) try { // ── 1. Load weights ─────────────────────────────────────────────────────── diff --git a/packages/lib-infer-diffusion/examples/generate-image.js b/packages/lib-infer-diffusion/examples/generate-image.js index 05ac42c39b..a6b8c0feb2 100644 --- a/packages/lib-infer-diffusion/examples/generate-image.js +++ b/packages/lib-infer-diffusion/examples/generate-image.js @@ -41,18 +41,17 @@ async function main () { console.log('Seed :', SEED) console.log() - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: MODELS_DIR, - modelName: MODEL_NAME, - llmModel: LLM_MODEL, - vaeModel: VAE_MODEL + const model = new ImgStableDiffusion({ + files: { + model: path.join(MODELS_DIR, MODEL_NAME), + llm: path.join(MODELS_DIR, LLM_MODEL), + vae: path.join(MODELS_DIR, VAE_MODEL) }, - { + config: { threads: 4 - } - ) + }, + logger: console + }) try { // ── 1. Load weights ─────────────────────────────────────────────────────── diff --git a/packages/lib-infer-diffusion/examples/load-model.js b/packages/lib-infer-diffusion/examples/load-model.js index cb15e90e06..c3a264bc83 100644 --- a/packages/lib-infer-diffusion/examples/load-model.js +++ b/packages/lib-infer-diffusion/examples/load-model.js @@ -24,18 +24,17 @@ async function main () { console.log() // ── 1. Construct — stores config, allocates nothing ──────────────────────── - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: MODELS_DIR, - modelName: MODEL_NAME, - llmModel: LLM_MODEL, - vaeModel: VAE_MODEL + const model = new ImgStableDiffusion({ + files: { + model: path.join(MODELS_DIR, MODEL_NAME), + llm: path.join(MODELS_DIR, LLM_MODEL), + vae: path.join(MODELS_DIR, VAE_MODEL) }, - { + config: { threads: 8 // Metal handles GPU; threads are for CPU fallback ops - } - ) + }, + logger: console + }) try { // ── 2. Load — reads weights into memory via activate() → new_sd_ctx() ─── diff --git a/packages/lib-infer-diffusion/examples/quickstart.js b/packages/lib-infer-diffusion/examples/quickstart.js index b1e9fa910b..d163f25a6c 100644 --- a/packages/lib-infer-diffusion/examples/quickstart.js +++ b/packages/lib-infer-diffusion/examples/quickstart.js @@ -46,19 +46,18 @@ async function main () { console.log(`Model : ${MODEL_NAME}`) console.log(`Prompt: ${PROMPT}\n`) - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: MODELS_DIR, - modelName: MODEL_NAME, - opts: { stats: true } + const model = new ImgStableDiffusion({ + files: { + model: path.join(MODELS_DIR, MODEL_NAME) }, - { + config: { threads: 4, prediction: 'v', verbosity: 2 - } - ) + }, + logger: console, + opts: { stats: true } + }) try { // 3. Load model weights diff --git a/packages/lib-infer-diffusion/examples/runtime-stats-sd2.js b/packages/lib-infer-diffusion/examples/runtime-stats-sd2.js index 8547fac071..dc48706eae 100644 --- a/packages/lib-infer-diffusion/examples/runtime-stats-sd2.js +++ b/packages/lib-infer-diffusion/examples/runtime-stats-sd2.js @@ -59,18 +59,17 @@ async function main () { console.log('Seed :', SEED) console.log() - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: MODELS_DIR, - modelName: MODEL_NAME, - opts: { stats: true } + const model = new ImgStableDiffusion({ + files: { + model: path.join(MODELS_DIR, MODEL_NAME) }, - { + config: { threads: 4, prediction: 'v' - } - ) + }, + logger: console, + opts: { stats: true } + }) try { // ── 1. Load weights ───────────────────────────────────────────────────── diff --git a/packages/lib-infer-diffusion/index.d.ts b/packages/lib-infer-diffusion/index.d.ts index 6d3897d831..ec004332b1 100644 --- a/packages/lib-infer-diffusion/index.d.ts +++ b/packages/lib-infer-diffusion/index.d.ts @@ -1,4 +1,3 @@ -import BaseInference from '@qvac/infer-base/WeightsProvider/BaseInference' import type { QvacResponse } from '@qvac/infer-base' import type QvacLogger from '@qvac/logging' @@ -11,7 +10,6 @@ export interface Addon { unload(): Promise } -/** Supported diffusion sampling methods */ export type SamplerMethod = | 'euler' | 'euler_a' @@ -28,7 +26,6 @@ export type SamplerMethod = | 'res_multistep' | 'res_2s' -/** Supported weight quantization types */ export type WeightType = | 'auto' | 'f32' @@ -45,10 +42,8 @@ export type WeightType = | 'q6_k' | 'q8_0' -/** Supported RNG types */ export type RngType = 'cpu' | 'cuda' | 'std_default' -/** Supported sampling schedules */ export type ScheduleType = | 'discrete' | 'karras' @@ -62,172 +57,106 @@ export type ScheduleType = | 'kl_optimal' | 'bong_tangent' -/** Supported noise prediction types */ export type PredictionType = 'auto' | 'eps' | 'v' | 'edm_v' | 'flow' | 'flux_flow' | 'flux2_flow' -/** LoRA application mode */ export type LoraApplyMode = 'auto' | 'immediately' | 'at_runtime' -/** Step-caching algorithm */ export type CacheMode = 'disabled' | 'easycache' | 'ucache' | 'dbcache' | 'taylorseer' | 'cache-dit' export interface SdConfig { - /** Number of CPU threads (-1 = auto) */ threads?: NumericLike - /** Preferred compute device: 'gpu' (Metal/Vulkan) or 'cpu' */ device?: 'gpu' | 'cpu' - /** Weight quantization type */ type?: WeightType - /** RNG type for reproducible generation */ rng?: RngType - /** RNG type for the sampler (separate from context RNG) */ sampler_rng?: RngType - /** Run CLIP encoder on CPU even when GPU is available */ clip_on_cpu?: boolean - /** Run VAE decoder on CPU even when GPU is available */ vae_on_cpu?: boolean - /** Enable VAE tiling to reduce VRAM usage */ vae_tiling?: boolean - /** Enable flash attention for memory efficiency */ flash_attn?: boolean - /** Enable flash attention for diffusion model specifically */ diffusion_fa?: boolean - /** Use memory-mapped model loading */ mmap?: boolean - /** Offload model weights to CPU when not in use */ offload_to_cpu?: boolean - /** Noise prediction type override (auto-detected from model by default) */ prediction?: PredictionType - /** Flow-matching guidance shift */ flow_shift?: number - /** Use direct convolution in diffusion model */ diffusion_conv_direct?: boolean - /** Use direct convolution in VAE */ vae_conv_direct?: boolean - /** Force SDXL VAE conv scale factor */ force_sdxl_vae_conv_scale?: boolean - /** Custom backends directory path (defaults to prebuilds/) */ backendsDir?: string - /** Custom tensor type rules string */ tensor_type_rules?: string - /** LoRA application mode */ lora_apply_mode?: LoraApplyMode - /** Logging verbosity: 0=error, 1=warn, 2=info, 3=debug */ verbosity?: NumericLike [key: string]: string | number | boolean | undefined } +export interface DiffusionFiles { + model: string + clipL?: string + clipG?: string + t5Xxl?: string + llm?: string + vae?: string +} + +export interface ImgStableDiffusionArgs { + files: DiffusionFiles + config: SdConfig + logger?: QvacLogger | Console | null + opts?: { stats?: boolean } +} + export interface GenerationParams { prompt: string negative_prompt?: string width?: number height?: number steps?: number - /** CFG scale (SD1/SD2/SDXL/SD3) */ cfg_scale?: number - /** Distilled guidance (FLUX.2) */ guidance?: number - /** Sampler name (e.g. 'euler', 'dpm++2m') */ sampling_method?: SamplerMethod - /** Alias for sampling_method — accepted by the C++ layer */ sampler?: SamplerMethod - /** Scheduler name */ scheduler?: ScheduleType seed?: number batch_count?: number - /** Enable VAE tiling (for large images) */ vae_tiling?: boolean - /** VAE tile dimensions — integer or 'WxH' string (e.g. '512x512') */ vae_tile_size?: number | string - /** VAE tile overlap fraction (0.0–1.0) */ vae_tile_overlap?: number - /** Step-caching algorithm */ cache_mode?: CacheMode - /** Cache preset: slow/medium/fast/ultra (shorthand for cache_mode + threshold) */ cache_preset?: string - /** Direct cache reuse threshold override (0 = library default) */ cache_threshold?: number - /** Stochasticity parameter for DDIM/TCD samplers */ eta?: number - /** Image CFG scale for img2img/inpaint (-1 = use cfg_scale) */ img_cfg_scale?: number - /** Skip last N CLIP encoder layers (SD1.x/SD2.x) */ clip_skip?: number - /** Input image as PNG/JPEG bytes for img2img (not yet supported — throws at runtime) */ init_image?: Uint8Array - /** img2img denoising strength (0.0–1.0). 0 = keep source, 1 = ignore source (not yet supported) */ strength?: number } -/** - * Shape of the stats object emitted on the 'stats' event of a QvacResponse. - * - * All time values are in milliseconds. Cumulative fields (totalGenerationMs, - * totalWallMs, totalSteps, totalGenerations, totalImages, totalPixels) accumulate - * across the lifetime of the model instance; per-job fields (generationMs, width, - * height, seed) reflect only the most recent generation. - * - * Derivable rates (stepsPerSecond, msPerStep, megapixelsPerSecond) are intentionally - * omitted — callers can compute them from the primitives provided: - * stepsPerSecond = totalSteps / (totalWallMs / 1000) - * msPerStep = totalWallMs / totalSteps - * megapixelsPerSec = (totalPixels / 1e6) / (totalWallMs / 1000) - */ export interface RuntimeStats { - /** Wall time to load the model weights (ms) */ modelLoadMs: number - /** Wall time for the most recent generation job (ms) */ generationMs: number - /** Cumulative generation time across all jobs (ms) */ totalGenerationMs: number - /** Cumulative wall time across all jobs (ms) */ totalWallMs: number - /** Cumulative diffusion steps across all jobs */ totalSteps: number - /** Cumulative number of generation calls */ totalGenerations: number - /** Cumulative number of images produced */ totalImages: number - /** Cumulative number of pixels produced */ totalPixels: number - /** Width of the most recent generated image (px) */ width: number - /** Height of the most recent generated image (px) */ height: number - /** Seed used for the most recent generation */ seed: number } -export interface ImgStableDiffusionArgs { - logger?: QvacLogger | Console | null - opts?: { stats?: boolean } - diskPath?: string - modelName: string - /** FLUX.1 / SD3: separate CLIP-L text encoder */ - clipLModel?: string - /** SDXL / SD3: separate CLIP-G text encoder */ - clipGModel?: string - /** FLUX.1 / SD3: separate T5-XXL text encoder */ - t5XxlModel?: string - /** FLUX.2 [klein]: Qwen3 4B text encoder (llm_path) */ - llmModel?: string - vaeModel?: string -} - -export default class ImgStableDiffusion extends BaseInference { - protected addon: Addon +export default class ImgStableDiffusion { + protected addon: Addon | null + opts: { stats?: boolean } + logger: QvacLogger + state: { configLoaded: boolean } - constructor(args: ImgStableDiffusionArgs, config: SdConfig) - - _load(): Promise + constructor(args: ImgStableDiffusionArgs) load(): Promise - run(params: GenerationParams): Promise - unload(): Promise - cancel(): Promise + getState(): { configLoaded: boolean } } export { QvacResponse, RuntimeStats } diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 64a6d12056..1d479b425e 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -1,106 +1,61 @@ 'use strict' -const path = require('bare-path') - -const BaseInference = require('@qvac/infer-base/WeightsProvider/BaseInference') +const QvacLogger = require('@qvac/logging') +const { createJobHandler, exclusiveRunQueue } = require('@qvac/infer-base') const { SdInterface } = require('./addon') const LOG_METHODS = ['error', 'warn', 'info', 'debug'] const RUN_BUSY_ERROR_MESSAGE = 'Cannot set new job: a job is already set or being processed' -/** - * Text-to-image and image-to-image generation using stable-diffusion.cpp. - * Supports SD1.x, SD2.x, SDXL, SD3, and FLUX.2 [klein]. - */ -class ImgStableDiffusion extends BaseInference { - /** - * @param {object} args - * @param {object} [args.logger] - Structured logger - * @param {object} [args.opts] - Optional inference options - * @param {string} [args.diskPath='.'] - Local directory containing model weight files - * @param {string} args.modelName - Model file name (e.g. 'flux1-dev-q4_0.gguf') - * @param {string} [args.clipLModel] - Optional CLIP-L model file name (FLUX.1 / SD3) - * @param {string} [args.clipGModel] - Optional CLIP-G model file name (SDXL / SD3) - * @param {string} [args.t5XxlModel] - Optional T5-XXL text encoder file name (FLUX.1 / SD3) - * @param {string} [args.llmModel] - Optional LLM text encoder file name (FLUX.2 klein → Qwen3 4B) - * @param {string} [args.vaeModel] - Optional VAE file name - * @param {object} config - SD context configuration (threads, device, type, etc.) - */ - constructor ( - { - opts = {}, - logger = null, - diskPath = '.', - modelName, - clipLModel, - clipGModel, - t5XxlModel, - llmModel, - vaeModel - }, - config - ) { - super({ logger, opts }) +class ImgStableDiffusion { + constructor ({ files, config, logger = null, opts = {} }) { + this._files = files this._config = config - this._diskPath = diskPath - this._modelName = modelName - this._clipLModel = clipLModel || null - this._clipGModel = clipGModel || null - this._t5XxlModel = t5XxlModel || null - this._llmModel = llmModel || null - this._vaeModel = vaeModel || null + this.logger = new QvacLogger(logger) + this.opts = opts + this._job = createJobHandler({ cancel: () => this.addon.cancel() }) + this._run = exclusiveRunQueue() + this.addon = null this._hasActiveResponse = false + this._binding = null + this._nativeLoggerActive = false + this.state = { configLoaded: false } + } + + async load () { + if (this.state.configLoaded) { + this.logger.info('Reload requested - unloading existing model first') + await this.unload() + } + await this._load() + this.state.configLoaded = true } async _load () { this.logger.info('Starting stable-diffusion model load') - try { - // Route the primary model file to the correct stable-diffusion.cpp param: - // - // model_path — all-in-one checkpoints that embed their own text - // encoders and version metadata (SD1.x, SD2.x, SDXL, - // SD3 all-in-one GGUF). - // - // diffusion_model_path — standalone diffusion-only weights that have no - // embedded SD metadata and require separate encoders: - // FLUX.2 [klein] → llmModel (Qwen3) - // SD3 pure GGUF → t5XxlModel (T5-XXL) + clipLModel + clipGModel - // - // Heuristic: if any separate encoder is provided (LLM for FLUX.2, T5-XXL - // for SD3 split) the caller is using a pure diffusion GGUF that must be - // loaded via diffusion_model_path. - const isSplitLayout = !!this._llmModel || !!this._t5XxlModel - const resolve = (name) => name ? (path.isAbsolute(name) ? name : path.join(this._diskPath, name)) : '' - const configurationParams = { - path: isSplitLayout ? '' : resolve(this._modelName), - diffusionModelPath: isSplitLayout ? resolve(this._modelName) : '', - clipLPath: resolve(this._clipLModel), - clipGPath: resolve(this._clipGModel), - t5XxlPath: resolve(this._t5XxlModel), - llmPath: resolve(this._llmModel), - vaePath: resolve(this._vaeModel), - config: this._config - } + const isSplitLayout = !!this._files.llm || !!this._files.t5Xxl + const configurationParams = { + path: isSplitLayout ? '' : (this._files.model || ''), + diffusionModelPath: isSplitLayout ? (this._files.model || '') : '', + clipLPath: this._files.clipL || '', + clipGPath: this._files.clipG || '', + t5XxlPath: this._files.t5Xxl || '', + llmPath: this._files.llm || '', + vaePath: this._files.vae || '', + config: this._config + } - this.logger.info('Creating stable-diffusion addon with configuration:', configurationParams) - this.addon = this._createAddon(configurationParams) + this.logger.info('Creating stable-diffusion addon with configuration:', configurationParams) + this.addon = this._createAddon(configurationParams) - this.logger.info('Activating stable-diffusion addon') - await this.addon.activate() + this.logger.info('Activating stable-diffusion addon') + await this.addon.activate() - this.logger.info('Stable-diffusion model load completed successfully') - } catch (error) { - this.logger.error('Error during stable-diffusion model load:', error) - throw error - } + this.logger.info('Stable-diffusion model load completed successfully') } - /** - * @param {object} configurationParams - * @returns {SdInterface} - */ _createAddon (configurationParams) { this._binding = require('./binding') this._connectNativeLogger() @@ -136,78 +91,28 @@ class ImgStableDiffusion extends BaseInference { _addonOutputCallback (addon, event, data, error) { if (event.includes('Error')) { - return this._outputCallback(addon, 'Error', 'OnlyOneJob', data, error) + this.logger.error(`Job failed with error: ${error}`) + this._job.fail(error) + return } if (data instanceof Uint8Array || typeof data === 'string') { - return this._outputCallback(addon, 'Output', 'OnlyOneJob', data, error) + this._job.output(data) + return } - // RuntimeStats is the only plain-object payload the C++ addon emits. - // Matching structurally avoids coupling to specific stats key names. if (typeof data === 'object' && data !== null) { - return this._outputCallback(addon, 'JobEnded', 'OnlyOneJob', data, null) + this._job.end(this.opts.stats ? data : null) + return } - return this._outputCallback(addon, event, 'OnlyOneJob', data, error) - } - - /** - * Cancel the current generation job. - */ - async cancel () { - if (this.addon?.cancel) { - await this.addon.cancel() - } + this._job.output(data) } - /** - * Unload the model and release all resources. - */ - async unload () { - return await this._withExclusiveRun(async () => { - await this.cancel() - const currentJobResponse = this._jobToResponse.get('OnlyOneJob') - if (currentJobResponse) { - currentJobResponse.failed(new Error('Model was unloaded')) - this._deleteJobMapping('OnlyOneJob') - } - this._hasActiveResponse = false - if (this.addon) { - await super.unload() - } - this._releaseNativeLogger() - }) + async run (params) { + return this._run(() => this._runInternal(params)) } - /** - * Generate an image from a text prompt, or from an input image + text prompt. - * - * Currently supports txt2img only. img2img is not yet wired in the JS - * layer — passing `init_image` will throw. - * - * Returns a QvacResponse that streams two types of updates: - * - Uint8Array — PNG-encoded output image (one per batch_count) - * - string — JSON step-progress tick: {"step":N,"total":M,"elapsed_ms":T} - * - * @param {object} params - * @param {string} params.prompt - Text prompt - * @param {string} [params.negative_prompt] - Negative prompt - * @param {number} [params.steps=20] - Denoising step count - * @param {number} [params.width=512] - Output width (multiple of 8) - * @param {number} [params.height=512] - Output height (multiple of 8) - * @param {number} [params.guidance=3.5] - Distilled guidance (FLUX.2) - * @param {number} [params.cfg_scale=7.0] - CFG scale (SD1/SD2) - * @param {string} [params.sampling_method] - Sampler name - * @param {string} [params.scheduler] - Scheduler name - * @param {number} [params.seed=-1] - RNG seed; -1 = random - * @param {number} [params.batch_count=1] - Images per call - * @param {boolean} [params.vae_tiling=false] - Enable VAE tiling (for large images) - * @param {string} [params.cache_preset] - Cache preset: slow/medium/fast/ultra - * @param {Uint8Array} [params.init_image] - Source image bytes for img2img (PNG/JPEG) — not yet supported - * @param {number} [params.strength=0.75] - img2img: 0 = keep source, 1 = ignore source — not yet supported - * @returns {Promise} - */ async _runInternal (params) { if (params.init_image) { throw new Error('img2img is not yet supported — omit init_image to run txt2img') @@ -216,39 +121,56 @@ class ImgStableDiffusion extends BaseInference { const mode = 'txt2img' this.logger.info('Starting generation with mode:', mode) - return await this._withExclusiveRun(async () => { - if (this._hasActiveResponse) { - throw new Error(RUN_BUSY_ERROR_MESSAGE) - } + if (this._hasActiveResponse) { + throw new Error(RUN_BUSY_ERROR_MESSAGE) + } - const response = this._createResponse('OnlyOneJob') + const response = this._job.start() - let accepted - try { - accepted = await this.addon.runJob({ ...params, mode }) - } catch (error) { - this._deleteJobMapping('OnlyOneJob') - response.failed(error) - throw error - } + let accepted + try { + accepted = await this.addon.runJob({ ...params, mode }) + } catch (error) { + this._job.fail(error) + throw error + } - if (!accepted) { - this._deleteJobMapping('OnlyOneJob') - const msg = RUN_BUSY_ERROR_MESSAGE - response.failed(new Error(msg)) - throw new Error(msg) - } + if (!accepted) { + this._job.fail(new Error(RUN_BUSY_ERROR_MESSAGE)) + throw new Error(RUN_BUSY_ERROR_MESSAGE) + } - this._hasActiveResponse = true - const finalized = response.await().finally(() => { this._hasActiveResponse = false }) - finalized.catch(() => {}) - response.await = () => finalized + this._hasActiveResponse = true + const finalized = response.await().finally(() => { this._hasActiveResponse = false }) + finalized.catch(() => {}) + response.await = () => finalized - this.logger.info('Generation job started successfully') + this.logger.info('Generation job started successfully') + return response + } - return response + async cancel () { + if (this.addon) { + await this.addon.cancel() + } + } + + async unload () { + return this._run(async () => { + await this.cancel() + if (this._job.active) { + this._job.fail(new Error('Model was unloaded')) + } + this._hasActiveResponse = false + if (this.addon) { + await this.addon.unload() + } + this._releaseNativeLogger() + this.state.configLoaded = false }) } + + getState () { return this.state } } module.exports = ImgStableDiffusion diff --git a/packages/lib-infer-diffusion/package.json b/packages/lib-infer-diffusion/package.json index 808d499328..427d78cc2f 100644 --- a/packages/lib-infer-diffusion/package.json +++ b/packages/lib-infer-diffusion/package.json @@ -73,7 +73,8 @@ "typescript": "^5.9.2" }, "dependencies": { - "@qvac/infer-base": "^0.2.2", + "@qvac/infer-base": "^0.4.0", + "@qvac/logging": "^0.1.0", "bare-path": "^3.0.0", "bare-process": "^4.2.2" }, diff --git a/packages/lib-infer-diffusion/test/integration/api-behavior.test.js b/packages/lib-infer-diffusion/test/integration/api-behavior.test.js index e541bc18b8..a168ebc34a 100644 --- a/packages/lib-infer-diffusion/test/integration/api-behavior.test.js +++ b/packages/lib-infer-diffusion/test/integration/api-behavior.test.js @@ -1,6 +1,7 @@ 'use strict' const test = require('brittle') +const path = require('bare-path') const os = require('bare-os') const proc = require('bare-process') const binding = require('../../binding') @@ -49,20 +50,19 @@ async function setupModel (t) { downloadUrl: MODEL.url }) - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: modelDir, - modelName + const model = new ImgStableDiffusion({ + files: { + model: path.join(modelDir, modelName) }, - { + config: { device: useCpu ? 'cpu' : 'gpu', vae_on_cpu: isAndroid, threads: 4, prediction: 'v', verbosity: '2' - } - ) + }, + logger: console + }) await model.load() diff --git a/packages/lib-infer-diffusion/test/integration/generate-image-flux2.test.js b/packages/lib-infer-diffusion/test/integration/generate-image-flux2.test.js index 6b76b9d3bd..73f892d750 100644 --- a/packages/lib-infer-diffusion/test/integration/generate-image-flux2.test.js +++ b/packages/lib-infer-diffusion/test/integration/generate-image-flux2.test.js @@ -68,19 +68,18 @@ test('FLUX.2 klein txt2img — generates a valid PNG image', { timeout: 1800000, const modelPath = path.join(modelDir, downloadedModelName) t.ok(fs.existsSync(modelPath), 'Model file exists on disk') - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: modelDir, - modelName: downloadedModelName, - llmModel: LLM_MODEL.name, - vaeModel: VAE_MODEL.name + const model = new ImgStableDiffusion({ + files: { + model: path.join(modelDir, downloadedModelName), + llm: path.join(modelDir, LLM_MODEL.name), + vae: path.join(modelDir, VAE_MODEL.name) }, - { + config: { threads: 4, device: useCpu ? 'cpu' : 'gpu' - } - ) + }, + logger: console + }) const images = [] const progressTicks = [] diff --git a/packages/lib-infer-diffusion/test/integration/generate-image-sd3.test.js b/packages/lib-infer-diffusion/test/integration/generate-image-sd3.test.js index 92ec2475eb..5e33a47d09 100644 --- a/packages/lib-infer-diffusion/test/integration/generate-image-sd3.test.js +++ b/packages/lib-infer-diffusion/test/integration/generate-image-sd3.test.js @@ -46,19 +46,18 @@ test('SD3 Medium txt2img — generates a valid PNG image', { timeout: 900000, sk const modelPath = path.join(modelDir, downloadedModelName) t.ok(fs.existsSync(modelPath), 'Model file exists on disk') - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: modelDir, - modelName: downloadedModelName + const model = new ImgStableDiffusion({ + files: { + model: path.join(modelDir, downloadedModelName) }, - { + config: { threads: 4, device: useCpu ? 'cpu' : 'gpu', prediction: 'flow', flow_shift: '3.0' - } - ) + }, + logger: console + }) const images = [] const progressTicks = [] diff --git a/packages/lib-infer-diffusion/test/integration/generate-image-sdxl.test.js b/packages/lib-infer-diffusion/test/integration/generate-image-sdxl.test.js index 3985c26633..528abd9db1 100644 --- a/packages/lib-infer-diffusion/test/integration/generate-image-sdxl.test.js +++ b/packages/lib-infer-diffusion/test/integration/generate-image-sdxl.test.js @@ -46,18 +46,16 @@ test('SDXL txt2img — generates a valid PNG image', { timeout: 900000, skip }, const modelPath = path.join(modelDir, downloadedModelName) t.ok(fs.existsSync(modelPath), 'Model file exists on disk') - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: modelDir, - modelName: downloadedModelName + const model = new ImgStableDiffusion({ + files: { + model: path.join(modelDir, downloadedModelName) }, - { + config: { threads: 4, device: useCpu ? 'cpu' : 'gpu' - - } - ) + }, + logger: console + }) const images = [] const progressTicks = [] diff --git a/packages/lib-infer-diffusion/test/integration/generate-image.test.js b/packages/lib-infer-diffusion/test/integration/generate-image.test.js index 3cc175a188..2afb9596c2 100644 --- a/packages/lib-infer-diffusion/test/integration/generate-image.test.js +++ b/packages/lib-infer-diffusion/test/integration/generate-image.test.js @@ -45,18 +45,17 @@ test('SD2.1 txt2img — generates a valid PNG image', { timeout: 600000, skip }, const modelPath = path.join(modelDir, downloadedModelName) t.ok(fs.existsSync(modelPath), 'Model file exists on disk') - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: modelDir, - modelName: downloadedModelName + const model = new ImgStableDiffusion({ + files: { + model: path.join(modelDir, downloadedModelName) }, - { + config: { threads: 4, device: useCpu ? 'cpu' : 'gpu', prediction: 'v' // SD2.1 uses v-prediction - } - ) + }, + logger: console + }) const images = [] const progressTicks = [] diff --git a/packages/lib-infer-diffusion/test/integration/model-loading.test.js b/packages/lib-infer-diffusion/test/integration/model-loading.test.js index 6adef92070..7daf13d1d5 100644 --- a/packages/lib-infer-diffusion/test/integration/model-loading.test.js +++ b/packages/lib-infer-diffusion/test/integration/model-loading.test.js @@ -1,6 +1,7 @@ 'use strict' const test = require('brittle') +const path = require('bare-path') const os = require('bare-os') const proc = require('bare-process') @@ -32,10 +33,12 @@ test('model loading - load and unload', { timeout: 600_000 }, async t => { } const addon = new ImgStableDiffusion({ - modelName: downloadedModelName, - diskPath: modelDir, + files: { + model: path.join(modelDir, downloadedModelName) + }, + config, logger: console - }, config) + }) await addon.load() t.pass('model loaded successfully') From c76b80922dc609d85082ed2a8ab893d0474e0eab Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 9 Apr 2026 21:49:56 +0100 Subject: [PATCH 02/30] fix: restore JSDoc comments in index.js and index.d.ts --- packages/lib-infer-diffusion/index.d.ts | 79 +++++++++++++++++++++++++ packages/lib-infer-diffusion/index.js | 21 +++++++ 2 files changed, 100 insertions(+) diff --git a/packages/lib-infer-diffusion/index.d.ts b/packages/lib-infer-diffusion/index.d.ts index ec004332b1..8912b96e8c 100644 --- a/packages/lib-infer-diffusion/index.d.ts +++ b/packages/lib-infer-diffusion/index.d.ts @@ -10,6 +10,7 @@ export interface Addon { unload(): Promise } +/** Supported diffusion sampling methods */ export type SamplerMethod = | 'euler' | 'euler_a' @@ -26,6 +27,7 @@ export type SamplerMethod = | 'res_multistep' | 'res_2s' +/** Supported weight quantization types */ export type WeightType = | 'auto' | 'f32' @@ -42,8 +44,10 @@ export type WeightType = | 'q6_k' | 'q8_0' +/** Supported RNG types */ export type RngType = 'cpu' | 'cuda' | 'std_default' +/** Supported sampling schedules */ export type ScheduleType = | 'discrete' | 'karras' @@ -57,43 +61,73 @@ export type ScheduleType = | 'kl_optimal' | 'bong_tangent' +/** Supported noise prediction types */ export type PredictionType = 'auto' | 'eps' | 'v' | 'edm_v' | 'flow' | 'flux_flow' | 'flux2_flow' +/** LoRA application mode */ export type LoraApplyMode = 'auto' | 'immediately' | 'at_runtime' +/** Step-caching algorithm */ export type CacheMode = 'disabled' | 'easycache' | 'ucache' | 'dbcache' | 'taylorseer' | 'cache-dit' export interface SdConfig { + /** Number of CPU threads (-1 = auto) */ threads?: NumericLike + /** Preferred compute device: 'gpu' (Metal/Vulkan) or 'cpu' */ device?: 'gpu' | 'cpu' + /** Weight quantization type */ type?: WeightType + /** RNG type for reproducible generation */ rng?: RngType + /** RNG type for the sampler (separate from context RNG) */ sampler_rng?: RngType + /** Run CLIP encoder on CPU even when GPU is available */ clip_on_cpu?: boolean + /** Run VAE decoder on CPU even when GPU is available */ vae_on_cpu?: boolean + /** Enable VAE tiling to reduce VRAM usage */ vae_tiling?: boolean + /** Enable flash attention for memory efficiency */ flash_attn?: boolean + /** Enable flash attention for diffusion model specifically */ diffusion_fa?: boolean + /** Use memory-mapped model loading */ mmap?: boolean + /** Offload model weights to CPU when not in use */ offload_to_cpu?: boolean + /** Noise prediction type override (auto-detected from model by default) */ prediction?: PredictionType + /** Flow-matching guidance shift */ flow_shift?: number + /** Use direct convolution in diffusion model */ diffusion_conv_direct?: boolean + /** Use direct convolution in VAE */ vae_conv_direct?: boolean + /** Force SDXL VAE conv scale factor */ force_sdxl_vae_conv_scale?: boolean + /** Custom backends directory path (defaults to prebuilds/) */ backendsDir?: string + /** Custom tensor type rules string */ tensor_type_rules?: string + /** LoRA application mode */ lora_apply_mode?: LoraApplyMode + /** Logging verbosity: 0=error, 1=warn, 2=info, 3=debug */ verbosity?: NumericLike [key: string]: string | number | boolean | undefined } export interface DiffusionFiles { + /** Absolute path to main model weights */ model: string + /** FLUX.1 / SD3: absolute path to CLIP-L text encoder */ clipL?: string + /** SDXL / SD3: absolute path to CLIP-G text encoder */ clipG?: string + /** FLUX.1 / SD3: absolute path to T5-XXL text encoder */ t5Xxl?: string + /** FLUX.2 [klein]: absolute path to Qwen3 4B text encoder (llm_path) */ llm?: string + /** Absolute path to VAE file */ vae?: string } @@ -110,37 +144,78 @@ export interface GenerationParams { width?: number height?: number steps?: number + /** CFG scale (SD1/SD2/SDXL/SD3) */ cfg_scale?: number + /** Distilled guidance (FLUX.2) */ guidance?: number + /** Sampler name (e.g. 'euler', 'dpm++2m') */ sampling_method?: SamplerMethod + /** Alias for sampling_method — accepted by the C++ layer */ sampler?: SamplerMethod + /** Scheduler name */ scheduler?: ScheduleType seed?: number batch_count?: number + /** Enable VAE tiling (for large images) */ vae_tiling?: boolean + /** VAE tile dimensions — integer or 'WxH' string (e.g. '512x512') */ vae_tile_size?: number | string + /** VAE tile overlap fraction (0.0–1.0) */ vae_tile_overlap?: number + /** Step-caching algorithm */ cache_mode?: CacheMode + /** Cache preset: slow/medium/fast/ultra (shorthand for cache_mode + threshold) */ cache_preset?: string + /** Direct cache reuse threshold override (0 = library default) */ cache_threshold?: number + /** Stochasticity parameter for DDIM/TCD samplers */ eta?: number + /** Image CFG scale for img2img/inpaint (-1 = use cfg_scale) */ img_cfg_scale?: number + /** Skip last N CLIP encoder layers (SD1.x/SD2.x) */ clip_skip?: number + /** Input image as PNG/JPEG bytes for img2img (not yet supported — throws at runtime) */ init_image?: Uint8Array + /** img2img denoising strength (0.0–1.0). 0 = keep source, 1 = ignore source (not yet supported) */ strength?: number } +/** + * Shape of the stats object emitted on the 'stats' event of a QvacResponse. + * + * All time values are in milliseconds. Cumulative fields (totalGenerationMs, + * totalWallMs, totalSteps, totalGenerations, totalImages, totalPixels) accumulate + * across the lifetime of the model instance; per-job fields (generationMs, width, + * height, seed) reflect only the most recent generation. + * + * Derivable rates (stepsPerSecond, msPerStep, megapixelsPerSecond) are intentionally + * omitted — callers can compute them from the primitives provided: + * stepsPerSecond = totalSteps / (totalWallMs / 1000) + * msPerStep = totalWallMs / totalSteps + * megapixelsPerSec = (totalPixels / 1e6) / (totalWallMs / 1000) + */ export interface RuntimeStats { + /** Wall time to load the model weights (ms) */ modelLoadMs: number + /** Wall time for the most recent generation job (ms) */ generationMs: number + /** Cumulative generation time across all jobs (ms) */ totalGenerationMs: number + /** Cumulative wall time across all jobs (ms) */ totalWallMs: number + /** Cumulative diffusion steps across all jobs */ totalSteps: number + /** Cumulative number of generation calls */ totalGenerations: number + /** Cumulative number of images produced */ totalImages: number + /** Cumulative number of pixels produced */ totalPixels: number + /** Width of the most recent generated image (px) */ width: number + /** Height of the most recent generated image (px) */ height: number + /** Seed used for the most recent generation */ seed: number } @@ -153,9 +228,13 @@ export default class ImgStableDiffusion { constructor(args: ImgStableDiffusionArgs) load(): Promise + run(params: GenerationParams): Promise + unload(): Promise + cancel(): Promise + getState(): { configLoaded: boolean } } diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 1d479b425e..64d5c6e197 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -8,7 +8,24 @@ const LOG_METHODS = ['error', 'warn', 'info', 'debug'] const RUN_BUSY_ERROR_MESSAGE = 'Cannot set new job: a job is already set or being processed' +/** + * Text-to-image and image-to-image generation using stable-diffusion.cpp. + * Supports SD1.x, SD2.x, SDXL, SD3, and FLUX.2 [klein]. + */ class ImgStableDiffusion { + /** + * @param {object} args + * @param {object} args.files - Absolute file paths for model components + * @param {string} args.files.model - Main model weights + * @param {string} [args.files.clipL] - CLIP-L text encoder (FLUX.1 / SD3) + * @param {string} [args.files.clipG] - CLIP-G text encoder (SDXL / SD3) + * @param {string} [args.files.t5Xxl] - T5-XXL text encoder (FLUX.1 / SD3) + * @param {string} [args.files.llm] - LLM text encoder (FLUX.2 klein) + * @param {string} [args.files.vae] - VAE file + * @param {object} args.config - SD context configuration (threads, device, type, etc.) + * @param {object} [args.logger] - Structured logger + * @param {object} [args.opts] - Optional inference options + */ constructor ({ files, config, logger = null, opts = {} }) { this._files = files this._config = config @@ -35,6 +52,10 @@ class ImgStableDiffusion { async _load () { this.logger.info('Starting stable-diffusion model load') + // Route the primary model file to the correct stable-diffusion.cpp param: + // path — all-in-one checkpoints (SD1.x, SD2.x, SDXL, SD3 all-in-one GGUF) + // diffusionModelPath — standalone diffusion weights requiring separate encoders + // (FLUX.2 klein → llm, SD3 pure GGUF → t5Xxl + clipL + clipG) const isSplitLayout = !!this._files.llm || !!this._files.t5Xxl const configurationParams = { path: isSplitLayout ? '' : (this._files.model || ''), From 3b27c36d3d7c3c69da5d760147686284f09751fd Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Fri, 10 Apr 2026 05:30:23 +0100 Subject: [PATCH 03/30] docs: update SD README for new constructor pattern --- packages/lib-infer-diffusion/README.md | 33 +++++++++++++++----------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/lib-infer-diffusion/README.md b/packages/lib-infer-diffusion/README.md index 7ecaa17d02..722c255bfa 100644 --- a/packages/lib-infer-diffusion/README.md +++ b/packages/lib-infer-diffusion/README.md @@ -172,23 +172,28 @@ const path = require('bare-path') const MODELS_DIR = path.resolve(__dirname, './models') const args = { logger: console, - diskPath: MODELS_DIR, - modelName: 'flux-2-klein-4b-Q8_0.gguf', - llmModel: 'Qwen3-4B-Q4_K_M.gguf', // Qwen3 text encoder for FLUX.2 [klein] - vaeModel: 'flux2-vae.safetensors' + files: { + model: path.join(MODELS_DIR, 'flux-2-klein-4b-Q8_0.gguf'), + llm: path.join(MODELS_DIR, 'Qwen3-4B-Q4_K_M.gguf'), // Qwen3 text encoder for FLUX.2 [klein] + vae: path.join(MODELS_DIR, 'flux2-vae.safetensors') + }, + config: { threads: 8 }, + opts: { stats: true } } ``` | Property | Required | Description | |----------|----------|-------------| -| `diskPath` | ✅ | Local directory where model files are already stored | -| `modelName` | ✅ | Diffusion model file name (all-in-one for SD1.x/2.x; diffusion-only GGUF for FLUX.2) | +| `files` | ✅ | Object of absolute paths to model files (see below) | +| `files.model` | ✅ | Absolute path to diffusion model file (all-in-one for SD1.x/2.x; diffusion-only GGUF for FLUX.2) | +| `files.clipL` | — | Absolute path to separate CLIP-L text encoder (FLUX.1 / SD3) | +| `files.clipG` | — | Absolute path to separate CLIP-G text encoder (SDXL / SD3) | +| `files.t5Xxl` | — | Absolute path to separate T5-XXL text encoder (FLUX.1 / SD3) | +| `files.llm` | — | Absolute path to Qwen3 LLM text encoder (FLUX.2 [klein]) | +| `files.vae` | — | Absolute path to separate VAE file | +| `config` | — | Native backend configuration object (see next section) | | `logger` | — | Logger instance (e.g. `console`) | -| `clipLModel` | — | Separate CLIP-L text encoder (FLUX.1 / SD3) | -| `clipGModel` | — | Separate CLIP-G text encoder (SDXL / SD3) | -| `t5XxlModel` | — | Separate T5-XXL text encoder (FLUX.1 / SD3) | -| `llmModel` | — | Qwen3 LLM text encoder (FLUX.2 [klein]) | -| `vaeModel` | — | Separate VAE file | +| `opts` | — | Additional options (e.g. `{ stats: true }`) | ### 3. Create the `config` object @@ -212,7 +217,7 @@ All config values are coerced to strings internally before being passed to the n ### 4. Create a Model Instance ```js -const model = new ImgStableDiffusion(args, config) +const model = new ImgStableDiffusion(args) ``` The constructor stores configuration only — no memory is allocated yet. @@ -223,7 +228,7 @@ The constructor stores configuration only — no memory is allocated yet. await model.load() ``` -This creates the native `sd_ctx_t` and loads all weights into memory. It can take 10–30 seconds depending on disk speed and model size. All model files must already be present on disk at `diskPath`. +This creates the native `sd_ctx_t` and loads all weights into memory. It can take 10–30 seconds depending on disk speed and model size. All model files must be passed as absolute paths via the `files` object. ### 6. Run Inference @@ -316,7 +321,7 @@ await model.unload() ### Stable Diffusion 1.x / 2.x -Pass an all-in-one checkpoint directly as `modelName`. No separate encoders needed. +Pass an all-in-one checkpoint absolute path as `files.model`. No separate encoders needed. --- From 71d885b29f0a8565b0a6a26a3acb9f0555deb50e Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Fri, 10 Apr 2026 06:09:36 +0100 Subject: [PATCH 04/30] fix: guard SD _runInternal against run-before-load with clear error --- packages/lib-infer-diffusion/index.js | 3 +++ .../test/integration/api-behavior.test.js | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 64d5c6e197..1e7a3df4d5 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -135,6 +135,9 @@ class ImgStableDiffusion { } async _runInternal (params) { + if (!this.addon) { + throw new Error('Addon not initialized. Call load() first.') + } if (params.init_image) { throw new Error('img2img is not yet supported — omit init_image to run txt2img') } diff --git a/packages/lib-infer-diffusion/test/integration/api-behavior.test.js b/packages/lib-infer-diffusion/test/integration/api-behavior.test.js index a168ebc34a..28c2b1c512 100644 --- a/packages/lib-infer-diffusion/test/integration/api-behavior.test.js +++ b/packages/lib-infer-diffusion/test/integration/api-behavior.test.js @@ -183,6 +183,30 @@ test('cancel | run: can run again after cancel', { timeout: 600000 }, async t => t.ok(images.length > 0, 'can run again after cancel') }) +test('run() before load() throws clear initialization error', { timeout: 60000 }, async t => { + const [, modelDir] = await ensureModel({ + modelName: MODEL.name, + downloadUrl: MODEL.url + }) + + const model = new ImgStableDiffusion({ + files: { model: path.join(modelDir, MODEL.name) }, + config: { device: useCpu ? 'cpu' : 'gpu', threads: 4 }, + logger: console, + opts: { stats: true } + }) + + let caught = null + try { + await model.run(SHORT_PARAMS) + } catch (err) { + caught = err + } + + t.ok(caught, 'run() before load() throws') + t.ok(/load\(\) first/i.test(caught?.message || ''), 'error message instructs to call load() first') +}) + // Keep event loop alive briefly to let pending async operations complete. // Prevents C++ destructors from running while async cleanup is still happening. setImmediate(() => { From c7e51a36a79cb382dd9ff630db16806f5469a780 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Fri, 10 Apr 2026 11:16:41 +0100 Subject: [PATCH 05/30] docs: align SD architecture.md with new constructor and composition pattern --- .../lib-infer-diffusion/docs/architecture.md | 95 +++++++++++-------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/packages/lib-infer-diffusion/docs/architecture.md b/packages/lib-infer-diffusion/docs/architecture.md index a42ffabad9..a0fd88e9fa 100644 --- a/packages/lib-infer-diffusion/docs/architecture.md +++ b/packages/lib-infer-diffusion/docs/architecture.md @@ -1,7 +1,7 @@ # Architecture Documentation -**Package:** `@qvac/diffusion-cpp` v0.1.0 -**Stack:** JavaScript, C++20, stable-diffusion.cpp, Bare Runtime, CMake, vcpkg +**Package:** `@qvac/diffusion-cpp` v0.1.2 +**Stack:** JavaScript, C++20, stable-diffusion.cpp, Bare Runtime, CMake, vcpkg **License:** Apache-2.0 --- @@ -49,7 +49,7 @@ ## Key Features - **Cross-platform**: macOS, Linux, Windows, iOS, Android -- **Disk-local models**: Files must be present on disk at `diskPath` +- **Disk-local models**: Files must be present on disk; the caller passes absolute file paths via `files.{model,clipL,clipG,t5Xxl,llm,vae}` to the constructor - **Progress tracking**: Step-by-step generation progress callbacks - **GPU acceleration**: Metal, Vulkan, OpenCL - **Quantized models**: GGUF, safetensors, checkpoint formats @@ -122,8 +122,8 @@ graph TB | Package | Type | Version | Purpose | |---------|------|---------|---------| -| @qvac/infer-base | Framework | ^0.2.0 | Base classes (BaseInference, QvacResponse) | -| qvac-lib-inference-addon-cpp | Native | ≥1.1.1 | C++ addon framework (single-job runner) | +| @qvac/infer-base | Framework | ^0.4.0 | Composition utilities (`createJobHandler`, `exclusiveRunQueue`, `QvacResponse`) | +| qvac-lib-inference-addon-cpp | Native | ≥1.1.2 | C++ addon framework (single-job runner) | | stable-diffusion.cpp | Native | latest | Diffusion inference engine | | Bare Runtime | Runtime | ≥1.24.0 | JavaScript execution | @@ -131,8 +131,8 @@ graph TB | From | To | Mechanism | Data Format | |------|-----|-----------|-------------| -| JavaScript | ImgStableDiffusion | Constructor | args, config objects | -| ImgStableDiffusion | BaseInference | Inheritance | Template method pattern | +| JavaScript | ImgStableDiffusion | Constructor | Single `{ files, config, logger?, opts? }` object | +| ImgStableDiffusion | createJobHandler / exclusiveRunQueue | Composition | Job lifecycle + run-queue helpers from `@qvac/infer-base` | | ImgStableDiffusion | SdInterface | Composition | Method calls | | SdInterface | C++ Addon | require.addon() | Native binding | @@ -147,20 +147,26 @@ graph TB ```mermaid classDiagram class ImgStableDiffusion { - +constructor(args, config) + +constructor(args: {files, config, logger?, opts?}) +load() Promise~void~ +run(params: GenerationParams) Promise~QvacResponse~ +cancel() Promise~void~ +unload() Promise~void~ + +getState() {configLoaded} } - class BaseInference { - <> - +load() Promise~void~ - +run() Promise~QvacResponse~ - +unload() Promise~void~ - #_runInternal() Promise~QvacResponse~ - #_withExclusiveRun(fn) Promise~any~ + class JobHandler { + <> + +start() QvacResponse + +output(data) + +end(stats?, payload?) + +fail(error) + +active QvacResponse + } + + class ExclusiveRunQueue { + <> + +(fn) Promise~T~ } class QvacResponse { @@ -169,8 +175,9 @@ classDiagram +cancel() Promise~void~ } - ImgStableDiffusion --|> BaseInference - ImgStableDiffusion ..> QvacResponse : creates + ImgStableDiffusion ..> JobHandler : composes via createJobHandler() + ImgStableDiffusion ..> ExclusiveRunQueue : composes via exclusiveRunQueue() + JobHandler ..> QvacResponse : creates per start() ```
@@ -180,16 +187,20 @@ classDiagram | Class | Responsibility | Lifecycle | Dependencies | |-------|----------------|-----------|--------------| -| ImgStableDiffusion | Orchestrate model lifecycle, manage loading/inference | Created by user, persistent | SdInterface | -| BaseInference | Define standard inference API (template method pattern) | Abstract base class | None | -| QvacResponse | Handle generation progress and result | Created per `run()` call | None | +| ImgStableDiffusion | Orchestrate model lifecycle, manage loading/inference | Created by user, persistent | SdInterface, JobHandler, ExclusiveRunQueue | +| JobHandler (`createJobHandler`) | Start/end/fail a single in-flight job and emit a `QvacResponse` | Per-instance, lives as long as the model | None | +| ExclusiveRunQueue (`exclusiveRunQueue`) | Serialize public API calls so only one job is in flight at a time | Per-instance | None | +| QvacResponse | Handle generation progress and result | Created per `run()` call by the JobHandler | None | **Key Relationships:** | From | To | Type | Purpose | |------|-----|------|---------| -| ImgStableDiffusion | BaseInference | Inheritance | Standard QVAC inference API | -| ImgStableDiffusion | QvacResponse | Creates | Progress/result per generation | +| ImgStableDiffusion | JobHandler | Composition | Lifecycle of the active job (replaces inheriting from `BaseInference`) | +| ImgStableDiffusion | ExclusiveRunQueue | Composition | Serializes `run()` / `cancel()` / `unload()` | +| JobHandler | QvacResponse | Creates | Progress/result per generation | + +> **Note:** `ImgStableDiffusion` no longer extends `BaseInference`. It composes the helpers exposed by `@qvac/infer-base` (`createJobHandler`, `exclusiveRunQueue`) directly.
@@ -206,7 +217,7 @@ graph TB subgraph "Layer 1: JavaScript API" APP["Application Code"] IMGCLASS["ImgStableDiffusion
(index.js)"] - BASEINF["BaseInference
(@qvac/infer-base)"] + BASE["createJobHandler / exclusiveRunQueue
(@qvac/infer-base)"] RESPONSE["QvacResponse"] end @@ -234,7 +245,7 @@ graph TB end APP --> IMGCLASS - IMGCLASS --> BASEINF + IMGCLASS --> BASE IMGCLASS --> SDIF IMGCLASS -.-> RESPONSE @@ -267,7 +278,7 @@ graph TB | Layer | Components | Responsibility | Language | Why This Layer | |-------|------------|----------------|----------|----------------| -| 1. JavaScript API | ImgStableDiffusion, BaseInference, QvacResponse | High-level API, error handling | JS | Ergonomic API for npm consumers | +| 1. JavaScript API | ImgStableDiffusion, `createJobHandler` / `exclusiveRunQueue` (from `@qvac/infer-base`), QvacResponse | High-level API, error handling | JS | Ergonomic API for npm consumers | | 2. Bridge | SdInterface, binding.js | JS↔C++ communication | JS wrapper | Lifecycle management, handle safety | | 3. C++ Addon | JsInterface, AddonCpp/AddonJs | Single-job runner, threading, callbacks | C++ | Performance, native integration | | 4. Model | SdModel, Contexts | Diffusion logic, sampling | C++ | Direct stable-diffusion.cpp integration | @@ -533,14 +544,14 @@ See [qvac-lib-inference-addon-cpp Decision 4: Why Bare Runtime](https://github.c --- -## Decision 3: Disk-Local Model Files +## Decision 3: Disk-Local Model Files (caller-supplied absolute paths)
⚡ TL;DR -**Chose:** Require model files to already exist on disk at `diskPath` -**Why:** Simplicity — the addon loads files directly from disk, no streaming/download layer needed -**Cost:** Caller must ensure files are present before calling `load()` +**Chose:** Require model files to already exist on disk; the caller passes absolute paths via `files.{model,clipL,clipG,t5Xxl,llm,vae}` +**Why:** Simplicity — the addon loads files directly from disk, no streaming/download layer needed and no loader abstraction +**Cost:** Caller must ensure files are present and supply absolute paths before calling `load()`
@@ -548,28 +559,28 @@ See [qvac-lib-inference-addon-cpp Decision 4: Why Bare Runtime](https://github.c Diffusion models consist of multiple large files (diffusion model, text encoders, VAE). The addon needs these files to create the native `sd_ctx_t` context. -Unlike the LLM addon which historically used WeightsProvider for streaming weights, diffusion loads files directly from disk paths — no loader abstraction is involved. +Unlike the LLM addon which historically used WeightsProvider for streaming weights, diffusion has always loaded files directly from disk. After the addon-loader-abstraction refactor, there is also no `Loader` interface and no `diskPath` / `modelName` joining inside the addon — the caller passes absolute paths through the new `files` argument. ### Decision -Require all model files to be present on disk at `diskPath` before `load()` is called. The addon constructs file paths by joining `diskPath` with each model filename and passes them directly to stable-diffusion.cpp. +Require all model files to be present on disk before `load()` is called. The constructor accepts a single `files` object whose entries are absolute paths (`files.model` is required; `files.clipL`, `files.clipG`, `files.t5Xxl`, `files.llm`, `files.vae` are optional companions). `_load()` reads `this._files` and forwards the paths directly to stable-diffusion.cpp. ### Rationale **Simplicity:** - No download/streaming abstraction layer needed -- No WeightsProvider, no progress tracking for downloads +- No WeightsProvider, no Loader, no progress tracking for downloads - Direct file paths to stable-diffusion.cpp **Split-model support:** - Diffusion models may have multiple components (diffusion GGUF, CLIP-L, CLIP-G, T5-XXL, LLM encoder, VAE) -- All resolved as `path.join(diskPath, filename)` in `_load()` -- Split vs all-in-one layout detected via heuristic (`isSplitLayout = !!llmModel || !!t5XxlModel`) +- The caller supplies each component as an absolute path on `files` +- Split vs all-in-one layout is detected via heuristic in `_load()` (`isSplitLayout = !!this._files.llm || !!this._files.t5Xxl`) ### Trade-offs - ✅ Simple, no abstraction overhead - ✅ No streaming/buffering complexity -- ❌ Caller responsible for ensuring files exist on disk +- ❌ Caller responsible for ensuring files exist on disk and for resolving absolute paths --- @@ -578,7 +589,7 @@ Require all model files to be present on disk at `diskPath` before `load()` is c
⚡ TL;DR -**Chose:** Pass file paths directly to stable-diffusion.cpp via `sd_ctx_params_t` +**Chose:** Pass absolute file paths directly to stable-diffusion.cpp via `sd_ctx_params_t` **Why:** stable-diffusion.cpp natively loads from file paths; no need for buffer intermediary **Cost:** Files must exist on disk (no streaming from P2P sources) @@ -586,11 +597,11 @@ Require all model files to be present on disk at `diskPath` before `load()` is c ### Context -stable-diffusion.cpp accepts model files via file paths in its context parameters (`model_path`, `diffusion_model_path`, `clip_l_path`, `vae_path`, etc.). The addon constructs these paths from `diskPath` + filenames. +stable-diffusion.cpp accepts model files via file paths in its context parameters (`model_path`, `diffusion_model_path`, `clip_l_path`, `vae_path`, etc.). The caller supplies these as absolute paths on the constructor's `files` object; the addon never joins a base directory with a filename. ### Decision -Pass absolute file paths directly to stable-diffusion.cpp rather than using buffer-based loading. The `_load()` method constructs a `configurationParams` object with resolved paths and passes it to the native addon. +Pass absolute file paths directly to stable-diffusion.cpp rather than using buffer-based loading. `_load()` builds a `configurationParams` object from `this._files` and passes it to the native addon as-is. ### Rationale @@ -679,8 +690,8 @@ interface GenerationParams {
⚡ TL;DR -**Chose:** Promise-based exclusive run queue using `_withExclusiveRun()` wrapper -**Why:** Ensure generation jobs complete without interruption (long-running operations) +**Chose:** Compose `exclusiveRunQueue()` from `@qvac/infer-base` to serialize public API entrypoints +**Why:** Ensure generation jobs complete without interruption (long-running operations) **Cost:** One generation at a time per model instance
@@ -691,7 +702,7 @@ Diffusion generation takes significant time (seconds to minutes). Without coordi ### Decision -Implement JavaScript-level promise queue ensuring only one generation job runs at a time per model instance. +Use the `exclusiveRunQueue()` helper from `@qvac/infer-base`. The constructor stores the queue as `this._run`, and `run()`, `cancel()`, and `unload()` all wrap their bodies with `this._run(() => …)`. This replaces the previous `BaseInference._withExclusiveRun()` template-method approach with a small composable utility. ### Rationale @@ -760,4 +771,4 @@ Provide hand-written TypeScript definitions in `index.d.ts`. --- -**Last Updated:** 2026-03-11 +**Last Updated:** 2026-04-10 From f5c9426516d04c880f984350c84c8df570059582 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Fri, 10 Apr 2026 12:04:46 +0100 Subject: [PATCH 06/30] chore[bc]: address PR #1496 review findings and bump to 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps `@qvac/diffusion-cpp` to `0.2.0` per the addon-changelog process — minor bump on a pre-1.0 package signals the breaking constructor change to consumers using semver ranges. Adds the matching `0.2.0` block to `CHANGELOG.md` documenting the new single-object constructor with `files`, the removal of `BaseInference`, and every behaviour change in this release. Hardens the JS layer based on the review: - Constructor now throws a clear `TypeError` when `files` / `files.model` is missing, instead of crashing with an opaque "cannot read properties of undefined" later. - `createJobHandler({ cancel })` closure uses optional chaining so a `response.cancel()` after `unload()` is a no-op rather than a `TypeError`. - `unload()` sets `this.addon = null` after `addon.unload()`, so the existing `if (!this.addon)` guard in `_runInternal` is also effective post-unload. - `cancel()` re-adds the defensive `?.cancel` check. - `isSplitLayout` now also triggers on `clipL` / `clipG`, closing a footgun where a FLUX.1 caller passing only encoders without `t5Xxl` would silently misroute the diffusion model into the all-in-one `path` parameter and fail to load. - `_addonOutputCallback` no longer pushes unknown event payloads into the active response output stream — unknown events are logged at debug level instead. The error log line is updated to pass the `Error` object directly so loggers can format the full stack. Doc + test cleanup: - README section 3 now describes `args.config` as a field of the same `args` object built in section 2 (the old wording made it sound like a separate constructor argument). - The `api-behavior` integration test no longer calls `binding.releaseLogger()` manually in the teardown — `unload()` already releases the native logger via `_releaseNativeLogger`. --- packages/lib-infer-diffusion/CHANGELOG.md | 69 +++++++++++++++++++ packages/lib-infer-diffusion/README.md | 8 ++- packages/lib-infer-diffusion/index.js | 28 ++++++-- packages/lib-infer-diffusion/package.json | 2 +- .../test/integration/api-behavior.test.js | 1 - 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/packages/lib-infer-diffusion/CHANGELOG.md b/packages/lib-infer-diffusion/CHANGELOG.md index 4447042cc2..80c4fc3e08 100644 --- a/packages/lib-infer-diffusion/CHANGELOG.md +++ b/packages/lib-infer-diffusion/CHANGELOG.md @@ -1,5 +1,74 @@ # Changelog +## [0.2.0] - 2026-04-10 + +This release migrates the diffusion addon off `BaseInference` inheritance and onto the composable `createJobHandler` + `exclusiveRunQueue` utilities from `@qvac/infer-base@^0.4.0`. The constructor signature is replaced with a single object whose `files` field carries absolute paths for every model component, mirroring the parallel embed and LLM addon refactors. This is a breaking change — every caller must update. + +## Breaking Changes + +### Constructor signature: single object with `files` instead of `(args, config)` + +`ImgStableDiffusion` now takes a single `{ files, config, logger?, opts? }` object. The old `diskPath` + `modelName` + per-component filename pattern is gone — callers pass absolute paths directly via `files`. Companion model fields are renamed (`clipLModel` → `clipL`, `clipGModel` → `clipG`, `t5XxlModel` → `t5Xxl`, `llmModel` → `llm`, `vaeModel` → `vae`). + +```js +// BEFORE (≤ 0.1.x) +const model = new ImgStableDiffusion({ + diskPath: '/models', + modelName: 'flux-2-klein-4b-Q8_0.gguf', + llmModel: 'Qwen3-4B-Q4_K_M.gguf', + vaeModel: 'flux2-vae.safetensors', + logger: console +}, { threads: 8 }) + +// AFTER (0.2.0) +const model = new ImgStableDiffusion({ + files: { + model: '/models/flux-2-klein-4b-Q8_0.gguf', + llm: '/models/Qwen3-4B-Q4_K_M.gguf', + vae: '/models/flux2-vae.safetensors' + }, + config: { threads: 8 }, + logger: console, + opts: { stats: true } +}) +``` + +### `BaseInference` inheritance removed + +`ImgStableDiffusion` no longer extends `BaseInference`. The class composes `createJobHandler` and `exclusiveRunQueue` from `@qvac/infer-base@^0.4.0` directly. The public lifecycle (`load` / `run` / `cancel` / `unload` / `getState`) is unchanged in shape; only construction differs. Internal helpers like `_withExclusiveRun` and `_outputCallback` are removed. + +### Caller owns absolute paths — addon no longer joins `diskPath` + filename + +Callers that previously relied on the addon to resolve `path.join(diskPath, filename)` must now do that resolution themselves before constructing the model. + +## Features + +### Constructor input validation + +The constructor now throws `TypeError('files.model must be an absolute path to the main model weights')` when `files` or `files.model` is missing. This produces a clear error for callers porting old code instead of a confusing `Cannot read properties of undefined`. + +### `run()`-before-`load()` guard + +Calling `run()` before `load()` now throws `Error('Addon not initialized. Call load() first.')` instead of crashing in native code. Covered by a new regression test in `test/integration/api-behavior.test.js`. + +### Broader split-layout detection + +`isSplitLayout` now also triggers when only `clipL` or `clipG` is supplied. This closes a footgun where a FLUX.1 caller passing `{ model, clipL, clipG, vae }` (without `t5Xxl`) would silently mis-route the diffusion model into the all-in-one `path` parameter and fail to load. + +## Bug Fixes + +### `unload()` clears the addon reference + +`unload()` now sets `this.addon = null` after `await this.addon.unload()`, so post-unload `cancel()` / `run()` calls hit the explicit `if (!this.addon)` guard rather than dereferencing a disposed native handle. + +### Unknown addon events no longer pollute the output stream + +`_addonOutputCallback` previously had a fallthrough that pushed any non-error / non-image / non-stats event into `response.output` (including `null` and `undefined`). It now logs unknown events at debug level and does not feed them into the active response. + +## Pull Requests + +- [#1496](https://github.com/tetherto/qvac/pull/1496) - chore[bc]: diffusion addon interface refactor — remove BaseInference + ## [0.1.2] - 2026-04-03 ### Changed diff --git a/packages/lib-infer-diffusion/README.md b/packages/lib-infer-diffusion/README.md index 722c255bfa..529f14dd3b 100644 --- a/packages/lib-infer-diffusion/README.md +++ b/packages/lib-infer-diffusion/README.md @@ -195,10 +195,12 @@ const args = { | `logger` | — | Logger instance (e.g. `console`) | | `opts` | — | Additional options (e.g. `{ stats: true }`) | -### 3. Create the `config` object +### 3. Configure the native backend (`args.config`) + +`config` is a field on the `args` object built in step 2 — there is no separate constructor argument. The native backend reads it during `load()`. ```js -const config = { +args.config = { threads: 8 // CPU threads for tensor operations (Metal handles GPU automatically) } ``` @@ -220,7 +222,7 @@ All config values are coerced to strings internally before being passed to the n const model = new ImgStableDiffusion(args) ``` -The constructor stores configuration only — no memory is allocated yet. +The constructor takes a single object containing `files`, `config`, `logger`, and `opts`. It stores configuration only — no memory is allocated yet. ### 5. Load the Model diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 1e7a3df4d5..72b1a3f78f 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -27,11 +27,17 @@ class ImgStableDiffusion { * @param {object} [args.opts] - Optional inference options */ constructor ({ files, config, logger = null, opts = {} }) { + if (!files || typeof files !== 'object' || typeof files.model !== 'string' || files.model.length === 0) { + throw new TypeError('files.model must be an absolute path to the main model weights') + } this._files = files this._config = config this.logger = new QvacLogger(logger) this.opts = opts - this._job = createJobHandler({ cancel: () => this.addon.cancel() }) + // The cancel closure dereferences `this.addon` lazily, so it is safe even though + // `this.addon` is `null` at construction time — it is only invoked from + // `response.cancel()` after `_load()` has assigned the addon. + this._job = createJobHandler({ cancel: () => this.addon?.cancel() }) this._run = exclusiveRunQueue() this.addon = null this._hasActiveResponse = false @@ -55,8 +61,12 @@ class ImgStableDiffusion { // Route the primary model file to the correct stable-diffusion.cpp param: // path — all-in-one checkpoints (SD1.x, SD2.x, SDXL, SD3 all-in-one GGUF) // diffusionModelPath — standalone diffusion weights requiring separate encoders - // (FLUX.2 klein → llm, SD3 pure GGUF → t5Xxl + clipL + clipG) - const isSplitLayout = !!this._files.llm || !!this._files.t5Xxl + // (FLUX.2 klein → llm, SD3 pure GGUF → t5Xxl + clipL + clipG, + // FLUX.1 → t5Xxl + clipL, etc.) + // Any caller-supplied separate encoder implies the primary file is the standalone + // diffusion model, not an all-in-one checkpoint. + const isSplitLayout = !!this._files.llm || !!this._files.t5Xxl || + !!this._files.clipL || !!this._files.clipG const configurationParams = { path: isSplitLayout ? '' : (this._files.model || ''), diffusionModelPath: isSplitLayout ? (this._files.model || '') : '', @@ -112,7 +122,7 @@ class ImgStableDiffusion { _addonOutputCallback (addon, event, data, error) { if (event.includes('Error')) { - this.logger.error(`Job failed with error: ${error}`) + this.logger.error('Job failed with error:', error) this._job.fail(error) return } @@ -127,7 +137,10 @@ class ImgStableDiffusion { return } - this._job.output(data) + // Unknown event/data combination — log it instead of feeding null/undefined into the + // active response output stream. The native layer is expected to emit only the shapes + // handled above; reaching this branch indicates a native-layer bug worth surfacing. + this.logger.debug(`Unhandled addon event: ${event} (data type: ${typeof data})`) } async run (params) { @@ -174,7 +187,7 @@ class ImgStableDiffusion { } async cancel () { - if (this.addon) { + if (this.addon?.cancel) { await this.addon.cancel() } } @@ -188,6 +201,9 @@ class ImgStableDiffusion { this._hasActiveResponse = false if (this.addon) { await this.addon.unload() + // Null the addon reference so post-unload `cancel()` / `run()` calls hit the + // `if (!this.addon)` guard instead of dereferencing a disposed native handle. + this.addon = null } this._releaseNativeLogger() this.state.configLoaded = false diff --git a/packages/lib-infer-diffusion/package.json b/packages/lib-infer-diffusion/package.json index 427d78cc2f..2d5d866a03 100644 --- a/packages/lib-infer-diffusion/package.json +++ b/packages/lib-infer-diffusion/package.json @@ -1,6 +1,6 @@ { "name": "@qvac/diffusion-cpp", - "version": "0.1.2", + "version": "0.2.0", "description": "stable-diffusion.cpp addon for qvac image/video generation", "addon": true, "scripts": { diff --git a/packages/lib-infer-diffusion/test/integration/api-behavior.test.js b/packages/lib-infer-diffusion/test/integration/api-behavior.test.js index 28c2b1c512..c2b1d59193 100644 --- a/packages/lib-infer-diffusion/test/integration/api-behavior.test.js +++ b/packages/lib-infer-diffusion/test/integration/api-behavior.test.js @@ -68,7 +68,6 @@ async function setupModel (t) { t.teardown(async () => { await model.unload().catch(() => {}) - try { binding.releaseLogger() } catch (_) {} }) return { model } From 7083f7ec014cdd1c2ff1a2e186bf921af99fcbce Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Fri, 10 Apr 2026 13:05:27 +0100 Subject: [PATCH 07/30] refactor: move SD C++ event normalization into addon.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the team-2 task doc (`TD-ADDON-INTERFACE-LLM-EMBED-SD.md`, applied to SD for parity with the LLM/Embed extractions): the native binding wrapper should own the mapping from raw C++ events to Output / Error / JobEnded. Adds `mapAddonEvent(rawEvent, data, error)` as a free export from `addon.js`, co-located with `SdInterface`. The function normalizes the C++-mangled event vocabulary into one of `Output` / `Error` / `JobEnded`: - `Error`-flavored event names → Error. - `Uint8Array` payloads (encoded image bytes) and `string` payloads (per-step progress JSON ticks) → Output. - Plain object payloads (RuntimeStats) → JobEnded. - Anything else → `null` (caller logs at debug level). `ImgStableDiffusion._addonOutputCallback` becomes a thin shim that imports `mapAddonEvent`, runs it on the raw C++ event, and dispatches the mapped logical event onto the active job. The "unhandled event" debug log is preserved at the dispatch site so a future C++ event-shape change still surfaces. --- packages/lib-infer-diffusion/addon.js | 42 ++++++++++++++++++++++++++- packages/lib-infer-diffusion/index.js | 31 ++++++++++++-------- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/lib-infer-diffusion/addon.js b/packages/lib-infer-diffusion/addon.js index 4e8198ed1c..3355ee6176 100644 --- a/packages/lib-infer-diffusion/addon.js +++ b/packages/lib-infer-diffusion/addon.js @@ -2,6 +2,46 @@ const path = require('bare-path') +/** + * Map a raw native event from the C++ stable-diffusion addon to a logical + * event consumed by `ImgStableDiffusion`. + * + * The native binding emits events with C++-mangled names and varied + * payload shapes. This wrapper normalizes them into one of: + * - `'Output'` — image bytes (`Uint8Array`) or progress JSON tick (`string`) + * - `'Error'` — failure + * - `'JobEnded'` — terminal RuntimeStats payload (object) + * + * Returns `{ type, data, error }` or `null` for unknown event/data shapes + * (caller logs at debug level). + * + * The C++ event vocabulary is owned by this module so the JS class only + * sees logical events. See team-2 task doc: + * "Move event normalization into `addon.js` `SdInterface` — the native + * binding wrapper should own the mapping from raw C++ events to + * Output / Error / JobEnded". + * + * @param {string} rawEvent + * @param {*} rawData + * @param {*} rawError + * @returns {{ type: string, data: *, error: * } | null} + */ +function mapAddonEvent (rawEvent, rawData, rawError) { + if (typeof rawEvent === 'string' && rawEvent.includes('Error')) { + return { type: 'Error', data: rawData, error: rawError } + } + + if (rawData instanceof Uint8Array || typeof rawData === 'string') { + return { type: 'Output', data: rawData, error: null } + } + + if (rawData && typeof rawData === 'object') { + return { type: 'JobEnded', data: rawData, error: null } + } + + return null +} + /** * JavaScript wrapper around the native stable-diffusion.cpp addon. * Manages the native handle lifecycle and bridges JS ↔ C++. @@ -76,4 +116,4 @@ class SdInterface { } } -module.exports = { SdInterface } +module.exports = { SdInterface, mapAddonEvent } diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 72b1a3f78f..f76ae726e3 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -2,7 +2,7 @@ const QvacLogger = require('@qvac/logging') const { createJobHandler, exclusiveRunQueue } = require('@qvac/infer-base') -const { SdInterface } = require('./addon') +const { SdInterface, mapAddonEvent } = require('./addon') const LOG_METHODS = ['error', 'warn', 'info', 'debug'] @@ -121,26 +121,33 @@ class ImgStableDiffusion { } _addonOutputCallback (addon, event, data, error) { - if (event.includes('Error')) { - this.logger.error('Job failed with error:', error) - this._job.fail(error) + // Event-name normalization lives in `addon.js` (`mapAddonEvent`) so the + // native binding wrapper owns the C++ event vocabulary. This shim only + // dispatches the resulting logical event onto the active job. + const mapped = mapAddonEvent(event, data, error) + if (mapped === null) { + // Unknown event/data combination — log it instead of feeding null/undefined + // into the active response output stream. The native layer is expected to + // emit only the shapes handled above; reaching this branch indicates a + // native-layer bug worth surfacing. + this.logger.debug(`Unhandled addon event: ${event} (data type: ${typeof data})`) return } - if (data instanceof Uint8Array || typeof data === 'string') { - this._job.output(data) + if (mapped.type === 'Error') { + this.logger.error('Job failed with error:', mapped.error) + this._job.fail(mapped.error) return } - if (typeof data === 'object' && data !== null) { - this._job.end(this.opts.stats ? data : null) + if (mapped.type === 'JobEnded') { + this._job.end(this.opts.stats ? mapped.data : null) return } - // Unknown event/data combination — log it instead of feeding null/undefined into the - // active response output stream. The native layer is expected to emit only the shapes - // handled above; reaching this branch indicates a native-layer bug worth surfacing. - this.logger.debug(`Unhandled addon event: ${event} (data type: ${typeof data})`) + if (mapped.type === 'Output') { + this._job.output(mapped.data) + } } async run (params) { From e5062044cb6b3024adfa5cc24656bad5c6014643 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Fri, 10 Apr 2026 14:48:42 +0100 Subject: [PATCH 08/30] fix: address PR #1496 second-round review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. `index.d.ts` `ImgStableDiffusionArgs.config` is now optional (`config?: SdConfig`). The README and the runtime already treated it as optional — the runtime forwards an empty config object when omitted and the C++ layer falls back to stable-diffusion.cpp defaults — but the type required it, producing a compile-time error for the call shape the runtime accepts. The JSDoc on the constructor is updated in lockstep. 2. The constructor now actually enforces the "absolute paths only" contract that the README and the error messages advertise. `assertAbsolute(key, value)` checks `path.isAbsolute` on `files.model` and on every supplied companion path (`clipL`, `clipG`, `t5Xxl`, `llm`, `vae`). Relative paths are rejected at construction time with a clear `TypeError` instead of silently being passed through to the native loader and failing later with an opaque error. 3. `docs/architecture.md` is no longer stale: - Version stamp updated from `v0.1.2` to `v0.2.0`. - The `isSplitLayout` description now matches the current code (`!!llm || !!t5Xxl || !!clipL || !!clipG`) and explains the FLUX.1 case the broader heuristic was added for. - The Decision 6 ("Exclusive Run Queue") section no longer claims `cancel()` is wrapped in `this._run(...)`. The "Key Relationships" table is updated to match — `cancel()` is intentionally outside the queue so it can interrupt an in-flight `run()`, with a short note explaining why. --- .../lib-infer-diffusion/docs/architecture.md | 8 ++-- packages/lib-infer-diffusion/index.d.ts | 7 +++- packages/lib-infer-diffusion/index.js | 40 ++++++++++++++----- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/lib-infer-diffusion/docs/architecture.md b/packages/lib-infer-diffusion/docs/architecture.md index a0fd88e9fa..23c4a16cf9 100644 --- a/packages/lib-infer-diffusion/docs/architecture.md +++ b/packages/lib-infer-diffusion/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture Documentation -**Package:** `@qvac/diffusion-cpp` v0.1.2 +**Package:** `@qvac/diffusion-cpp` v0.2.0 **Stack:** JavaScript, C++20, stable-diffusion.cpp, Bare Runtime, CMake, vcpkg **License:** Apache-2.0 @@ -197,7 +197,7 @@ classDiagram | From | To | Type | Purpose | |------|-----|------|---------| | ImgStableDiffusion | JobHandler | Composition | Lifecycle of the active job (replaces inheriting from `BaseInference`) | -| ImgStableDiffusion | ExclusiveRunQueue | Composition | Serializes `run()` / `cancel()` / `unload()` | +| ImgStableDiffusion | ExclusiveRunQueue | Composition | Serializes `run()` and `unload()` (cancel is intentionally outside the queue so it can interrupt an in-flight run) | | JobHandler | QvacResponse | Creates | Progress/result per generation | > **Note:** `ImgStableDiffusion` no longer extends `BaseInference`. It composes the helpers exposed by `@qvac/infer-base` (`createJobHandler`, `exclusiveRunQueue`) directly. @@ -575,7 +575,7 @@ Require all model files to be present on disk before `load()` is called. The con **Split-model support:** - Diffusion models may have multiple components (diffusion GGUF, CLIP-L, CLIP-G, T5-XXL, LLM encoder, VAE) - The caller supplies each component as an absolute path on `files` -- Split vs all-in-one layout is detected via heuristic in `_load()` (`isSplitLayout = !!this._files.llm || !!this._files.t5Xxl`) +- Split vs all-in-one layout is detected via heuristic in `_load()` (`isSplitLayout = !!this._files.llm || !!this._files.t5Xxl || !!this._files.clipL || !!this._files.clipG`). Any caller-supplied separate encoder implies the primary file is the standalone diffusion model rather than an all-in-one checkpoint, so FLUX.1 (`{ model, clipL, clipG, vae }` without `t5Xxl`) is also routed correctly. ### Trade-offs - ✅ Simple, no abstraction overhead @@ -702,7 +702,7 @@ Diffusion generation takes significant time (seconds to minutes). Without coordi ### Decision -Use the `exclusiveRunQueue()` helper from `@qvac/infer-base`. The constructor stores the queue as `this._run`, and `run()`, `cancel()`, and `unload()` all wrap their bodies with `this._run(() => …)`. This replaces the previous `BaseInference._withExclusiveRun()` template-method approach with a small composable utility. +Use the `exclusiveRunQueue()` helper from `@qvac/infer-base`. The constructor stores the queue as `this._run`, and `run()` and `unload()` wrap their bodies with `this._run(() => …)`. `cancel()` is intentionally **not** queued — it must be able to interrupt an in-flight `run()` to terminate it, so it bypasses the queue and delegates straight to `addon.cancel()` (which is itself a no-op when there is no active job). This replaces the previous `BaseInference._withExclusiveRun()` template-method approach with a small composable utility. ### Rationale diff --git a/packages/lib-infer-diffusion/index.d.ts b/packages/lib-infer-diffusion/index.d.ts index 8912b96e8c..63d72ac503 100644 --- a/packages/lib-infer-diffusion/index.d.ts +++ b/packages/lib-infer-diffusion/index.d.ts @@ -133,7 +133,12 @@ export interface DiffusionFiles { export interface ImgStableDiffusionArgs { files: DiffusionFiles - config: SdConfig + /** + * Native backend configuration. Optional — when omitted, the addon + * forwards an empty config object and the C++ layer falls back to + * stable-diffusion.cpp defaults for every parameter. + */ + config?: SdConfig logger?: QvacLogger | Console | null opts?: { stats?: boolean } } diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index f76ae726e3..fee6aa3b96 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -1,9 +1,21 @@ 'use strict' +const path = require('bare-path') const QvacLogger = require('@qvac/logging') const { createJobHandler, exclusiveRunQueue } = require('@qvac/infer-base') const { SdInterface, mapAddonEvent } = require('./addon') +const COMPANION_FILE_KEYS = ['clipL', 'clipG', 't5Xxl', 'llm', 'vae'] + +function assertAbsolute (key, value) { + if (typeof value !== 'string' || value.length === 0) { + throw new TypeError(`files.${key} must be an absolute path string`) + } + if (!path.isAbsolute(value)) { + throw new TypeError(`files.${key} must be an absolute path (got: ${value})`) + } +} + const LOG_METHODS = ['error', 'warn', 'info', 'debug'] const RUN_BUSY_ERROR_MESSAGE = 'Cannot set new job: a job is already set or being processed' @@ -16,22 +28,30 @@ class ImgStableDiffusion { /** * @param {object} args * @param {object} args.files - Absolute file paths for model components - * @param {string} args.files.model - Main model weights - * @param {string} [args.files.clipL] - CLIP-L text encoder (FLUX.1 / SD3) - * @param {string} [args.files.clipG] - CLIP-G text encoder (SDXL / SD3) - * @param {string} [args.files.t5Xxl] - T5-XXL text encoder (FLUX.1 / SD3) - * @param {string} [args.files.llm] - LLM text encoder (FLUX.2 klein) - * @param {string} [args.files.vae] - VAE file - * @param {object} args.config - SD context configuration (threads, device, type, etc.) + * @param {string} args.files.model - Main model weights (absolute path) + * @param {string} [args.files.clipL] - CLIP-L text encoder (FLUX.1 / SD3, absolute path) + * @param {string} [args.files.clipG] - CLIP-G text encoder (SDXL / SD3, absolute path) + * @param {string} [args.files.t5Xxl] - T5-XXL text encoder (FLUX.1 / SD3, absolute path) + * @param {string} [args.files.llm] - LLM text encoder (FLUX.2 klein, absolute path) + * @param {string} [args.files.vae] - VAE file (absolute path) + * @param {object} [args.config] - SD context configuration (threads, device, type, etc.). + * Optional — when omitted, the addon forwards an empty config and the C++ layer falls + * back to stable-diffusion.cpp defaults for every parameter. * @param {object} [args.logger] - Structured logger * @param {object} [args.opts] - Optional inference options */ constructor ({ files, config, logger = null, opts = {} }) { - if (!files || typeof files !== 'object' || typeof files.model !== 'string' || files.model.length === 0) { - throw new TypeError('files.model must be an absolute path to the main model weights') + if (!files || typeof files !== 'object') { + throw new TypeError('files must be an object containing at least { model }') + } + assertAbsolute('model', files.model) + for (const key of COMPANION_FILE_KEYS) { + if (files[key] !== undefined) { + assertAbsolute(key, files[key]) + } } this._files = files - this._config = config + this._config = config || {} this.logger = new QvacLogger(logger) this.opts = opts // The cancel closure dereferences `this.addon` lazily, so it is safe even though From ab7171011082f112c8082d5b062e4b541c3e4bc2 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Sun, 12 Apr 2026 07:14:35 +0100 Subject: [PATCH 09/30] fix: remove task-doc reference and refactor-narration comments - Remove task-doc reference from mapAddonEvent JSDoc in addon.js - Remove refactor-narration comment from _addonOutputCallback in index.js --- packages/lib-infer-diffusion/addon.js | 5 ----- packages/lib-infer-diffusion/index.js | 3 --- 2 files changed, 8 deletions(-) diff --git a/packages/lib-infer-diffusion/addon.js b/packages/lib-infer-diffusion/addon.js index 3355ee6176..efbe6c9f81 100644 --- a/packages/lib-infer-diffusion/addon.js +++ b/packages/lib-infer-diffusion/addon.js @@ -15,11 +15,6 @@ const path = require('bare-path') * Returns `{ type, data, error }` or `null` for unknown event/data shapes * (caller logs at debug level). * - * The C++ event vocabulary is owned by this module so the JS class only - * sees logical events. See team-2 task doc: - * "Move event normalization into `addon.js` `SdInterface` — the native - * binding wrapper should own the mapping from raw C++ events to - * Output / Error / JobEnded". * * @param {string} rawEvent * @param {*} rawData diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index fee6aa3b96..1daa0493fc 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -141,9 +141,6 @@ class ImgStableDiffusion { } _addonOutputCallback (addon, event, data, error) { - // Event-name normalization lives in `addon.js` (`mapAddonEvent`) so the - // native binding wrapper owns the C++ event vocabulary. This shim only - // dispatches the resulting logical event onto the active job. const mapped = mapAddonEvent(event, data, error) if (mapped === null) { // Unknown event/data combination — log it instead of feeding null/undefined From 45d3b19e8601d459de395ac8c05d54ec42df54f0 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Tue, 14 Apr 2026 11:41:46 +0100 Subject: [PATCH 10/30] fix: throw on second load(), log rejected responses, add mapAddonEvent unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - load(): throw if already loaded. Caller must unload() first. Aligns with the team consensus (Yury/Gianfranco/Gustavo) — silent reload masks caller bugs. unload() already clears configLoaded. - _runInternal: replace silent `finalized.catch(() => {})` with a warn-level log so rejected responses are not swallowed when the caller does not await. - test/unit/map-addon-event.test.js: new unit test covering Error event mapping, Uint8Array → Output (image bytes), string → Output (progress tick), plain object → JobEnded (RuntimeStats), Error precedence over data shape, and null returns for unknown shapes. - CHANGELOG 0.2.0: document the load() throw. --- packages/lib-infer-diffusion/CHANGELOG.md | 4 ++ packages/lib-infer-diffusion/index.js | 7 +-- .../test/unit/map-addon-event.test.js | 51 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 packages/lib-infer-diffusion/test/unit/map-addon-event.test.js diff --git a/packages/lib-infer-diffusion/CHANGELOG.md b/packages/lib-infer-diffusion/CHANGELOG.md index 80c4fc3e08..769e70f40b 100644 --- a/packages/lib-infer-diffusion/CHANGELOG.md +++ b/packages/lib-infer-diffusion/CHANGELOG.md @@ -51,6 +51,10 @@ The constructor now throws `TypeError('files.model must be an absolute path to t Calling `run()` before `load()` now throws `Error('Addon not initialized. Call load() first.')` instead of crashing in native code. Covered by a new regression test in `test/integration/api-behavior.test.js`. +### `load()` after already loaded now throws + +A second `load()` call on an already-loaded instance now throws `Error('Model is already loaded. Call unload() before calling load() again.')` instead of silently unloading and reloading. `unload()` clears `configLoaded`, so callers that intentionally want to swap weights must call `unload()` first. This makes the lifecycle explicit and prevents accidental reloads from masking caller bugs. + ### Broader split-layout detection `isSplitLayout` now also triggers when only `clipL` or `clipG` is supplied. This closes a footgun where a FLUX.1 caller passing `{ model, clipL, clipG, vae }` (without `t5Xxl`) would silently mis-route the diffusion model into the all-in-one `path` parameter and fail to load. diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 1daa0493fc..0aff9a4998 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -68,8 +68,7 @@ class ImgStableDiffusion { async load () { if (this.state.configLoaded) { - this.logger.info('Reload requested - unloading existing model first') - await this.unload() + throw new Error('Model is already loaded. Call unload() before calling load() again.') } await this._load() this.state.configLoaded = true @@ -203,7 +202,9 @@ class ImgStableDiffusion { this._hasActiveResponse = true const finalized = response.await().finally(() => { this._hasActiveResponse = false }) - finalized.catch(() => {}) + finalized.catch((err) => { + this.logger?.warn?.('Generation response rejected:', err?.message || err) + }) response.await = () => finalized this.logger.info('Generation job started successfully') diff --git a/packages/lib-infer-diffusion/test/unit/map-addon-event.test.js b/packages/lib-infer-diffusion/test/unit/map-addon-event.test.js new file mode 100644 index 0000000000..bfe6cdc3c4 --- /dev/null +++ b/packages/lib-infer-diffusion/test/unit/map-addon-event.test.js @@ -0,0 +1,51 @@ +'use strict' + +const test = require('brittle') +const { mapAddonEvent } = require('../../addon.js') + +test('event name containing "Error" maps to Error type carrying rawError', function (t) { + const err = new Error('generation failed') + const result = mapAddonEvent('GenerationError', null, err) + t.is(result.type, 'Error') + t.is(result.error, err) +}) + +test('Uint8Array data maps to Output (image bytes)', function (t) { + const bytes = new Uint8Array([137, 80, 78, 71]) + const result = mapAddonEvent('ImageOutput', bytes, null) + t.is(result.type, 'Output') + t.is(result.data, bytes) + t.is(result.error, null) +}) + +test('string data maps to Output (progress JSON tick)', function (t) { + const tick = '{"step":3,"total":20,"elapsed_ms":1234}' + const result = mapAddonEvent('Progress', tick, null) + t.is(result.type, 'Output') + t.is(result.data, tick) +}) + +test('plain object data maps to JobEnded (RuntimeStats)', function (t) { + const stats = { total_time_ms: 5000, steps: 20 } + const result = mapAddonEvent('Stats', stats, null) + t.is(result.type, 'JobEnded') + t.is(result.data, stats) + t.is(result.error, null) +}) + +test('Error event takes precedence over data shape', function (t) { + const err = new Error('boom') + const bytes = new Uint8Array([1, 2, 3]) + const result = mapAddonEvent('FatalError', bytes, err) + t.is(result.type, 'Error', 'Error event name beats Uint8Array output shape') + t.is(result.error, err) +}) + +test('null data with unknown event returns null', function (t) { + t.is(mapAddonEvent('Unknown', null, null), null) +}) + +test('number/boolean data with unknown event returns null', function (t) { + t.is(mapAddonEvent('Unknown', 42, null), null) + t.is(mapAddonEvent('Unknown', true, null), null) +}) From 51bb1e36e0e6fe4060ac8bfec20e802d2fd6f81f Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Tue, 14 Apr 2026 15:32:44 +0100 Subject: [PATCH 11/30] fix: restore JSDoc on run() that was dropped during BaseInference removal The extensive JSDoc documenting every run() parameter (prompt, steps, width, height, guidance, cfg_scale, sampling_method, scheduler, seed, batch_count, vae_tiling, cache_preset, init_image, strength) was accidentally removed during the BaseInference removal refactor when run() was split into run() + _runInternal(). Restore it on the public run() method since that is the caller-facing contract. --- packages/lib-infer-diffusion/index.js | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 0aff9a4998..d7764ded3b 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -166,6 +166,34 @@ class ImgStableDiffusion { } } + /** + * Generate an image from a text prompt, or from an input image + text prompt. + * + * Currently supports txt2img only. img2img is not yet wired in the JS + * layer — passing `init_image` will throw. + * + * Returns a QvacResponse that streams two types of updates: + * - Uint8Array — PNG-encoded output image (one per batch_count) + * - string — JSON step-progress tick: {"step":N,"total":M,"elapsed_ms":T} + * + * @param {object} params + * @param {string} params.prompt - Text prompt + * @param {string} [params.negative_prompt] - Negative prompt + * @param {number} [params.steps=20] - Denoising step count + * @param {number} [params.width=512] - Output width (multiple of 8) + * @param {number} [params.height=512] - Output height (multiple of 8) + * @param {number} [params.guidance=3.5] - Distilled guidance (FLUX.2) + * @param {number} [params.cfg_scale=7.0] - CFG scale (SD1/SD2) + * @param {string} [params.sampling_method] - Sampler name + * @param {string} [params.scheduler] - Scheduler name + * @param {number} [params.seed=-1] - RNG seed; -1 = random + * @param {number} [params.batch_count=1] - Images per call + * @param {boolean} [params.vae_tiling=false] - Enable VAE tiling (for large images) + * @param {string} [params.cache_preset] - Cache preset: slow/medium/fast/ultra + * @param {Uint8Array} [params.init_image] - Source image bytes for img2img (PNG/JPEG) — not yet supported + * @param {number} [params.strength=0.75] - img2img: 0 = keep source, 1 = ignore source — not yet supported + * @returns {Promise} + */ async run (params) { return this._run(() => this._runInternal(params)) } From 5063a20c630db5f51205dcc0c972de4d8c9903f3 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Tue, 14 Apr 2026 17:21:04 +0100 Subject: [PATCH 12/30] fix: correct CHANGELOG error quote and remove dead files.model fallback Address review findings from the qvac-staff-code-reviewer agent: - CHANGELOG quoted a fabricated error message ("must be an absolute path to the main model weights") that the code does not throw. Replace with the actual messages emitted by assertAbsolute(): "must be an absolute path string" and "must be an absolute path (got: )". Note that the same validation applies to the optional companion fields. - index.js: remove `this._files.model || ''` fallbacks in _load(). The constructor's assertAbsolute('model', files.model) already guarantees a non-empty absolute string, so the fallbacks are unreachable and encode a phantom contract (empty model path) that can never hold. --- packages/lib-infer-diffusion/CHANGELOG.md | 2 +- packages/lib-infer-diffusion/index.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/lib-infer-diffusion/CHANGELOG.md b/packages/lib-infer-diffusion/CHANGELOG.md index 769e70f40b..9fc1499fdd 100644 --- a/packages/lib-infer-diffusion/CHANGELOG.md +++ b/packages/lib-infer-diffusion/CHANGELOG.md @@ -45,7 +45,7 @@ Callers that previously relied on the addon to resolve `path.join(diskPath, file ### Constructor input validation -The constructor now throws `TypeError('files.model must be an absolute path to the main model weights')` when `files` or `files.model` is missing. This produces a clear error for callers porting old code instead of a confusing `Cannot read properties of undefined`. +The constructor now throws `TypeError('files.model must be an absolute path string')` when `files.model` is missing or not a string, or `TypeError('files.model must be an absolute path (got: )')` when supplied as a relative path. This produces a clear error for callers porting old code instead of a confusing `Cannot read properties of undefined`. The same validation applies to optional companion fields (`clipL`, `clipG`, `t5Xxl`, `llm`, `vae`) when supplied. ### `run()`-before-`load()` guard diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index d7764ded3b..fa2d9a8379 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -87,8 +87,8 @@ class ImgStableDiffusion { const isSplitLayout = !!this._files.llm || !!this._files.t5Xxl || !!this._files.clipL || !!this._files.clipG const configurationParams = { - path: isSplitLayout ? '' : (this._files.model || ''), - diffusionModelPath: isSplitLayout ? (this._files.model || '') : '', + path: isSplitLayout ? '' : this._files.model, + diffusionModelPath: isSplitLayout ? this._files.model : '', clipLPath: this._files.clipL || '', clipGPath: this._files.clipG || '', t5XxlPath: this._files.t5Xxl || '', From f6f424d0443ef7e90484120d1b6f95ba39cf2a41 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Wed, 15 Apr 2026 10:32:45 +0100 Subject: [PATCH 13/30] fix: make load() idempotent when already loaded Second load() on an already-loaded instance returns immediately instead of throwing. Matches the ReadyResource pattern used elsewhere in QVAC: open/load is idempotent; explicit unload() is required to swap weights. CHANGELOG updated. --- packages/lib-infer-diffusion/CHANGELOG.md | 4 ++-- packages/lib-infer-diffusion/index.js | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/lib-infer-diffusion/CHANGELOG.md b/packages/lib-infer-diffusion/CHANGELOG.md index 9fc1499fdd..0ed4225857 100644 --- a/packages/lib-infer-diffusion/CHANGELOG.md +++ b/packages/lib-infer-diffusion/CHANGELOG.md @@ -51,9 +51,9 @@ The constructor now throws `TypeError('files.model must be an absolute path stri Calling `run()` before `load()` now throws `Error('Addon not initialized. Call load() first.')` instead of crashing in native code. Covered by a new regression test in `test/integration/api-behavior.test.js`. -### `load()` after already loaded now throws +### `load()` is now idempotent when already loaded -A second `load()` call on an already-loaded instance now throws `Error('Model is already loaded. Call unload() before calling load() again.')` instead of silently unloading and reloading. `unload()` clears `configLoaded`, so callers that intentionally want to swap weights must call `unload()` first. This makes the lifecycle explicit and prevents accidental reloads from masking caller bugs. +A second `load()` call on an already-loaded instance is now a silent no-op instead of unloading and reloading. This aligns with the ReadyResource pattern used elsewhere in QVAC and prevents accidental double-loads from triggering expensive work. Callers that intentionally want to swap weights must call `unload()` first (which clears `configLoaded`) and then `load()` again. ### Broader split-layout detection diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index fa2d9a8379..b04008aace 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -67,9 +67,7 @@ class ImgStableDiffusion { } async load () { - if (this.state.configLoaded) { - throw new Error('Model is already loaded. Call unload() before calling load() again.') - } + if (this.state.configLoaded) return await this._load() this.state.configLoaded = true } From 943220f422dfbef7544933c4c99023e75ed0f83f Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Wed, 15 Apr 2026 18:10:24 +0100 Subject: [PATCH 14/30] doc: document missing breaking changes from BaseInference removal Address feedback to report all breaking changes from the BaseInference refactor, not just the constructor shape: - ImgStableDiffusion public methods removed: downloadWeights, pause, unpause, stop, status, destroy, getApiDefinition - cancel() no longer accepts a jobId argument getState() shape change and unload() addon-reference fix were already documented in prior commits. --- packages/lib-infer-diffusion/CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/lib-infer-diffusion/CHANGELOG.md b/packages/lib-infer-diffusion/CHANGELOG.md index 1e44c6ecb8..1b18566412 100644 --- a/packages/lib-infer-diffusion/CHANGELOG.md +++ b/packages/lib-infer-diffusion/CHANGELOG.md @@ -45,6 +45,20 @@ Callers that previously relied on the addon to resolve `path.join(diskPath, file `getState()` previously returned `{ configLoaded, weightsLoaded, destroyed }` (the three-field shape from `BaseInference`). It now returns `{ configLoaded }` only. The `weightsLoaded` and `destroyed` fields are gone — `weightsLoaded` collapsed into `configLoaded` because the refactored `load()` does both in one step, and `destroyed` is no longer tracked since `unload()` resets `configLoaded` and nulls the addon handle instead. Callers reading `state.weightsLoaded` or `state.destroyed` must switch to `state.configLoaded`. +### Public methods removed from `ImgStableDiffusion` + +`ImgStableDiffusion` previously exposed these methods via `BaseInference` inheritance, all of which are now gone: + +- `downloadWeights(onDownloadProgress, opts)` — the diffusion addon never used the loader in practice, but the inherited method was still present on the public surface. It is removed along with the base class. +- `pause()` / `unpause()` / `stop()` — BaseInference job-lifecycle helpers. The refactor uses `createJobHandler` directly; use `cancel()` to terminate an in-flight generation. +- `status()` — replaced by `getState()` for the static readiness flag; per-job state is observed via the `QvacResponse` returned by `run()`. +- `destroy()` — folded into `unload()`, which now both releases native resources and nulls `this.addon`. +- `getApiDefinition()` — no longer exposed; consumers should import types from `index.d.ts`. + +### `cancel()` no longer accepts a `jobId` + +`BaseInference.cancel(jobId)` took an optional `jobId` argument. The refactor's `cancel()` is parameterless — there is always at most one active generation per instance, owned by `createJobHandler`. Any caller passing a `jobId` will have it ignored; update call sites to `await model.cancel()`. + ## Features ### Constructor input validation From de7e693089a7b36488e029e7f05ac5c06a19bbab Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 06:51:54 +0100 Subject: [PATCH 15/30] fix: address lifecycle, cleanup, and CI-surface review findings - load() now runs through `this._run()` so concurrent calls on the same instance serialize instead of racing past the `configLoaded` guard. Two overlapping loads could previously both allocate a native addon and clobber `this.addon`, leaking one native handle. - _load() now wraps `addon.activate()` in a try/catch that best-effort unloads the partially-initialized addon, releases the native logger, and resets `this.addon = null` before re-throwing. Matches the crash-safety pattern already in embed and LLM. A failing activate() no longer leaves a zombie native instance that the next load() would orphan. - Add `test:unit` and `test:unit:generate` scripts that run the JS unit tests under `test/unit/*.test.js` via brittle + bare. Wire `test:unit` into `test:all` and into the PR workflow's ts-checks job so `map-addon-event.test.js` runs on every PR. - `.gitignore` the generated `test/unit/all.js` brittle runner. - CHANGELOG: document both fixes under Bug Fixes. --- .../workflows/on-pr-lib-infer-diffusion.yml | 4 ++++ packages/lib-infer-diffusion/.gitignore | 1 + packages/lib-infer-diffusion/CHANGELOG.md | 8 +++++++ packages/lib-infer-diffusion/index.js | 21 ++++++++++++++----- packages/lib-infer-diffusion/package.json | 4 +++- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/on-pr-lib-infer-diffusion.yml b/.github/workflows/on-pr-lib-infer-diffusion.yml index 319aca1d53..881fc23e71 100644 --- a/.github/workflows/on-pr-lib-infer-diffusion.yml +++ b/.github/workflows/on-pr-lib-infer-diffusion.yml @@ -99,6 +99,10 @@ jobs: working-directory: packages/lib-infer-diffusion run: npm run test:dts + - name: JS unit tests + working-directory: packages/lib-infer-diffusion + run: npm run test:unit + prebuild: needs: [authorize, sanity-checks] if: needs.authorize.outputs.allowed == 'true' diff --git a/packages/lib-infer-diffusion/.gitignore b/packages/lib-infer-diffusion/.gitignore index e7b80f8934..058e76dad7 100644 --- a/packages/lib-infer-diffusion/.gitignore +++ b/packages/lib-infer-diffusion/.gitignore @@ -25,3 +25,4 @@ temp/ *.deb *.zip test/integration/all.js +test/unit/all.js diff --git a/packages/lib-infer-diffusion/CHANGELOG.md b/packages/lib-infer-diffusion/CHANGELOG.md index 1b18566412..571e6b37b5 100644 --- a/packages/lib-infer-diffusion/CHANGELOG.md +++ b/packages/lib-infer-diffusion/CHANGELOG.md @@ -87,6 +87,14 @@ A second `load()` call on an already-loaded instance is now a silent no-op inste `_addonOutputCallback` previously had a fallthrough that pushed any non-error / non-image / non-stats event into `response.output` (including `null` and `undefined`). It now logs unknown events at debug level and does not feed them into the active response. +### Crash-safe activation + +If `addon.activate()` throws during `_load()` (for example a native init failure or a missing model file discovered late), the partially-initialized addon is now best-effort-unloaded, the native logger is released, and `this.addon` is reset to `null`. A subsequent `load()` call starts cleanly instead of leaking a zombie native instance. + +### `load()` is serialized through the exclusive run queue + +`load()` is now routed through the same `exclusiveRunQueue` used by `run()` and `unload()`. Previously two overlapping `load()` calls on the same instance could both pass the `configLoaded` guard before it flipped to `true`, both allocate a native addon, and clobber `this.addon` — leaking one native handle. Concurrent `load()` on a single instance is now safe. + ## Pull Requests - [#1496](https://github.com/tetherto/qvac/pull/1496) - chore[bc]: diffusion addon interface refactor — remove BaseInference diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 1cd34eaf21..5700a40282 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -67,9 +67,11 @@ class ImgStableDiffusion { } async load () { - if (this.state.configLoaded) return - await this._load() - this.state.configLoaded = true + return this._run(async () => { + if (this.state.configLoaded) return + await this._load() + this.state.configLoaded = true + }) } async _load () { @@ -98,8 +100,17 @@ class ImgStableDiffusion { this.logger.info('Creating stable-diffusion addon with configuration:', configurationParams) this.addon = this._createAddon(configurationParams) - this.logger.info('Activating stable-diffusion addon') - await this.addon.activate() + try { + this.logger.info('Activating stable-diffusion addon') + await this.addon.activate() + } catch (loadError) { + // Best-effort cleanup of the partially-initialized addon so a subsequent + // load() does not leak a zombie native instance. + try { await this.addon?.unload?.() } catch (_) {} + this.addon = null + this._releaseNativeLogger() + throw loadError + } this.logger.info('Stable-diffusion model load completed successfully') } diff --git a/packages/lib-infer-diffusion/package.json b/packages/lib-infer-diffusion/package.json index 14e8f86b4a..0561c9fb70 100644 --- a/packages/lib-infer-diffusion/package.json +++ b/packages/lib-infer-diffusion/package.json @@ -20,6 +20,8 @@ "test": "npm run test:integration", "test:integration": "npm run test:integration:generate && bare test/integration/all.js --exit", "test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js && npm run test:mobile:generate", + "test:unit:generate": "brittle -r test/unit/all.js test/unit/*.test.js", + "test:unit": "npm run test:unit:generate && bare test/unit/all.js --exit", "test:mobile:generate": "bare ./scripts/generate-mobile-integration-tests.js", "test:mobile:validate": "node scripts/validate-mobile-tests.js", "test:dts": "tsc -p tsconfig.dts.json", @@ -37,7 +39,7 @@ "coverage:cpp:summary": "cd build/test/unit && llvm-cov-19 report ./addon-test --instr-profile=coverage.profdata -ignore-filename-regex='(tests|build|node_modules|gtest|gmock|\\.vcpkg|/usr)/' > coverage-summary.txt", "coverage:cpp:report": "cd build/test/unit/ && ls -lha && llvm-profdata-19 merge -sparse default.profraw -o coverage.profdata && llvm-cov-19 show ./addon-test -instr-profile=coverage.profdata -format=html -output-dir=coverage-html -ignore-filename-regex='(tests|build|node_modules|gtest|gmock|\\.vcpkg|/usr)/' && llvm-cov-19 export ./addon-test -instr-profile=coverage.profdata -format=lcov -ignore-filename-regex='(tests|build|node_modules|gtest|gmock|\\.vcpkg|/usr)/' > lcov.info && npm run coverage:cpp:summary", "coverage:cpp": "npm run coverage:cpp:build && npm run coverage:cpp:run && npm run coverage:cpp:report", - "test:all": "npm run test && npm run test:cpp" + "test:all": "npm run test:unit && npm run test && npm run test:cpp" }, "files": [ "binding.js", From 297312676796147478a2e53007abc2f8d10b14fa Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 11:12:36 +0100 Subject: [PATCH 16/30] fix[ci]: run test:unit inside test:integration flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests need the bare runtime, which is only installed globally in the integration-test workflow (via npm install -g bare). My previous commit wired test:unit into the ts-checks job, which doesn't install bare, so it would have failed with command not found in CI. Chain test:unit at the script level instead — the integration-test workflow already runs npm run test:integration, which now runs unit tests first. Matches the standalone-repo precedent (qvac-lib-dl-filesystem, qvac-lib-decoder-audio, qvac-lib-error-base, etc.) of having the test script drive both. --- .github/workflows/on-pr-lib-infer-diffusion.yml | 4 ---- packages/lib-infer-diffusion/package.json | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/on-pr-lib-infer-diffusion.yml b/.github/workflows/on-pr-lib-infer-diffusion.yml index 881fc23e71..319aca1d53 100644 --- a/.github/workflows/on-pr-lib-infer-diffusion.yml +++ b/.github/workflows/on-pr-lib-infer-diffusion.yml @@ -99,10 +99,6 @@ jobs: working-directory: packages/lib-infer-diffusion run: npm run test:dts - - name: JS unit tests - working-directory: packages/lib-infer-diffusion - run: npm run test:unit - prebuild: needs: [authorize, sanity-checks] if: needs.authorize.outputs.allowed == 'true' diff --git a/packages/lib-infer-diffusion/package.json b/packages/lib-infer-diffusion/package.json index 0561c9fb70..582d38d879 100644 --- a/packages/lib-infer-diffusion/package.json +++ b/packages/lib-infer-diffusion/package.json @@ -17,8 +17,8 @@ "lint": "standard --ignore \"addon/**\"", "lint:fix": "standard --ignore \"addon/**\" --fix", "lint-cpp": "clang-tidy -p build $(find addon -name '*.cpp')", - "test": "npm run test:integration", - "test:integration": "npm run test:integration:generate && bare test/integration/all.js --exit", + "test": "npm run test:unit && npm run test:integration", + "test:integration": "npm run test:unit && npm run test:integration:generate && bare test/integration/all.js --exit", "test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js && npm run test:mobile:generate", "test:unit:generate": "brittle -r test/unit/all.js test/unit/*.test.js", "test:unit": "npm run test:unit:generate && bare test/unit/all.js --exit", From 5519deae6fc1db561a58347caf219efa7fb93473 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 11:29:34 +0100 Subject: [PATCH 17/30] fix[ci]: run test:unit via run-lint-and-unit-tests action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer flagged that test:unit invoked the `bare` CLI, but the ts-checks job did not install it. My previous commit's workaround — chaining test:unit into test:integration at the script level — would have re-run unit tests on every platform in the 7-way integration matrix. Revert both. Use the existing `tetherto/oss-actions/.github/actions/run-lint-and-unit-tests` action instead, same as `qvac-lib-infer-onnx` and `ocr-onnx`. The action installs bare globally and runs `npm run test:unit --if-present` in a single fast step. --- .github/workflows/on-pr-lib-infer-diffusion.yml | 9 +++++++++ packages/lib-infer-diffusion/package.json | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/on-pr-lib-infer-diffusion.yml b/.github/workflows/on-pr-lib-infer-diffusion.yml index 319aca1d53..4248bb4aae 100644 --- a/.github/workflows/on-pr-lib-infer-diffusion.yml +++ b/.github/workflows/on-pr-lib-infer-diffusion.yml @@ -99,6 +99,15 @@ jobs: working-directory: packages/lib-infer-diffusion run: npm run test:dts + - name: Run JavaScript tests + id: run_js_tests + uses: tetherto/oss-actions/.github/actions/run-lint-and-unit-tests@4c64bed91fc8eba3a201adb1495e61b4c1a2246d + with: + gpr-token: ${{ secrets.GITHUB_TOKEN }} + pat-token: ${{ secrets.GITHUB_TOKEN }} + registry-type: gpr + workdir: packages/lib-infer-diffusion + prebuild: needs: [authorize, sanity-checks] if: needs.authorize.outputs.allowed == 'true' diff --git a/packages/lib-infer-diffusion/package.json b/packages/lib-infer-diffusion/package.json index 582d38d879..0561c9fb70 100644 --- a/packages/lib-infer-diffusion/package.json +++ b/packages/lib-infer-diffusion/package.json @@ -17,8 +17,8 @@ "lint": "standard --ignore \"addon/**\"", "lint:fix": "standard --ignore \"addon/**\" --fix", "lint-cpp": "clang-tidy -p build $(find addon -name '*.cpp')", - "test": "npm run test:unit && npm run test:integration", - "test:integration": "npm run test:unit && npm run test:integration:generate && bare test/integration/all.js --exit", + "test": "npm run test:integration", + "test:integration": "npm run test:integration:generate && bare test/integration/all.js --exit", "test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js && npm run test:mobile:generate", "test:unit:generate": "brittle -r test/unit/all.js test/unit/*.test.js", "test:unit": "npm run test:unit:generate && bare test/unit/all.js --exit", From 71d1c83b30d6af10ad0c5626b59d9c47a74cfba7 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 11:30:25 +0100 Subject: [PATCH 18/30] chore: test script chains test:unit + test:integration Matches the standalone-repo precedent (qvac-lib-inference-addon-base, qvac-lib-dl-filesystem, etc.) so 'npm run test' runs both flows locally for developers. --- packages/lib-infer-diffusion/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib-infer-diffusion/package.json b/packages/lib-infer-diffusion/package.json index 0561c9fb70..079f78a719 100644 --- a/packages/lib-infer-diffusion/package.json +++ b/packages/lib-infer-diffusion/package.json @@ -17,7 +17,7 @@ "lint": "standard --ignore \"addon/**\"", "lint:fix": "standard --ignore \"addon/**\" --fix", "lint-cpp": "clang-tidy -p build $(find addon -name '*.cpp')", - "test": "npm run test:integration", + "test": "npm run test:unit && npm run test:integration", "test:integration": "npm run test:integration:generate && bare test/integration/all.js --exit", "test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js && npm run test:mobile:generate", "test:unit:generate": "brittle -r test/unit/all.js test/unit/*.test.js", From 1b302cbf9056aef733dc8f135bb88051843d418d Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 13:34:17 +0100 Subject: [PATCH 19/30] doc: fix mermaid classDiagram parsing error in architecture.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mermaid's classDiagram uses { and } as class-body delimiters, so the inline object literal in the method signatures broke the parser and prevented the diagram from rendering on GitHub. Replace: - constructor(args: {files, config, logger?, opts?}) → constructor(args: ImgStableDiffusionArgs) (matches index.d.ts) - getState() {configLoaded} → getState() State Reported by maxim-smotrov at architecture.md:150. --- packages/lib-infer-diffusion/docs/architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lib-infer-diffusion/docs/architecture.md b/packages/lib-infer-diffusion/docs/architecture.md index 23c4a16cf9..cac0f1d12e 100644 --- a/packages/lib-infer-diffusion/docs/architecture.md +++ b/packages/lib-infer-diffusion/docs/architecture.md @@ -147,12 +147,12 @@ graph TB ```mermaid classDiagram class ImgStableDiffusion { - +constructor(args: {files, config, logger?, opts?}) + +constructor(args: ImgStableDiffusionArgs) +load() Promise~void~ +run(params: GenerationParams) Promise~QvacResponse~ +cancel() Promise~void~ +unload() Promise~void~ - +getState() {configLoaded} + +getState() State } class JobHandler { From ff980bd0aca49997e24c3fdce58dbb3bee1dd93d Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 16:57:54 +0100 Subject: [PATCH 20/30] chore[ci]: rename step to reflect what the action actually runs The run-lint-and-unit-tests action runs `npm run lint` and `npm run test:unit`. The step name "Run JavaScript tests" hides the lint half. Rename to "Run lint and unit tests" and update the step id accordingly. --- .github/workflows/on-pr-lib-infer-diffusion.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/on-pr-lib-infer-diffusion.yml b/.github/workflows/on-pr-lib-infer-diffusion.yml index 4248bb4aae..2c5b4430e4 100644 --- a/.github/workflows/on-pr-lib-infer-diffusion.yml +++ b/.github/workflows/on-pr-lib-infer-diffusion.yml @@ -99,8 +99,8 @@ jobs: working-directory: packages/lib-infer-diffusion run: npm run test:dts - - name: Run JavaScript tests - id: run_js_tests + - name: Run lint and unit tests + id: run_lint_and_unit_tests uses: tetherto/oss-actions/.github/actions/run-lint-and-unit-tests@4c64bed91fc8eba3a201adb1495e61b4c1a2246d with: gpr-token: ${{ secrets.GITHUB_TOKEN }} From 78c8cd018f1bac2afe29595c4c9d66eb12caeda1 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 18:44:11 +0100 Subject: [PATCH 21/30] fix: doc and type drift around img2img; dead code in SdModel.cpp - index.d.ts: replace the stale "not yet supported, throws at runtime" JSDoc on `init_image` and `strength` with accurate docs covering the FLUX.2 in-context-conditioning branch and the SD/SDEdit branch. This PR ships img2img support end-to-end, so the type hover docs contradicted the runtime behavior. - docs/img2img-quickstart.md: rewrite against the refactored API. Replace the two-arg constructor (`diskPath`, `modelName`, `llmModel`, `vaeModel`) with the single-object `{ files, config, logger }` shape, switch every example from `model.img2img(...)` to `model.run({ init_image, ... })`, and correct the package name from `@qvac/lib-infer-diffusion` to `@qvac/diffusion-cpp`. - docs/architecture.md: bump the package header to v0.3.0. - examples/quick-test.js: delete. It used ESM `import` syntax in a CommonJS package and imported a non-existent `diffusionAddon` named export; nothing referenced it. - addon/src/model-interface/SdModel.cpp: remove the duplicate `genParams.height = imgH;` in the `useRefImages` branch. Harmless dead store, but easy to miss in review. --- .../addon/src/model-interface/SdModel.cpp | 1 - .../lib-infer-diffusion/docs/architecture.md | 2 +- .../docs/img2img-quickstart.md | 47 +++++++++++-------- .../examples/quick-test.js | 18 ------- packages/lib-infer-diffusion/index.d.ts | 13 ++++- 5 files changed, 39 insertions(+), 42 deletions(-) delete mode 100644 packages/lib-infer-diffusion/examples/quick-test.js diff --git a/packages/lib-infer-diffusion/addon/src/model-interface/SdModel.cpp b/packages/lib-infer-diffusion/addon/src/model-interface/SdModel.cpp index 642351c4bf..70013f4373 100644 --- a/packages/lib-infer-diffusion/addon/src/model-interface/SdModel.cpp +++ b/packages/lib-infer-diffusion/addon/src/model-interface/SdModel.cpp @@ -575,7 +575,6 @@ std::any SdModel::process(const std::any& input) { if (gen.width == 512 && gen.height == 512) { genParams.width = imgW; genParams.height = imgH; - genParams.height = imgH; } gen.width = genParams.width; gen.height = genParams.height; diff --git a/packages/lib-infer-diffusion/docs/architecture.md b/packages/lib-infer-diffusion/docs/architecture.md index cac0f1d12e..270d0a899e 100644 --- a/packages/lib-infer-diffusion/docs/architecture.md +++ b/packages/lib-infer-diffusion/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture Documentation -**Package:** `@qvac/diffusion-cpp` v0.2.0 +**Package:** `@qvac/diffusion-cpp` v0.3.0 **Stack:** JavaScript, C++20, stable-diffusion.cpp, Bare Runtime, CMake, vcpkg **License:** Apache-2.0 diff --git a/packages/lib-infer-diffusion/docs/img2img-quickstart.md b/packages/lib-infer-diffusion/docs/img2img-quickstart.md index 626ae1321a..b3bc02f151 100644 --- a/packages/lib-infer-diffusion/docs/img2img-quickstart.md +++ b/packages/lib-infer-diffusion/docs/img2img-quickstart.md @@ -13,23 +13,29 @@ This downloads ~6.7 GB of models (FLUX2-klein, Qwen3 text encoder, VAE). ## Basic Usage ```javascript -const ImgStableDiffusion = require('@qvac/lib-infer-diffusion') +const ImgStableDiffusion = require('@qvac/diffusion-cpp') const fs = require('bare-fs') +const path = require('bare-path') + +const modelsDir = path.resolve('./models') // 1. Setup model -const model = new ImgStableDiffusion( - { - diskPath: './models', - modelName: 'flux-2-klein-4b-Q8_0.gguf', - llmModel: 'Qwen3-4B-Q4_K_M.gguf', - vaeModel: 'flux2-vae.safetensors' +const model = new ImgStableDiffusion({ + files: { + model: path.join(modelsDir, 'flux-2-klein-4b-Q8_0.gguf'), + llm: path.join(modelsDir, 'Qwen3-4B-Q4_K_M.gguf'), + vae: path.join(modelsDir, 'flux2-vae.safetensors') }, - { + config: { threads: 4, - device: 'gpu', // or 'cpu' + device: 'gpu', // or 'cpu' + // FLUX img2img requires an explicit prediction type. Without this the + // addon silently falls back to the SD/SDEdit branch instead of the + // FLUX in-context conditioning path. prediction: 'flux2_flow' - } -) + }, + logger: console +}) // 2. Load model await model.load() @@ -37,8 +43,8 @@ await model.load() // 3. Read input image const inputImage = fs.readFileSync('input.jpg') -// 4. Transform image -const response = await model.img2img({ +// 4. Transform image (mode is auto-detected: init_image => img2img) +const response = await model.run({ prompt: 'professional portrait, studio lighting', init_image: inputImage, strength: 0.5, @@ -47,11 +53,12 @@ const response = await model.img2img({ }) // 5. Handle output -await response.onUpdate((data) => { +response.onUpdate((data) => { if (data instanceof Uint8Array) { fs.writeFileSync('output.png', data) } -}).await() +}) +await response.await() // 6. Cleanup await model.unload() @@ -63,7 +70,7 @@ await model.unload() - **`prompt`**: Text description of desired transformation - **`init_image`**: Source image as `Uint8Array` (PNG or JPEG) -- **`strength`**: Transformation strength (0-1) +- **`strength`**: Transformation strength (0-1). Applies to the SD/SDEdit branch. For FLUX.2 the image is routed through in-context conditioning instead, so `strength` is ignored. - `0.3-0.4`: Subtle changes (style tweaks) - `0.5-0.6`: Moderate transformation (recommended starting point) - `0.7-0.8`: Strong changes (significant alterations) @@ -81,7 +88,7 @@ await model.unload() ### 1. Subtle Style Change ```javascript -await model.img2img({ +await model.run({ prompt: 'same photo, cinematic color grading', init_image: photo, strength: 0.35, @@ -93,7 +100,7 @@ await model.img2img({ ### 2. Moderate Transformation ```javascript -await model.img2img({ +await model.run({ prompt: 'professional headshot, studio lighting, sharp focus', negative_prompt: 'blurry, low quality, distorted', init_image: photo, @@ -106,7 +113,7 @@ await model.img2img({ ### 3. Strong Artistic Style ```javascript -await model.img2img({ +await model.run({ prompt: 'oil painting, impressionist style, vibrant colors', init_image: photo, strength: 0.75, @@ -172,4 +179,4 @@ See [`examples/img2img-flux2.js`](../examples/img2img-flux2.js) for a complete w ## API Reference -Full documentation: [README.md](../README.md#image-to-image-modelimg2img) +Full documentation: [README.md](../README.md) diff --git a/packages/lib-infer-diffusion/examples/quick-test.js b/packages/lib-infer-diffusion/examples/quick-test.js deleted file mode 100644 index 4230253924..0000000000 --- a/packages/lib-infer-diffusion/examples/quick-test.js +++ /dev/null @@ -1,18 +0,0 @@ -// Quick test example for img2img functionality -import { diffusionAddon } from '../index.js' - -async function quickTest () { - console.log('✓ Testing @qvac/diffusion-cpp package...') - console.log(` Package loaded: ${typeof diffusionAddon}`) - - // Example test cases - console.log('\n📋 Available test cases:') - console.log(' 1. txt2img generation') - console.log(' 2. img2img generation (NEW!)') - console.log(' 3. Model loading verification') - - console.log('\n✅ Package is accessible without token!') - console.log('📦 Ready for publishing to npm') -} - -quickTest().catch(console.error) diff --git a/packages/lib-infer-diffusion/index.d.ts b/packages/lib-infer-diffusion/index.d.ts index 88f3fc75f2..b7b4999f93 100644 --- a/packages/lib-infer-diffusion/index.d.ts +++ b/packages/lib-infer-diffusion/index.d.ts @@ -184,9 +184,18 @@ export interface GenerationParams { img_cfg_scale?: number /** Skip last N CLIP encoder layers (SD1.x/SD2.x) */ clip_skip?: number - /** Input image as PNG/JPEG bytes for img2img (not yet supported — throws at runtime) */ + /** + * Input image as PNG/JPEG bytes for img2img. + * + * FLUX.2: in-context conditioning (`ref_images`). Requires an explicit + * `prediction: 'flux2_flow'` in the config or the addon falls back to the + * SD/SDEdit branch. + * + * SD1.x / SD2.x / SDXL / SD3: SDEdit (the image is noised to the level + * set by `strength`, then denoised for the remaining steps). + */ init_image?: Uint8Array - /** img2img denoising strength (0.0–1.0). 0 = keep source, 1 = ignore source (not yet supported) */ + /** img2img denoising strength (0.0 to 1.0). 0 keeps the source image, 1 ignores it. Applies to the SDEdit branch (SD1.x/SD2.x/SDXL/SD3); ignored for FLUX.2 in-context conditioning. */ strength?: number } From 0170b29a9230ee9d1a735eb80d5136028e29fc50 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 19:44:11 +0100 Subject: [PATCH 22/30] doc: refresh Key Features, migration marker, and img2img JSDoc - architecture.md: Key Features list `Generation modes` now includes img2img alongside txt2img, matching the shipped runtime. - CHANGELOG.md: migration example marker changes from `BEFORE (<= 0.1.x)` to `BEFORE (<= 0.2.x)` since 0.2.x also used the old two-argument constructor. - index.d.ts: trim the init_image / strength JSDoc to verified facts. The old text said both were "not yet supported, throws at runtime", which is false on this branch (img2img ships end-to-end). Previous revision added branch-specific behavior claims; replaced with the minimal accurate description. --- packages/lib-infer-diffusion/CHANGELOG.md | 2 +- packages/lib-infer-diffusion/docs/architecture.md | 2 +- packages/lib-infer-diffusion/index.d.ts | 13 ++----------- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/lib-infer-diffusion/CHANGELOG.md b/packages/lib-infer-diffusion/CHANGELOG.md index 571e6b37b5..2c5a5131b8 100644 --- a/packages/lib-infer-diffusion/CHANGELOG.md +++ b/packages/lib-infer-diffusion/CHANGELOG.md @@ -11,7 +11,7 @@ This release migrates the diffusion addon off `BaseInference` inheritance and on `ImgStableDiffusion` now takes a single `{ files, config, logger?, opts? }` object. The old `diskPath` + `modelName` + per-component filename pattern is gone — callers pass absolute paths directly via `files`. Companion model fields are renamed (`clipLModel` → `clipL`, `clipGModel` → `clipG`, `t5XxlModel` → `t5Xxl`, `llmModel` → `llm`, `vaeModel` → `vae`). ```js -// BEFORE (≤ 0.1.x) +// BEFORE (≤ 0.2.x) const model = new ImgStableDiffusion({ diskPath: '/models', modelName: 'flux-2-klein-4b-Q8_0.gguf', diff --git a/packages/lib-infer-diffusion/docs/architecture.md b/packages/lib-infer-diffusion/docs/architecture.md index 270d0a899e..9f65985923 100644 --- a/packages/lib-infer-diffusion/docs/architecture.md +++ b/packages/lib-infer-diffusion/docs/architecture.md @@ -54,7 +54,7 @@ - **GPU acceleration**: Metal, Vulkan, OpenCL - **Quantized models**: GGUF, safetensors, checkpoint formats - **Diffusion models**: SD1.x, SD2.x, SDXL, SD3, FLUX.2 [klein] -- **Generation modes**: txt2img +- **Generation modes**: txt2img, img2img ## Target Platforms diff --git a/packages/lib-infer-diffusion/index.d.ts b/packages/lib-infer-diffusion/index.d.ts index b7b4999f93..19c040ccdd 100644 --- a/packages/lib-infer-diffusion/index.d.ts +++ b/packages/lib-infer-diffusion/index.d.ts @@ -184,18 +184,9 @@ export interface GenerationParams { img_cfg_scale?: number /** Skip last N CLIP encoder layers (SD1.x/SD2.x) */ clip_skip?: number - /** - * Input image as PNG/JPEG bytes for img2img. - * - * FLUX.2: in-context conditioning (`ref_images`). Requires an explicit - * `prediction: 'flux2_flow'` in the config or the addon falls back to the - * SD/SDEdit branch. - * - * SD1.x / SD2.x / SDXL / SD3: SDEdit (the image is noised to the level - * set by `strength`, then denoised for the remaining steps). - */ + /** Input image as PNG/JPEG bytes for img2img. */ init_image?: Uint8Array - /** img2img denoising strength (0.0 to 1.0). 0 keeps the source image, 1 ignores it. Applies to the SDEdit branch (SD1.x/SD2.x/SDXL/SD3); ignored for FLUX.2 in-context conditioning. */ + /** img2img denoising strength (0.0 to 1.0). 0 = keep source, 1 = ignore source. */ strength?: number } From 7250731008635e5527a922ecdb22ea589fa5b6e5 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 19:49:23 +0100 Subject: [PATCH 23/30] doc: restore JSDoc on SD cancel() and unload() Both methods had one-line JSDoc on main describing what they do ("Cancel the current generation job.", "Unload the model and release all resources."). The refactor dropped the JSDoc comments when it rewrote the method bodies. Restore them since the purpose statement is still accurate. --- packages/lib-infer-diffusion/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 5700a40282..2b14421be8 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -286,12 +286,18 @@ class ImgStableDiffusion { return response } + /** + * Cancel the current generation job. + */ async cancel () { if (this.addon?.cancel) { await this.addon.cancel() } } + /** + * Unload the model and release all resources. + */ async unload () { return this._run(async () => { await this.cancel() From ff968f6ea0d228d9228ed32800372081ca2e1250 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 20:16:24 +0100 Subject: [PATCH 24/30] doc: trim verbose comments added during the refactor Tighten comments this PR introduced that drifted into over-explanation. Leave pre-existing comments as-is. - addon.js mapAddonEvent JSDoc: drop multi-paragraph prose; keep the one-sentence contract plus the param block. - index.js constructor: collapse the cancel-closure rationale to one line. - docs/architecture.md: bump Last Updated to 2026-04-16. --- packages/lib-infer-diffusion/addon.js | 15 +++------------ packages/lib-infer-diffusion/docs/architecture.md | 2 +- packages/lib-infer-diffusion/index.js | 4 +--- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/lib-infer-diffusion/addon.js b/packages/lib-infer-diffusion/addon.js index 5e39d021bd..06f6544ccc 100644 --- a/packages/lib-infer-diffusion/addon.js +++ b/packages/lib-infer-diffusion/addon.js @@ -3,18 +3,9 @@ const path = require('bare-path') /** - * Map a raw native event from the C++ stable-diffusion addon to a logical - * event consumed by `ImgStableDiffusion`. - * - * The native binding emits events with C++-mangled names and varied - * payload shapes. This wrapper normalizes them into one of: - * - `'Output'` — image bytes (`Uint8Array`) or progress JSON tick (`string`) - * - `'Error'` — failure - * - `'JobEnded'` — terminal RuntimeStats payload (object) - * - * Returns `{ type, data, error }` or `null` for unknown event/data shapes - * (caller logs at debug level). - * + * Normalize a raw native event into `Output` (image bytes or progress + * tick), `Error`, or `JobEnded`. Returns `null` for unknown shapes + * (caller logs and skips). * * @param {string} rawEvent * @param {*} rawData diff --git a/packages/lib-infer-diffusion/docs/architecture.md b/packages/lib-infer-diffusion/docs/architecture.md index 9f65985923..3d65dd4819 100644 --- a/packages/lib-infer-diffusion/docs/architecture.md +++ b/packages/lib-infer-diffusion/docs/architecture.md @@ -771,4 +771,4 @@ Provide hand-written TypeScript definitions in `index.d.ts`. --- -**Last Updated:** 2026-04-10 +**Last Updated:** 2026-04-16 diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 2b14421be8..773a4cbd1a 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -54,9 +54,7 @@ class ImgStableDiffusion { this._config = config || {} this.logger = new QvacLogger(logger) this.opts = opts - // The cancel closure dereferences `this.addon` lazily, so it is safe even though - // `this.addon` is `null` at construction time — it is only invoked from - // `response.cancel()` after `_load()` has assigned the addon. + // Lazy deref + optional chain: safe before `_load()` and after `unload()`. this._job = createJobHandler({ cancel: () => this.addon?.cancel() }) this._run = exclusiveRunQueue() this.addon = null From a2710baad6fac9d1d34785838937e8c1aa1de915 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 20:54:33 +0100 Subject: [PATCH 25/30] doc: restore pre-refactor createAddon JSDoc and load error log The refactor commit silently dropped the JSDoc block on _createAddon() and the 'Error during stable-diffusion model load' error log in _load(). Put them back so the refactor only changes what needs to change. --- packages/lib-infer-diffusion/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 773a4cbd1a..5efd382d7e 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -102,6 +102,7 @@ class ImgStableDiffusion { this.logger.info('Activating stable-diffusion addon') await this.addon.activate() } catch (loadError) { + this.logger.error('Error during stable-diffusion model load:', loadError) // Best-effort cleanup of the partially-initialized addon so a subsequent // load() does not leak a zombie native instance. try { await this.addon?.unload?.() } catch (_) {} @@ -113,6 +114,10 @@ class ImgStableDiffusion { this.logger.info('Stable-diffusion model load completed successfully') } + /** + * @param {object} configurationParams + * @returns {SdInterface} + */ _createAddon (configurationParams) { this._binding = require('./binding') this._connectNativeLogger() From 80819f521e4fac8b775868e08f53ddc805cda994 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 21:19:20 +0100 Subject: [PATCH 26/30] fix: release native logger when addon construction throws _load() wrapped only await this.addon.activate() in try/catch, but _createAddon() calls _connectNativeLogger() and then constructs SdInterface. If the SdInterface constructor throws, _nativeLoggerActive stays set and the native logger hook is never released; a retry on the same instance would reconnect on top of a stale hook. Move _createAddon() inside the try so the existing catch path runs _releaseNativeLogger() for every pre-activate failure. --- packages/lib-infer-diffusion/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib-infer-diffusion/index.js b/packages/lib-infer-diffusion/index.js index 5efd382d7e..b4f7a18a14 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -96,9 +96,9 @@ class ImgStableDiffusion { } this.logger.info('Creating stable-diffusion addon with configuration:', configurationParams) - this.addon = this._createAddon(configurationParams) try { + this.addon = this._createAddon(configurationParams) this.logger.info('Activating stable-diffusion addon') await this.addon.activate() } catch (loadError) { From a0f71ade0216b78565eb1575df9ad90885f8a9be Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 21:19:30 +0100 Subject: [PATCH 27/30] chore: drop unused 'test' script, inline into 'test:all' The 'test' alias was only consumed by 'test:all', and neither was referenced in CI workflows or the README. 'test:all' ran test:unit twice because it called both test:unit and the 'test' alias. Remove 'test' and rewrite 'test:all' to run test:unit, test:integration, and test:cpp directly. --- packages/lib-infer-diffusion/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/lib-infer-diffusion/package.json b/packages/lib-infer-diffusion/package.json index 079f78a719..2ea885a8b8 100644 --- a/packages/lib-infer-diffusion/package.json +++ b/packages/lib-infer-diffusion/package.json @@ -17,7 +17,6 @@ "lint": "standard --ignore \"addon/**\"", "lint:fix": "standard --ignore \"addon/**\" --fix", "lint-cpp": "clang-tidy -p build $(find addon -name '*.cpp')", - "test": "npm run test:unit && npm run test:integration", "test:integration": "npm run test:integration:generate && bare test/integration/all.js --exit", "test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js && npm run test:mobile:generate", "test:unit:generate": "brittle -r test/unit/all.js test/unit/*.test.js", @@ -39,7 +38,7 @@ "coverage:cpp:summary": "cd build/test/unit && llvm-cov-19 report ./addon-test --instr-profile=coverage.profdata -ignore-filename-regex='(tests|build|node_modules|gtest|gmock|\\.vcpkg|/usr)/' > coverage-summary.txt", "coverage:cpp:report": "cd build/test/unit/ && ls -lha && llvm-profdata-19 merge -sparse default.profraw -o coverage.profdata && llvm-cov-19 show ./addon-test -instr-profile=coverage.profdata -format=html -output-dir=coverage-html -ignore-filename-regex='(tests|build|node_modules|gtest|gmock|\\.vcpkg|/usr)/' && llvm-cov-19 export ./addon-test -instr-profile=coverage.profdata -format=lcov -ignore-filename-regex='(tests|build|node_modules|gtest|gmock|\\.vcpkg|/usr)/' > lcov.info && npm run coverage:cpp:summary", "coverage:cpp": "npm run coverage:cpp:build && npm run coverage:cpp:run && npm run coverage:cpp:report", - "test:all": "npm run test:unit && npm run test && npm run test:cpp" + "test:all": "npm run test:unit && npm run test:integration && npm run test:cpp" }, "files": [ "binding.js", From 9f6823711728763e61ce9288c50844356bed0e05 Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Thu, 16 Apr 2026 21:55:02 +0100 Subject: [PATCH 28/30] doc: fix 0.3.0 CHANGELOG heading depth and queue serialization scope The 0.3.0 section used ## for Breaking Changes / Features / Bug Fixes / Pull Requests, making them siblings of the version heading instead of children; bump those to ### and the leaf subsections from ### to #### so the TOC renders correctly, matching the 0.2.x entries. Two architecture.md spots still said the exclusive run queue serialises only run()/unload() even though load() now also wraps in this._run(...); align both. --- packages/lib-infer-diffusion/CHANGELOG.md | 36 +++++++++---------- .../lib-infer-diffusion/docs/architecture.md | 4 +-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/lib-infer-diffusion/CHANGELOG.md b/packages/lib-infer-diffusion/CHANGELOG.md index 2c5a5131b8..7fd8e6cdcb 100644 --- a/packages/lib-infer-diffusion/CHANGELOG.md +++ b/packages/lib-infer-diffusion/CHANGELOG.md @@ -4,9 +4,9 @@ This release migrates the diffusion addon off `BaseInference` inheritance and onto the composable `createJobHandler` + `exclusiveRunQueue` utilities from `@qvac/infer-base@^0.4.0`. The constructor signature is replaced with a single object whose `files` field carries absolute paths for every model component, mirroring the parallel embed and LLM addon refactors. This is a breaking change — every caller must update. -## Breaking Changes +### Breaking Changes -### Constructor signature: single object with `files` instead of `(args, config)` +#### Constructor signature: single object with `files` instead of `(args, config)` `ImgStableDiffusion` now takes a single `{ files, config, logger?, opts? }` object. The old `diskPath` + `modelName` + per-component filename pattern is gone — callers pass absolute paths directly via `files`. Companion model fields are renamed (`clipLModel` → `clipL`, `clipGModel` → `clipG`, `t5XxlModel` → `t5Xxl`, `llmModel` → `llm`, `vaeModel` → `vae`). @@ -33,19 +33,19 @@ const model = new ImgStableDiffusion({ }) ``` -### `BaseInference` inheritance removed +#### `BaseInference` inheritance removed `ImgStableDiffusion` no longer extends `BaseInference`. The class composes `createJobHandler` and `exclusiveRunQueue` from `@qvac/infer-base@^0.4.0` directly. The public lifecycle (`load` / `run` / `cancel` / `unload` / `getState`) is unchanged in shape; only construction differs. Internal helpers like `_withExclusiveRun` and `_outputCallback` are removed. -### Caller owns absolute paths — addon no longer joins `diskPath` + filename +#### Caller owns absolute paths — addon no longer joins `diskPath` + filename Callers that previously relied on the addon to resolve `path.join(diskPath, filename)` must now do that resolution themselves before constructing the model. -### `getState()` returns a narrower shape +#### `getState()` returns a narrower shape `getState()` previously returned `{ configLoaded, weightsLoaded, destroyed }` (the three-field shape from `BaseInference`). It now returns `{ configLoaded }` only. The `weightsLoaded` and `destroyed` fields are gone — `weightsLoaded` collapsed into `configLoaded` because the refactored `load()` does both in one step, and `destroyed` is no longer tracked since `unload()` resets `configLoaded` and nulls the addon handle instead. Callers reading `state.weightsLoaded` or `state.destroyed` must switch to `state.configLoaded`. -### Public methods removed from `ImgStableDiffusion` +#### Public methods removed from `ImgStableDiffusion` `ImgStableDiffusion` previously exposed these methods via `BaseInference` inheritance, all of which are now gone: @@ -55,47 +55,47 @@ Callers that previously relied on the addon to resolve `path.join(diskPath, file - `destroy()` — folded into `unload()`, which now both releases native resources and nulls `this.addon`. - `getApiDefinition()` — no longer exposed; consumers should import types from `index.d.ts`. -### `cancel()` no longer accepts a `jobId` +#### `cancel()` no longer accepts a `jobId` `BaseInference.cancel(jobId)` took an optional `jobId` argument. The refactor's `cancel()` is parameterless — there is always at most one active generation per instance, owned by `createJobHandler`. Any caller passing a `jobId` will have it ignored; update call sites to `await model.cancel()`. -## Features +### Features -### Constructor input validation +#### Constructor input validation The constructor now throws `TypeError('files.model must be an absolute path string')` when `files.model` is missing or not a string, or `TypeError('files.model must be an absolute path (got: )')` when supplied as a relative path. This produces a clear error for callers porting old code instead of a confusing `Cannot read properties of undefined`. The same validation applies to optional companion fields (`clipL`, `clipG`, `t5Xxl`, `llm`, `vae`) when supplied. -### `run()`-before-`load()` guard +#### `run()`-before-`load()` guard Calling `run()` before `load()` now throws `Error('Addon not initialized. Call load() first.')` instead of crashing in native code. Covered by a new regression test in `test/integration/api-behavior.test.js`. -### `load()` is now idempotent when already loaded +#### `load()` is now idempotent when already loaded A second `load()` call on an already-loaded instance is now a silent no-op instead of unloading and reloading. This aligns with the ReadyResource pattern used elsewhere in QVAC and prevents accidental double-loads from triggering expensive work. Callers that intentionally want to swap weights must call `unload()` first (which clears `configLoaded`) and then `load()` again. -### Broader split-layout detection +#### Broader split-layout detection `isSplitLayout` now also triggers when only `clipL` or `clipG` is supplied. This closes a footgun where a FLUX.1 caller passing `{ model, clipL, clipG, vae }` (without `t5Xxl`) would silently mis-route the diffusion model into the all-in-one `path` parameter and fail to load. -## Bug Fixes +### Bug Fixes -### `unload()` clears the addon reference +#### `unload()` clears the addon reference `unload()` now sets `this.addon = null` after `await this.addon.unload()`, so post-unload `cancel()` / `run()` calls hit the explicit `if (!this.addon)` guard rather than dereferencing a disposed native handle. -### Unknown addon events no longer pollute the output stream +#### Unknown addon events no longer pollute the output stream `_addonOutputCallback` previously had a fallthrough that pushed any non-error / non-image / non-stats event into `response.output` (including `null` and `undefined`). It now logs unknown events at debug level and does not feed them into the active response. -### Crash-safe activation +#### Crash-safe activation If `addon.activate()` throws during `_load()` (for example a native init failure or a missing model file discovered late), the partially-initialized addon is now best-effort-unloaded, the native logger is released, and `this.addon` is reset to `null`. A subsequent `load()` call starts cleanly instead of leaking a zombie native instance. -### `load()` is serialized through the exclusive run queue +#### `load()` is serialized through the exclusive run queue `load()` is now routed through the same `exclusiveRunQueue` used by `run()` and `unload()`. Previously two overlapping `load()` calls on the same instance could both pass the `configLoaded` guard before it flipped to `true`, both allocate a native addon, and clobber `this.addon` — leaking one native handle. Concurrent `load()` on a single instance is now safe. -## Pull Requests +### Pull Requests - [#1496](https://github.com/tetherto/qvac/pull/1496) - chore[bc]: diffusion addon interface refactor — remove BaseInference diff --git a/packages/lib-infer-diffusion/docs/architecture.md b/packages/lib-infer-diffusion/docs/architecture.md index 3d65dd4819..e8af38df44 100644 --- a/packages/lib-infer-diffusion/docs/architecture.md +++ b/packages/lib-infer-diffusion/docs/architecture.md @@ -197,7 +197,7 @@ classDiagram | From | To | Type | Purpose | |------|-----|------|---------| | ImgStableDiffusion | JobHandler | Composition | Lifecycle of the active job (replaces inheriting from `BaseInference`) | -| ImgStableDiffusion | ExclusiveRunQueue | Composition | Serializes `run()` and `unload()` (cancel is intentionally outside the queue so it can interrupt an in-flight run) | +| ImgStableDiffusion | ExclusiveRunQueue | Composition | Serializes `load()`, `run()`, and `unload()` (cancel is intentionally outside the queue so it can interrupt an in-flight run) | | JobHandler | QvacResponse | Creates | Progress/result per generation | > **Note:** `ImgStableDiffusion` no longer extends `BaseInference`. It composes the helpers exposed by `@qvac/infer-base` (`createJobHandler`, `exclusiveRunQueue`) directly. @@ -702,7 +702,7 @@ Diffusion generation takes significant time (seconds to minutes). Without coordi ### Decision -Use the `exclusiveRunQueue()` helper from `@qvac/infer-base`. The constructor stores the queue as `this._run`, and `run()` and `unload()` wrap their bodies with `this._run(() => …)`. `cancel()` is intentionally **not** queued — it must be able to interrupt an in-flight `run()` to terminate it, so it bypasses the queue and delegates straight to `addon.cancel()` (which is itself a no-op when there is no active job). This replaces the previous `BaseInference._withExclusiveRun()` template-method approach with a small composable utility. +Use the `exclusiveRunQueue()` helper from `@qvac/infer-base`. The constructor stores the queue as `this._run`, and `load()`, `run()`, and `unload()` wrap their bodies with `this._run(() => …)`. `cancel()` is intentionally **not** queued — it must be able to interrupt an in-flight `run()` to terminate it, so it bypasses the queue and delegates straight to `addon.cancel()` (which is itself a no-op when there is no active job). This replaces the previous `BaseInference._withExclusiveRun()` template-method approach with a small composable utility. ### Rationale From 1f4a8b9ad5b096bef18fe58b10e6c585e346aeda Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Fri, 17 Apr 2026 12:14:44 +0100 Subject: [PATCH 29/30] doc: fix flowchart mermaid parse errors in data-flows Three flowchart node labels contained ( ) inside unquoted [ ] labels: RunJob[addon.runJob(paramsJson)] EncodePrompt[Encode prompt (CLIP)] InitLatents[Initialize random latents (seed)] Mermaid treats the ( as the start of a stadium-shaped node inside the rectangular node, so the diagram failed to parse. Quote each label so the parens render literally. --- packages/lib-infer-diffusion/docs/data-flows-detailed.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/lib-infer-diffusion/docs/data-flows-detailed.md b/packages/lib-infer-diffusion/docs/data-flows-detailed.md index 0f5510a126..fd40b19adb 100644 --- a/packages/lib-infer-diffusion/docs/data-flows-detailed.md +++ b/packages/lib-infer-diffusion/docs/data-flows-detailed.md @@ -38,7 +38,7 @@ flowchart TD Start([JS: model.run]) --> ParseParams[Parse generation params] ParseParams --> SerializeJSON[Serialize to JSON] - SerializeJSON --> RunJob[addon.runJob(paramsJson)] + SerializeJSON --> RunJob["addon.runJob(paramsJson)"] RunJob --> CreateResp[Create QvacResponse] CreateResp --> ReturnJS([Return to JavaScript]) @@ -60,9 +60,9 @@ flowchart TD EmitStart --> SendAsync1[uv_async_send] SendAsync1 --> ParseJSON[Parse JSON params] - ParseJSON --> EncodePrompt[Encode prompt (CLIP)] + ParseJSON --> EncodePrompt["Encode prompt (CLIP)"] EncodePrompt --> EncodeNeg[Encode negative prompt] - EncodeNeg --> InitLatents[Initialize random latents (seed)] + EncodeNeg --> InitLatents["Initialize random latents (seed)"] InitLatents --> DiffusionLoop{Diffusion Loop} DiffusionLoop -->|Continue| PredictNoise[UNet predict noise] From 98657e61567768174c404635c1ca4db4c54bbe0b Mon Sep 17 00:00:00 2001 From: Ridwan Taiwo Date: Fri, 17 Apr 2026 15:04:14 +0100 Subject: [PATCH 30/30] doc: note FLUX.2 ignores strength in GenerationParams JSDoc docs/img2img-quickstart.md already notes that the FLUX.2 in-context conditioning path does not use strength and the input image is routed through ref_images instead. The d.ts on GenerationParams.strength still implied the knob applies universally; match the doc so IDE hover docs tell the same story. --- packages/lib-infer-diffusion/index.d.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/lib-infer-diffusion/index.d.ts b/packages/lib-infer-diffusion/index.d.ts index 19c040ccdd..2bb1b606b0 100644 --- a/packages/lib-infer-diffusion/index.d.ts +++ b/packages/lib-infer-diffusion/index.d.ts @@ -186,7 +186,11 @@ export interface GenerationParams { clip_skip?: number /** Input image as PNG/JPEG bytes for img2img. */ init_image?: Uint8Array - /** img2img denoising strength (0.0 to 1.0). 0 = keep source, 1 = ignore source. */ + /** + * img2img denoising strength (0.0 to 1.0). 0 = keep source, 1 = ignore source. + * SD1.x/SD2.x/SDXL/SD3 only. FLUX.2 ignores `strength` and routes `init_image` + * through in-context conditioning instead. + */ strength?: number }