From ac06aaded942e2201b79a796354fa5e3007f49e0 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 20 Sep 2026 18:49:11 +0000 Subject: [PATCH 1/2] feat: support Qwen Image 2.1 local generation and editing --- data.reference/media-models.json | 11 +++++ docs/features/qwen-image-21.md | 32 ++++++++++++++ scripts/setup-image-video.sh | 12 ++--- scripts/z_image_turbo.py | 55 ++++++++++++++--------- scripts/z_image_turbo_test.py | 68 +++++++++++++++++++++++++++++ server/lib/huggingfaceModel.js | 11 ++++- server/lib/huggingfaceModel.test.js | 9 ++++ server/lib/mediaModels.js | 13 ++++++ server/lib/mediaModels.test.js | 16 +++++++ server/lib/pythonSetup.js | 8 ++-- 10 files changed, 202 insertions(+), 33 deletions(-) create mode 100644 docs/features/qwen-image-21.md create mode 100644 scripts/z_image_turbo_test.py diff --git a/data.reference/media-models.json b/data.reference/media-models.json index c1b0a7e3f7..fd5a43351f 100644 --- a/data.reference/media-models.json +++ b/data.reference/media-models.json @@ -1898,6 +1898,17 @@ "guidance": 4, "editOnly": true }, + { + "id": "qwen-image-2.1", + "name": "Qwen-Image 2.1 (7B DiT, generation + editing, research license)", + "runner": "qwen", + "repo": "Qwen/Qwen-Image-2.1", + "pipelineClass": "QwenImage21Pipeline", + "steps": 40, + "guidance": 1, + "cfgDisabled": true, + "licenseUrl": "https://huggingface.co/Qwen/Qwen-Image-2.1/blob/main/LICENSE" + }, { "id": "z-image-turbo-bf16", "name": "Z-Image-Turbo (bf16, ~13 GB)", diff --git a/docs/features/qwen-image-21.md b/docs/features/qwen-image-21.md new file mode 100644 index 0000000000..52e6f36afd --- /dev/null +++ b/docs/features/qwen-image-21.md @@ -0,0 +1,32 @@ +# Qwen-Image 2.1 + +Choose **Local → Qwen-Image 2.1** in Image Gen. The model uses +`Qwen/Qwen-Image-2.1` and Diffusers' `QwenImage21Pipeline`, with 40 steps +and guidance disabled by default. Existing Qwen models remain available. +The registry adds the new choice to existing installs without changing their +selected model or customized settings. + +Install or repair the shared FLUX.2 image runtime through the app, or run: + +```sh +INSTALL_FLUX2=1 FLUX2_FORCE_REINSTALL=1 bash scripts/setup-image-video.sh +``` + +Both installers use current Diffusers from git and Transformers >=5.17. +An older runtime missing `QwenImage21Pipeline` reports the repair command +before loading weights. Setup does not download model weights; those load +when a render is requested. + +Text-to-image and editing through the existing single **Init Image** input +are supported. Editing preserves the reference's aspect ratio and alpha +channel; this unified pipeline does not use denoising strength. PNG output +retains generated transparency. For transparent output, explicitly describe +an RGBA image with an alpha channel and a transparent background in the prompt. +PortOS does not expose Qwen's multi-reference or separate-mask interfaces yet. + +The model ships under the **Qwen Research License**, unlike the earlier +Apache-licensed Qwen image models. See the +[model and license](https://huggingface.co/Qwen/Qwen-Image-2.1) and +[upstream usage examples](https://github.com/QwenLM/Qwen-Image-2.1). +The integration's automated checks use fixture pipelines; they do not establish +render quality or hardware memory requirements for the full model. diff --git a/scripts/setup-image-video.sh b/scripts/setup-image-video.sh index bba4e09704..a6a1e7257b 100755 --- a/scripts/setup-image-video.sh +++ b/scripts/setup-image-video.sh @@ -1083,10 +1083,10 @@ if [[ "$INSTALL_FLUX2" == "1" ]]; then fi FLUX2_PY="$(venv_python "$FLUX2_VENV")" - # Skip the (slow, network-heavy) pip path when Flux2KleinPipeline already - # imports — diffusers-from-git is a git clone every run otherwise. Use + # Skip the (slow, network-heavy) pip path when both FLUX.2 and Qwen 2.1 already + # import — diffusers-from-git is a git clone every run otherwise. Use # FLUX2_FORCE_REINSTALL=1 to bypass. - if [[ "${FLUX2_FORCE_REINSTALL:-}" != "1" ]] && "$FLUX2_PY" -c "from diffusers import Flux2KleinPipeline" 2>/dev/null; then + if [[ "${FLUX2_FORCE_REINSTALL:-}" != "1" ]] && "$FLUX2_PY" -c "from diffusers import Flux2KleinPipeline, QwenImage21Pipeline" 2>/dev/null; then echo "✅ FLUX.2 venv already ready: $FLUX2_PY" else echo "📦 Installing FLUX.2 packages into $FLUX2_VENV..." @@ -1099,7 +1099,7 @@ if [[ "$INSTALL_FLUX2" == "1" ]]; then "torch>=2.5" \ torchvision \ accelerate \ - "transformers>=4.51" \ + "transformers>=5.17" \ sentencepiece \ protobuf \ safetensors \ @@ -1110,9 +1110,9 @@ if [[ "$INSTALL_FLUX2" == "1" ]]; then "optimum-quanto>=0.2.7" \ pillow probe_or_fail \ - "flux2 venv built but 'from diffusers import Flux2KleinPipeline' failed." \ + "flux2 venv built but 'from diffusers import Flux2KleinPipeline, QwenImage21Pipeline' failed." \ "Try: $FLUX2_PY -m pip install --upgrade --force-reinstall 'diffusers @ git+https://github.com/huggingface/diffusers'" \ - "$FLUX2_PY" -c "from diffusers import Flux2KleinPipeline" + "$FLUX2_PY" -c "from diffusers import Flux2KleinPipeline, QwenImage21Pipeline" echo "✅ FLUX.2 venv ready: $FLUX2_PY" fi fi diff --git a/scripts/z_image_turbo.py b/scripts/z_image_turbo.py index 6a3f23bf32..afe20bd6ef 100644 --- a/scripts/z_image_turbo.py +++ b/scripts/z_image_turbo.py @@ -157,7 +157,12 @@ def load_pipeline( if pipeline_class: cls = getattr(diffusers, pipeline_class, None) if cls is None: - print(f"❌ Unknown diffusers pipeline class: {pipeline_class}", file=sys.stderr) + print( + f"❌ Unknown diffusers pipeline class: {pipeline_class}. " + "Upgrade the image runtime: INSTALL_FLUX2=1 FLUX2_FORCE_REINSTALL=1 " + "bash scripts/setup-image-video.sh", + file=sys.stderr, + ) sys.exit(2) pipe = cls.from_pretrained( repo, torch_dtype=dtype, low_cpu_mem_usage=True, **extra_kwargs @@ -237,21 +242,23 @@ def main() -> None: init_image = None if args.image_path: - init_image = Image.open(args.image_path).convert("RGB").resize( - (int(args.width), int(args.height)), Image.LANCZOS - ) - # Promote the txt2img pipe to its i2i sibling. If the model family - # doesn't expose an i2i variant, fall through to txt2img with a warning - # — better than failing the request outright. - try: - pipe = to_i2i_pipeline(pipe) - except Exception as err: - print( - f"⚠️ z-image: i2i sibling unavailable ({type(err).__name__}: {err}); " - f"falling back to txt2img and ignoring init image", - file=sys.stderr, - ) - init_image = None + # Unified editing pipelines already accept `image`; auto-conversion + # can reject them and silently discard the user's conditioning image. + native_image = "image" in inspect.signature(pipe.__call__).parameters + mode = "RGBA" if args.pipeline_class == "QwenImage21Pipeline" else "RGB" + with Image.open(args.image_path) as source: + init_image = source.convert(mode) + if not native_image: + init_image = init_image.resize((int(args.width), int(args.height)), Image.LANCZOS) + try: + pipe = to_i2i_pipeline(pipe) + except Exception as err: + print( + f"⚠️ z-image: i2i sibling unavailable ({type(err).__name__}: {err}); " + f"falling back to txt2img and ignoring init image", + file=sys.stderr, + ) + init_image = None apply_memory_optimizations(pipe, width=args.width, height=args.height) apply_loras(pipe, args.lora_paths or [], args.lora_scales or []) @@ -289,7 +296,9 @@ def preview_decoder(p, latents): # noqa: E306 — local closure on purpose # rebuilding from a list every step is pure waste (mirrors how the # ERNIE branch above pulls vae.bn directly, not from a list). qwen_mean_cpu = torch.tensor(pipe_vae.config.latents_mean).view(1, z_dim, 1, 1, 1) - qwen_inv_std_cpu = (1.0 / torch.tensor(pipe_vae.config.latents_std)).view(1, z_dim, 1, 1, 1) + # 2.1 stores the standard deviation; legacy Qwen stores its inverse. + qwen_std = torch.tensor(pipe_vae.config.latents_std) + qwen_scale_cpu = (qwen_std if args.pipeline_class == "QwenImage21Pipeline" else 1.0 / qwen_std).view(1, z_dim, 1, 1, 1) qwen_norm_cache = {} def unpack_latents(latents, height, width): # noqa: E306 @@ -301,11 +310,11 @@ def preview_decoder(p, latents): # noqa: E306 if cached is None: cached = ( qwen_mean_cpu.to(device=latents.device, dtype=latents.dtype), - qwen_inv_std_cpu.to(device=latents.device, dtype=latents.dtype), + qwen_scale_cpu.to(device=latents.device, dtype=latents.dtype), ) qwen_norm_cache[key] = cached - mean, inv_std = cached - unnorm = latents * inv_std + mean + mean, scale = cached + unnorm = latents * scale + mean decoded = p.vae.decode(unnorm, return_dict=False)[0] # Qwen's image VAE returns `(B, C, T, H, W)` with T=1 for stills. # Slice the temporal dim out so the generic post-decode path's @@ -327,9 +336,13 @@ def preview_decoder(p, latents): # noqa: E306 height=int(args.height), width=int(args.width), num_inference_steps=int(args.steps), - guidance_scale=float(args.guidance), generator=generator, ) + # Qwen 2.1 exposes true_cfg_scale instead of guidance_scale. + if "guidance_scale" in accepted: + pipe_kwargs["guidance_scale"] = float(args.guidance) + elif "true_cfg_scale" in accepted: + pipe_kwargs["true_cfg_scale"] = float(args.guidance) if args.negative_prompt and "negative_prompt" in accepted: pipe_kwargs["negative_prompt"] = args.negative_prompt if callback is not None and "callback_on_step_end" in accepted: diff --git a/scripts/z_image_turbo_test.py b/scripts/z_image_turbo_test.py new file mode 100644 index 0000000000..727970bd3c --- /dev/null +++ b/scripts/z_image_turbo_test.py @@ -0,0 +1,68 @@ +"""Runner contract tests without model weights or a torch installation.""" +import contextlib +import importlib +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from PIL import Image + + +class Qwen21Pipeline: + # Deliberately matches the upstream boundary: no guidance_scale/strength. + def __call__(self, prompt, height, width, num_inference_steps, generator, + true_cfg_scale=1.0, image=None): + self.received = dict(prompt=prompt, steps=num_inference_steps, + cfg=true_cfg_scale, image=image) + return SimpleNamespace(images=[Image.new('RGBA', (32, 32), (10, 20, 30, 64))]) + + +class RunnerContract(unittest.TestCase): + def test_qwen21_generation_and_edit_preserve_alpha(self): + torch = SimpleNamespace( + bfloat16='bf16', float32='fp32', inference_mode=contextlib.nullcontext, + backends=SimpleNamespace(mps=SimpleNamespace(is_available=lambda: False)), + cuda=SimpleNamespace(is_available=lambda: False), + ) + with patch.dict(sys.modules, {'torch': torch}): + runner = importlib.import_module('z_image_turbo') + for editing in (False, True): + with self.subTest(editing=editing), tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'source.png' + output = Path(directory) / 'output.png' + Image.new('RGBA', (48, 64), (1, 2, 3, 50)).save(source) + argv = ['runner', '--model', 'qwen-image-2.1', '--repo', 'Qwen/Qwen-Image-2.1', + '--pipeline-class', 'QwenImage21Pipeline', '--prompt', 'A transparent icon', + '--steps', '40', '--guidance', '1', '--seed', '42', '--output', str(output)] + if editing: + argv += ['--image-path', str(source), '--image-strength', '0.5'] + pipe = Qwen21Pipeline() + with patch.object(sys, 'argv', argv), patch.multiple( + runner, pick_device=lambda _: 'cpu', + load_pipeline=lambda *a, **kw: (pipe, 'cpu'), + emit_image_execution_marker=lambda *a: None, + apply_memory_optimizations=lambda *a, **kw: None, + apply_loras=lambda *a: None, + make_generator=lambda *a: 'generator', + make_stepwise_callback=lambda *a, **kw: None, + set_vae_tiling=lambda *a: None, + to_i2i_pipeline=lambda _: self.fail('Unified pipeline must not be converted'), + ): + runner.main() + self.assertEqual(pipe.received['steps'], 40) + self.assertEqual(pipe.received['cfg'], 1) + if editing: + self.assertEqual(pipe.received['image'].size, (48, 64)) + self.assertEqual(pipe.received['image'].getpixel((0, 0))[3], 50) + else: + self.assertIsNone(pipe.received['image']) + with Image.open(output) as result: + self.assertEqual(result.mode, 'RGBA') + self.assertEqual(result.getpixel((0, 0))[3], 64) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/lib/huggingfaceModel.js b/server/lib/huggingfaceModel.js index 951501a51b..dc8c230a34 100644 --- a/server/lib/huggingfaceModel.js +++ b/server/lib/huggingfaceModel.js @@ -395,6 +395,10 @@ export const classifyHfMediaModel = ({ repo, model, kind, runtime, runner, isWin // REQUIRES a source image — a text-only render crashes deep in diffusers, so // the built-in carries `editOnly`. Detect the edit repo so the added entry // gets the right pipeline + gate instead of the plain text-to-image config. + // 2.1 handles text and image conditioning in one new pipeline. + if (resolvedRunner === RUNNER_FAMILIES.QWEN && /qwen[\s._-]?image[\s._-]?2[._-]1\b/.test(blob)) { + return { kind: 'image', runner: resolvedRunner, format: 'safetensors', qwen21: true }; + } const editVariant = resolvedRunner === RUNNER_FAMILIES.QWEN && /qwen[\s._-]?image[\s._-]?edit|-edit\b|\bedit\b/.test(blob); return { kind: 'image', runner: resolvedRunner, format: 'safetensors', ...(editVariant ? { editVariant: true } : {}) }; }; @@ -425,7 +429,7 @@ export const buildCustomModelEntry = ({ repo, model, classification, name, steps || repo; const defaults = classification.kind === 'video' ? (VIDEO_DEFAULTS[classification.runtime] || { steps: 25, guidance: 3.0 }) - : (IMAGE_DEFAULTS[classification.runner] || { steps: 20, guidance: 3.5 }); + : (classification.qwen21 ? { steps: 40, guidance: 1.0 } : IMAGE_DEFAULTS[classification.runner] || { steps: 20, guidance: 3.5 }); const entry = { id, name: displayName, @@ -450,7 +454,10 @@ export const buildCustomModelEntry = ({ repo, model, classification, name, steps Object.assign(entry, RUNNER_METADATA[classification.runner] || {}); // A Qwen-Image-Edit repo needs the edit pipeline + the editOnly gate // (text-only renders crash it), overriding the plain QwenImagePipeline. - if (classification.editVariant) { + if (classification.qwen21) { + entry.pipelineClass = 'QwenImage21Pipeline'; + entry.cfgDisabled = true; + } else if (classification.editVariant) { entry.pipelineClass = 'QwenImageEditPipeline'; entry.editOnly = true; } diff --git a/server/lib/huggingfaceModel.test.js b/server/lib/huggingfaceModel.test.js index 2ac370002e..dbb087f72a 100644 --- a/server/lib/huggingfaceModel.test.js +++ b/server/lib/huggingfaceModel.test.js @@ -151,6 +151,15 @@ describe('classifyHfMediaModel — happy paths', () => { })).toEqual({ kind: 'video', runtime: 'ltx25', format: 'safetensors' }); }); + it('adds Qwen 2.1 with its unified pipeline even when tagged for editing', () => { + const repo = 'Qwen/Qwen-Image-2.1'; + const model = hf({ files: ['model.safetensors'], tags: ['edit'], pipeline: 'image-to-image' }); + const classification = classifyHfMediaModel({ repo, model }); + const entry = buildCustomModelEntry({ repo, model, classification }); + expect(entry).toMatchObject({ runner: 'qwen', pipelineClass: 'QwenImage21Pipeline', steps: 40, guidance: 1, cfgDisabled: true }); + expect(entry.editOnly).toBeUndefined(); + }); + it('detects a Qwen-Image-Edit repo and stamps the edit pipeline + editOnly', () => { const c = classifyHfMediaModel({ repo: 'Qwen/Qwen-Image-Edit', diff --git a/server/lib/mediaModels.js b/server/lib/mediaModels.js index 6a1c58bf86..cb5c06387f 100644 --- a/server/lib/mediaModels.js +++ b/server/lib/mediaModels.js @@ -1074,6 +1074,19 @@ const DEFAULT_REGISTRY = { // reject (and the UI gate) a render with no init image up-front. editOnly: true, }, + // Qwen 2.1 unifies generation/editing in a new RGBA pipeline. Its + // research license differs from the original Apache-licensed Qwen models. + { + id: 'qwen-image-2.1', + name: 'Qwen-Image 2.1 (7B DiT, generation + editing, research license)', + runner: 'qwen', + repo: 'Qwen/Qwen-Image-2.1', + pipelineClass: 'QwenImage21Pipeline', + steps: 40, + guidance: 1.0, + cfgDisabled: true, + licenseUrl: 'https://huggingface.co/Qwen/Qwen-Image-2.1/blob/main/LICENSE', + }, // z-image runner — Apache 2.0, ungated, reuses the FLUX.2 venv. Turbo // distillation runs ~8 steps with CFG disabled (guidance 1.0). { diff --git a/server/lib/mediaModels.test.js b/server/lib/mediaModels.test.js index 2ba7641f09..37736f0ee6 100644 --- a/server/lib/mediaModels.test.js +++ b/server/lib/mediaModels.test.js @@ -46,6 +46,22 @@ describe('data.reference seed file', () => { expect(reloadMediaModels().video.mlx.some((entry) => entry.id === v2.id)).toBe(false); }); + it('adds Qwen 2.1 to an existing registry without changing the selected legacy model', async () => { + const { loadMediaModels, reloadMediaModels } = await import('./mediaModels.js'); + const registry = loadMediaModels(); + registry.image = registry.image.filter((m) => m.id !== 'qwen-image-2.1'); + registry._shippedDefaults.image.list = registry._shippedDefaults.image.list.filter((id) => id !== 'qwen-image-2.1'); + const legacy = registry.image.find((m) => m.id === 'qwen-image'); + legacy.steps = 23; + writeFileSync(registryFile, JSON.stringify(registry)); + const upgraded = reloadMediaModels(); + expect(upgraded.image.find((m) => m.id === 'qwen-image-2.1')).toMatchObject({ + repo: 'Qwen/Qwen-Image-2.1', runner: 'qwen', pipelineClass: 'QwenImage21Pipeline', + steps: 40, guidance: 1, cfgDisabled: true, + }); + expect(upgraded.image.find((m) => m.id === 'qwen-image')).toEqual(legacy); + }); + it('matches the runtime-seeded DEFAULT_REGISTRY', async () => { const sample = JSON.parse(readFileSync(SAMPLE_REGISTRY_PATH, 'utf-8')); const { loadMediaModels } = await import('./mediaModels.js'); diff --git a/server/lib/pythonSetup.js b/server/lib/pythonSetup.js index 2cc658d5ed..56b6a004bd 100644 --- a/server/lib/pythonSetup.js +++ b/server/lib/pythonSetup.js @@ -916,7 +916,7 @@ export const WIN_TORCH_CUDA_INDEX = 'https://download.pytorch.org/whl/cu126'; export const FLUX2_PIP_SPECS = [ ...FLUX2_TORCH_SPECS, 'accelerate', - 'transformers>=4.51', + 'transformers>=5.17', 'sentencepiece', 'protobuf', 'safetensors', @@ -1067,9 +1067,9 @@ export function installFlux2Venv(onLog) { } if (killed) return { ok: false, stage: 'install', cancelled: true }; - stage('verify', 'Verifying Flux2KleinPipeline import…'); - if (!await runPython([venvPython, '-c', 'from diffusers import Flux2KleinPipeline; print("ok")'])) { - onLog({ type: 'error', message: 'Verification failed: Flux2KleinPipeline did not import. Try INSTALL_FLUX2=1 FLUX2_FORCE_REINSTALL=1 bash scripts/setup-image-video.sh' }); + stage('verify', 'Verifying FLUX.2 and Qwen 2.1 pipeline imports…'); + if (!await runPython([venvPython, '-c', 'from diffusers import Flux2KleinPipeline, QwenImage21Pipeline; print("ok")'])) { + onLog({ type: 'error', message: 'Verification failed: FLUX.2 or Qwen 2.1 pipeline did not import. Try INSTALL_FLUX2=1 FLUX2_FORCE_REINSTALL=1 bash scripts/setup-image-video.sh' }); return { ok: false, stage: 'verify' }; } From a528f03db9ec36a617f305098fabbc89b4aaa58c Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 20 Sep 2026 20:19:20 +0000 Subject: [PATCH 2/2] fix: make Qwen runner test importable from repo root --- scripts/z_image_turbo_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/z_image_turbo_test.py b/scripts/z_image_turbo_test.py index 727970bd3c..783e9dcadd 100644 --- a/scripts/z_image_turbo_test.py +++ b/scripts/z_image_turbo_test.py @@ -10,6 +10,8 @@ from PIL import Image +sys.path.insert(0, str(Path(__file__).resolve().parent)) + class Qwen21Pipeline: # Deliberately matches the upstream boundary: no guidance_scale/strength.