Skip to content

Commit 298d8f1

Browse files
committed
add LoRA loader node and weight fusion
1 parent 7946868 commit 298d8f1

17 files changed

Lines changed: 563 additions & 40 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<p align="center">
88
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge"></a>
99
<a href="https://www.python.org/downloads/"><img alt="Python 3.11+" src="https://img.shields.io/badge/Python-3.11%2B-blue?style=for-the-badge&logo=python&logoColor=white"></a>
10-
<a href="../../releases/latest"><img alt="Latest release" src="https://img.shields.io/badge/Release-v1.2.3-blue?style=for-the-badge"></a>
10+
<a href="../../releases/latest"><img alt="Latest release" src="https://img.shields.io/badge/Release-v1.2.31-blue?style=for-the-badge"></a>
1111
<a href="https://discord.gg/cSUS88VdY9"><img alt="Join our Discord" src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white&style=for-the-badge"></a>
1212
</p>
1313

core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
# PyPI name; the import package is `inline_core` (src/inline_core).
33
name = "inline-core"
4-
version = "1.2.3"
4+
version = "1.2.31"
55
description = "The generation engine behind Inline Studio."
66
readme = "README.md"
77
requires-python = ">=3.11"

core/src/inline_core/ffmpeg.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Locate the ffmpeg/ffprobe binaries. Prefers a bundled ``imageio-ffmpeg``, else PATH.
2+
3+
Lives at the top level rather than under ``studio/`` because both the timeline (studio) and the
4+
take store (runtime) need it, and runtime must not import studio.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import shutil
10+
from functools import lru_cache
11+
12+
13+
@lru_cache(maxsize=1)
14+
def ffmpeg_exe() -> str | None:
15+
try:
16+
import imageio_ffmpeg
17+
18+
return imageio_ffmpeg.get_ffmpeg_exe()
19+
except Exception: # noqa: BLE001
20+
return shutil.which("ffmpeg")
21+
22+
23+
@lru_cache(maxsize=1)
24+
def ffprobe_exe() -> str | None:
25+
"""PATH only - imageio bundles ffmpeg alone, so probing degrades gracefully when absent."""
26+
return shutil.which("ffprobe")
27+
28+
29+
def ffmpeg_available() -> bool:
30+
return ffmpeg_exe() is not None

core/src/inline_core/graph/loader_runners.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from ..config import models_dir
2222
from ..errors import ComponentError
23-
from .primitives import LOAD_DIFFUSION_MODEL, LOAD_TEXT_ENCODER, LOAD_VAE
23+
from .primitives import LOAD_DIFFUSION_MODEL, LOAD_LORA, LOAD_TEXT_ENCODER, LOAD_VAE
2424
from .runners import NodeResult, NodeRunner
2525
from .schema import Node
2626

@@ -44,6 +44,16 @@ class ComponentRef:
4444
file: str
4545

4646

47+
@dataclass(frozen=True)
48+
class LoraRef:
49+
"""One LoRA in a stack: its absolute file path and blend strength. A ``load/lora`` node emits a
50+
tuple of these (its own ref appended to any upstream stack); the model runner fuses them into
51+
the diffusion transformer in order. Frozen + hashable so it can key the loader cache."""
52+
53+
file: str
54+
strength: float
55+
56+
4757
def _resolve_file(category: str, chosen: str) -> Path:
4858
"""The single weight file a Load node points at: the explicit dropdown pick, else the first
4959
weight file in ``models/<category>/`` (mirrors the model node's "auto"). Raises if none."""
@@ -81,6 +91,26 @@ def run(self, node: Node, inputs: dict[str, list[Any]], ctx: ExecutionContext) -
8191
return NodeResult(outputs={self._output: ref})
8292

8393

94+
class LoadLoraRunner(NodeRunner):
95+
"""Resolve this node's ``file``/``strength`` into a ``LoraRef`` and append it to any upstream
96+
stack on the ``lora`` input - so chaining ``load/lora`` nodes stacks them in wiring order. The
97+
stack rides its own ``lora`` edge into the model runner, which fuses it into the transformer."""
98+
99+
produces_takes = False
100+
101+
def run(self, node: Node, inputs: dict[str, list[Any]], ctx: ExecutionContext) -> NodeResult:
102+
upstream = _first(inputs.get("lora")) or ()
103+
file = _resolve_file("loras", str(node.params.get("file", "")))
104+
strength = float(node.params.get("strength", 1.0))
105+
stack = (*upstream, LoraRef(file=str(file), strength=strength))
106+
return NodeResult(outputs={"lora": stack})
107+
108+
109+
def _first(values: list[Any] | None) -> Any:
110+
"""The first wired value on a port, or None (an optional input may be absent/unconnected)."""
111+
return values[0] if values else None
112+
113+
84114
def register_loaders(registry: Registry) -> None:
85115
"""Register the ``load/*`` nodes **visible** (unhidden) with their runners, so they appear in
86116
the add-node menu and can feed a model node's component inputs. Torch-free - always on."""
@@ -98,3 +128,4 @@ def register_loaders(registry: Registry) -> None:
98128
kind="text_encoder", category="text_encoders", output_port="text_encoder"
99129
),
100130
)
131+
registry.register(replace(LOAD_LORA, hidden=False), LoadLoraRunner())

core/src/inline_core/graph/primitives.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@
4747
outputs=(Port("text_encoder", "Text encoder", PortKind.TEXT_ENCODER),),
4848
)
4949

50+
LOAD_LORA = NodeDescriptor(
51+
type="load/lora",
52+
title="Load LoRA",
53+
category="Loaders",
54+
icon="box",
55+
# Optional upstream lora: chain load/lora -> load/lora to stack several in order.
56+
inputs=(Port("lora", "LoRA", PortKind.LORA, required=False),),
57+
params=(
58+
ParamField("file", "LoRA", Widget.SELECT, "", options_from="loras"),
59+
ParamField("strength", "Strength", Widget.NUMBER, 1.0, min=-2.0, max=2.0, step=0.05),
60+
),
61+
outputs=(Port("lora", "LoRA", PortKind.LORA),),
62+
)
63+
5064
ENCODE_TEXT = NodeDescriptor(
5165
type="encode/text",
5266
title="Encode Text",
@@ -122,6 +136,7 @@
122136
LOAD_DIFFUSION_MODEL,
123137
LOAD_VAE,
124138
LOAD_TEXT_ENCODER,
139+
LOAD_LORA,
125140
ENCODE_TEXT,
126141
EMPTY_LATENT,
127142
SAMPLE,
@@ -132,7 +147,7 @@
132147

133148
#: The loader primitives now have runners and are offered in the add-node menu - registered
134149
#: (unhidden) with their runners by ``graph/loader_runners.py``, so they are skipped here.
135-
_HAS_RUNNER = {LOAD_DIFFUSION_MODEL.type, LOAD_VAE.type, LOAD_TEXT_ENCODER.type}
150+
_HAS_RUNNER = {LOAD_DIFFUSION_MODEL.type, LOAD_VAE.type, LOAD_TEXT_ENCODER.type, LOAD_LORA.type}
136151

137152

138153
def register_primitives(registry: Registry) -> None:

core/src/inline_core/graph/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class PortKind(str, Enum):
2323
MODEL = "model"
2424
VAE = "vae"
2525
TEXT_ENCODER = "text-encoder"
26+
LORA = "lora"
2627
CONDITIONING = "conditioning"
2728
LATENT = "latent"
2829

core/src/inline_core/models/loaders.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@
3030
from dataclasses import dataclass
3131
from pathlib import Path
3232
from threading import Lock
33-
from typing import Any
33+
from typing import TYPE_CHECKING, Any
3434

3535
from ..config import data_dir
3636
from ..device.policy import Quantization
3737
from ..errors import ComponentError
3838

39+
if TYPE_CHECKING:
40+
from ..graph.loader_runners import LoraRef
41+
3942

4043
@dataclass(frozen=True)
4144
class ArchSpec:
@@ -260,13 +263,15 @@ def _quantize_in_place(model: Any, quant: Quantization) -> None:
260263
# --- component loaders (cached in-process) ------------------------------------------------------
261264

262265
# Keyed by (arch, kind, file, dtype, quant, device) so switching one file (e.g. a different VAE),
263-
# the quantization, or the load device reuses the other already-loaded components. The run manager
264-
# executes one run at a time; the lock guards each build.
265-
_CACHE: dict[tuple[str, str, str, str, str, str], Any] = {}
266+
# the quantization, or the load device reuses the other already-loaded components. A fused diffusion
267+
# transformer appends its LoRA stack (see ``lora_cache_key``) to that base key, so the same file
268+
# with a different stack is a distinct entry. The run manager executes one run at a time; the lock
269+
# guards each build.
270+
_CACHE: dict[tuple[str, ...], Any] = {}
266271
_CACHE_LOCK = Lock()
267272

268273

269-
def _cached(key: tuple[str, str, str, str, str, str], build: Callable[[], Any]) -> Any:
274+
def _cached(key: tuple[str, ...], build: Callable[[], Any]) -> Any:
270275
with _CACHE_LOCK:
271276
hit = _CACHE.get(key)
272277
if hit is not None:
@@ -284,17 +289,36 @@ def _device_key(device: str | None) -> str:
284289
return device or "cpu"
285290

286291

287-
def unload_components(keep_files: set[str] | None = None) -> None:
292+
def lora_cache_key(loras: tuple[LoraRef, ...]) -> tuple[str, ...]:
293+
"""The cache-key suffix a LoRA stack contributes to a fused diffusion transformer. Order- and
294+
strength-sensitive (fusing is not commutative), keyed by file+strength; an empty stack adds
295+
nothing, so an un-LoRA'd load keys exactly as it did before LoRA existed."""
296+
return tuple(f"{lora.file}@{lora.strength}" for lora in loras)
297+
298+
299+
def unload_components(
300+
keep_files: set[str] | None = None, keep_loras: tuple[str, ...] | None = None
301+
) -> None:
288302
"""Drop cached components whose source file is NOT in ``keep_files``, freeing their VRAM/RAM.
289303
290304
Called when switching checkpoints so a new model doesn't stack on top of the previous one (the
291305
cache never evicted before, so a second distinct model roughly doubled resident memory). Only
292306
drops references + empties the CUDA cache - it does not move weights to CPU RAM (that would just
293307
relocate the pressure on a RAM-tight box). The caller must drop any pipeline holding these
294-
components first, or the references keep them alive."""
308+
components first, or the references keep them alive.
309+
310+
``keep_loras`` (the LoRA-stack suffix being loaded, from ``lora_cache_key``) additionally evicts
311+
a kept file's diffusion transformer when it carries a *different* stack: fusing a LoRA into an
312+
already-resident checkpoint would otherwise keep the unfused transformer AND the fused one - two
313+
full-size models on the card. Left None, eviction is file-only (the pre-LoRA behaviour)."""
295314
keep = keep_files or set()
296315
with _CACHE_LOCK:
297-
stale = [k for k in _CACHE if k[2] not in keep] # k = (arch, kind, file, dtype, quant, dev)
316+
stale = []
317+
for k in _CACHE: # k = (arch, kind, file, dtype, quant, dev, *lora_suffix)
318+
if k[2] not in keep:
319+
stale.append(k)
320+
elif keep_loras is not None and k[1] == "diffusion" and tuple(k[6:]) != keep_loras:
321+
stale.append(k)
298322
for k in stale:
299323
comp = _CACHE.pop(k)
300324
del comp
@@ -307,14 +331,16 @@ def load_diffusion(
307331
dtype: Any,
308332
quant: Quantization = Quantization.NONE,
309333
device: str | None = None,
334+
loras: tuple[LoraRef, ...] = (),
310335
) -> Any:
311336
"""The diffusion transformer from a single ``.safetensors``. diffusers converts the checkpoint
312337
keys; the config comes from the bundled assets, so nothing is fetched at load time. ``quant``
313338
(smart memory) quantizes the weights on load. ``device`` (e.g. ``"cuda:0"``) streams each tensor
314339
**straight to the GPU** from an mmap-backed checkpoint - the fp16 weights are never materialized
315340
as an anonymous CPU copy (the host-RAM spike that OOM-killed the server), and for the int8 path
316341
torchao quantizes on-device per tensor. ``None`` loads to CPU (the offload path, where
317-
accelerate installs its hooks before placing)."""
342+
accelerate installs its hooks before placing). ``loras`` are fused into the weights in order
343+
(the stack is part of the cache key), so a fused transformer is itself the cached artifact."""
318344

319345
def build() -> Any:
320346
from diffusers import ZImageTransformer2DModel
@@ -333,12 +359,20 @@ def build() -> Any:
333359
# ``from_pretrained``), so the transformer would load at full size and the "int8" plan would
334360
# blow the VRAM budget (a T4 OOMs mid-load). Quantize it explicitly with torchao after the
335361
# load instead - the weights briefly sit full-size on the device, then halve in place.
362+
# Fuse LoRAs BEFORE quantizing: the fuse adds a full-precision delta into each weight, which
363+
# int8 can't accept in place, and int8-quantized weights aren't a plain tensor to add into.
364+
if loras:
365+
from .lora import fuse_loras
366+
367+
fuse_loras(model, loras)
336368
_quantize_in_place(model, quant)
337369
return model
338370

339-
return _cached(
340-
(arch, "diffusion", file, _dtype_key(dtype), quant.value, _device_key(device)), build
371+
key = (
372+
arch, "diffusion", file, _dtype_key(dtype), quant.value, _device_key(device),
373+
*lora_cache_key(loras),
341374
)
375+
return _cached(key, build)
342376

343377

344378
def load_vae(arch: str, file: str, dtype: Any, device: str | None = None) -> Any:
@@ -431,6 +465,7 @@ def assemble_zimage_pipeline(
431465
quant: Quantization = Quantization.NONE,
432466
vae_dtype: Any = None,
433467
device: str | None = None,
468+
loras: tuple[LoraRef, ...] = (),
434469
cancel_check: Callable[[], None] | None = None,
435470
) -> Any:
436471
"""Build a Z-Image pipeline from three local single files. Components are cached individually,
@@ -448,7 +483,7 @@ def assemble_zimage_pipeline(
448483
from diffusers import ZImageImg2ImgPipeline, ZImagePipeline
449484

450485
arch = _ZIMAGE.key
451-
transformer = load_diffusion(arch, diffusion_file, dtype, quant, device=device)
486+
transformer = load_diffusion(arch, diffusion_file, dtype, quant, device=device, loras=loras)
452487
_release_transient()
453488
if cancel_check is not None:
454489
cancel_check()

0 commit comments

Comments
 (0)