feat: ernie image/turbo - #9115
Conversation
Adds Baidu ERNIE-Image and ERNIE-Image-Turbo as a new BaseModelType, mirroring the FLUX.2 / Z-Image integration pattern. Both models share the ErnieImageTransformer2DModel architecture (3072 hidden, 24 layers, 24 heads) and an AutoencoderKLFlux2 VAE; they differ only in default inference settings (50 steps + CFG 4.0 vs 8 steps + CFG 1.0 for Turbo). Built on top of PR invoke-ai#8859 (transformers 5.1+) and additionally bumps diffusers 0.36.0 -> 0.38.0, which is the first release containing the ErnieImagePipeline and ErnieImageTransformer2DModel. Backend - BaseModelType.ErnieImage, ModelType.PromptEnhancer, two new SubModelTypes (pe, pe_tokenizer) for the bundled prompt enhancer - Main_Diffusers_ErnieImage_Config + Main_Checkpoint_ErnieImage_Config with state-dict-based detection (x_embedder + text_proj + adaLN_modulation) - Diffusers loader registered for ERNIE-Image; uses upstream subdir conventions, loads transformer / vae / text_encoder / tokenizer plus optional pe / pe_tokenizer - New invokeai/backend/ernie_image/ with sampling utilities (2x2 patchify, BN normalize/denormalize, sigma schedule, padded text packing) and a rectified-flow denoise loop supporting Euler/Heun/LCM - Five invocations: model_loader, text_encoder (with prompt-enhancer toggle), denoise, vae_encode, vae_decode - ErnieImageConditioningInfo + ConditioningField/Output + pickle allowlist - ERNIE_IMAGE_SCHEDULER_MAP reusing the FlowMatch* scheduler classes - New generation modes ernie_image_{txt2img,img2img,inpaint,outpaint} - Starter models for baidu/ERNIE-Image and baidu/ERNIE-Image-Turbo + STARTER_BUNDLES entry Frontend - Regenerated services/api/schema.ts to expose the new node types - Type unions extended (ImageOutput / LatentToImage / ImageToLatents / DenoiseLatents / MainModelLoaderNodes) - ParamsState gains ernieImageScheduler + ernieImageUsePromptEnhancer, with reducers, selectors, and selectIsErnieImage - buildErnieImageGraph (txt2img/img2img/inpaint/outpaint) wired into useEnqueueCanvas - ERNIE entries added to MODEL_BASE_TO_{COLOR,LONG_NAME,SHORT_NAME} and prompt_enhancer to MODEL_TYPE_TO_LONG_NAME - ParamErnieImageScheduler and ParamErnieImagePromptEnhancer rendered conditionally in GenerationSettingsAccordion - All add{TextTo,ImageTo,Inpaint,Outpaint}Image type guards extended to accept ernie_image_denoise; isMainModelWithoutUnet ditto Diffusers 0.38 fix-out - hotfixes.py: import LoRACompatibleConv directly; the lazy-module __getattr__ no longer exposes diffusers.models.lora as an attribute Verification - pytest tests/ -m "not slow": 619 passed, 0 failed - pnpm lint:tsc / lint:eslint / lint:prettier: clean - pnpm test:no-watch: 563 passed, 0 failed - Manual smoketest pending: requires baidu/ERNIE-Image weights and a GPU (8B parameters; CPU not practical) Out of scope (follow-up phases) - ControlNet, IP-Adapter, LoRA support for ERNIE-Image - Single-file checkpoint loading (defensive scaffolding only) - Metadata recall handlers in the gallery side panel
…nd UI cleanup - Pass timesteps in [0, num_train_timesteps] to the transformer instead of [0, 1]; the diffusers Timesteps embedding expects the unnormalised range, which produced mosaic-pattern garbage instead of an image. - Unpatchify predicted-x0 before the denoise step callback and route through sd_step_callback so the canvas shows a live preview during sampling (uses FLUX.2's RGB factors -- same AutoencoderKLFlux2 / 32 latent channels). - Add the ernie-image case to useEnqueueGenerate (Generate tab); was only wired up in useEnqueueCanvas, so plain text-to-image failed with "No graph builders for base ernie-image". - Move the Prompt Enhancer toggle from the Generation accordion into a dedicated ERNIE-Image block in the Advanced accordion; hide the rest of the SD-style advanced controls (CLIP skip, CFG rescale, seamless, color comp., separate VAE) since none apply to ERNIE-Image. - Detect ERNIE-Image-Turbo by name in MainModelDefaultSettings.from_base so installs (starter or manual) get steps=8, cfg_scale=1.0 instead of the standard 50/4.0. - Pin compel to Cstannahill/compel5@chore/transformers5-diffusers-smoke for transformers>=5 compatibility (PR damian0815/compel#129).
ERNIE's denoise node has no denoise_mask input, so masked modes are unsupported. Drop img2img/inpaint/outpaint from the ERNIE graph builder, exclude ernie_image_denoise from a new MaskableDenoiseNodes type used by addInpaint/addOutpaint, and align addImageToImage unions with the node type aliases. Fixes tsc failures on the ernie-image branch.
ERNIE-Image's denoise node has no denoise_mask input and no mask logic,
so masked modes (inpaint/outpaint) are impossible and image-to-image is
dropped as well.
Frontend:
- buildErnieImageGraph now builds txt2img only; asserts on other modes
- add MaskableDenoiseNodes (DenoiseLatentsNodes minus ernie_image_denoise)
and use it in addInpaint/addOutpaint
- drop ernie_image_vae_encode from ImageToLatentsNodes; align
addImageToImage unions with the LatentToImage/ImageToLatents aliases
- regenerate schema.ts
Backend:
- delete the ernie_image_vae_encode invocation (i2l, only used by the
removed image-input modes)
- drop ernie_image_{img2img,inpaint,outpaint} from GENERATION_MODES
Fixes the tsc failures on the ernie-image branch.
…I-bundled prefix support A duplicate _has_anima_keys definition (older, net.-only) shadowed the complete version that also recognizes the `model.diffusion_model.` ComfyUI-bundled prefix, causing Anima identification to reject bundled checkpoints.
# Conflicts: # invokeai/app/api/dependencies.py # invokeai/app/invocations/fields.py # invokeai/app/invocations/metadata.py # invokeai/app/invocations/primitives.py # invokeai/backend/model_manager/configs/factory.py # invokeai/backend/model_manager/taxonomy.py # invokeai/backend/stable_diffusion/diffusion/conditioning_data.py # invokeai/frontend/web/openapi.json # invokeai/frontend/web/src/features/controlLayers/store/types.ts # invokeai/frontend/web/src/features/modelManagerV2/models.ts # invokeai/frontend/web/src/features/nodes/types/common.ts # invokeai/frontend/web/src/features/nodes/util/graph/types.ts # invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts # invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts # invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.tsx # invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx # invokeai/frontend/web/src/services/api/schema.ts # uv.lock
lstein
left a comment
There was a problem hiding this comment.
Thanks for this — the ERNIE integration follows the established Z-Image/FLUX.2 shape closely, and the sampling code is a faithful port of the upstream pipeline (I diffed it against ErnieImagePipeline/ErnieImageTransformer2DModel line by line; see "Verified clean" at the bottom for what held up).
That said, I think the feature is currently non-functional on two independent counts, and there's a third issue that affects users who never touch ERNIE. Details below, reviewed at head 7696bf7f79.
Blockers
1. diffusers is pinned to 0.37.0 — the ERNIE classes don't exist, so no ERNIE model can load
The PR description's central premise is the 0.36 → 0.38 bump, because 0.38.0 is the first release containing ErnieImagePipeline and ErnieImageTransformer2DModel. That bump is no longer in the branch: pyproject.toml:39 reads diffusers[torch]==0.37.0, and uv.lock resolves 0.37.0. It looks like it was lost in one of the Merge remote-tracking branch 'upstream/main' merges — main moved 0.36 → 0.37 and won the conflict.
Verified in the branch's own venv:
diffusers 0.37.0
has ErnieImageTransformer2DModel: False
has ErnieImagePipeline: False
The failure mode is unpleasant: identification only reads _class_name out of model_index.json, so install succeeds. The error only surfaces on first generate, when GenericDiffusersLoader._hf_definition_to_type (generic_diffusers.py:88) calls getattr(diffusers, "ErnieImageTransformer2DModel") and raises AttributeError. So a user downloads an 8B pipeline and then hits a hard error.
2. The prompt enhancer is unconditionally disabled — config.path is relative
invokeai/app/invocations/ernie_image_model_loader.py:83-89:
config = context.models.get_config(self.model)
pe_dir = Path(config.path) / "pe"
return pe_dir.is_dir()context.models.get_config() returns the raw record store config. Models inside the Invoke-managed models dir are stored with relative paths (model_install_default.py:1086-1088: "Models in the Invoke-managed models dir should use relative paths."). Only ModelLoader._load_and_cache rewrites config.path to absolute (load_default.py:132), and that happens well after this check.
So pe_dir.is_dir() resolves against the server process's CWD and returns False for every model installed through the starter bundle or a normal HF install. prompt_enhancer is always None, and the UI toggle is a no-op. QA step 4 in the description can't pass as written.
Fix: resolve against models_path before the check (or reuse the loader's own path resolution).
3. paramsSlice: two new required fields with no defaults and no migration — every existing user's params slice resets on upgrade
features/controlLayers/store/types.ts:827-829:
ernieImageScheduler: zParameterErnieImageScheduler,
ernieImageUsePromptEnhancer: z.boolean(),
// Defaults make these resilient to rehydration of persisted state saved before the fields existed.The two new fields landed directly above the comment that explains why they need .default() — and don't have it. _version stays at 3, so no migration branch adds them, and migrate ends at zParamsState.parse(state) (paramsSlice.ts:795). A persisted v3 state written by current main lacks both keys, so parse throws → the catch in unserialize (app/store/store.ts:166-172) → the whole slice falls back to getInitialState(). Model selection, dimensions, prompt history, every generation parameter: reset.
I verified this rather than just reading it — a throwaway vitest probe that deletes the two keys from a v3 state and calls migrate() reports MIGRATE_THREW: true.
This is the checklist item marked N/A as "only additive fields with defaults in paramsSlice" — the fields are additive, but they don't have defaults. Adding .default('euler') and .default(true) (matching the ideogram4* fields immediately below) is sufficient; no _version bump needed.
High
4. Heun runs 2N−1 iterations against total_steps = N, so progress overruns to ~190%
backend/ernie_image/denoise.py:59 sets total_steps = len(timesteps) - 1, but line 78 loops for step_index in range(len(scheduler.timesteps)).
FlowMatchHeunDiscreteScheduler.set_timesteps doesn't accept sigmas, so line 75 falls back to num_inference_steps=len(sigmas). Verified against the installed diffusers:
heun: requested steps 8 -> timesteps 15, sigmas 16
euler: sigmas in 8 -> timesteps 8, sigmas 9
With steps=8 the loop runs 15 times and emits step=9..15 against total_steps=8. The same fallback also silently discards the custom sigma schedule (and therefore denoising_start / denoising_end) whenever Heun is selected.
5. getGridSize has no 'ernie-image' case, but the backend enforces multiple_of=16
features/parameters/util/optimalDimension.ts:73-90 falls through to default: 8, and that grid size drives bbox snapping (canvasSlice.ts:1214+, paramsSlice.ts:433+). Meanwhile ernie_image_denoise.py:58-59 declares multiple_of=16 on width/height.
Concrete trigger: select an ERNIE model, set the bbox width to 1032 (legal at grid 8), invoke → the backend rejects the graph with a validation error.
Ideogram-4 handles exactly this with both a getGridSize entry and a readiness check (readiness.ts:764-790); ERNIE has neither.
6. The loader drops _apply_fp8_layerwise_casting
model_loaders/ernie_image.py:57-62 returns load_class.from_pretrained(...) directly. Both comparable loaders apply the casting pass before returning — z_image.py:167 and qwen_image.py:248.
_should_use_fp8 gates on default_settings.fp8_storage (load_default.py:198-202), which is a user-facing Model Manager toggle and is reachable for ERNIE Main configs. As written, the toggle will show up and silently do nothing.
Medium
7. prerelease = "allow" and the transformers>=5.1.0 override are now stale
The stated justification for prerelease = "allow" is diffusers 0.38 pinning safetensors>=0.8.0-rc.0 — that no longer applies, since diffusers is 0.37.0 and safetensors resolves to stable 0.8.0. And main already declares transformers>=5.5,<5.6, so the >=5.1.0 override adds nothing while, as a global uv override, weakening that deliberate upper bound on the next relock.
prerelease = "allow" is repo-wide, not scoped to safetensors: any future uv lock may pick up prereleases of torch, transformers, or anything else. I'd drop both lines.
8. Schedulers are default-constructed, ignoring the checkpoint's scheduler/ config
ernie_image_denoise.py:130-131 does scheduler_cls(). Upstream uses the scheduler that ships with the pipeline. The diffusers defaults are shift=1.0, num_train_timesteps=1000; FlowMatchEulerDiscreteScheduler.set_timesteps applies shift to the sigmas you pass in, so if Baidu ships shift != 1.0, use_dynamic_shifting, or a different num_train_timesteps, output silently diverges from the reference implementation. model_timestep_scale (denoise.py:65) is derived from that same config.
9. Initial noise is generated on the compute device in bf16
ernie_image_denoise.py:116-122. Every other denoise node in the repo generates on CPU in fp16 and then casts, with an explicit comment — sd3_denoise.py:160-172, z_image_denoise.py:217-227, flux2/sampling_utils.py:37-55, latent_noise.py:
# We always generate noise on the same device and dtype then cast to ensure consistency across devices/dtypes.
rand_device = "cpu"
rand_dtype = torch.float16As written, the same seed produces different images on CUDA vs ROCm vs MPS vs CPU, and shouldUseCpuNoise is ignored.
10. The starter bundle installs both 8B pipelines
starter_models.py:1913 — ernie_image_bundle contains Turbo and base. Every other bundle is one main model plus its shared dependencies. Turbo alone seems like the better default given the download size.
11. The checkpoint config installs models that can never load
Main_Checkpoint_ErnieImage_Config matches and installs successfully, and then ErnieImageDiffusersModel._load_model raises NotImplementedError (ernie_image.py:40-41). I'd either not register it yet, or keep detection strict enough that nothing unloadable can match — as-is it's a way to end up with a permanently broken entry in the Model Manager.
Low / nits
- Latent preview uses the pre-step sigma with the post-step sample.
denoise.py:102computesimg - t_sigma * pred, butimgwas already stepped at line 93. Usingt_prevgives the exact x0 estimate. Same at line 139. - Preview RGB factors are applied to BN-normalized latents.
step_callback.pyuses the FLUX.2 factors, but ERNIE's denoise space is BN-normalized (denormalized only aternie_image_vae_decode.py:59), so previews will be off in color/contrast. Probably unavoidable since the BN stats live on the VAE, but the comment currently claims the factors "apply directly" — worth correcting. LatentsOutput.buildreports half the real size.ernie_image_denoise.py:151— the latents are patched, sowidth = shape[3] * 8yields 512 for a 1024 image. Node-editor-only, but wrong._SUBDIR_OVERRIDESis a no-op.ernie_image.py:30-33mapsSubModelType.PromptEnhancer -> "pe", butSubModelType.PromptEnhancer.valueis already"pe"(taxonomy.py:107-108). Same for the tokenizer. The dict and its comment can go.- Unsupported generation modes use
assertrather thanUnsupportedGenerationModeError, which produces a red "Failed to build graph" error instead of the friendlier warning toastuseEnqueueCanvas.ts:93-96exists for. (Ideogram-4 does the same thing, so this is precedent-consistent — just noting the nicer path exists.) - Hardcoded English label in
ParamErnieImagePromptEnhancer.tsx(<FormLabel>Prompt Enhancer</FormLabel>); the sibling scheduler component usest(...). - The
addImageToImageERNIE branch is dead code — the graph builder assertstxt2imgbefore it can be reached. - Turbo detection by name substring (
configs/main.py:87-92,"turbo" in name.lower()) means renaming the model on install loses the 8-step / CFG-1.0 defaults. Z-Image models this with a variant enum.
Verified clean
Things I specifically attacked that held up, so you don't have to re-check them:
- Separate positive/negative text padding. Upstream concatenates uncond+cond before
_pad_textso both share oneTmax; this PR pads them separately with differentTmaxand runs two forwards. That's equivalent — the transformer buildsattention_maskfromtext_lens(transformer_ernie_image.py:415-422) and image-token RoPE ids fromtext_lens(lines 409-411), both independent ofTmax. Two forwards also use less memory than the batched version. - Noise shape.
VAE_SCALE_FACTOR = 16withlatent_h = height // 16looks like a 4x spatial error, since AutoencoderKLFlux2's 32-channel latents live at H/8 (cf.flux2/sampling_utils.py:44). It's correct — upstream definesvae_scale_factor = 2 ** len(block_out_channels) = 16and usesheight // vae_scale_factorfor the patched grid. Confusing name, right math. - Patchify / unpatchify roundtrip — identical to upstream's
_patchify_latents/_unpatchify_latents. - Sigma schedule —
get_schedule+timesteps[:-1]reproduces upstream'slinspace(1, 0, N+1)[:-1]. - bf16 timestep vector — upstream does the same thing, so not something this PR introduces.
- ModelPicker ordering —
'ernie-image'isn't in the ordered group list, but ungrouped bases are appended afterwards, same as anima/flux2. No problem. - Loader registration (auto-imported via
load/__init__.py:15-17),get_size_fs(submodel_type.valueis the real subdir name forpe/pe_tokenizer),from_basesignature change (only two callers;namedefaults toNone), andfrom diffusers.models.lora import LoRACompatibleConv(imports fine on 0.37.0) — all fine. openapi.json/schema.ts— I regeneratedopenapi.jsonlocally and it's byte-identical to what's committed.
Repo / CI state
- The branch currently conflicts with main in
invocations/model.py,util/step_callback.py,configs/main.py,starter_models.py, andopenapi.json. - Local
pytest tests/ -m "not slow"gives 9 failed / 2730 passed (reproduced twice). The 4test_qwen_image_state_dict_utilsfailures are a transformers-5.5 API change (_checkpoint_conversion_mapping) and main's lock resolves the same 5.5.4, so they're pre-existing; the 5test_node_graphfailures pass when that file is run alone, so they're cross-file registration pollution. Neither group looks attributable to this PR — but the description's "619 / 619" figure is stale and worth refreshing.
Requesting changes primarily on 1-3; the rest can be triaged. Happy to re-review once the diffusers pin and the PE path resolution are sorted.
Resolves conflicts against the Krea-2, Wan 2.2 and video-generation work that landed on main. All conflicts are additive (new base model entries in shared unions, maps and switch statements); openapi.json and schema.ts were regenerated rather than merged. Two behavioural resolutions worth noting: - graphBuilderUtils: keep main's isMainModelWithoutUnet type guard, which covers ernie_image_model_loader automatically. - GenerationSettingsAccordion: keep main's shouldShowStandardScheduler() and register 'ernie-image' in BASES_WITHOUT_STANDARD_SCHEDULER. fix(ernie-image): address review feedback on invoke-ai#9115 Blockers: - Resolve the prompt-enhancer probe against models_path. Invoke-managed models are recorded with relative paths and only the loader rewrites config.path, so the check probed the server CWD and the enhancer was unconditionally disabled. - Give ernieImageScheduler/ernieImageUsePromptEnhancer zod defaults. A persisted params slice written before the fields existed failed to parse, resetting the entire slice on upgrade. - Drop the now-stale `prerelease = "allow"` and transformers>=5.1.0 override; pyproject.toml and uv.lock match main again. Correctness: - Heun drives progress off the real iteration count (set_timesteps(N) yields 2N-1 timesteps) and refuses a partial-denoise range it cannot honor instead of silently running a full denoise. - Generate initial noise on CPU in fp32 and cast, so a seed reproduces across CUDA/ROCm/MPS/CPU. - Build the scheduler from the pipeline's own scheduler/ config instead of default-constructing it (shift / num_train_timesteps feed the sampling math). - Apply fp8 layerwise casting in the loader; the Model Manager toggle was reachable but did nothing. - Add 'ernie-image' to getGridSize (16) plus a readiness check, matching the backend's multiple_of=16 on width/height. - Remove Main_Checkpoint_ErnieImage_Config: it matched on install and then raised NotImplementedError on first generate. Nits: x0 previews use the post-step sigma; LatentsOutput reports the real size; starter bundle ships Turbo only; Turbo detection also consults the install path; unsupported generation modes raise UnsupportedGenerationModeError; prompt-enhancer label is translated; _SUBDIR_OVERRIDES was a no-op and is gone. Tests: ERNIE denoise progress/schedule regression tests and default settings tests; a params-slice rehydration test for the new fields.
Conflicts, both in the frontend: - addImageToImage.ts: main's PiD work retyped `l2i` and the return value from `LatentToImageNodes` to `ImageOutputNodes` (a PiD decode chain can stand in for the VAE decode); this branch had factored the inline `i2l` union out into an `ImageToLatentsNodes` alias. Kept both — `ImageOutputNodes` for `l2i` and the return type, `ImageToLatentsNodes` for `i2l`. ERNIE has no VAE-encode node, so nothing was added to the `i2l` union. - schema.ts: regenerated rather than merged, together with openapi.json (the auto-merged openapi.json turned out to be byte-identical to the regenerated one). Verified after the merge: pytest tests/ 4040 passed / 0 failed; frontend tsc, eslint, prettier and vitest (139 files, 1686 tests) all clean; ruff check + format clean; `uv lock --locked` clean; regenerating schema.ts a second time is a no-op (CI typegen parity).
lstein
left a comment
There was a problem hiding this comment.
Re-reviewed the commits added since my last round (7696bf7f79..4d39efba3a). All three blockers, all three High findings, and every Medium and nit from the last round are addressed — thanks for the thorough pass, and for the regression tests. I re-verified each fix rather than taking the commit message at its word; details below, then two new items the fp8 fix opened up.
Note for future rounds: the fixes are all inside the merge commit 81a920d7ce, so git log 7696bf7f79..HEAD shows nothing but merges and git diff against either parent is dominated by main's churn. git show --cc 81a920d7ce is the only view that isolates them. A separate fix commit before the merge would be easier to review (and to bisect).
Verified fixed
1. diffusers pin. Resolved — main itself moved to diffusers[torch]==0.39.0 in #9304, and this branch dropped the local prerelease = "allow" / transformers>=5.1.0 overrides in favour of it. pyproject.toml and uv.lock are now byte-identical to origin/main, uv lock --locked is clean, and in the branch venv from diffusers import ErnieImagePipeline, ErnieImageTransformer2DModel imports. This also closes Medium #7.
2. Prompt-enhancer path. (models_path / config.path).resolve() / "pe" matches ModelLoader._get_model_path exactly. I checked the two cases that could still bite it and both hold: an in-place install stores an absolute config.path, and Path(models_path) / <absolute> yields the absolute path unchanged (true on Windows too, since WindowsPath handles drive-absolute joins); and _load_and_cache mutates config.path to absolute in place (load_default.py:160), so on a second generate the join is idempotent rather than double-prefixed.
3. paramsSlice defaults. .default('euler') / .default(true) plus the rehydration test. Whole frontend suite green (139 files / 1686 tests).
4. Heun step accounting. Driving total_steps off len(scheduler.timesteps) is right, and I confirmed the sigma indexing can't run off the end: Heun's set_timesteps(N) produces 2N-1 timesteps against 2N sigmas, so scheduler.sigmas[step_index + 1] tops out at the last real index. Ran the loop end-to-end against a stub transformer for euler / heun / lcm at steps ∈ {1, 2, 8} with CFG on — no overrun, no IndexError, finite output, and step == total_steps on the final callback in every case (heun 8 → 15 iterations, as the test asserts). The refusal on a partial range is the right call, and the t_prev preview lines up with Heun's duplicated sigmas: at the corrector substep sigmas[i+1] is the same sigma the corrector lands on.
5. getGridSize + readiness. Both present, and getScaledBoundingBoxDimensions picks the 16 up automatically, so the scaleMethod: 'auto' path is covered as well as the explicit 'none' / 'manual' checks.
6. fp8 casting. Applied — see the new finding below for the one submodel it now reaches that it shouldn't.
8. Scheduler from the checkpoint's config. Confirmed the config actually feeds the math: FlowMatchEulerDiscreteScheduler(shift=3).set_timesteps(sigmas=[1, .8, .6, .4, .2]) gives [1, .923, .818, .667, .429] rather than the identity, and Heun honours shift through its own set_timesteps(N) too. Loading an Euler-authored scheduler_config.json into the Heun or LCM class works (diffusers drops the unknown keys), so switching schedulers in the UI doesn't blow up.
9–11 and the nits. CPU noise (fp32 rather than the repo's usual fp16, which is fine — the point was device-independence), Turbo-only bundle, Main_Checkpoint_ErnieImage_Config removed along with its now-dead _has_ernie_image_keys helper, post-step sigma in the preview, honest step_callback comment about BN-normalized latent space, real LatentsOutput size, _SUBDIR_OVERRIDES gone, UnsupportedGenerationModeError, translated PE label. All confirmed.
New (introduced by the fp8 fix)
A. fp8 layerwise casting now hits the prompt-enhancer LM — blocker
model_loaders/ernie_image.py:55 routes every submodel through _apply_fp8_layerwise_casting, and _should_use_fp8's exclusion set (load_default.py:255-267) lists the text encoders and tokenizers but not SubModelType.PromptEnhancer — which this PR introduces.
Trigger: install ERNIE-Image, flip FP8 storage on in Model Manager (it's rendered for every main base except Z-Image, MainModelDefaultSettings.tsx:151 — and it's exactly what someone squeezing an 8B transformer into VRAM will do), leave the prompt-enhancer toggle at its default true, generate on CUDA/ROCm. The pe submodel is a Ministral3ForCausalLM, so every nn.Linear and nn.Embedding in it gets fp8 storage plus the pre/post-forward cast hooks. _enhance_prompt then calls lm.generate(), which is one full forward per generated token — so the whole LM is cast bf16↔fp8 on every token of the rewritten prompt, on top of the fp8 rounding of a model whose entire job is text quality.
Fix: add SubModelType.PromptEnhancer to _excluded_submodel_types, alongside the existing "Don't apply FP8 to text encoders" group. Worth adding PromptEnhancerTokenizer for symmetry — it's harmless today only by accident, because _apply_fp8_layerwise_casting bails on anything that isn't an nn.Module.
B. Turbo detection now matches on the install path — nice-to-have
configs/main.py:96-102 joins name and path into one haystack. For an Invoke-managed install config.path is relative and the risk is negligible, but an in-place install records an absolute path, so any ancestor directory containing "turbo" — /mnt/turbo-nvme/models/ERNIE-Image/ — silently gives the base model 8 steps and CFG 1.0. Matching Path(path).name instead of the whole string keeps the rename-resilience you wanted without the ancestor-directory surface.
Nits (all nice-to-have)
ernie_image.py:48omitslocal_files_only=True, whichGenericDiffusersLoaderand every sibling loader (z_image.py,krea2.py,qwen_image.py) pass. It also handstorch_dtype=/variant=to the tokenizer andpe_tokenizerloads, where the siblings special-caseAutoTokenizer.from_pretrained(path, local_files_only=True). I checked and transformers 5.14 accepts and ignores both kwargs (it does warn thattorch_dtypeis deprecated in favour ofdtype, but that's repo-wide), so this is style/offline-robustness, not a bug.- Heun now reports
2N-1as the total, so asking for 8 steps shows a 15-step progress bar. Correct accounting, and I'd rather have that than the overrun — just flagging that it will read as surprising. _enhance_prompt'smax_new_tokens=tokenizer.model_max_lengthis a faithful port of upstream_enhance_prompt_with_pe, but if the PE tokenizer config omitsmodel_max_lengththe HF sentinel makes that effectively unbounded and a non-terminating rewrite hangs the graph. A literal cap would be cheap insurance. Not a regression — noting it because it's newly reachable now that the enhancer actually runs.
Repo / CI state
The branch conflicted with main in addImageToImage.ts and schema.ts. I've resolved both and pushed the merge to this branch as a68a02a5c8 — shout if you'd rather redo it yourself and I'll drop it.
addImageToImage.ts— main's PiD work retypedl2iand the return value fromLatentToImageNodestoImageOutputNodes; this branch had factored the inlinei2lunion into anImageToLatentsNodesalias. Both survive:ImageOutputNodesforl2i/return,ImageToLatentsNodesfori2l. Nothing to add to thei2lunion, since ERNIE has no VAE-encode node.schema.tsregenerated rather than merged. The auto-mergedopenapi.jsonturned out byte-identical to the regenerated file.
On the merged tree: pytest tests/ is 4040 passed / 0 failed — the 9 failures I reported last round were a stale local venv, not real. Frontend tsc / eslint / prettier / vitest all clean, ruff check + ruff format --check clean, uv lock --locked clean, and regenerating schema.ts a second time is a no-op (CI typegen parity).
Leaving this as a comment rather than a formal review state: (A) is the one thing I'd want fixed before merge, and it's a two-line change to the exclusion set. (B) and the nits are all nice-to-haves — take or leave them. Everything else looks good to me.
…ection Address review findings on invoke-ai#9115: - Exclude SubModelType.PromptEnhancer and PromptEnhancerTokenizer from fp8 layerwise casting. The prompt enhancer is a causal LM driven by generate() — one full forward per generated token — so casting made the whole LM round-trip bf16<->fp8 per token, on top of fp8 rounding a model whose entire job is text quality. - Match turbo detection on the install directory's leaf name instead of the whole path string. An in-place install records an absolute path, so an ancestor directory like /mnt/turbo-nvme/ silently gave the base model Turbo's 8 steps and CFG 1.0. - Pass local_files_only=True on all ERNIE-Image from_pretrained calls and load tokenizers bare, matching the sibling loaders. - Cap the prompt enhancer's max_new_tokens at 1024. Driving it off model_max_length hangs the graph if the tokenizer config omits it and transformers substitutes its int(1e30) sentinel. Adds regression tests for the fp8 exclusion and the turbo path matching.
…PE gate Self-review follow-ups on invoke-ai#9115: - Honor denoising_end. Every FlowMatch scheduler appends its own terminal 0 sigma, and passing the window minus its last entry let that zero stand in for the requested end sigma - so denoising_end < 1.0 ran a full denoise in fewer, coarser steps. Hand the scheduler the whole window and truncate its appended zero instead, which also keeps the scheduler's own `shift` applied to the terminal sigma. - Reject a denoising window that rounds down to a single sigma. It yielded zero steps, so the loop returned its input untouched and the graph decoded raw noise with no error at all. - Blend image-to-image init latents with noise at the first sigma, and reject denoising_start > 0 when no latents are provided. Both cases previously lied to the model about where the sample sits on the rectified-flow path. The shape check now covers batch and spatial dims, and its message no longer points at a VAE-encode node that does not exist. - Require both `pe` and `pe_tokenizer` to be present AND declared in model_index.json before offering the prompt enhancer. get_hf_load_class resolves submodels from that file, so a directory-only check let a partial install pass the gate and then hard-fail the generation - by default, since the toggle is on. - Pass the real generation dimensions to the prompt enhancer instead of leaving it on its 1024x1024 defaults. Adds regression tests for each, including a graph-builder test suite for ERNIE (which had none). Added ERNIE-Image + Krea 2 Raw into README.md under Supported Model.
lstein
left a comment
There was a problem hiding this comment.
Re-reviewed a68a02a5c8..74ae2a1144. The blocker and both nice-to-haves from last round are fixed, and the self-review pass on top of them found four real problems I had missed — the denoising_end one in particular is a genuine sampling bug, well diagnosed. Nice work. Details below, then one new blocker (pre-existing, not from these commits) and two smaller items.
Verified fixed
A. fp8 no longer reaches the prompt enhancer. SubModelType.PromptEnhancer and PromptEnhancerTokenizer are in _excluded_submodel_types (load_default.py:263-264), and the test pins both directions — excluded submodel False, transformer still True on the same fp8-enabled config, so it can't pass by the config simply failing to opt in.
B. Turbo detection. Path(path).name (configs/main.py:103); Path was already imported. /mnt/turbo-nvme/models/ERNIE-Image → 50 steps / CFG 4.0, .../ERNIE-Image-Turbo → 8 / 1.0, both pinned.
Nits. local_files_only=True on every from_pretrained, with tokenizers now loaded bare like the sibling loaders. I checked the one thing that could have broken silently: the variant-fallback in _load_model keys off "no file named" in str(e), and adding local_files_only=True doesn't change that message — a local dir missing *.fp16.safetensors still raises Error no file named diffusion_pytorch_model.fp16.bin found in directory ... either way, so the fallback still fires. max_new_tokens=min(tokenizer.model_max_length, PE_MAX_NEW_TOKENS) closes the int(1e30) sentinel hole.
Verified — self-review fixes
denoising_end is now honored. This is the important one and the fix is right. Passing the whole window and truncating the scheduler's appended zero — rather than patching the last sigma by hand — is what keeps the scheduler's own shift on the terminal sigma. With shift=3, denoising_end 0.75 / 0.5 / 0.25 land on 0.500 / 0.750 / 0.900, exactly 3σ/(1+2σ) of the requested raw end sigma.
The thing I most wanted to confirm is that this doesn't perturb the default path, since upstream ErnieImagePipeline does set_timesteps(sigmas=sigmas[:-1]) (pipeline_ernie_image.py:325) and you now do something different. It doesn't: for denoising_end=1.0 the requested terminal sigma is 0, shift(0) == 0, so hand-the-whole-window-then-truncate lands on the same tensors. I checked torch.equal on both sigmas and timesteps for shift ∈ {1.0, 3.0, 6.0} × N ∈ {1, 2, 8, 50} — identical in all 12 cases.
Ran the loop end-to-end against a stub transformer over {euler, euler shift=3, lcm} × steps {1, 2, 8} × windows {(0,1), (0,.75), (0,.5), (.5,1), (.25,.75)}: no IndexError from the truncated sigmas/timesteps, len(states) == len(sigmas) - 1 in every case, last emitted step equals the reported total, output finite. FlowMatchLCMScheduler does accept sigmas=, so it takes the same branch as euler. Heun still refuses partial ranges and still reports its real 15 iterations for 8 steps.
Degenerate window rejected. Confirmed the guard can't fire on a legitimate request: denoising_end=1.0 gives end == num_steps, and denoising_start < denoising_end bounds start ≤ num_steps - 1, so a full-range window is always ≥ 2 sigmas.
PE gate. Now mirrors get_hf_load_class exactly — it does config[submodel_type.value] against model_index.json and raises ValueError on KeyError (generic_diffusers.py), so requiring both the directory and the declaration is the correct predicate. json.loads failure modes are covered: missing file and a directory in its place both raise OSError, malformed JSON raises ValueError, and both are caught.
Init latents / denoising_start. The blend is the right rectified-flow form and matches diffusers' own add_noise. See item 2 below for the one thing it gets wrong.
pe_width / pe_height. Correctly placed after addTextToImage, which is what writes the resolved dimensions onto the denoise node.
New — the last remaining blocker
1. The LCM scheduler ignores the seed entirely
denoise.py:109 calls scheduler.step(model_output=pred, timestep=timestep, sample=img) with no generator. FlowMatchLCMScheduler.step is stochastic — it reconstructs x0_pred and re-noises:
noise = randn_tensor(x0_pred.shape, generator=generator, device=..., dtype=...)
prev_sample = (1 - sigma_next) * x0_pred + sigma_next * noiseWith generator=None that draws from the global RNG, so every step injects noise the seed field does not control. The carefully seeded CPU noise from the last round only fixes the initial latent.
Trigger: select LCM in the scheduler dropdown (it's one of three offered, flux/schedulers.py:70), generate, then generate again with the same seed in the same server process. I ran exactly that through denoise() — seeding the initial latent identically and leaving the global RNG to advance between runs, as it does in a long-lived process — and got a max elementwise difference of 2.17 between the two results, on a stub model that barely moves the sample. Euler under the same test is bit-identical. So: same seed, different image; and recalling a seed from gallery metadata will not reproduce an LCM generation.
This is pre-existing rather than something these commits introduced — I missed it in both earlier rounds — but it's the one thing I'd want fixed before merge, because it's a silent violation of the seed contract on a user-selectable option.
Fix follows two existing patterns in the repo: denoise_latents.py:759 builds torch.Generator(device).manual_seed(seed ^ 0xFFFFFFFF) into scheduler_step_kwargs for the ancestral SD schedulers, and anima/scheduler_driver.py:143 passes a seeded _step_generator straight into scheduler.step. Threading self.seed down into denoise() and passing generator= is safe for all three schedulers here — euler and heun accept the kwarg too and only consult it when s_churn > 0, which is 0 on this path. (Dropping LCM from ERNIE_IMAGE_SCHEDULER_MAP would also close it, if you'd rather not carry a stochastic sampler in a Prototype node.)
New — nice-to-haves
2. The img2img blend uses the pre-shift sigma while the loop starts at the post-shift sigma
ernie_image_denoise.py:131 passes float(timesteps[0]) into _prepare_initial_latents. That's the raw linspace value out of get_schedule, which doesn't apply shift — the shift is applied later, inside the scheduler's set_timesteps (denoise.py:75). So the sample is built at one sigma and the first model call is told another.
Measured with shift=3: denoising_start=0.25 blends at σ=0.750 but the loop opens at σ=0.900; denoising_start=0.5 blends at 0.500 and opens at 0.750. It's exact only at denoising_start=0, where both are 1.0 — which is why the default path is unaffected.
Contrast Z-Image, which applies its own time shift in _get_sigmas before clipping to the denoising window, so its blend and its loop agree by construction. Here the shift lives in the scheduler, so the blend has to ask the scheduler for it. Cheapest fix is to let the scheduler do the noising: after set_timesteps, scheduler.scale_noise(init_latents, scheduler.timesteps[:1], noise) is exactly σ·noise + (1-σ)·sample with the shifted σ. That needs the blend to move after set_timesteps, i.e. into denoise().
Reachable only from the workflow editor — buildErnieImageGraph throws UnsupportedGenerationModeError for anything but txt2img, so denoising_start is always 0 in the linear UI — and only when the checkpoint's scheduler_config.json has shift != 1.0. Hence nice-to-have rather than a blocker.
3. Nothing in the PR can produce the latents that input documents
latents wants "VAE-encoded, BN-normalized, and patchified" latents, but the PR ships ernie_image_vae_decode with no encode counterpart, and there's no ERNIE member in the i2l union. So the only node that can feed it is another ernie_image_denoise.
That matters for the multi-stage handoff your own test docstring cites as the motivation for the denoising_end fix. Chaining stage 1 (0 → 0.5) into stage 2 (0.5 → 1.0) hands stage 2 latents that already sit at σ=0.5, and _prepare_initial_latents will noise them again to 0.5. The blend is correct for its stated contract (init latents are clean, σ=0) — it's just that the handoff is the one reachable caller and it doesn't satisfy that contract. Worth either shipping the encode node or narrowing the docstring to say the input expects fully-denoised latents.
Nit
posCond.pe_width = denoise.width picks up the scaled size, so on canvas with scaling active the enhancer is told the intermediate render size rather than the final output size. Aspect ratio is roughly preserved and that's most of what the enhancer uses it for, so this is cosmetic — getOriginalAndScaledSizesForTextToImage already hands back originalSize if you'd prefer it.
CI / verification
On 74ae2a1144: the 43 ERNIE + fp8 tests pass, pytest tests/backend/model_manager tests/app is 2901 passed / 130 skipped / 6 xfailed / 0 failed, ruff check and ruff format --check clean over 1165 files, and on the frontend the 10 new buildErnieImageGraph tests pass with tsc --noEmit, eslint --max-warnings=0 and prettier --check all clean.
Comment rather than a formal state again. (1) is the last remaining blocker — everything else I've raised across all three rounds is now closed. (2), (3) and the nit are yours to take or leave.
… sigma Round-4 review follow-ups on invoke-ai#9115: - Pass a seeded generator into scheduler.step. FlowMatchLCMScheduler is stochastic - it re-noises the sample every step - so with generator=None it drew from the global RNG. The seed field only controlled the initial latent, so the same seed produced a different image on every run and seed recall from gallery metadata could not reproduce an LCM generation. Seeded from `seed ^ 0xFFFFFFFF` (as denoise_latents.py does) so the step noise stays decorrelated from the initial noise, and from a CPU generator so it is device-independent like the initial noise. - Blend image-to-image init latents at the scheduler's post-shift first sigma instead of the raw schedule value. get_schedule emits raw linspace values and the scheduler applies `shift` in set_timesteps, so the blend built the sample at one sigma and then told the first model call it was at another. The blend moves into denoise(), which is the only layer that knows the shifted sigma. - Add an `add_noise` field mirroring z_image_denoise. The only node that can currently feed `latents` is another ernie_image_denoise, whose output already sits at the handoff sigma - re-noising it broke the very multi-stage handoff the denoising_end fix enables. - Give the prompt enhancer the original size rather than the intermediate scaled render size. Regenerates openapi.json / schema.ts for the new field. Adds regression tests for the seed contract (same seed identical, different seeds different, euler unaffected) and for the post-shift blend.
lstein
left a comment
There was a problem hiding this comment.
Re-reviewed 74ae2a1144..1748457ed9. All four items from the last round are fixed, and the two that mattered are pinned by tests that fail if the fix is reverted. No blockers left — this is good to merge from my side.
Verified fixed
1. The LCM seed blocker is closed
generator=torch.Generator(device="cpu").manual_seed(seed ^ 0xFFFFFFFF) is threaded from the invocation through denoise() into every scheduler.step. Re-ran my reproduction from last round against this commit: three LCM runs at the same seed with the global RNG deliberately advanced by torch.rand(1e4) between them are now bit-identical, and a different seed still diverges (max diff 1.72). Previously runs 0 and 1 differed by 2.17.
Details I checked rather than assumed:
- CPU generator, GPU sample.
FlowMatchLCMScheduler.stepcallsrandn_tensor(..., device=x0_pred.device, ...). With a CPU generator and a CUDA/ROCm tensor,randn_tensorsetsrand_device="cpu", draws there and.to(device)s the result — so the step noise is device-independent in the same way the initial latent already is, which is the right call. It logs anINFOline per call, below diffusers' default WARNING verbosity, so no log spam. - The XOR. Matches
denoise_latents.py, and keeps the step noise decorrelated from the initial noise drawn atself.seed. - Euler is genuinely unperturbed.
test_deterministic_scheduler_is_unaffected_by_the_generatorpins it, and it isn't a vacuous test —test_stochastic_scheduler_respects_different_seedsis the guard that stops the seeded test from passing because the generator is ignored. Good pairing.
One thing the fix covers that the comment doesn't claim: in diffusers 0.39 FlowMatchEulerDiscreteScheduler has a stochastic_sampling config flag, and when it's set step() re-noises exactly like LCM. Since _build_scheduler loads the scheduler config from the checkpoint's scheduler/ dir, a checkpoint shipping stochastic_sampling: true would have had this identical bug on the default scheduler. I ran euler both ways — same-seed identical under both. So the fix is broader than advertised.
Minor correction to the code comment at denoise.py:126: flow-match Euler in this version has no s_churn/gamma path at all (that's Heun) — it branches on config.stochastic_sampling. Doesn't change a line of code, just the rationale.
2. The img2img blend now uses the post-shift sigma
Blending moved into denoise() at float(scheduler.sigmas[0]). Verified numerically against scheduler.scale_noise across shift ∈ {1.0, 3.0, 6.0} × denoising_start ∈ {0, 0.25, 0.5, 0.75} — 12/12 exact. The pre-shift/post-shift gap I reported is visible in the matrix (shift=3: 0.750→0.900, 0.500→0.750) and gone.
The regression test is the right shape: all-ones noise against all-zeros init collapses the blend to the sigma itself, so it reads the value directly out of the first model input, and the shift != 1.0 branch pins the specific wrong value (0.5) the old code produced.
3. add_noise — the multi-stage handoff is now correct
This is the better fix for item 3; I'd flagged the docstring as the thing to narrow and this actually closes the hole. Semantics match z_image_denoise exactly (add_noise: bool = InputField(default=True, ...), ignored without init latents), so it's the field people already know.
Checked the handoff arithmetic end to end. get_schedule slices sigmas[start:end+1] with start = int(N·denoising_start), so stage 1 (0 → 0.5) at N=8 ends on sigmas[4] = 0.5 and stage 2 (0.5 → 1.0) opens on sigmas[4] = 0.5 — same raw value, same shift applied by the same scheduler, so the sigma the stage-2 loop announces is exactly where stage 1 left the sample. With add_noise=False I confirmed the first model input is the incoming sample verbatim (torch.equal), i.e. no residual blend.
addImageToImage already lists ernie_image_denoise in its type union and leaves add_noise at its default, which is the correct setting for a clean i2l latent — so whenever the encode node does land, that path is already right.
4. pe_width nit
Now getOriginalAndScaledSizesForTextToImage(state).originalSize. Same helper addTextToImage calls a few lines earlier, so the assert(false, 'Cannot get sizes for tab ...') inside it can't newly fire here. The test fixture was changed to make originalSize (832×1216) and scaledSize (512×768) actually differ, which is what makes the new assertion meaningful — the old fixture had them equal and would have passed either way.
Attacks that didn't find anything
- Loop sweep over {euler, euler shift=3, lcm, heun} × steps {1, 2, 8} × windows {(0,1), (0,.5), (.5,1), (.25,.75)} × {init latents, none}, all with a generator: output finite, no progress overrun, no
IndexErrorfrom the truncatedsigmas/timesteps. The only errors raised were the intended ones — the degenerate-window guard atsteps=1, and Heun refusing a partial range. - Lifecycle of the new state.
init_latentshas exactly one clear site (img, init_latents = init_latents, None) and one read per branch;generatoris created once per invoke and never reset mid-loop, which is what reproducibility requires. No other caller ofbackend/ernie_image/denoise.denoiseexists, and both new params are optional. - Truncated schedule vs LCM's internals.
step()readssigmas[step_index + 1]; after truncationlen(sigmas) == len(timesteps) + 1, so the last step'ssigma_nextis the requested end sigma rather than an out-of-range index._scale_factors/_upscale_modeare unset, so its length assertion is skipped. - Generated files. Regenerated both with CI's own commands from a clean tree —
schema.tsandopenapi.jsonare byte-identical to what's committed.
CI / verification
On 1748457ed9: 48 ERNIE + fp8 tests pass (up from 43), pytest tests/backend/model_manager tests/app is 2901 passed / 130 skipped / 6 xfailed / 0 failed, ruff check + ruff format --check clean over 1165 files, frontend tsc --noEmit / eslint --max-warnings=0 / prettier --check clean, and the 11 buildErnieImageGraph tests pass. All 17 checks green on the PR.
The one thing still open is not a defect: there's no ERNIE VAE encode node, so latents + add_noise=true still has no producer in-tree and the input is only reachable from another ernie_image_denoise. That's a feature gap for a follow-up, not something to hold this on — the field is documented for both cases and the chained case is now correct.
Approving. Nice work on the self-review passes — the denoising_end and add_noise findings were both yours.
Brings in ernie image/turbo (invoke-ai#9115) and UNet-only SDXL LoRA identification (invoke-ai#9383), closing the remaining gap with upstream. Only openapi.json and schema.ts conflicted; both are generated, so they were resolved by regenerating from the merged backend rather than by hand-merging.
Summary
Adds Baidu ERNIE-Image and ERNIE-Image-Turbo (HF, HF Turbo) as a new
BaseModelType, mirroring the FLUX.2 Klein and Z-Image integration patterns.The two checkpoints share the same
ErnieImageTransformer2DModelarchitecture (3072 hidden, 24 layers, 24 heads, Mistral3 text encoder, AutoencoderKLFlux2 VAE) and only differ in inference defaults (50 steps + CFG 4.0 vs. 8 steps + CFG 1.0 for Turbo), so they live under oneBaseModelType.ErnieImagewithout a variant enum. The optional 3B Mistral3-based prompt enhancer that ships with the pipeline is wired through with a UI toggle.ERNIE-Image is text-to-image only in this PR — its denoise node has no
denoise_maskinput, so masked modes (inpaint/outpaint) are unsupported, and image-to-image is not wired.This PR builds on top of #8859 (transformers 5.1+) and additionally bumps
diffusers0.36.0 → 0.38.0, which is the first release containingErnieImagePipelineandErnieImageTransformer2DModel.Backend changes
BaseModelType.ErnieImage,ModelType.PromptEnhancer, plus two newSubModelTypes (pe,pe_tokenizer) for the bundled prompt enhancer.Main_Diffusers_ErnieImage_ConfigandMain_Checkpoint_ErnieImage_Configwith state-dict-based detection (x_embedder+text_proj+adaLN_modulation).transformer/vae/text_encoder/tokenizerplus optionalpe/pe_tokenizerfrom a single pipeline directory.invokeai/backend/ernie_image/with sampling utilities (2×2 patchify, BN normalize/denormalize, sigma schedule, padded text packing) and a rectified-flow denoise loop supporting Euler / Heun / LCM (reusing the FlowMatch* schedulers from FLUX).ernie_image_model_loader,ernie_image_text_encoder(with prompt-enhancer toggle),ernie_image_denoise,ernie_image_vae_decode.ErnieImageConditioningInfo+ conditioning field/output, including pickle allowlist for the disk serializer.ernie_image_txt2img. ERNIE-Image is text-to-image only — its denoise node has nodenoise_maskinput, so masked modes (inpaint/outpaint) are unsupported, and image-to-image is not wired.baidu/ERNIE-Imageandbaidu/ERNIE-Image-Turbo, plus aSTARTER_BUNDLESentry.Frontend changes
services/api/schema.tsregenerated to expose the new node types.ImageOutput,LatentToImage,DenoiseLatents,MainModelLoaderNodes). NewMaskableDenoiseNodestype excludesernie_image_denoisefrom the inpaint/outpaint helpers (ERNIE has nodenoise_mask).ParamsStategainsernieImageSchedulerandernieImageUsePromptEnhancer, with reducers, selectors, andselectIsErnieImage.buildErnieImageGraph(txt2img only) wired intouseEnqueueCanvas.MODEL_BASE_TO_{COLOR,LONG_NAME,SHORT_NAME};prompt_enhanceradded toMODEL_TYPE_TO_LONG_NAME.ParamErnieImageSchedulerandParamErnieImagePromptEnhancerrendered conditionally inGenerationSettingsAccordion.addTextToImage/addImageToImagerectified-flow type guards accepternie_image_denoise;isMainModelWithoutUnetlikewise.addInpaint/addOutpainttakeMaskableDenoiseNodes, which excludes ERNIE.Diffusers 0.38 fix-out
hotfixes.py: importLoRACompatibleConvdirectly. The lazy module loader in 0.38 no longer exposesdiffusers.models.loraas an attribute, so the legacy patch path crashes on import.pyproject.tomldeclaresprerelease = "allow"under[tool.uv], with a comment explaining that diffusers 0.38.0 itself hard-pinssafetensors>=0.8.0-rc.0. We can drop this again once a diffusers patch release ships with a stable safetensors floor.Related Issues / Discussions
QA Instructions
Automated
pytest tests/ -m "not slow"— passes (619 / 619, the sametests/model_identificationLFS-skip as onmain).pnpm lint:tsc,pnpm lint:eslint,pnpm lint:prettier— all clean.pnpm test:no-watch— 563 / 563 frontend tests pass.Manual smoketest of pre-existing models (regression for the diffusers / transformers bump)
Pick at least two of these and run a single txt2img each. Confirm no crashes and visually-plausible output:
Plus the relevant items from #8859's test plan: SD 1.5 prompt-weighted generation (compel path), FLUX text-to-image (T5 tokenizer path), HF model install via repo ID, NSFW checker first-time download.
Manual smoketest of ERNIE-Image (requires a GPU — 8B parameters)
baidu/ERNIE-Image-Turbofrom the new starter bundle. The Model Manager should classify it asBaseModelType.ErnieImage."a fox"); the enhancer log line in the backend should show a rewritten longer prompt before encoding.baidu/ERNIE-Image(50 steps, CFG 4.0).Merge Plan
transformers>=5.1.0override). I have no preference; happy to rebase whenever Update to transformers 5.1.0 #8859 lands.prerelease = "allow"line inpyproject.tomlis a temporary measure tied to diffusers 0.38.0'ssafetensors>=0.8.0-rc.0upstream pin. Worth revisiting (and removing) once a diffusers patch release relaxes that requirement.Checklist
paramsSlice.docs-old/contributing/NEW_MODEL_INTEGRATION.mdchecklist describes the integration shape this PR follows.What's Newcopy (if doing a release after this PR)Out of scope (planned follow-ups)
denoise_maskinput on the backend denoise node; img2img could be added later (the denoise node already accepts optional starting latents).Main_Checkpoint_ErnieImage_Configis in place as defensive scaffolding, but the loader currently raisesNotImplementedErrorfor that format).