diff --git a/.github/workflows/on-pr-lib-infer-diffusion.yml b/.github/workflows/on-pr-lib-infer-diffusion.yml index 319aca1d53..2c5b4430e4 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 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 }} + 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/.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 691900133e..7fd8e6cdcb 100644 --- a/packages/lib-infer-diffusion/CHANGELOG.md +++ b/packages/lib-infer-diffusion/CHANGELOG.md @@ -1,5 +1,104 @@ # Changelog +## [0.3.0] - 2026-04-15 + +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.2.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.3.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. + +#### `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` + +`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 + +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 + +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 + +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 + +`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. + +#### 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 + ## [0.2.0] - 2026-04-15 ### Added diff --git a/packages/lib-infer-diffusion/README.md b/packages/lib-infer-diffusion/README.md index ceccbc1584..44f901aabd 100644 --- a/packages/lib-infer-diffusion/README.md +++ b/packages/lib-infer-diffusion/README.md @@ -176,28 +176,35 @@ 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 (SD3) | +| `files.clipG` | — | Absolute path to separate CLIP-G text encoder (SDXL / SD3) | +| `files.t5Xxl` | — | Absolute path to separate T5-XXL text encoder (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 (SD3) | -| `clipGModel` | — | Separate CLIP-G text encoder (SDXL / SD3) | -| `t5XxlModel` | — | Separate T5-XXL text encoder (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 +### 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) } ``` @@ -216,10 +223,10 @@ Config values are coerced to strings internally. Generation parameters (prompt, ### 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. +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 @@ -227,7 +234,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 @@ -360,7 +367,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. --- diff --git a/packages/lib-infer-diffusion/addon.js b/packages/lib-infer-diffusion/addon.js index 9d8bc1f620..06f6544ccc 100644 --- a/packages/lib-infer-diffusion/addon.js +++ b/packages/lib-infer-diffusion/addon.js @@ -2,6 +2,32 @@ const path = require('bare-path') +/** + * 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 + * @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 +} + /** * Extract pixel dimensions from a PNG or JPEG buffer without a full decode. * @@ -151,4 +177,4 @@ class SdInterface { } } -module.exports = { SdInterface, readImageDimensions } +module.exports = { SdInterface, mapAddonEvent, readImageDimensions } 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 a42ffabad9..e8af38df44 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.3.0 +**Stack:** JavaScript, C++20, stable-diffusion.cpp, Bare Runtime, CMake, vcpkg **License:** Apache-2.0 --- @@ -49,12 +49,12 @@ ## 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 - **Diffusion models**: SD1.x, SD2.x, SDXL, SD3, FLUX.2 [klein] -- **Generation modes**: txt2img +- **Generation modes**: txt2img, img2img ## Target Platforms @@ -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: ImgStableDiffusionArgs) +load() Promise~void~ +run(params: GenerationParams) Promise~QvacResponse~ +cancel() Promise~void~ +unload() Promise~void~ + +getState() State } - 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 `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.
@@ -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 || !!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 - ✅ 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 `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 @@ -760,4 +771,4 @@ Provide hand-written TypeScript definitions in `index.d.ts`. --- -**Last Updated:** 2026-03-11 +**Last Updated:** 2026-04-16 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] 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/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/img2img-flux2-f16.js b/packages/lib-infer-diffusion/examples/img2img-flux2-f16.js index ab20a485f4..34f23d0387 100644 --- a/packages/lib-infer-diffusion/examples/img2img-flux2-f16.js +++ b/packages/lib-infer-diffusion/examples/img2img-flux2-f16.js @@ -29,20 +29,19 @@ async function main () { console.log('Loading FLUX2-klein F16 model (full precision)...') - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: modelDir, - modelName: 'flux-2-klein-4b-F16.gguf', // F16 full precision - llmModel: 'Qwen3-4B-Q8_0.gguf', // Q8 text encoder - vaeModel: 'flux2-vae.safetensors' + const model = new ImgStableDiffusion({ + files: { + model: path.join(modelDir, 'flux-2-klein-4b-F16.gguf'), // F16 full precision + llm: path.join(modelDir, 'Qwen3-4B-Q8_0.gguf'), // Q8 text encoder + vae: path.join(modelDir, 'flux2-vae.safetensors') }, - { + config: { threads: 4, device: 'gpu', prediction: 'flux2_flow' - } - ) + }, + logger: console + }) try { // Load model weights diff --git a/packages/lib-infer-diffusion/examples/img2img-flux2.js b/packages/lib-infer-diffusion/examples/img2img-flux2.js index 7ba167ae1d..3ed7879e1c 100644 --- a/packages/lib-infer-diffusion/examples/img2img-flux2.js +++ b/packages/lib-infer-diffusion/examples/img2img-flux2.js @@ -30,20 +30,19 @@ async function main () { console.log('Loading FLUX2-klein model...') - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: modelDir, - 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(modelDir, 'flux-2-klein-4b-Q8_0.gguf'), + llm: path.join(modelDir, 'Qwen3-4B-Q4_K_M.gguf'), + vae: path.join(modelDir, 'flux2-vae.safetensors') }, - { + config: { threads: 4, device: 'gpu', // or 'cpu' for MacBook Air prediction: 'flux2_flow' - } - ) + }, + logger: console + }) try { // Load model weights diff --git a/packages/lib-infer-diffusion/examples/img2img-sd3.js b/packages/lib-infer-diffusion/examples/img2img-sd3.js index 5511650822..e43c293ee7 100644 --- a/packages/lib-infer-diffusion/examples/img2img-sd3.js +++ b/packages/lib-infer-diffusion/examples/img2img-sd3.js @@ -70,22 +70,21 @@ async function main () { console.log(' Seed : ' + SEED) console.log(' Note : VAE encode runs first (no progress tick) — please wait...\n') - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: modelDir, - modelName: MODEL_NAME - // All-in-one safetensors: no clipLModel, clipGModel, t5XxlModel, or vaeModel. + const model = new ImgStableDiffusion({ + files: { + model: path.join(modelDir, MODEL_NAME) + // All-in-one safetensors: no clipL, clipG, t5Xxl, or vae. // To improve text-following, add T5-XXL (download via download-model-sd3.sh): - // t5XxlModel: 't5xxl_fp8_e4m3fn.safetensors' + // t5Xxl: path.join(modelDir, 't5xxl_fp8_e4m3fn.safetensors') }, - { + config: { threads: 4, device: 'gpu', prediction: 'flow', // SD3 rectified flow-matching (not flux2_flow) flow_shift: '3.0' // SD3 Medium default; controls noise schedule shift - } - ) + }, + logger: console + }) try { console.log('Loading SD3 Medium model...') 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/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/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 b0395003ab..2bb1b606b0 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' @@ -122,6 +121,33 @@ export interface SdConfig { [key: string]: string | number | boolean | undefined } +export interface DiffusionFiles { + /** Absolute path to main model weights */ + model: string + /** SD3: absolute path to CLIP-L text encoder */ + clipL?: string + /** SDXL / SD3: absolute path to CLIP-G text encoder */ + clipG?: string + /** 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 +} + +export interface ImgStableDiffusionArgs { + files: DiffusionFiles + /** + * 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 } +} + export interface GenerationParams { prompt: string negative_prompt?: string @@ -158,9 +184,13 @@ 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. */ 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 = 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 } @@ -203,28 +233,13 @@ export interface RuntimeStats { seed: number } -export interface ImgStableDiffusionArgs { - logger?: QvacLogger | Console | null - opts?: { stats?: boolean } - diskPath?: string - modelName: string - /** CLIP-L text encoder */ - clipLModel?: string - /** CLIP-G text encoder */ - clipGModel?: string - /** 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 @@ -233,6 +248,8 @@ export default class ImgStableDiffusion extends BaseInference { 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 c90187556c..b4f7a18a14 100644 --- a/packages/lib-infer-diffusion/index.js +++ b/packages/lib-infer-diffusion/index.js @@ -1,9 +1,20 @@ '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 BaseInference = require('@qvac/infer-base/WeightsProvider/BaseInference') -const { SdInterface } = 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'] @@ -13,88 +24,94 @@ const RUN_BUSY_ERROR_MESSAGE = 'Cannot set new job: a job is already set or bein * 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 { +class ImgStableDiffusion { /** * @param {object} args + * @param {object} args.files - Absolute file paths for model components + * @param {string} args.files.model - Main model weights (absolute path) + * @param {string} [args.files.clipL] - CLIP-L text encoder (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 (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 - * @param {string} [args.diskPath='.'] - Local directory containing model weight files - * @param {string} args.modelName - Model file name (e.g. 'flux-2-klein-4b-Q8_0.gguf') - * @param {string} [args.clipLModel] - Optional CLIP-L text encoder file name (SD3) - * @param {string} [args.clipGModel] - Optional CLIP-G text encoder file name (SDXL / SD3) - * @param {string} [args.t5XxlModel] - Optional T5-XXL text encoder file name (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 }) - 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 + constructor ({ files, config, logger = null, opts = {} }) { + 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.logger = new QvacLogger(logger) + this.opts = opts + // Lazy deref + optional chain: safe before `_load()` and after `unload()`. + 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 () { + return this._run(async () => { + if (this.state.configLoaded) return + 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 - } + // 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, + // 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 : '', + 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) + try { + this.addon = this._createAddon(configurationParams) 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 + } 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 (_) {} + this.addon = null + this._releaseNativeLogger() + throw loadError } + + this.logger.info('Stable-diffusion model load completed successfully') } /** @@ -135,51 +152,32 @@ class ImgStableDiffusion extends BaseInference { } _addonOutputCallback (addon, event, data, error) { - if (event.includes('Error')) { - return this._outputCallback(addon, 'Error', 'OnlyOneJob', data, error) + 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') { - return this._outputCallback(addon, 'Output', 'OnlyOneJob', data, error) + if (mapped.type === 'Error') { + this.logger.error('Job failed with error:', mapped.error) + this._job.fail(mapped.error) + 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) + if (mapped.type === 'JobEnded') { + this._job.end(this.opts.stats ? mapped.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() + if (mapped.type === 'Output') { + this._job.output(mapped.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() - }) - } - /** * Generate an image from a text prompt, or transform an input image with a prompt. * @@ -223,8 +221,13 @@ class ImgStableDiffusion extends BaseInference { * Others: SDEdit (init_image + strength). * @returns {Promise} */ + async run (params) { + return this._run(() => this._runInternal(params)) + } + async _runInternal (params) { - // Validate init_image is Uint8Array if provided + // Validate inputs first so callers get precise errors before any + // readiness/busy checks. if (params.init_image != null && !(params.init_image instanceof Uint8Array)) { throw new Error( 'init_image must be a Uint8Array (e.g. fs.readFileSync("image.png")). ' + @@ -237,7 +240,7 @@ class ImgStableDiffusion extends BaseInference { // SdModel::process() only enters the FLUX ref_images path when // config_.prediction is FLUX_FLOW_PRED or FLUX2_FLOW_PRED. Without // an explicit value the addon silently falls back to SDEdit. - if (params.init_image && this._llmModel) { + if (params.init_image && this._files.llm) { const pred = this._config?.prediction if (pred !== 'flux2_flow' && pred !== 'flux_flow') { throw new Error( @@ -249,42 +252,74 @@ class ImgStableDiffusion extends BaseInference { } } + if (!this.addon) { + throw new Error('Addon not initialized. Call load() first.') + } + const mode = params.init_image ? 'img2img' : '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((err) => { + this.logger?.warn?.('Generation response rejected:', err?.message || err) + }) + response.await = () => finalized - this.logger.info('Generation job started successfully') + this.logger.info('Generation job started successfully') + return response + } - 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() + if (this._job.active) { + this._job.fail(new Error('Model was unloaded')) + } + 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 }) } + + getState () { return this.state } } module.exports = ImgStableDiffusion diff --git a/packages/lib-infer-diffusion/package.json b/packages/lib-infer-diffusion/package.json index 0795fe1c1e..2ea885a8b8 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.2.0", + "version": "0.3.0", "description": "stable-diffusion.cpp addon for qvac image/video generation", "addon": true, "scripts": { @@ -17,9 +17,10 @@ "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: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 +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 && npm run test:cpp" + "test:all": "npm run test:unit && npm run test:integration && npm run test:cpp" }, "files": [ "binding.js", @@ -72,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-fs": "^4.5.1", "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 ffd442f26c..8fa0ea67de 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') @@ -55,26 +56,24 @@ 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() t.teardown(async () => { await model.unload().catch(() => {}) - try { binding.releaseLogger() } catch (_) {} }) return { model, modelDir } @@ -199,6 +198,30 @@ test('cancel | run: can run again after cancel', { timeout: testTimeout }, async saveGeneratedImages(modelDir, 'cancel-run-second-response', images) }) +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(() => { diff --git a/packages/lib-infer-diffusion/test/integration/generate-image-flux2-i2i.test.js b/packages/lib-infer-diffusion/test/integration/generate-image-flux2-i2i.test.js index 4e2f42e1dd..0cf3c57e7e 100644 --- a/packages/lib-infer-diffusion/test/integration/generate-image-flux2-i2i.test.js +++ b/packages/lib-infer-diffusion/test/integration/generate-image-flux2-i2i.test.js @@ -72,20 +72,19 @@ test('FLUX2-klein img2img — transforms an input image', { timeout: 1800000, 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, - llmModel: qwenName, - vaeModel: vaeName + const model = new ImgStableDiffusion({ + files: { + model: path.join(modelDir, downloadedModelName), + llm: path.join(modelDir, qwenName), + vae: path.join(modelDir, vaeName) }, - { + config: { threads: 4, device: useCpu ? 'cpu' : 'gpu', prediction: 'flux2_flow' - } - ) + }, + logger: console + }) const images = [] const progressTicks = [] 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 9c98c5ee45..2540b297bd 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-i2i.test.js b/packages/lib-infer-diffusion/test/integration/generate-image-sd3-i2i.test.js index 7ca196a71a..fffaaa8fb9 100644 --- a/packages/lib-infer-diffusion/test/integration/generate-image-sd3-i2i.test.js +++ b/packages/lib-infer-diffusion/test/integration/generate-image-sd3-i2i.test.js @@ -51,20 +51,19 @@ test('SD3 Medium img2img — transforms an input image', { timeout: 1800000, ski 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', vae_on_cpu: true, prediction: 'flow', flow_shift: '3.0' - } - ) + }, + 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/input-validation.test.js b/packages/lib-infer-diffusion/test/integration/input-validation.test.js index ced543c97b..6f3bae3d57 100644 --- a/packages/lib-infer-diffusion/test/integration/input-validation.test.js +++ b/packages/lib-infer-diffusion/test/integration/input-validation.test.js @@ -87,15 +87,14 @@ test('readImageDimensions | unrecognised format returns null', async (t) => { // ---------- FLUX img2img prediction guard ---------- test('FLUX img2img | throws when prediction is omitted', async (t) => { - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: '.', - modelName: 'flux-2-klein-4b-Q8_0.gguf', - llmModel: 'Qwen3-4B-Q4_K_M.gguf' + const model = new ImgStableDiffusion({ + files: { + model: '/tmp/flux-2-klein-4b-Q8_0.gguf', + llm: '/tmp/Qwen3-4B-Q4_K_M.gguf' }, - { threads: 1 } - ) + config: { threads: 1 }, + logger: console + }) const fakeImage = VALID_PNG_HEADER @@ -115,15 +114,14 @@ test('FLUX img2img | throws when prediction is omitted', async (t) => { }) test('FLUX img2img | throws when prediction is "auto"', async (t) => { - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: '.', - modelName: 'flux-2-klein-4b-Q8_0.gguf', - llmModel: 'Qwen3-4B-Q4_K_M.gguf' + const model = new ImgStableDiffusion({ + files: { + model: '/tmp/flux-2-klein-4b-Q8_0.gguf', + llm: '/tmp/Qwen3-4B-Q4_K_M.gguf' }, - { threads: 1, prediction: 'auto' } - ) + config: { threads: 1, prediction: 'auto' }, + logger: console + }) const fakeImage = VALID_PNG_HEADER @@ -139,15 +137,14 @@ test('FLUX img2img | throws when prediction is "auto"', async (t) => { }) test('FLUX img2img | does NOT throw for txt2img even without prediction', async (t) => { - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: '.', - modelName: 'flux-2-klein-4b-Q8_0.gguf', - llmModel: 'Qwen3-4B-Q4_K_M.gguf' + const model = new ImgStableDiffusion({ + files: { + model: '/tmp/flux-2-klein-4b-Q8_0.gguf', + llm: '/tmp/Qwen3-4B-Q4_K_M.gguf' }, - { threads: 1 } - ) + config: { threads: 1 }, + logger: console + }) // txt2img (no init_image) should pass the guard even without prediction. // It will fail later because no model is loaded, but that's expected — @@ -164,16 +161,15 @@ test('FLUX img2img | does NOT throw for txt2img even without prediction', async }) test('non-FLUX model | does NOT throw for img2img without prediction', async (t) => { - const model = new ImgStableDiffusion( - { - logger: console, - diskPath: '.', - modelName: 'stable-diffusion-v2-1-Q4_0.gguf' + const model = new ImgStableDiffusion({ + files: { + model: '/tmp/stable-diffusion-v2-1-Q4_0.gguf' }, - { threads: 1 } - ) + config: { threads: 1 }, + logger: console + }) - // SD model (no llmModel) should not trigger the FLUX guard. + // SD model (no files.llm) should not trigger the FLUX guard. try { await model.run({ prompt: 'test', init_image: VALID_PNG_HEADER }) t.fail('should have thrown (no model loaded)') 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 2caf344f0a..e6c18d2adf 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') @@ -37,10 +38,12 @@ test('model loading - load and unload', { timeout: testTimeout }, 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') 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) +})