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/__init__.py +5 -0
- cof_cli/cli.py +305 -0
- cof_cli/completeness.py +131 -0
- cof_cli/container.py +230 -0
- cof_cli/convert.py +362 -0
- cof_cli/migrate.py +297 -0
- cof_cli/model.py +465 -0
- cof_cli/ops.py +381 -0
- cof_cli/schemas/attitude.schema.json +248 -0
- cof_cli/schemas/body.schema.json +98 -0
- cof_cli/schemas/card.schema.json +107 -0
- cof_cli/schemas/consent.schema.json +134 -0
- cof_cli/schemas/garments-1.0.json +41 -0
- cof_cli/schemas/hair.schema.json +104 -0
- cof_cli/schemas/manifest.schema.json +1757 -0
- cof_cli/schemas/pose.schema.json +206 -0
- cof_cli/schemas/psyche.schema.json +252 -0
- cof_cli/schemas/skeleton.schema.json +88 -0
- cof_cli/schemas/visemes.schema.json +28 -0
- cof_cli/schemas/voice-profile.schema.json +254 -0
- cof_cli/schemas/weights.schema.json +188 -0
- cof_cli/tokens.py +30 -0
- cof_cli/validate.py +985 -0
- cof_cli-1.0.0.dist-info/METADATA +39 -0
- cof_cli-1.0.0.dist-info/RECORD +28 -0
- cof_cli-1.0.0.dist-info/WHEEL +5 -0
- cof_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cof_cli-1.0.0.dist-info/top_level.txt +1 -0
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
|