cof-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cof_cli/model.py ADDED
@@ -0,0 +1,465 @@
1
+ """Manifest model helpers (v0.5): categories → variants (with `kind`), presets with multi-slots, identities, regions."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from importlib import resources
6
+
7
+ CATEGORIES = ["personality", "appearance", "identity_weights", "volume3d", "voice", "posture", "motion", "files"]
8
+ KINDS = {
9
+ "personality": ["card", "description"],
10
+ "appearance": ["reference-set", "character-sheet", "face", "body", "hair", "clothing", "accessory", "intimate", "pilosity", "feature", "environment", "description"],
11
+ "identity_weights": ["lora", "lycoris", "dora", "textual-inversion", "ip-adapter-embedding", "reference-latent", "dreambooth", "other"],
12
+ "volume3d": ["mesh", "face-mesh", "point-cloud", "print", "other", "environment", "daz"],
13
+ "voice": ["samples", "described", "embedding"],
14
+ "posture": ["photo", "photos", "openpose", "silhouette", "attitude", "description"],
15
+ "motion": ["clips", "video", "attitude", "description"], # 'attitude' (movement signature) is allowed here AND in posture
16
+ "files": ["skill", "memory", "document", "other"], # free files: agent skills, memories, documents — library
17
+ }
18
+ # categories that are LIBRARIES in a preset: several variants of any kind, none mandatory at inference
19
+ LIBRARY_CATEGORIES = {"posture", "motion", "files"}
20
+ # kinds that may appear several times in one preset (subject to region rules)
21
+ MULTI_KINDS = {"clothing", "accessory", "pilosity", "feature", "environment"}
22
+
23
+ _garments = None
24
+
25
+
26
+ def garments() -> dict:
27
+ global _garments
28
+ if _garments is None:
29
+ try:
30
+ with resources.files("cof_cli").joinpath("schemas/garments-1.0.json").open("r", encoding="utf-8") as f:
31
+ _garments = json.load(f)
32
+ except Exception:
33
+ _garments = {"types": {}, "accessories": {"types": {}}}
34
+ return _garments
35
+
36
+
37
+ def asset_index(manifest: dict) -> dict[str, dict]:
38
+ return {a["id"]: a for a in manifest.get("assets", []) if "id" in a}
39
+
40
+
41
+ def variants_of(manifest: dict, cat: str) -> dict:
42
+ return ((manifest.get("elements", {}) or {}).get(cat, {}) or {}).get("variants", {}) or {}
43
+
44
+
45
+ def resolve_variant(manifest: dict, cat: str, vid: str, _seen=None) -> dict | None:
46
+ v = variants_of(manifest, cat).get(vid)
47
+ if v is None:
48
+ return None
49
+ _seen = _seen or set()
50
+ if vid in _seen:
51
+ return dict(v)
52
+ _seen.add(vid)
53
+ parent = resolve_variant(manifest, cat, v["derives_from"], _seen) if v.get("derives_from") else None
54
+ merged = dict(parent or {})
55
+ merged.update({k: val for k, val in v.items() if k != "derives_from"})
56
+ merged["_id"] = vid
57
+ return merged
58
+
59
+
60
+ def resolve_preset_slots(manifest: dict, pid: str, _seen=None) -> dict[str, list[str]]:
61
+ presets = manifest.get("presets", {}) or {}
62
+ p = presets.get(pid)
63
+ if p is None:
64
+ return {}
65
+ _seen = _seen or set()
66
+ if pid in _seen:
67
+ return {k: list(v) for k, v in (p.get("slots") or {}).items()}
68
+ _seen.add(pid)
69
+ base = resolve_preset_slots(manifest, p["extends"], _seen) if p.get("extends") else {}
70
+ for k, v in (p.get("slots") or {}).items():
71
+ base[k] = list(v) # a child replaces the whole category list
72
+ return base
73
+
74
+
75
+ def resolve_preset_alternatives(manifest: dict, pid: str, _seen=None) -> dict[str, list[str]]:
76
+ """Elements OFFERED (not applied) by a preset, as declared; same inheritance rule as slots (see preset_alternatives)."""
77
+ presets = manifest.get("presets", {}) or {}
78
+ p = presets.get(pid)
79
+ if p is None:
80
+ return {}
81
+ _seen = _seen or set()
82
+ if pid in _seen:
83
+ return {k: list(v) for k, v in (p.get("alternatives") or {}).items()}
84
+ _seen.add(pid)
85
+ base = resolve_preset_alternatives(manifest, p["extends"], _seen) if p.get("extends") else {}
86
+ for k, v in (p.get("alternatives") or {}).items():
87
+ base[k] = list(v)
88
+ return base
89
+
90
+
91
+ def preset_alternatives(manifest: dict, pid: str) -> dict[str, list[str]]:
92
+ """Offered elements minus those already applied."""
93
+ slots = resolve_preset_slots(manifest, pid)
94
+ alt = resolve_preset_alternatives(manifest, pid)
95
+ return {k: [x for x in v if x not in slots.get(k, [])] for k, v in alt.items() if [x for x in v if x not in slots.get(k, [])]}
96
+
97
+
98
+ def preset_primary_image(manifest: dict, pid: str | None) -> str | None:
99
+ """Asset id of the preset's primary image: explicit (inherited through extends) or the first image
100
+ of the first face / reference-set / character-sheet / body applied by the preset."""
101
+ presets = manifest.get("presets", {}) or {}
102
+ seen = set()
103
+ q = pid
104
+ while q and q in presets and q not in seen:
105
+ seen.add(q)
106
+ if presets[q].get("primary_image"):
107
+ return presets[q]["primary_image"]
108
+ q = presets[q].get("extends")
109
+ A = asset_index(manifest)
110
+ r = resolved(manifest, pid) if pid else implicit_resolved(manifest)
111
+ for k in ("face", "reference-set", "character-sheet", "body"):
112
+ for v in [x for x in r.get("appearance", []) if x.get("kind") == k]:
113
+ for i in v.get("images", []) or []:
114
+ if i in A and str(A[i].get("mediaType", "")).startswith("image/"):
115
+ return i
116
+ return None
117
+
118
+
119
+ def backstage(manifest: dict) -> list[tuple[str, str]]:
120
+ """Variants referenced by no preset — neither applied, offered, nor parent (derives_from) of one that is.
121
+ They are kept, never applied nor offered; an explorer MAY show them. Empty when the file has no preset."""
122
+ P = manifest.get("presets") or {}
123
+ if not P:
124
+ return []
125
+ used: set[tuple[str, str]] = set()
126
+ for pid in P:
127
+ for src in (resolve_preset_slots(manifest, pid), resolve_preset_alternatives(manifest, pid)):
128
+ for cat, vids in src.items():
129
+ for vid in vids:
130
+ cur, seen = vid, set()
131
+ while cur and cur not in seen:
132
+ seen.add(cur)
133
+ used.add((cat, cur))
134
+ cur = (variants_of(manifest, cat).get(cur) or {}).get("derives_from")
135
+ return [(cat, vid) for cat, e in (manifest.get("elements") or {}).items()
136
+ for vid in ((e or {}).get("variants") or {}) if (cat, vid) not in used]
137
+
138
+
139
+ def facet_switch(manifest: dict, pid: str, tag_id: str) -> list[str]:
140
+ """Facet navigation: the presets carrying `tag_id` whose tags in every OTHER facet equal those of `pid`
141
+ (e.g. from en-12, tag 'fr' → fr-12). Tags without a facet count as their own facet."""
142
+ T = {t.get("id"): t for t in manifest.get("preset_tags") or []}
143
+ if tag_id not in T:
144
+ return []
145
+ fac = lambda tid: T[tid].get("facet") or f"__{tid}"
146
+ target_facet = fac(tag_id)
147
+ tags_of = lambda p: {tid for tid, t in T.items() if p in (t.get("presets") or [])}
148
+ others = lambda p: {x for x in tags_of(p) if fac(x) != target_facet}
149
+ ref = others(pid)
150
+ me = preset_owner(manifest, pid) # never switch to another character
151
+ return [q for q in (manifest.get("presets") or {}) if q in (T[tag_id].get("presets") or []) and others(q) == ref and preset_owner(manifest, q) == me]
152
+
153
+
154
+ def implicit_preset(manifest: dict) -> dict[str, list[str]]:
155
+ slots = {}
156
+ for cat, e in (manifest.get("elements", {}) or {}).items():
157
+ vs = list((e or {}).get("variants", {}).keys())
158
+ if vs:
159
+ slots[cat] = [vs[0]]
160
+ return slots
161
+
162
+
163
+ def preset_ids(manifest: dict) -> list[str]:
164
+ return list((manifest.get("presets", {}) or {}).keys())
165
+
166
+
167
+ def default_preset_id(manifest: dict) -> str | None:
168
+ return manifest.get("default_preset") or (preset_ids(manifest)[0] if preset_ids(manifest) else None)
169
+
170
+
171
+ def resolved(manifest: dict, pid: str | None = None) -> dict[str, list[dict]]:
172
+ """category → list of resolved variants for a preset (or the implicit one)."""
173
+ slots = resolve_preset_slots(manifest, pid) if pid else implicit_preset(manifest)
174
+ out: dict[str, list[dict]] = {}
175
+ for cat, vids in slots.items():
176
+ lst = []
177
+ for vid in vids:
178
+ v = resolve_variant(manifest, cat, vid)
179
+ if v is not None:
180
+ lst.append(v)
181
+ if lst:
182
+ out[cat] = lst
183
+ return out
184
+
185
+
186
+ def resolved_all(manifest: dict, pid: str | None = None) -> dict[str, list[dict]]:
187
+ """Applied ∪ offered variants of a preset (age and minor rules apply to this union)."""
188
+ out = {k: list(v) for k, v in (resolved(manifest, pid) if pid else implicit_resolved(manifest)).items()}
189
+ if not pid:
190
+ return out
191
+ for cat, vids in preset_alternatives(manifest, pid).items():
192
+ have = {v["_id"] for v in out.get(cat, [])}
193
+ for vid in vids:
194
+ v = resolve_variant(manifest, cat, vid)
195
+ if v is not None and vid not in have:
196
+ out.setdefault(cat, []).append(v)
197
+ return out
198
+
199
+
200
+ def by_kind(res: dict[str, list[dict]], cat: str, kind: str) -> list[dict]:
201
+ return [v for v in res.get(cat, []) if v.get("kind") == kind]
202
+
203
+
204
+ def first(res: dict[str, list[dict]], cat: str, kind: str | None = None) -> dict | None:
205
+ lst = res.get(cat, [])
206
+ if kind:
207
+ lst = [v for v in lst if v.get("kind") == kind]
208
+ return lst[0] if lst else None
209
+
210
+
211
+ def preset_identity(manifest: dict, pid: str | None) -> dict:
212
+ """Identity record for a preset: identities[preset.identity] (inherited through extends) or the root copy."""
213
+ iid = preset_owner(manifest, pid)
214
+ ids = manifest.get("identities") or {}
215
+ if iid and iid in ids:
216
+ return ids[iid]
217
+ return {k: manifest.get(k) for k in ("name", "nickname", "summary", "fictional", "morphology")}
218
+
219
+
220
+ # ---------- characters (identities) as logical containers (1.0) ----------
221
+ LIKENESS_APPEARANCE = {"face", "body", "reference-set", "character-sheet", "hair", "intimate", "pilosity", "feature"}
222
+
223
+
224
+ # Neutral element = carries nothing of a particular character (no likeness, no voice, no personal data): its files
225
+ # MAY be shared by several characters. Fixed by kind, or chosen by the author (`neutral`) where the kind is ambiguous.
226
+ NEUTRAL_FIXED = {("appearance", "environment"): True, ("volume3d", "environment"): True,
227
+ ("posture", "openpose"): True, ("posture", "silhouette"): True, ("posture", "attitude"): True, ("posture", "description"): True,
228
+ ("motion", "attitude"): True, ("motion", "description"): True}
229
+ NEUTRAL_EDITABLE = {("appearance", "clothing"): True, ("appearance", "accessory"): True, # default value
230
+ ("appearance", "hair"): False, ("appearance", "pilosity"): False, ("appearance", "feature"): False,
231
+ ("posture", "photo"): False, ("posture", "photos"): False, ("motion", "video"): False,
232
+ ("motion", "clips"): True, # author's choice: a motion capture of a real person is personal data
233
+ ("volume3d", "other"): False,
234
+ ("files", "skill"): True, ("files", "memory"): False, ("files", "document"): False, ("files", "other"): False}
235
+
236
+
237
+ def neutrality(cat: str, v: dict) -> tuple[bool, bool]:
238
+ """(default neutral?, editable by the author?) for a resolved variant."""
239
+ k = v.get("kind")
240
+ if cat == "volume3d" and k == "daz":
241
+ return (v.get("daz_category") not in (None, "characters")), False
242
+ if (cat, k) in NEUTRAL_FIXED:
243
+ return NEUTRAL_FIXED[(cat, k)], False
244
+ if (cat, k) in NEUTRAL_EDITABLE:
245
+ return NEUTRAL_EDITABLE[(cat, k)], True
246
+ return False, False
247
+
248
+
249
+ def is_neutral(cat: str, v: dict) -> bool:
250
+ default, editable = neutrality(cat, v)
251
+ if v.get("neutral") is False:
252
+ return False # an author may always be stricter
253
+ if editable and v.get("neutral") is True:
254
+ return True
255
+ return default
256
+
257
+
258
+ def is_likeness(cat: str, v: dict) -> bool:
259
+ """Does this (resolved) variant carry a person's likeness or voice? Such files belong to ONE character."""
260
+ k = v.get("kind")
261
+ if cat in ("identity_weights", "voice"):
262
+ return True
263
+ if cat == "appearance":
264
+ return k in LIKENESS_APPEARANCE
265
+ if cat == "volume3d":
266
+ return k != "environment" and not (k == "daz" and v.get("daz_category") not in (None, "characters"))
267
+ return False
268
+
269
+
270
+ def variant_owner(manifest: dict, cat: str, vid: str) -> str | None:
271
+ """The character a variant belongs to: its `identity` (inherited through derives_from) or the default identity."""
272
+ v = resolve_variant(manifest, cat, vid) or {}
273
+ return v.get("identity") or manifest.get("default_identity")
274
+
275
+
276
+ def preset_owner(manifest: dict, pid: str | None) -> str | None:
277
+ """The character a preset belongs to: its `identity` (inherited through extends) or the default identity."""
278
+ P = manifest.get("presets") or {}
279
+ q, seen = pid, set()
280
+ while q in P and q not in seen:
281
+ seen.add(q)
282
+ if P[q].get("identity"):
283
+ return P[q]["identity"]
284
+ q = P[q].get("extends")
285
+ return manifest.get("default_identity")
286
+
287
+
288
+ def identity_rights(manifest: dict, iid: str | None) -> dict:
289
+ """Rights of a character: identities.<id>.rights REPLACES the root rights (no merge); root rights otherwise."""
290
+ ident = (manifest.get("identities") or {}).get(iid or "") or {}
291
+ return ident.get("rights") or manifest.get("rights") or {}
292
+
293
+
294
+ def effective_permissions(manifest: dict, pid: str | None) -> dict:
295
+ p = (manifest.get("presets") or {}).get(pid or "") or {}
296
+ base = (identity_rights(manifest, preset_owner(manifest, pid)).get("permissions") or {})
297
+ return {**base, **(p.get("permissions_override") or {})}
298
+
299
+
300
+ def variant_asset_ids(v: dict) -> set[str]:
301
+ ids = set()
302
+ for k in ("images", "derived", "samples", "clips", "videos", "files"):
303
+ ids |= set(v.get(k) or [])
304
+ if isinstance(v.get("mesh"), dict):
305
+ ids |= set(v["mesh"].get("textures") or [])
306
+ for en in v.get("engines") or []:
307
+ ids |= set(en.get("files") or [])
308
+ return ids
309
+
310
+
311
+ # Age is a property of the REPRESENTATION, never of the identity (v0.5).
312
+ AGE_CATEGORIES = {"appearance", "volume3d", "identity_weights"} # variants that MAY carry `age`
313
+ AGELESS_KINDS = {"clothing", "accessory", "feature", "description", "environment"} # variants that never do
314
+ AGE_REQUIRED_KINDS = {"face", "body", "character-sheet", "reference-set"} # MUST carry `age` for human-form morphologies
315
+ HUMAN_FORM = {"human", "humanoid", "anthropomorphic-animal"}
316
+ ADULT = 18
317
+
318
+
319
+ def _age_value(a) -> float | None:
320
+ """Age in YEARS (every comparison is made in years: `unit: months` is divided by 12)."""
321
+ if not (isinstance(a, dict) and isinstance(a.get("value"), (int, float)) and not isinstance(a.get("value"), bool)):
322
+ return None
323
+ u = a.get("unit")
324
+ if u not in (None, "years", "months"):
325
+ return 0.0 # unknown unit (e.g. from a later 1.x): an unknown age never counts as adult
326
+ return a["value"] / 12 if u == "months" else a["value"]
327
+
328
+
329
+ def preset_age_override(manifest: dict, pid: str | None):
330
+ """age_override is inherited through extends (nearest declaration wins) — inheriting can only lower a child's age."""
331
+ presets = manifest.get("presets", {}) or {}
332
+ seen, q = set(), pid
333
+ while q and q in presets and q not in seen:
334
+ seen.add(q)
335
+ if presets[q].get("age_override") is not None:
336
+ return presets[q]["age_override"]
337
+ q = presets[q].get("extends")
338
+ return None
339
+
340
+
341
+ def extends_cycle(manifest: dict, pid: str) -> bool:
342
+ presets = manifest.get("presets", {}) or {}
343
+ seen, q = set(), pid
344
+ while q and q in presets:
345
+ if q in seen:
346
+ return True
347
+ seen.add(q)
348
+ q = presets[q].get("extends")
349
+ return False
350
+
351
+
352
+ def variant_ages(manifest: dict) -> list[float]:
353
+ out = []
354
+ for cat in AGE_CATEGORIES:
355
+ for v in variants_of(manifest, cat).values():
356
+ if v.get("kind") in AGELESS_KINDS:
357
+ continue
358
+ x = _age_value(v.get("age"))
359
+ if x is not None:
360
+ out.append(x)
361
+ return out
362
+
363
+
364
+ def lowest_age(manifest: dict) -> float | None:
365
+ """Lowest age declared anywhere (presets' age_override, morphological variants). None = no age known."""
366
+ ages = []
367
+ for p in (manifest.get("presets", {}) or {}).values():
368
+ x = _age_value(p.get("age_override"))
369
+ if x is not None:
370
+ ages.append(x)
371
+ ages += variant_ages(manifest)
372
+ return min(ages) if ages else None
373
+
374
+
375
+ def preset_age(manifest: dict, pid: str | None) -> float | None:
376
+ """Age of a preset = min(ages of its applied AND offered morphological variants, age_override). None = no age known (e.g. text only)."""
377
+ res = resolved_all(manifest, pid)
378
+ va = [x for cat in AGE_CATEGORIES for v in res.get(cat, []) if v.get("kind") not in AGELESS_KINDS for x in [_age_value(v.get("age"))] if x is not None]
379
+ x = _age_value(preset_age_override(manifest, pid))
380
+ if x is not None:
381
+ va.append(x)
382
+ return min(va) if va else None
383
+
384
+
385
+ def implicit_resolved(manifest: dict) -> dict[str, list[dict]]:
386
+ out = {}
387
+ for cat, vids in implicit_preset(manifest).items():
388
+ lst = [v for v in (resolve_variant(manifest, cat, vid) for vid in vids) if v]
389
+ if lst:
390
+ out[cat] = lst
391
+ return out
392
+
393
+
394
+ def age_required(manifest: dict, pid: str | None, v: dict) -> bool:
395
+ """Does this (resolved) variant have to carry an age? intimate: always; face/body/sheet/reference: for human-form morphologies."""
396
+ if v.get("kind") == "intimate":
397
+ return True
398
+ if v.get("kind") == "daz": # Daz Studio content: only a character carries an age
399
+ if v.get("daz_category") != "characters":
400
+ return False
401
+ elif v.get("kind") not in AGE_REQUIRED_KINDS:
402
+ return False
403
+ cls = ((preset_identity(manifest, pid) or {}).get("morphology") or {}).get("class") or "human"
404
+ return cls in HUMAN_FORM
405
+
406
+
407
+ # ---------- region rules (clothing / accessories / pilosity / features / weights) ----------
408
+
409
+ def region_conflicts(res: dict[str, list[dict]]) -> list[str]:
410
+ """Return human-readable conflicts for a resolved preset (empty = OK)."""
411
+ g = garments()
412
+ issues = []
413
+ cloth = by_kind(res, "appearance", "clothing")
414
+ full = [v for v in cloth if (v.get("garment") or "other") == "full-outfit"]
415
+ if full and len(cloth) > 1:
416
+ issues.append(f"full outfit '{full[0]['_id']}' combined with other clothing")
417
+ occupied: dict[tuple[str, str], str] = {}
418
+ for v in cloth:
419
+ t = g["types"].get(v.get("garment") or "other", {"regions": [], "layer": "outer", "stackable": True})
420
+ if t.get("stackable") or t.get("exclusive"):
421
+ continue
422
+ for r in t["regions"]:
423
+ key = (r, t["layer"])
424
+ if key in occupied:
425
+ issues.append(f"clothing '{occupied[key]}' and '{v['_id']}' overlap ({r}, layer {t['layer']})")
426
+ occupied[key] = v["_id"]
427
+ occ = {}
428
+ for v in by_kind(res, "appearance", "accessory"):
429
+ if (v.get("accessory_use") or "worn") != "worn":
430
+ continue # not worn, companion, other: no body region
431
+ t = g["accessories"]["types"].get(v.get("accessory") or "other", {"region": "none", "stackable": True})
432
+ if t.get("stackable") or t["region"] in ("none",):
433
+ continue
434
+ if t["region"] in occ:
435
+ issues.append(f"accessories '{occ[t['region']]}' and '{v['_id']}' on the same region ({t['region']})")
436
+ occ[t["region"]] = v["_id"]
437
+ areas = {}
438
+ for v in by_kind(res, "appearance", "pilosity"):
439
+ a = v.get("area") or "face"
440
+ if a in areas:
441
+ issues.append(f"two pilosity variants for area '{a}' ('{areas[a]}', '{v['_id']}')")
442
+ areas[a] = v["_id"]
443
+ covered: dict[str, str] = {}
444
+ for v in res.get("identity_weights", []):
445
+ for c in v.get("covers") or []:
446
+ if c in covered:
447
+ issues.append(f"identity weights '{covered[c]}' and '{v['_id']}' both cover '{c}'")
448
+ covered[c] = v["_id"]
449
+ return issues
450
+
451
+
452
+ def single_kind_violations(res: dict[str, list[dict]]) -> list[str]:
453
+ out = []
454
+ for cat, lst in res.items():
455
+ if cat in LIBRARY_CATEGORIES:
456
+ continue
457
+ seen = {}
458
+ for v in lst:
459
+ k = v.get("kind")
460
+ if k in MULTI_KINDS:
461
+ continue
462
+ if k in seen:
463
+ out.append(f"{cat}: two variants of kind '{k}' ('{seen[k]}', '{v['_id']}')")
464
+ seen[k] = v["_id"]
465
+ return out