Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45666e72db | ||
|
|
2eb2a6d8fe | ||
|
|
cfc636e328 | ||
|
|
848db08469 | ||
|
|
f42af27da5 |
No files matched your search
@@ -26,8 +26,32 @@ APIs change between minors without a deprecation period.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.14.5 — 2026-10-14
|
||||
|
||||
Picture and 3D models that add themselves where a card fits them, with a
|
||||
card on the portal and a screen in the console to see and add them; a
|
||||
mesh's colour brought to the picture it was made from; and the whole
|
||||
licence written into every mesh.
|
||||
|
||||
**Upgrading.**
|
||||
- The store gains a table (migration 0047: preset_state); nothing to do.
|
||||
- **`presets.auto` is on**: once upgraded, the leading control plane adds
|
||||
flux-schnell and triposr, on demand, where a node's card fits one, the
|
||||
cluster installs ComfyUI (`engines.comfyui`) and the catalog makes
|
||||
nothing of that kind. Nothing is fetched until somebody asks for one.
|
||||
Turn it off with `llmgw ctl model presets auto off`.
|
||||
- **Nodes** need this release for `albedo_match` (it is in the save node
|
||||
the gate puts in each replica); a ComfyUI replica takes it when it
|
||||
starts again.
|
||||
|
||||
### Added
|
||||
|
||||
- **A mesh's base colour brought to its picture's**: `LLMGWSaveMesh` takes
|
||||
`albedo_match` with the picture and its mask, and scales the texture in
|
||||
linear light to the object's brightness in the picture. Stable Fast 3D
|
||||
takes the light out of what it sees and read a dark basalt (0.17) as a
|
||||
light stone dimly lit (0.67); matched, it is 0.17. On sf3d it is the
|
||||
`albedo_match` input (off unless asked).
|
||||
- **Picture and 3D models by themselves**: once a node has a card that fits
|
||||
flux-schnell or triposr and the catalog makes nothing of that kind, the
|
||||
leading control plane adds the preset on demand — so a new cluster's
|
||||
@@ -47,6 +71,16 @@ APIs change between minors without a deprecation period.
|
||||
API (and `ctl model presets auto on|off`), falling back to the entry as
|
||||
v0.14.4 sent it on an older control plane.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A mesh carried only its licence's name, URL and notice; it carries the
|
||||
whole licence as `/v1/models` gives it — id, commercial use, excluded
|
||||
regions, revenue cap, attribution — for a pipeline that sorts assets by
|
||||
licence.
|
||||
- A workflow's `{{name|false}}` default filled a BOOLEAN input with the
|
||||
word rather than a boolean.
|
||||
|
||||
|
||||
## v0.14.4 — 2026-10-13
|
||||
|
||||
Any ComfyUI workflow as a job, with typed inputs; picture and 3D models
|
||||
|
||||
@@ -40,7 +40,7 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
|
||||
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences) |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves) |
|
||||
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
|
||||
|
||||
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
|
||||
@@ -50,6 +50,13 @@ budgets and rate limits decide who may use any of it.
|
||||
|
||||
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
|
||||
|
||||
- **Picture and 3D models that add themselves** (v0.14.5). Where a node's
|
||||
card fits flux-schnell or triposr and the catalog makes nothing of that
|
||||
kind, the leader adds it on demand, so a new cluster's portal has its
|
||||
Images and 3D pages; the cluster page and the console show each preset
|
||||
and add it by hand, and `presets.auto` turns it off. Stable Fast 3D's
|
||||
colours can be brought to the picture's (`albedo_match`), and every mesh
|
||||
carries its whole licence.
|
||||
- **Any ComfyUI workflow, and models by name** (v0.14.4). `POST
|
||||
/v1/workflows/runs` runs a ComfyUI model's workflow as a job, its inputs
|
||||
(text, numbers, choices, images, masks, audio, video, files) declared in
|
||||
|
||||
@@ -222,6 +222,10 @@ func fillString(s string, p Params, missing *[]string) any {
|
||||
if f, err := strconv.ParseFloat(def, 64); err == nil {
|
||||
return f, true
|
||||
}
|
||||
// A BOOLEAN input refuses the word: {{albedo_match|false}}.
|
||||
if b, err := strconv.ParseBool(def); err == nil && (def == "true" || def == "false") {
|
||||
return b, true
|
||||
}
|
||||
return def, true
|
||||
}
|
||||
*missing = append(*missing, name)
|
||||
|
||||
@@ -252,3 +252,19 @@ func TestAPictureLosesTheWorkflowComfyUIWroteIntoIt(t *testing.T) {
|
||||
t.Error("a truncated PNG was changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrueOrFalseDefaultIsABoolean(t *testing.T) {
|
||||
// A BOOLEAN input refuses the word "false": sf3d's albedo_match.
|
||||
tpl, err := Load((`{"5":{"class_type":"LLMGWSaveMesh","inputs":{"albedo_match":"{{albedo_match|false}}","count":"{{count|2}}","s":"{{s|x}}"}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := tpl.Fill(Params{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := g["5"].(map[string]any)["inputs"].(map[string]any)
|
||||
if in["albedo_match"] != false || in["count"] != 2.0 || in["s"] != "x" {
|
||||
t.Errorf("inputs %#v", in)
|
||||
}
|
||||
}
|
||||
@@ -94,22 +94,60 @@ class LLMGWPrepareForMesh:
|
||||
return (both[..., :3].contiguous(), both[..., 3].contiguous())
|
||||
|
||||
|
||||
def _to_linear(x):
|
||||
return np.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4)
|
||||
|
||||
|
||||
def _to_srgb(x):
|
||||
return np.where(x <= 0.0031308, x * 12.92, 1.055 * np.power(np.clip(x, 0, None), 1 / 2.4) - 0.055)
|
||||
|
||||
|
||||
def _match_albedo(tex, reference, mask):
|
||||
"""The texture's brightness brought to the picture's: Stable Fast 3D
|
||||
takes the light out of what it sees, and a dark object it reads as a
|
||||
light one dimly lit — a basalt of 0.17 came back 0.67. Scaled in linear
|
||||
light by the ratio of the medians over the object, so its marks keep
|
||||
their contrast; texels nothing maps to (black) are left as they are."""
|
||||
t = np.asarray(tex.convert("RGB")).astype(np.float64) / 255.0
|
||||
used = t.max(-1) > 2 / 255
|
||||
ref = reference.cpu().numpy().astype(np.float64)
|
||||
obj = mask.cpu().numpy() > 0.5 if mask is not None else np.ones(ref.shape[:2], bool)
|
||||
if used.sum() < 16 or obj.sum() < 16:
|
||||
return tex
|
||||
lum = lambda a: a @ np.array([0.2126, 0.7152, 0.0722])
|
||||
want = np.median(lum(_to_linear(ref[obj])))
|
||||
have = np.median(lum(_to_linear(t[used])))
|
||||
if have <= 0:
|
||||
return tex
|
||||
k = float(np.clip(want / have, 0.05, 20.0))
|
||||
out = t.copy()
|
||||
out[used] = np.clip(_to_srgb(_to_linear(t[used]) * k), 0, 1)
|
||||
img = Image.fromarray((out * 255 + 0.5).astype(np.uint8))
|
||||
img.format = tex.format
|
||||
return img
|
||||
|
||||
|
||||
class LLMGWSaveMesh:
|
||||
"""Writes each mesh a 3D node made to output/ as a GLB and names the
|
||||
files, which is how the gate finds a result. A model's own save node
|
||||
may return the mesh as base64 (Stable Fast 3D's does) or as an OBJ
|
||||
without its texture, and the gate reads neither."""
|
||||
without its texture, and the gate reads neither.
|
||||
|
||||
With albedo_match and the picture the mesh was made from (and its
|
||||
mask), the mesh's base colour is brought to the picture's brightness."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"mesh": ("MESH",), "filename_prefix": ("STRING", {"default": "mesh"})}}
|
||||
return {"required": {"mesh": ("MESH",), "filename_prefix": ("STRING", {"default": "mesh"})},
|
||||
"optional": {"albedo_match": ("BOOLEAN", {"default": False}),
|
||||
"reference": ("IMAGE",), "reference_mask": ("MASK",)}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "save"
|
||||
CATEGORY = "llmgw"
|
||||
|
||||
def save(self, mesh, filename_prefix="mesh"):
|
||||
def save(self, mesh, filename_prefix="mesh", albedo_match=False, reference=None, reference_mask=None):
|
||||
import os
|
||||
import folder_paths
|
||||
import trimesh
|
||||
@@ -119,6 +157,9 @@ class LLMGWSaveMesh:
|
||||
filename_prefix, folder_paths.get_output_directory())
|
||||
saved = []
|
||||
for i, m in enumerate(mesh if isinstance(mesh, (list, tuple)) else [mesh]):
|
||||
if albedo_match and reference is not None:
|
||||
_match_scene(m, reference[min(i, reference.shape[0] - 1)],
|
||||
None if reference_mask is None else reference_mask[min(i, reference_mask.shape[0] - 1)])
|
||||
file = f"{name.replace('%batch_num%', str(i))}_{counter + i:05}_.glb"
|
||||
scene = m if isinstance(m, trimesh.Scene) else trimesh.Scene(m)
|
||||
with open(os.path.join(folder, file), "wb") as f:
|
||||
@@ -127,6 +168,16 @@ class LLMGWSaveMesh:
|
||||
return {"ui": {"mesh": saved}}
|
||||
|
||||
|
||||
def _match_scene(m, reference, mask):
|
||||
"""_match_albedo on every base colour texture of a mesh or scene."""
|
||||
geoms = m.geometry.values() if hasattr(m, "geometry") else [m]
|
||||
for g in geoms:
|
||||
mat = getattr(getattr(g, "visual", None), "material", None)
|
||||
tex = getattr(mat, "baseColorTexture", None)
|
||||
if tex is not None:
|
||||
mat.baseColorTexture = _match_albedo(tex, reference, mask)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"LLMGWPrepareForMesh": LLMGWPrepareForMesh, "LLMGWSaveMesh": LLMGWSaveMesh}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"LLMGWPrepareForMesh": "Prepare a picture for a 3D model (llmgw)",
|
||||
"LLMGWSaveMesh": "Save a mesh as GLB (llmgw)"}
|
||||
@@ -221,12 +221,16 @@ func glbWithLicense(glb []byte, lic catalog.License) ([]byte, error) {
|
||||
if extras == nil {
|
||||
extras = map[string]any{}
|
||||
}
|
||||
l := map[string]any{"name": lic.Name}
|
||||
if lic.URL != "" {
|
||||
l["url"] = lic.URL
|
||||
// The whole licence, as /v1/models gives it: its id, whether it allows
|
||||
// commercial use and where it grants nothing are what a pipeline that
|
||||
// sorts assets by licence reads, and the name alone is for people.
|
||||
var l map[string]any
|
||||
b, err := json.Marshal(lic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lic.Notice != "" {
|
||||
l["notice"] = lic.Notice
|
||||
if err := json.Unmarshal(b, &l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extras["license"] = l
|
||||
asset["extras"] = extras
|
||||
|
||||
@@ -401,3 +401,34 @@ func TestAModelAsleepUntilAskedIsListedAndSaidAsleep(t *testing.T) {
|
||||
t.Error("a model serving is not said loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshCarriesItsWholeLicence(t *testing.T) {
|
||||
// The name alone was written: a pipeline that sorts assets by licence
|
||||
// reads the id, whether commercial use is allowed and where it is not.
|
||||
glb, err := objToGLB([]byte("v 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yes := true
|
||||
out, err := glbWithLicense(glb, catalog.License{ID: "stability-community", Name: "Stability AI Community License",
|
||||
Commercial: &yes, RevenueCapUSD: 1000000, Attribution: "Powered by Stability AI", ExcludedRegions: []string{"XX"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := binary.LittleEndian.Uint32(out[12:])
|
||||
var doc struct {
|
||||
Asset struct {
|
||||
Extras struct {
|
||||
License map[string]any `json:"license"`
|
||||
} `json:"extras"`
|
||||
} `json:"asset"`
|
||||
}
|
||||
if err := json.Unmarshal(out[20:20+n], &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := doc.Asset.Extras.License
|
||||
if l["id"] != "stability-community" || l["commercial"] != true || l["revenue_cap_usd"] != 1000000.0 ||
|
||||
l["attribution"] != "Powered by Stability AI" || l["name"] == nil {
|
||||
t.Errorf("licence %v", l)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user