Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ Both versions are printed at launch, and Inline Studio checks PyPI once a day in
says so when either half is behind:

```
Versions: inline-core 1.3.11, inline-studio-frontend 1.3.10
UPDATE AVAILABLE: inline-studio-frontend 1.3.10 -> 1.3.11
Versions: inline-core 1.3.13, inline-studio-frontend 1.3.12
UPDATE AVAILABLE: inline-studio-frontend 1.3.12 -> 1.3.13
Update with: ./webui.sh --install (or: pip install -U inline-studio-frontend)
```

Expand Down
10 changes: 2 additions & 8 deletions TODO
Original file line number Diff line number Diff line change
@@ -1,26 +1,20 @@
Backlog
- Remove frame node entirely
- Update asset node to resize based on input
- drag drop from output to spawn asset node
- double click, search node


settings
- project output share settings
- Group based settings
- HF Token

handle:
- Clearly mention if gated model download failed

New models
- Ltx 2.5
- Minimax union CN

Training:
- Snapshot export on checkpoint
- Improve ui for edit dataset/trigger name

Char
- Body & cloth cosistency
- Support for minimax & krea2
- Look into this error: https://www.reddit.com/r/StableDiffusion/comments/1vp0xct/comment/p3vef5w/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

2 changes: 1 addition & 1 deletion core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
# PyPI name; the import package is `inline_core` (src/inline_core).
name = "inline-core"
version = "1.3.12"
version = "1.3.13"
description = "The generation engine behind Inline Studio."
readme = "README.md"
license = "GPL-3.0-or-later"
Expand Down
91 changes: 84 additions & 7 deletions core/src/inline_core/characters/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,48 @@ def __init__(
description: str,
lora: Path | None = None,
lora_strength: float = 1.0,
roles: list[str] | None = None,
) -> None:
self.name = name
self.refs = refs
self.description = description
#: One role per ref, in the same order. All face when a character predates roles, which is
#: what keeps an old character's prompt byte-identical to what it used to produce.
self.roles = roles or [cf.ROLE_FACE] * len(refs)
#: A trained adapter, which for a model with no reference channel is the only route.
self.lora = lora
#: What it fuses at. Set on Attach Adapter, because an overfit adapter is only usable
#: turned down and the character wire carries no controls of its own.
self.lora_strength = lora_strength

def prompt_prefix(self, first_position: int, style: str = "ordinal") -> str:
def _role_lines(self, first_position: int, style: str) -> str:
"""Sentences binding each role to the positions it actually landed on.

Written from the allocation rather than alongside it, so the numbers cannot drift from the
references. Silent when everything is face: an old character keeps its exact prompt.
"""
grouped: dict[str, list[int]] = {}
for offset, role in enumerate(self.roles[: len(self.refs)]):
grouped.setdefault(role, []).append(first_position + offset)
if set(grouped) <= {cf.ROLE_FACE}:
return ""
out = ""
for role in cf.ROLES:
numbers = grouped.get(role)
if numbers:
out += f" {_positions(style, numbers)} show {self.name}'s {_ROLE_BINDINGS[role]}."
return out

def prompt_prefix(
self, first_position: int, style: str = "ordinal", role_lines: bool = False
) -> str:
"""Text naming the positions the character lands on, so positional prompting resolves.

``style`` because a model only resolves the form it was trained on: FLUX.2 reads the ordinal
prose below, MiniMax H3 reads ``<Picture N>`` tokens (``models/references.py``), and handing
either the other one names positions it cannot see.

``role_lines`` defaults off: the bindings are unvalidated, see docs/characters.md.
"""
if not self.refs:
# A LoRA carries the likeness, so the description is all the prompt needs.
Expand All @@ -64,6 +90,8 @@ def prompt_prefix(self, first_position: int, style: str = "ordinal") -> str:
ordinals = [str(n) for n in positions]
which = f"Images {', '.join(ordinals[:-1])} and {ordinals[-1]} show"
line = f"{which} {self.name}, the same character in every image."
if role_lines:
line += self._role_lines(first_position, style)
detail = " ".join(self.description.split())
if not detail:
return f"{line} "
Expand All @@ -73,12 +101,34 @@ def prompt_prefix(self, first_position: int, style: str = "ordinal") -> str:
return f"{line} {detail} "


def _positions(style: str, numbers: list[int]) -> str:
"""How a role line refers *back* to positions, in prose, never re-declaring them."""
# Never `<Picture N>`: that is H3's reserved label and repeating it replayed the references.
noun = "Picture" if style == "token" else "Image"
if len(numbers) == 1:
return f"{noun} {numbers[0]}"
return f"{noun}s {', '.join(str(n) for n in numbers[:-1])} and {numbers[-1]}"


#: What each role binds to. Naming only, never describing: "slim build" or "red jacket" would be
#: text competing with the reference images, which is the caption-overrides-identity failure the
#: LoRA evals already found.
_ROLE_BINDINGS = {
cf.ROLE_FACE: "face",
cf.ROLE_BODY: "full body and build",
cf.ROLE_CLOTH: "outfit",
}


def _cache_root() -> Path:
return data_dir() / "characters"


def char_apply(
chosen: str, arch: str = encode.FLUX2_KLEIN_ARCH, prefer: str | None = None
chosen: str,
arch: str = encode.FLUX2_KLEIN_ARCH,
prefer: str | None = None,
limit: int | None = None,
) -> AppliedCharacter | None:
"""How a character applies on ``arch``, or None when none is picked. An unreadable pick raises
rather than silently generating the wrong person.
Expand Down Expand Up @@ -113,16 +163,41 @@ def char_apply(
# No reference channel on this arch, so the adapter is the only way it can apply at all.
if not references:
mode = "lora"
refs = [] if mode == "lora" else _extract(doc, digest, arch)
refs, roles = ([], []) if mode == "lora" else _extract(doc, digest, arch)
if limit is not None:
refs, roles = _fit_roles(refs, roles, limit)
return AppliedCharacter(
doc.manifest.name or path.stem,
refs,
description,
lora if mode == "lora" else None,
strength,
roles=roles,
)


def _fit_roles(
refs: list[AssetRef], roles: list[str], limit: int
) -> tuple[list[AssetRef], list[str]]:
"""Cut to what a model takes, dividing the slots by role rather than by arrival.

Trimming the tail instead would drop whichever role happens to be last, so a character with
face, body and cloth would silently lose its wardrobe on any model that takes fewer than it
holds. Order is preserved, because order is what the prompt numbers.
"""
if limit >= len(refs) or limit <= 0:
return refs[: max(0, limit)], roles[: max(0, limit)]
counts = {role: roles.count(role) for role in cf.ROLES}
share = encode.allocate_roles(counts, limit)
keep: list[int] = []
taken = dict.fromkeys(cf.ROLES, 0)
for index, role in enumerate(roles):
if taken[role] < share.get(role, 0):
taken[role] += 1
keep.append(index)
return [refs[i] for i in keep], [roles[i] for i in keep]


def _extract_lora(doc: cf.CharDoc, digest: str, arch: str) -> Path | None:
"""The adapter for ``arch``, or None. A stale one is the wrong face, so it is ignored."""
entry = encode.lora_payload(doc.manifest, arch)
Expand Down Expand Up @@ -172,11 +247,13 @@ def _recompile(doc: cf.CharDoc, path: Path, arch: str = encode.FLUX2_KLEIN_ARCH)
return doc


def _extract(doc: cf.CharDoc, digest: str, arch: str) -> list[AssetRef]:
def _extract(doc: cf.CharDoc, digest: str, arch: str) -> tuple[list[AssetRef], list[str]]:
payload = doc.manifest.payloads.get(arch) or {}
files = [str(entry.get("path")) for entry in payload.get("files") or []]
entries = list(payload.get("files") or [])
files = [str(entry.get("path")) for entry in entries]
roles = [cf.role_of(entry) for entry in entries]
if not files:
return []
return [], []

root = _cache_root() / digest
marker = root / ".complete"
Expand All @@ -194,7 +271,7 @@ def _extract(doc: cf.CharDoc, digest: str, arch: str) -> list[AssetRef]:
staging.replace(root)
_prune(_cache_root())

return [AssetRef(ref="path", path=str(root / Path(member).name)) for member in files]
return [AssetRef(ref="path", path=str(root / Path(member).name)) for member in files], roles


def _prune(root: Path) -> None:
Expand Down
20 changes: 20 additions & 0 deletions core/src/inline_core/characters/charfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ def origin_of(ref: dict[str, Any]) -> str:
return str(ref.get("origin") or ORIGIN_ORIGINAL)


#: What a reference is *of*. Sits beside `origin` for the same reason, and absent means face, so
#: every character written before roles existed keeps behaving exactly as it did.
ROLE_FACE = "face"
ROLE_BODY = "body"
ROLE_CLOTH = "cloth"
ROLES = (ROLE_FACE, ROLE_BODY, ROLE_CLOTH)


def role_of(ref: dict[str, Any]) -> str:
"""A reference's role, defaulting to face. An unknown value reads as face rather than raising:
a manifest is user data, and refusing to open a character over one bad string helps nobody."""
role = str(ref.get("role") or ROLE_FACE)
return role if role in ROLES else ROLE_FACE


def by_role(refs: list[dict[str, Any]], role: str) -> list[dict[str, Any]]:
"""The references carrying one role, in manifest order."""
return [ref for ref in refs if role_of(ref) == role]


class CharFileError(Exception):
"""A ``.char`` that cannot be trusted. The message is shown to the user."""

Expand Down
54 changes: 49 additions & 5 deletions core/src/inline_core/characters/encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,56 @@ def payload_key(arch: str, kind: str = PAYLOAD_REF) -> str:
}


#: How the slots divide when there are more references than a model takes: face gets half, body and
#: cloth a quarter each. Face is weighted because it is what identity is actually carried by; the
#: other two are conditioning on top of it.
ROLE_RATIO: dict[str, int] = {cf.ROLE_FACE: 2, cf.ROLE_BODY: 1, cf.ROLE_CLOTH: 1}


def allocate_roles(counts: dict[str, int], cap: int) -> dict[str, int]:
"""How many of each role to send, capped at ``cap`` total.

Under the cap everything goes. Over it, the split is `ROLE_RATIO` by largest remainder, and
then any slot a role cannot fill is handed to the roles that still have references waiting -
a character with no body shots should not lose those slots to nothing.
"""
wanted = {role: max(0, int(counts.get(role, 0))) for role in cf.ROLES}
if sum(wanted.values()) <= cap:
return wanted

total_weight = sum(ROLE_RATIO.values())
exact = {role: cap * ROLE_RATIO[role] / total_weight for role in cf.ROLES}
share = {role: min(wanted[role], int(exact[role])) for role in cf.ROLES}

# Largest remainder first, then whoever still has references left, so no slot goes unused.
spare = cap - sum(share.values())
order = sorted(cf.ROLES, key=lambda r: (-(exact[r] - int(exact[r])), -ROLE_RATIO[r], r))
while spare > 0:
moved = False
for role in order:
if spare and share[role] < wanted[role]:
share[role] += 1
spare -= 1
moved = True
if not moved:
break
return share


def reference_policy(arch: str) -> dict[str, Any]:
return REFERENCE_POLICIES.get(arch, PAYLOAD_POLICY)


#: What "Resized Reference Resolution" means when left at -1: the model's own policy, uncapped.
#: What "Stored Reference Resolution" means when left at -1: the model's own policy, uncapped.
NO_REFERENCE_CAP = -1


def capped_policy(arch: str, resolution: int | None) -> dict[str, Any]:
"""A model's reference policy, with its target lowered to ``resolution``.

Capping the source image instead would do nothing for a model whose policy scales *up*: H3
takes a 2048 short edge whatever it is handed, so a 4K reference and a 512 one cost the same
36,864 vision tokens. The lever has to be the target the policy resizes onto.
This sets what the `.char` stores, not what a render costs. H3's pipeline calls
`resolve_reference_image_size` on the way in and puts every reference back onto a 2048 short
edge, upscaling included, so lowering this saves disk and compile time and no VRAM at all.
"""
policy = dict(reference_policy(arch))
if resolution is None or int(resolution) <= 0:
Expand Down Expand Up @@ -239,6 +275,7 @@ def char_encode(
refs: list[Path | str],
*,
name: str,
roles: list[str] | None = None,
description: str = "",
app_version: str = "",
char_id: str | None = None,
Expand All @@ -251,6 +288,9 @@ def char_encode(
downloads ~370MB of encoder weights, which without a signal is indistinguishable from a hang."""
report = on_progress or (lambda _fraction, _status: None)
paths = [Path(p) for p in refs]
# Face when unsaid, so a caller that predates roles writes exactly what it always did.
tags = list(roles or [cf.ROLE_FACE] * len(paths))
tags += [cf.ROLE_FACE] * (len(paths) - len(tags))
if not paths:
raise ValueError("A character needs at least one reference image.")
missing = [p.name for p in paths if not p.is_file()]
Expand Down Expand Up @@ -281,6 +321,7 @@ def char_encode(
"height": image.height,
"source_name": path.name,
"origin": cf.ORIGIN_ORIGINAL,
"role": tags[index],
}
)

Expand Down Expand Up @@ -436,7 +477,10 @@ def build_payload(
member = f"payloads/{arch}/ref_{slot:03d}.png"
data = _png_bytes(normalise_reference(images[index], policy))
members[member] = data
files.append({"path": member, "sha256": cf.sha256_bytes(data)})
# The role rides with the compiled file: `apply` needs it to number the prompt, and
# recomputing the manifest order there would be the same rule written twice.
role = cf.role_of(manifest.refs[index]) if index < len(manifest.refs) else cf.ROLE_FACE
files.append({"path": member, "sha256": cf.sha256_bytes(data), "role": role})
manifest.payloads[arch] = {
"payload_version": 1,
"type": PAYLOAD_REF,
Expand Down
Loading