Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions data.reference/media-models.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
32 changes: 32 additions & 0 deletions docs/features/qwen-image-21.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 6 additions & 6 deletions scripts/setup-image-video.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand All @@ -1099,7 +1099,7 @@ if [[ "$INSTALL_FLUX2" == "1" ]]; then
"torch>=2.5" \
torchvision \
accelerate \
"transformers>=4.51" \
"transformers>=5.17" \
sentencepiece \
protobuf \
safetensors \
Expand All @@ -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
Expand Down
55 changes: 34 additions & 21 deletions scripts/z_image_turbo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 [])
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
70 changes: 70 additions & 0 deletions scripts/z_image_turbo_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""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

sys.path.insert(0, str(Path(__file__).resolve().parent))


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()
11 changes: 9 additions & 2 deletions server/lib/huggingfaceModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}) };
};
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
9 changes: 9 additions & 0 deletions server/lib/huggingfaceModel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 13 additions & 0 deletions server/lib/mediaModels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
{
Expand Down
16 changes: 16 additions & 0 deletions server/lib/mediaModels.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 4 additions & 4 deletions server/lib/pythonSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' };
}

Expand Down
Loading