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/validate.py ADDED
@@ -0,0 +1,985 @@
1
+ """Validation (v0.3): schema + structure + semantic rules + KPI recomputation (per preset)."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import json
6
+ import re
7
+ import tempfile
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from importlib import resources
11
+ from pathlib import Path
12
+
13
+ import jsonschema
14
+ from jsonschema import Draft202012Validator
15
+
16
+ from . import SPEC_VERSION, __version__
17
+ from .completeness import compute as compute_completeness
18
+ from .container import ContainerError, inspect, read_manifest, unpack, verify_hashes
19
+ from .model import HUMAN_FORM, AGE_REQUIRED_KINDS, extends_cycle, garments, ADULT, implicit_resolved, AGELESS_KINDS, AGE_CATEGORIES, CATEGORIES, KINDS, MULTI_KINDS, _age_value, age_required, asset_index, default_preset_id, first, lowest_age, preset_age, preset_identity, preset_ids, region_conflicts, resolve_preset_slots, resolve_variant, resolved, single_kind_violations, by_kind, preset_alternatives, preset_primary_image, resolved_all, backstage, variant_owner, preset_owner, is_likeness, is_neutral, neutrality, effective_permissions, identity_rights, variant_asset_ids
20
+ from .tokens import count as count_tokens, tokenizer_name
21
+
22
+ PROMPT_FIELDS = ("description", "personality", "scenario", "first_mes", "mes_example", "system_prompt", "post_history_instructions")
23
+ EXEC_EXT = {".js", ".mjs", ".lua", ".py", ".wasm", ".exe", ".com", ".msi", ".scr", ".dll", ".so", ".dylib", ".jar", ".apk",
24
+ ".sh", ".bash", ".bat", ".cmd", ".ps1", ".vbs", ".applescript"} # executable content (SPEC § 9.3)
25
+ ACTIVE_WEB = {".svg", ".html", ".htm", ".xhtml"} # executable only if they carry script
26
+ DAZ_SCRIPTS = (".dsa", ".dse") # run inside Daz Studio: flagged, not refused
27
+ PICKLE_EXT = {".pkl", ".pickle", ".ckpt", ".pt", ".pth"} # never allowed: loading them runs arbitrary code
28
+ ENGINE_EXT = {".safetensors", ".npz", ".npy", ".json", ".onnx", ".index"}
29
+
30
+
31
+ def _is_pickle(path: Path) -> bool:
32
+ """Pickle by extension, or by content for ambiguous .bin (raw glTF buffers are fine)."""
33
+ suf = path.suffix.lower()
34
+ if suf in PICKLE_EXT:
35
+ return True
36
+ if suf == ".bin":
37
+ try:
38
+ head = path.open("rb").read(4)
39
+ if head[:1] == b"\x80":
40
+ return True
41
+ if head == b"PK\x03\x04":
42
+ import zipfile as _z
43
+ return any(n.endswith((".pkl", "data.pkl")) for n in _z.ZipFile(path).namelist())
44
+ except Exception:
45
+ return False
46
+ return False
47
+
48
+
49
+ ARCHIVE_DEPTH = 3
50
+
51
+
52
+ def _archive_names(path: Path, rel: str, rep: "Report"):
53
+ """Entry names of a .zip, nested .zip archives included (detected by extension or by magic), up to ARCHIVE_DEPTH
54
+ levels. Returns (names as 'outer.zip!inner.zip!file', entries left uninspected because too deep)."""
55
+ import zipfile as _z
56
+ names, deep = [], []
57
+ def walk(zf, prefix, depth):
58
+ for n in zf.namelist():
59
+ names.append(prefix + n)
60
+ if n.endswith("/"):
61
+ continue
62
+ try:
63
+ with zf.open(n) as fh:
64
+ head = fh.read(4)
65
+ except Exception:
66
+ continue
67
+ if head == b"PK\x03\x04" or n.lower().endswith(".zip"):
68
+ if depth >= ARCHIVE_DEPTH:
69
+ deep.append(prefix + n); continue
70
+ try:
71
+ with _z.ZipFile(io.BytesIO(zf.read(n))) as inner:
72
+ walk(inner, prefix + n + "!", depth + 1)
73
+ except Exception:
74
+ deep.append(prefix + n)
75
+ elif n.lower().endswith((".rar", ".7z")):
76
+ deep.append(prefix + n)
77
+ try:
78
+ with _z.ZipFile(path) as zf:
79
+ walk(zf, "", 1)
80
+ except Exception:
81
+ rep.errors.append(f"unreadable archive: {rel}")
82
+ return names, deep
83
+
84
+
85
+ def _safetensors_header(path: Path):
86
+ """Return the parsed safetensors header (dict), or an error string. Reads only the header: no tensor data, no code."""
87
+ import struct
88
+ try:
89
+ size = path.stat().st_size
90
+ with path.open("rb") as f:
91
+ head = f.read(8)
92
+ if len(head) < 8:
93
+ return "file shorter than the 8-byte header length"
94
+ n = struct.unpack("<Q", head)[0]
95
+ if n < 2 or n > min(size - 8, 100_000_000):
96
+ return f"header length {n} does not fit the file ({size} bytes)"
97
+ h = json.loads(f.read(n).decode("utf-8"))
98
+ if not isinstance(h, dict):
99
+ return "header is not a JSON object"
100
+ end = max([t["data_offsets"][1] for k, t in h.items() if k != "__metadata__" and isinstance(t, dict) and "data_offsets" in t] or [0])
101
+ if 8 + n + end > size:
102
+ return "tensor data extends past the end of the file (truncated?)"
103
+ return h
104
+ except Exception as ex:
105
+ return f"unreadable header ({ex.__class__.__name__})"
106
+
107
+
108
+ def _npy_has_objects(data: bytes) -> bool:
109
+ """A .npy whose dtype is object needs pickle to load: refuse it."""
110
+ if not data.startswith(b"\x93NUMPY"):
111
+ return True
112
+ major = data[6]
113
+ hl = int.from_bytes(data[8:10], "little") if major == 1 else int.from_bytes(data[8:12], "little")
114
+ header = data[10 if major == 1 else 12:(10 if major == 1 else 12) + hl].decode("latin1")
115
+ return "'|O'" in header or "'O'" in header or "object" in header
116
+ PATH_FIELDS = ("card", "psyche", "story", "lorebook", "params", "code", "spec", "profile", "vec", "visemes", "path", "skeleton")
117
+
118
+
119
+ @dataclass
120
+ class Report:
121
+ errors: list[str] = field(default_factory=list)
122
+ warnings: list[str] = field(default_factory=list)
123
+ info: dict = field(default_factory=dict)
124
+
125
+ @property
126
+ def ok(self) -> bool:
127
+ return not self.errors
128
+
129
+ def to_dict(self) -> dict:
130
+ return {"ok": self.ok, "errors": self.errors, "warnings": self.warnings, "info": self.info}
131
+
132
+
133
+ _validator = None
134
+
135
+
136
+ def manifest_validator() -> Draft202012Validator:
137
+ global _validator
138
+ if _validator is None:
139
+ with resources.files("cof_cli").joinpath("schemas/manifest.schema.json").open("r", encoding="utf-8") as f:
140
+ _validator = Draft202012Validator(json.load(f), format_checker=jsonschema.FormatChecker())
141
+ return _validator
142
+
143
+
144
+ _sub_validators: dict[str, Draft202012Validator] = {}
145
+ # which sub-file schema validates which variant field (by category / kind)
146
+ SUBFILE_SCHEMAS = {
147
+ ("personality", None, "card"): "card", ("personality", None, "psyche"): "psyche",
148
+ ("identity_weights", None, "spec"): "weights", ("voice", None, "profile"): "voice-profile",
149
+ ("posture", "attitude", "spec"): "attitude", ("motion", "attitude", "spec"): "attitude",
150
+ ("posture", None, "pose"): "pose", ("appearance", "hair", "code"): "hair", ("appearance", "body", "params"): "body",
151
+ ("motion", None, "visemes"): "visemes",
152
+ }
153
+
154
+
155
+ def sub_validator(name: str) -> Draft202012Validator:
156
+ if name not in _sub_validators:
157
+ with resources.files("cof_cli").joinpath(f"schemas/{name}.schema.json").open("r", encoding="utf-8") as f:
158
+ _sub_validators[name] = Draft202012Validator(json.load(f), format_checker=jsonschema.FormatChecker())
159
+ return _sub_validators[name]
160
+
161
+
162
+ def validate_subfile(obj: dict, schema: str, rep: "Report", where: str) -> None:
163
+ """Validate a sub-file (or inline object) against spec/schemas/<schema>.schema.json. Errors are errors: these files are ours."""
164
+ for e in sorted(sub_validator(schema).iter_errors(obj), key=lambda e: list(e.path)):
165
+ loc = "/".join(str(p) for p in e.path) or "(root)"
166
+ rep.errors.append(f"{where} [{schema}.schema {loc}]: {_schema_msg(e)}")
167
+
168
+
169
+ def _schema_msg(e) -> str:
170
+ """Readable jsonschema message; enum errors always list every allowed value (agents rely on it)."""
171
+ if e.validator == "enum":
172
+ return f"{e.instance!r} is not an allowed value — allowed values: {', '.join(map(str, e.validator_value))}"
173
+ return e.message if len(e.message) <= 300 else e.message[:300] + "…"
174
+
175
+
176
+ def validate_manifest(manifest: dict, rep: Report, newer: bool = False) -> None:
177
+ """newer = the file declares a later 1.x minor: 1.x only ADDS enum values and fields, so an unknown enum value or
178
+ property is a warning there, never a reason to refuse the file (SPEC § 12)."""
179
+ for e in sorted(manifest_validator().iter_errors(manifest), key=lambda e: list(e.path)):
180
+ loc = "/".join(str(p) for p in e.path) or "(root)"
181
+ if newer and (e.validator == "enum" or (e.validator == "additionalProperties")):
182
+ rep.warnings.append(f"manifest.json [{loc}]: {_schema_msg(e)} (value or field from a later 1.x version — kept, not checked)")
183
+ elif e.validator == "required" and "'presets'" in e.message:
184
+ rep.errors.append(f"manifest.json [{loc}]: {_schema_msg(e)} — a file declares at least one preset (`cof migrate` adds 'default')")
185
+ else:
186
+ rep.errors.append(f"manifest.json [{loc}]: {_schema_msg(e)}")
187
+
188
+
189
+ def validate_card(card: dict, rep: Report, lang: str, where: str) -> dict:
190
+ tokens = {}
191
+ if card.get("spec") not in ("chara_card_v3", "chara_card_v2"):
192
+ rep.errors.append(f"{where}: 'spec' must be 'chara_card_v3' (or 'chara_card_v2')")
193
+ data = card.get("data")
194
+ if not isinstance(data, dict):
195
+ rep.errors.append(f"{where}: 'data' missing")
196
+ return tokens
197
+ if not data.get("name"):
198
+ rep.errors.append(f"{where}: data.name missing")
199
+ for f in PROMPT_FIELDS:
200
+ v = data.get(f)
201
+ if isinstance(v, str) and v:
202
+ tokens[f] = count_tokens(v, lang)
203
+ permanent = sum(tokens.get(k, 0) for k in ("description", "personality", "scenario"))
204
+ tokens["permanent"] = permanent
205
+ if permanent > 2500:
206
+ rep.warnings.append(f"{where}: permanent block = {permanent} tokens (> 2,500)")
207
+ book = data.get("character_book")
208
+ if isinstance(book, dict):
209
+ entries = book.get("entries", [])
210
+ tokens["lore"] = sum(count_tokens(e.get("content", ""), lang) for e in entries if isinstance(e, dict))
211
+ const = [e for e in entries if isinstance(e, dict) and e.get("constant")]
212
+ if const:
213
+ rep.warnings.append(f"{where}: {len(const)} 'constant' entry(ies) — show them on import")
214
+ if data.get("post_history_instructions"):
215
+ rep.warnings.append(f"{where}: post_history_instructions present — show it on import ('untrusted' text)")
216
+ return tokens
217
+
218
+
219
+ def _dup_ratio(a: str, b: str) -> float:
220
+ def sh(t):
221
+ w = t.lower().split()
222
+ return {" ".join(w[i:i + 4]) for i in range(max(0, len(w) - 3))}
223
+ sa, sb = sh(a), sh(b)
224
+ return len(sa & sb) / min(len(sa), len(sb)) if sa and sb else 0.0
225
+
226
+
227
+ _SRC_ROOT: list = [] # directory being validated (read by rules that open a sub-file)
228
+
229
+
230
+ def validate_dir(src: Path, rep: Report, *, recompute: bool = True) -> dict:
231
+ _SRC_ROOT[:] = [src]
232
+ mpath = src / "manifest.json"
233
+ if not mpath.is_file():
234
+ rep.errors.append("manifest.json not found")
235
+ return {}
236
+ try:
237
+ manifest = json.loads(mpath.read_text("utf-8"))
238
+ except Exception as e:
239
+ rep.errors.append(f"manifest.json unreadable: {e}")
240
+ return {}
241
+ ver = str(manifest.get("cof", ""))
242
+ if ver in ("0.2", "0.3", "0.4", "0.5"):
243
+ rep.errors.append(f"manifest v{ver}: run `cof migrate` (format 1.x expected)")
244
+ return manifest
245
+ newer = bool(re.fullmatch(r"1\.\d+", ver) and int(ver.split(".")[1]) > int(SPEC_VERSION.split(".")[1]))
246
+ if newer:
247
+ rep.warnings.append(f"manifest v{ver} is newer than this validator ({SPEC_VERSION}): unknown optional fields are preserved, not checked")
248
+ validate_manifest(manifest, rep, newer)
249
+ if rep.errors:
250
+ return manifest
251
+
252
+ lang = (manifest.get("languages") or ["en"])[0]
253
+ A = asset_index(manifest)
254
+ elements = manifest.get("elements", {}) or {}
255
+
256
+ # identities: root must mirror the default identity
257
+ ids = manifest.get("identities") or {}
258
+ if ids:
259
+ di = manifest.get("default_identity")
260
+ if di not in ids:
261
+ rep.errors.append(f"unknown default_identity: {di}")
262
+ else:
263
+ for k in ("name", "fictional", "morphology"):
264
+ if json.dumps(ids[di].get(k), sort_keys=True) != json.dumps(manifest.get(k), sort_keys=True):
265
+ rep.errors.append(f"the root must mirror the default identity ({k} differs)")
266
+ for iid, ident in ids.items():
267
+ if ident.get("fictional") is False and not ident.get("consent"):
268
+ rep.warnings.append(f"identities.{iid}: real person without a consent record — fine for personal use; required in principle for a private person, and strongly recommended before sharing or selling the file (the author is responsible)")
269
+ dpi = ident.get("default_preset")
270
+ if dpi and dpi not in (manifest.get("presets") or {}):
271
+ rep.errors.append(f"identities.{iid}.default_preset: unknown preset {dpi}")
272
+ elif dpi and preset_owner(manifest, dpi) != iid:
273
+ rep.errors.append(f"identities.{iid}.default_preset: preset {dpi} belongs to character {preset_owner(manifest, dpi)}")
274
+ _character_rules(manifest, ids, rep)
275
+ if ids and (manifest.get("rights") or {}).get("consent") is not None:
276
+ did = manifest.get("default_identity") or next(iter(ids), None)
277
+ if (ids.get(did) or {}).get("consent") != manifest["rights"]["consent"] and len(ids) > 1:
278
+ rep.errors.append(f"rights.consent must be a copy of identities.{did}.consent (the default character's record) — in a cast each character's consent lives in identities.<id>.consent")
279
+ for iid, ident in ({**ids} if ids else {None: {k: manifest.get(k) for k in ("fictional",)} | {"consent": (manifest.get("rights") or {}).get("consent")}}).items():
280
+ if (ident or {}).get("fictional") is False:
281
+ perms = identity_rights(manifest, iid).get("permissions") or {}
282
+ own = (ident or {}).get("consent")
283
+ if own is None and (not ids or (len(ids) == 1 and iid == (manifest.get("default_identity") or next(iter(ids), None)))):
284
+ own = (manifest.get("rights") or {}).get("consent") # single character: the root record is its record
285
+ scope = (own or {}).get("scope") or []
286
+ if perms.get("allowVoiceCloning") and "voice-synthesis" not in scope:
287
+ rep.warnings.append(f"{'identities.' + iid if iid else 'rights'}: allowVoiceCloning is true for a real person but the consent scope does not include 'voice-synthesis' — record that consent before letting others clone the voice")
288
+ who = 'identities.' + iid if iid else 'rights'
289
+ need: dict[str, list[str]] = {}
290
+ for cat, e in (manifest.get("elements") or {}).items():
291
+ for vid in ((e or {}).get("variants") or {}):
292
+ if ids and variant_owner(manifest, cat, vid) != iid:
293
+ continue
294
+ rv = resolve_variant(manifest, cat, vid) or {}
295
+ k = rv.get("kind")
296
+ sc = ("intimate" if (cat, k) == ("appearance", "intimate") else
297
+ "likeness-image" if cat == "identity_weights" or (cat == "appearance" and k in ("face", "body", "character-sheet", "reference-set", "hair")) else
298
+ "likeness-3d" if cat == "volume3d" and k not in ("environment", "other") and not is_neutral(cat, rv) else
299
+ "voice-synthesis" if cat == "voice" and k in ("samples", "embedding") else
300
+ "conversational-persona" if cat == "personality" or (cat == "files" and k == "memory") else
301
+ "motion-capture" if cat == "motion" and k in ("clips", "video") and not is_neutral(cat, rv) else None)
302
+ if sc and sc not in scope:
303
+ need.setdefault(sc, []).append(f"{cat}.{vid}")
304
+ if rv.get("nudity") is True and k != "intimate" and not ({"nudity", "intimate"} & set(scope)):
305
+ need.setdefault("nudity", []).append(f"{cat}.{vid}")
306
+ for sc, where in need.items():
307
+ msg = f"{who}: real person — {', '.join(where[:3])}{'…' if len(where) > 3 else ''} need(s) consent scope '{sc}'"
308
+ (rep.errors if sc in ("intimate", "nudity") else rep.warnings).append(msg + (" — nudity or intimate content of a real person (public or private) is never allowed without their explicit consent" if sc in ("intimate", "nudity") else ""))
309
+ if (perms.get("allowTraining") or "none") != "none" and "training" not in scope:
310
+ rep.warnings.append(f"{who}: real person — allowTraining is {perms.get('allowTraining')} but the consent scope does not include 'training'")
311
+
312
+ # asset ids unique, files present
313
+ seen = set()
314
+ for a in manifest.get("assets", []):
315
+ if a["id"] in seen:
316
+ rep.errors.append(f"duplicate asset id: {a['id']}")
317
+ seen.add(a["id"])
318
+ if a.get("path") and not (src / a["path"]).is_file() and not a.get("uri"):
319
+ rep.errors.append(f"missing asset: {a['path']}")
320
+ if a.get("role") == "derived" and a.get("source") not in A:
321
+ rep.errors.append(f"derived asset {a['id']}: unknown source {a.get('source')}")
322
+ if a.get("role") == "voice-sample":
323
+ t = a.get("transcript")
324
+ if t and not (src / t).is_file():
325
+ rep.errors.append(f"voice sample: transcript declared but missing: {t}")
326
+ elif not t:
327
+ rep.warnings.append(f"voice sample without transcript (optional; useful to text-aligned engines): {a.get('path')}")
328
+
329
+ # variants: kind valid, referenced files and assets exist; derives_from resolvable
330
+ for etype, e in elements.items():
331
+ for vid, v in ((e or {}).get("variants", {}) or {}).items():
332
+ where = f"elements.{etype}.{vid}"
333
+ if v.get("derives_from") and v["derives_from"] not in e["variants"]:
334
+ rep.errors.append(f"{where}: unknown derives_from {v['derives_from']}")
335
+ au = (v.get("age") or {}).get("unit") if isinstance(v.get("age"), dict) else None
336
+ if au not in (None, "years", "months"):
337
+ rep.warnings.append(f"{where}: age unit '{au}' is not understood — the age counts as unknown, never as adult")
338
+ seen_df, cur = set(), vid
339
+ while cur and cur in e["variants"]:
340
+ if cur in seen_df:
341
+ rep.errors.append(f"{where}: derives_from cycle"); break
342
+ seen_df.add(cur); cur = (e["variants"][cur] or {}).get("derives_from")
343
+ rv = resolve_variant(manifest, etype, vid) or {}
344
+ if etype in KINDS and rv.get("kind") not in KINDS[etype]:
345
+ rep.errors.append(f"{where}: unknown kind '{rv.get('kind')}' for {etype} (expected: {', '.join(KINDS[etype])})")
346
+ if etype == "appearance" and rv.get("kind") == "clothing" and not rv.get("garment"):
347
+ rep.warnings.append(f"{where}: clothing without a 'garment' type (layering rules cannot apply)")
348
+ if rv.get("accessory_use") and rv.get("kind") != "accessory":
349
+ rep.warnings.append(f"{where}: accessory_use only applies to kind 'accessory' (ignored)")
350
+ if rv.get("kind") == "accessory":
351
+ use = rv.get("accessory_use") or "worn"
352
+ acc_types = garments()["accessories"]["types"]
353
+ if use == "worn" and rv.get("accessory") and rv["accessory"] not in acc_types:
354
+ rep.warnings.append(f"{where}: unknown accessory type '{rv['accessory']}' (garments-1.0: {', '.join(acc_types)}); for a vehicle or a companion set accessory_use to 'not-worn' or 'companion'")
355
+ if use != "worn" and rv.get("accessory"):
356
+ rep.warnings.append(f"{where}: accessory type '{rv['accessory']}' ignored — accessory_use is '{use}' (only worn accessories have a body region)")
357
+ if etype == "motion" and rv.get("kind") == "video":
358
+ for aid in rv.get("videos") or []:
359
+ a = A.get(aid)
360
+ if a and a.get("bytes", 0) > 20_000_000 and not rv.get("max_bytes_ack"):
361
+ rep.warnings.append(f"{where}: video {aid} > 20 MB without `max_bytes_ack` (portability)")
362
+ if "identity" in rv and ids and rv["identity"] not in ids:
363
+ rep.errors.append(f"{where}: unknown identity {rv['identity']}")
364
+ for fld in PATH_FIELDS:
365
+ rel = rv.get(fld)
366
+ if isinstance(rel, str) and not (src / rel).is_file():
367
+ rep.errors.append(f"{where}.{fld}: missing file {rel}")
368
+ elif isinstance(rel, str) and rel.endswith(".json"):
369
+ schema = SUBFILE_SCHEMAS.get((etype, rv.get("kind"), fld)) or SUBFILE_SCHEMAS.get((etype, None, fld))
370
+ if schema:
371
+ try:
372
+ validate_subfile(json.loads((src / rel).read_text("utf-8")), schema, rep, f"{where}.{fld} ({rel})")
373
+ except Exception as ex:
374
+ rep.errors.append(f"{where}.{fld}: unreadable JSON ({ex})")
375
+ if isinstance(rv.get("mesh"), dict) and not (src / rv["mesh"]["path"]).is_file():
376
+ rep.errors.append(f"{where}.mesh: missing file {rv['mesh']['path']}")
377
+ for lst in ("images", "derived", "samples", "clips", "videos", "files"):
378
+ for aid in rv.get(lst, []) or []:
379
+ if aid not in A:
380
+ rep.errors.append(f"{where}.{lst}: unknown asset {aid}")
381
+ elif lst == "images" and A[aid].get("role") == "derived":
382
+ rep.errors.append(f"{where}.images: {aid} is a derived asset — list it in 'derived', not in 'images'")
383
+ if etype == "identity_weights" and rv.get("spec"):
384
+ w = json.loads((src / rv["spec"]).read_text("utf-8")) if (src / rv["spec"]).is_file() else {}
385
+ if not (w.get("base_model") or {}).get("name"):
386
+ rep.errors.append(f"{where}: weights.json without base_model.name (a LoRA without its base model is unusable)")
387
+ if w.get("file") and w["file"] not in A:
388
+ rep.errors.append(f"{where}: unknown weights asset {w['file']}")
389
+ if rv.get("kind") == "reference-latent":
390
+ srcs = (w.get("encoding") or {}).get("sources") or []
391
+ for aid in srcs:
392
+ if aid not in A:
393
+ rep.errors.append(f"{where}: encoding.sources: unknown asset {aid}")
394
+ if not srcs:
395
+ rep.warnings.append(f"{where}: reference-latent without encoding.sources — the source images are not in the file, so it cannot be re-encoded for another model")
396
+
397
+ # presets: slots resolvable, extends known, identity known, kind/region rules
398
+ pids = preset_ids(manifest)
399
+ perms_all = (manifest.get("rights", {}) or {}).get("permissions", {}) or {}
400
+ for pid in pids:
401
+ p = manifest["presets"][pid]
402
+ if extends_cycle(manifest, pid):
403
+ rep.errors.append(f"presets.{pid}: extends cycle")
404
+ if p.get("extends") and p["extends"] not in manifest["presets"]:
405
+ rep.errors.append(f"presets.{pid}: unknown extends {p['extends']}")
406
+ elif p.get("extends") and preset_owner(manifest, p["extends"]) != preset_owner(manifest, pid):
407
+ rep.errors.append(f"presets.{pid}: extends {p['extends']}, a preset of another character")
408
+ if p.get("identity") and ids and p["identity"] not in ids:
409
+ rep.errors.append(f"presets.{pid}: unknown identity {p['identity']}")
410
+ for cat, vids in resolve_preset_slots(manifest, pid).items():
411
+ for vid in vids:
412
+ if cat not in elements or vid not in (elements[cat] or {}).get("variants", {}):
413
+ rep.errors.append(f"presets.{pid}.slots.{cat}: unknown variant {vid}")
414
+ elif ids and variant_owner(manifest, cat, vid) != preset_owner(manifest, pid):
415
+ rep.errors.append(f"presets.{pid} (character {preset_owner(manifest, pid)}): '{cat}.{vid}' belongs to character {variant_owner(manifest, cat, vid)} — a preset only uses its own character's elements")
416
+ own_slots = p.get("slots") or {}
417
+ for cat, vids in (p.get("alternatives") or {}).items():
418
+ for vid in vids:
419
+ if vid in (own_slots.get(cat) or []):
420
+ rep.warnings.append(f"presets.{pid}: '{vid}' is both applied (slots) and offered (alternatives) — kept as applied")
421
+ for cat, vids in preset_alternatives(manifest, pid).items():
422
+ for vid in vids:
423
+ if cat not in elements or vid not in (elements[cat] or {}).get("variants", {}):
424
+ rep.errors.append(f"presets.{pid}.alternatives.{cat}: unknown variant {vid}")
425
+ elif ids and variant_owner(manifest, cat, vid) != preset_owner(manifest, pid):
426
+ rep.errors.append(f"presets.{pid} (character {preset_owner(manifest, pid)}): alternative '{cat}.{vid}' belongs to character {variant_owner(manifest, cat, vid)} — a preset only offers its own character's elements")
427
+ rules = p.get("rules") or {}
428
+ res = resolved(manifest, pid)
429
+ if rules.get("single_per_kind", True):
430
+ rep.errors += [f"presets.{pid}: {x}" for x in single_kind_violations(res)]
431
+ if rules.get("exclusive_regions", True):
432
+ rep.warnings += [f"presets.{pid}: {x}" for x in region_conflicts(res)]
433
+ _primary_image_rules(manifest, pid, res, A, rep)
434
+ _age_rules(manifest, pid, resolved_all(manifest, pid), perms_all, rep) # applied ∪ offered (perms: the character's)
435
+ if not pids:
436
+ _age_rules(manifest, None, implicit_resolved(manifest), perms_all, rep)
437
+ for etype, e in elements.items():
438
+ V = (e or {}).get("variants", {}) or {}
439
+ for vid, v in V.items():
440
+ par = V.get(v.get("derives_from") or "")
441
+ if par and par.get("kind") != v.get("kind"):
442
+ rep.warnings.append(f"elements.{etype}.{vid} ({v.get('kind')}) derives from {v['derives_from']} ({par.get('kind')}): different kinds, inheritance mixes unrelated fields")
443
+ ages_by_voice: dict[str, list] = {}
444
+ for pid in pids:
445
+ pa = preset_age(manifest, pid)
446
+ for v in resolved(manifest, pid).get("voice", []):
447
+ if pa is not None:
448
+ ages_by_voice.setdefault(v["_id"], []).append((pa, pid))
449
+ for vid, lst in ages_by_voice.items():
450
+ lo, hi = min(lst), max(lst)
451
+ if hi[0] - lo[0] > 15:
452
+ rep.warnings.append(f"voice.{vid} shared by presets of very different ages ({lo[1]}: {lo[0]:g} years, {hi[1]}: {hi[0]:g} years) — provide one voice per age range")
453
+ # intimate ⇒ tags.content must flag it (opt-in element, never hidden)
454
+ has_intimate = any((v or {}).get("kind") == "intimate" for v in ((elements.get("appearance") or {}).get("variants", {}) or {}).values())
455
+ content = set(((manifest.get("tags") or {}).get("content") or []))
456
+ if "none" in content and len(content) > 1:
457
+ rep.errors.append(f"tags.content: 'none' excludes every other value (found {sorted(content)})")
458
+ if has_intimate and not (content & {"nudity", "sexual"}):
459
+ rep.errors.append("intimate variant present: tags.content must include 'nudity' or 'sexual'")
460
+ nude = [f"{c}.{vid}" for c, e in elements.items() for vid in ((e or {}).get("variants") or {}) if (resolve_variant(manifest, c, vid) or {}).get("nudity") is True]
461
+ if nude and "nudity" not in content:
462
+ rep.errors.append(f"variant(s) marked nudity ({', '.join(nude[:3])}): tags.content must include 'nudity'")
463
+ if manifest.get("preset_groups") is not None:
464
+ rep.warnings.append("preset_groups is deprecated (builder 0.8.0): use preset_tags — `cof migrate` converts it")
465
+ seen_t = set()
466
+ for tg in manifest.get("preset_tags") or []:
467
+ tid = tg.get("id")
468
+ if tid in seen_t:
469
+ rep.errors.append(f"preset_tags: duplicate tag id '{tid}'")
470
+ seen_t.add(tid)
471
+ for pid in tg.get("presets") or []:
472
+ if pid not in (manifest.get("presets") or {}):
473
+ rep.errors.append(f"preset_tags.{tid}: unknown preset '{pid}'")
474
+ by_tags: dict[frozenset, list[str]] = {}
475
+ for pid in pids:
476
+ ts = frozenset(tg.get("id") for tg in manifest.get("preset_tags") or [] if pid in (tg.get("presets") or []))
477
+ if ts:
478
+ by_tags.setdefault(ts, []).append(pid)
479
+ for ts, same in by_tags.items():
480
+ if len(same) > 1:
481
+ rep.warnings.append(f"presets {', '.join(same)} carry the same tags ({', '.join(sorted(ts))}) — facet navigation cannot tell them apart; add a tag (e.g. facet 'look')")
482
+ dp = default_preset_id(manifest)
483
+ if pids and dp not in manifest["presets"]:
484
+ rep.errors.append(f"unknown default_preset: {dp}")
485
+ for cat, vid in backstage(manifest):
486
+ rep.warnings.append(f"elements.{cat}.{vid} is backstage: referenced by no preset (kept, never applied nor offered) — offer it in a preset, or delete it if it is a leftover")
487
+
488
+ # intimate variants: age mandatory and ≥ 18, wherever they are (even outside any preset)
489
+ for vid, v in ((elements.get("appearance") or {}).get("variants", {}) or {}).items():
490
+ rv = resolve_variant(manifest, "appearance", vid) or {}
491
+ if rv.get("kind") == "intimate":
492
+ x = _age_value(rv.get("age"))
493
+ if x is None:
494
+ rep.errors.append(f"appearance.{vid} (intimate): age required (≥ {ADULT})")
495
+ elif x < ADULT:
496
+ rep.errors.append(f"appearance.{vid} (intimate): age {x:g} < {ADULT} — forbidden")
497
+ _asset_age_rules(manifest, A, rep)
498
+ for cwhere, cobj in [("rights.consent", (manifest.get("rights", {}) or {}).get("consent")), *[(f"identities.{i}.consent", (x or {}).get("consent")) for i, x in ids.items()]]:
499
+ if isinstance(cobj, dict):
500
+ validate_subfile(cobj, "consent", rep, cwhere)
501
+ if manifest.get("fictional") is False and not (manifest.get("rights", {}) or {}).get("consent"):
502
+ rep.warnings.append("non-fictional character without rights.consent — the author is responsible for the use of a real person's likeness; consent is required in principle for a private person and strongly recommended before any public or commercial sharing")
503
+
504
+ for vid, v in ((elements.get("personality") or {}).get("variants") or {}).items():
505
+ if v.get("kind") == "description" and not (v.get("text") or "").strip():
506
+ rep.warnings.append(f"personality.{vid} (description): empty `text` field — free text goes inline in `text`")
507
+ if v.get("kind") == "card" and not v.get("card"):
508
+ rep.errors.append(f"personality.{vid} (card): `card` path (card.json) missing")
509
+
510
+ # voice engine artifacts: tied to an exact engine/model, safe formats only
511
+ perms0 = (manifest.get("rights") or {}).get("permissions") or {}
512
+ for vid, v in ((elements.get("voice") or {}).get("variants") or {}).items():
513
+ prof_path = v.get("profile")
514
+ try:
515
+ prof = json.loads((src / prof_path).read_text("utf-8")) if prof_path and (src / prof_path).is_file() else {}
516
+ except Exception:
517
+ prof = {}
518
+ for i, en in enumerate(prof.get("engines") or []):
519
+ w = f"voice.{vid}.engines[{i}] ({en.get('engine')})"
520
+ eids = list(en.get("files") or []) + ([en["artifact"]] if en.get("artifact") else [])
521
+ if not eids and not en.get("voice_id"):
522
+ rep.warnings.append(f"{w}: neither files nor voice_id — entry has no effect")
523
+ if eids and not en.get("model"):
524
+ rep.warnings.append(f"{w}: specify `model` (exact model, e.g. coqui/XTTS-v2) — a voice embedding is only readable by the model that produced it")
525
+ for aid in eids:
526
+ a = A.get(aid)
527
+ if not a:
528
+ rep.errors.append(f"{w}: unknown asset '{aid}'")
529
+ elif a.get("path") and Path(a["path"]).suffix.lower() not in ENGINE_EXT:
530
+ rep.warnings.append(f"{w}: unexpected format {Path(a['path']).suffix} — expected: {', '.join(sorted(ENGINE_EXT))}")
531
+ if eids and _perms_of(manifest, "voice", vid).get("allowVoiceCloning") is False:
532
+ rep.warnings.append(f"{w}: voice embedding included while allowVoiceCloning = false — readers may only use it to voice THIS character")
533
+ # Daz Studio content: references a content library by design; licence of store products
534
+ for vid, v in ((elements.get("volume3d") or {}).get("variants") or {}).items():
535
+ if v.get("kind") != "daz":
536
+ continue
537
+ w = f"volume3d.{vid} (daz)"
538
+ if not v.get("daz_category"):
539
+ rep.warnings.append(f"{w}: specify daz_category (characters, wearables, hair, environments, props, poses-animations)")
540
+ if not v.get("requires"):
541
+ rep.warnings.append(f"{w}: list in `requires` the Daz products the .duf files depend on (base figure, morphs, textures)")
542
+ for aid in v.get("files") or []:
543
+ a = A.get(aid) or {}
544
+ ap = src / a.get("path", "") if a.get("path") else None
545
+ if not ap or not ap.is_file():
546
+ continue
547
+ if ap.suffix.lower() == ".zip":
548
+ try:
549
+ import zipfile as _z
550
+ names = _z.ZipFile(ap).namelist()
551
+ risky = [x for x in names if x.lower().endswith(DAZ_SCRIPTS)]
552
+ if risky:
553
+ rep.warnings.append(f"{w}: archive {ap.name} contains {len(risky)} Daz script(s) (e.g. {risky[0]}) — they run inside Daz Studio: only open trusted archives")
554
+ except Exception:
555
+ rep.errors.append(f"{w}: unreadable archive {ap.name}")
556
+ if str(a.get("license", "")).startswith("LicenseRef-Daz") and _perms_of(manifest, "volume3d", vid).get("allowRedistribution"):
557
+ rep.warnings.append(f"{w}: {ap.name} is under a Daz licence (Store product) while allowRedistribution = true — such a .cof must not be shared")
558
+ rep.warnings.append(f"{w}: Daz licence — the standard licence covers renders, not redistribution of Store 3D files (an Interactive License is required); only embed your own files (preset or pose .duf, morphs you made)")
559
+ # assets by link (e.g. LoRA weights): allowed, flagged
560
+ for a in manifest.get("assets", []) or []:
561
+ if a.get("uri") and not a.get("path"):
562
+ if a.get("sha256"):
563
+ rep.warnings.append(f"asset {a.get('id')} by link ({a.get('uri')}): version pinned by sha256, but the link may disappear or change — embedding it in the .cof guarantees reproducibility")
564
+ else:
565
+ rep.warnings.append(f"asset {a.get('id')} by link without sha256: follows the latest version (not reproducible) and the link may disappear")
566
+ # 3D must be self-contained: one file carrying meshes AND textures (GLB/VRM/USDZ/3MF/splats)
567
+ for vid, v in ((elements.get("volume3d") or {}).get("variants") or {}).items():
568
+ for rel in [v.get("path"), (v.get("mesh") or {}).get("path")]:
569
+ if not rel:
570
+ continue
571
+ ext = rel.rsplit(".", 1)[-1].lower()
572
+ where3 = f"volume3d.{vid} ({v.get('kind')})"
573
+ if ext in ("obj", "gltf") and v.get("self_contained") is False:
574
+ rep.warnings.append(f"{where3}: declared not self-contained (`self_contained: false`) — external materials / textures / data missing, accepted by the author's choice")
575
+ continue
576
+ if ext == "obj":
577
+ rep.errors.append(f"{where3}: OBJ is not self-contained (separate MTL and textures) — convert to GLB (Blender, obj2gltf), or declare `self_contained: false` if an untextured mesh is enough (e.g. poses)")
578
+ elif ext == "gltf":
579
+ try:
580
+ g = json.loads((src / rel).read_text("utf-8"))
581
+ ext_uris = [x.get("uri") for k in ("buffers", "images") for x in g.get(k, []) if x.get("uri") and not str(x.get("uri")).startswith("data:")]
582
+ except Exception:
583
+ ext_uris = ["?"]
584
+ if ext_uris:
585
+ rep.errors.append(f"{where3}: glTF with external resources ({len(ext_uris)}) — convert to GLB (gltf-transform copy in.gltf out.glb), or declare `self_contained: false`")
586
+ elif ext == "fbx":
587
+ rep.warnings.append(f"{where3}: FBX may reference external textures and is proprietary — prefer GLB/VRM")
588
+ elif v.get("kind") == "environment" and ext not in ("glb", "usdz", "spz", "ply", "splat", "gltf"):
589
+ rep.warnings.append(f"{where3}: unexpected format for a 3D environment (.{ext}) — expected: GLB, USDZ, splats (SPZ/PLY/SPLAT)")
590
+
591
+ # a coherence finding of severity `illegal` (e.g. sexualised content in a minor preset) invalidates the file
592
+ for pid_, p_ in (manifest.get("presets") or {}).items():
593
+ for iss in ((p_.get("coherence") or {}).get("issues") or []):
594
+ if iss.get("severity") == "illegal":
595
+ rep.errors.append(f"preset {pid_}: coherence finding of severity `illegal` — {iss.get('message', '')} (the file must not be published)")
596
+
597
+ # per-preset checks: cards, psyche refs, duplicates, style coherence
598
+ tokens_by_preset = {}
599
+ for pid in (pids or [None]):
600
+ r = resolved(manifest, pid)
601
+ label = pid or "(implicit)"
602
+ p = first(r, "personality")
603
+ if p and p.get("card") and (src / p["card"]).is_file():
604
+ try:
605
+ card = json.loads((src / p["card"]).read_text("utf-8"))
606
+ tokens_by_preset[label] = validate_card(card, rep, lang, f"{p['card']}")
607
+ except Exception as e:
608
+ rep.errors.append(f"{p['card']} unreadable: {e}")
609
+ ids_ = set()
610
+ if p and p.get("psyche") and (src / p["psyche"]).is_file():
611
+ try:
612
+ ps = json.loads((src / p["psyche"]).read_text("utf-8"))
613
+ ids_ |= {t.get("id") for t in (ps.get("traits", {}).get("custom") or [])}
614
+ for k in ("goals", "fears"):
615
+ ids_ |= {it.get("id") for it in ps.get(k, []) or []}
616
+ ids_ |= {f"trait:big5.{d}" for d in (ps.get("traits", {}).get("big5") or {})}
617
+ except Exception as e:
618
+ rep.errors.append(f"{p['psyche']} unreadable: {e}")
619
+ for v in r.get("posture", []) + r.get("voice", []):
620
+ rel = v.get("spec") or v.get("profile")
621
+ if rel and (src / rel).is_file():
622
+ try:
623
+ obj = json.loads((src / rel).read_text("utf-8"))
624
+ refs = set(obj.get("expresses", []) or [])
625
+ for g in obj.get("gestures", []) or []:
626
+ refs |= set(g.get("expresses", []) or [])
627
+ refs |= set((obj.get("speech_style") or {}).get("expresses", []) or [])
628
+ missing = [x for x in refs if x not in ids_]
629
+ if missing and ids_:
630
+ rep.warnings.append(f"{rel}: unresolved references to psyche: {', '.join(missing)}")
631
+ except Exception as e:
632
+ rep.errors.append(f"{rel} unreadable: {e}")
633
+ pstyle = (manifest.get("presets", {}).get(pid, {}) if pid else {}).get("style")
634
+ styles = set()
635
+ for v in r.get("appearance", []):
636
+ if v.get("style"):
637
+ styles.add(v["style"])
638
+ for aid in v.get("images", []) or []:
639
+ if aid in A and A[aid].get("style"):
640
+ styles.add(A[aid]["style"])
641
+ if pstyle and styles - {pstyle}:
642
+ rep.warnings.append(f"preset {label}: declared style '{pstyle}' but variants/images in {sorted(styles - {pstyle})}")
643
+ elif not pstyle and len(styles) > 1:
644
+ rep.warnings.append(f"preset {label}: mixed visual styles {sorted(styles)} with no declared style")
645
+
646
+ # executable / unsafe serialized content
647
+ declared = (manifest.get("container") or {}).get("executableContent", "none")
648
+ for pth in src.rglob("*"):
649
+ if not pth.is_file():
650
+ continue
651
+ rel = pth.relative_to(src).as_posix()
652
+ if _is_pickle(pth):
653
+ rep.errors.append(f"pickle file refused: {rel} — loading it runs code; convert to .safetensors (or numeric .npz)")
654
+ elif pth.suffix.lower() in EXEC_EXT and declared == "none":
655
+ rep.errors.append(f"undeclared executable content: {rel} — declare container.executableContent: \"scripts\" or remove the file")
656
+ elif pth.suffix.lower() in ACTIVE_WEB and declared == "none" and re.search(rb"<script|\son[a-z]+\s*=|javascript:", pth.read_bytes()[:2_000_000], re.I):
657
+ rep.errors.append(f"undeclared executable content: {rel} carries script (<script>, on…= handler or javascript: URL) — remove it or declare container.executableContent")
658
+ elif pth.suffix.lower() == ".zip":
659
+ names, deep = _archive_names(pth, rel, rep)
660
+ if deep:
661
+ rep.warnings.append(f"archive {rel} nests archives deeper than {ARCHIVE_DEPTH} levels ({deep[0]}) — not inspected; only open trusted archives")
662
+ pk = [x for x in names if Path(x).suffix.lower() in PICKLE_EXT]
663
+ ex = [x for x in names if Path(x).suffix.lower() in EXEC_EXT]
664
+ if pk:
665
+ rep.errors.append(f"pickle file refused inside archive {rel}: {pk[0]}{f' (+{len(pk) - 1})' if len(pk) > 1 else ''}")
666
+ if ex and declared == "none":
667
+ rep.errors.append(f"undeclared executable content inside archive {rel}: {ex[0]}{f' (+{len(ex) - 1})' if len(ex) > 1 else ''} — remove it or declare container.executableContent")
668
+ elif pth.suffix.lower() in (".rar", ".7z"):
669
+ rep.warnings.append(f"archive {rel} not inspected by the validator (only .zip is) — prefer .zip, or only open trusted archives")
670
+ elif pth.suffix.lower() == ".safetensors" and isinstance(err := _safetensors_header(pth), str):
671
+ rep.errors.append(f"invalid safetensors file: {rel} — {err}")
672
+ elif pth.suffix.lower() == ".npy" and _npy_has_objects(pth.read_bytes()[:4096]):
673
+ rep.errors.append(f"NumPy object array (requires pickle): {rel} — save a numeric array")
674
+ elif pth.suffix.lower() == ".npz":
675
+ import zipfile as _z
676
+ try:
677
+ with _z.ZipFile(pth) as z:
678
+ if any(_npy_has_objects(z.open(nm).read(4096)) for nm in z.namelist() if nm.endswith(".npy")):
679
+ rep.errors.append(f"NumPy archive with object arrays (requires pickle): {rel} — save numeric arrays")
680
+ except Exception:
681
+ rep.errors.append(f"unreadable NumPy archive: {rel}")
682
+
683
+ _duplicate_images(src, manifest, rep)
684
+ th = manifest.get("thumbnail")
685
+ if th and not recompute and not (src / th).is_file():
686
+ rep.errors.append(f"manifest.thumbnail: missing file {th} (normally {'Thumbnails/thumbnail.png'}, regenerated by cof pack)")
687
+
688
+ # KPIs
689
+ comp = compute_completeness(manifest, src)
690
+ if recompute:
691
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
692
+ manifest["completeness"] = {**comp["file"], "computed_by": f"cof-cli/{__version__}", "computed_at": now}
693
+ manifest["coverage"] = comp["coverage"]
694
+ for pid, c in comp["presets"].items():
695
+ manifest["presets"][pid]["completeness"] = {**c, "computed_by": f"cof-cli/{__version__}", "computed_at": now}
696
+ # thumbnail = thumbnail_source, else the default preset's primary_image, else head.front of its face, else first image of face / character-sheet / reference-set / body
697
+ r = resolved(manifest, dp)
698
+ cand = []
699
+ for k in ("face", "character-sheet", "reference-set", "body"):
700
+ for v in [x for x in r.get("appearance", []) if x.get("kind") == k and x.get("nudity") is not True]:
701
+ cand += [A[i] for i in v.get("images", []) or [] if i in A and A[i].get("path")]
702
+ best = next((a for a in cand if a.get("subject") == "head" and a.get("angle") == "front"), cand[0] if cand else None)
703
+ pim = ((manifest.get("presets") or {}).get(dp or "") or {}).get("primary_image")
704
+ if pim and A.get(pim, {}).get("path"):
705
+ best = A[pim] # the default preset's primary image
706
+ ts = A.get(manifest.get("thumbnail_source") or "")
707
+ if ts and ts.get("path"):
708
+ best = ts # the author's explicit choice wins
709
+ if best:
710
+ manifest["thumbnail"] = best["path"]
711
+ manifest.setdefault("tags", {})["auto"] = auto_tags(manifest)
712
+ # languages = union of personality / voice sample languages
713
+ langs = set(manifest.get("languages") or [])
714
+ for v in (elements.get("personality") or {}).get("variants", {}).values():
715
+ if v.get("language"):
716
+ langs.add(v["language"])
717
+ for a in manifest.get("assets", []):
718
+ if a.get("role") == "voice-sample" and a.get("language"):
719
+ langs.add(a["language"])
720
+ if langs:
721
+ manifest["languages"] = sorted(langs)
722
+ rep.info["completeness"] = comp["file"]
723
+ rep.info["presets"] = {k: v["score"] for k, v in comp["presets"].items()}
724
+ rep.info["coverage"] = comp["coverage"]
725
+ rep.info["tokens"] = {**(tokens_by_preset.get(dp) or next(iter(tokens_by_preset.values()), {})), "tokenizer": tokenizer_name()}
726
+ return manifest
727
+
728
+
729
+ def validate_cof(cof: Path) -> Report:
730
+ rep = Report()
731
+ try:
732
+ st = inspect(cof)
733
+ except Exception as e:
734
+ rep.errors.append(f"unreadable archive: {e}")
735
+ return rep
736
+ rep.errors += st["errors"]
737
+ rep.warnings += st["warnings"]
738
+ rep.info["entries"] = st["entries"]
739
+ rep.info["bytes"] = st["compressed"]
740
+ try:
741
+ manifest = read_manifest(cof)
742
+ except ContainerError as e:
743
+ rep.errors.append(str(e))
744
+ return rep
745
+ rep.errors += verify_hashes(cof, manifest)
746
+ with tempfile.TemporaryDirectory() as td:
747
+ try:
748
+ folder = unpack(cof, td)
749
+ except ContainerError as e:
750
+ rep.errors.append(str(e))
751
+ return rep
752
+ validate_dir(folder, rep, recompute=False)
753
+ limit = {"lite": 20_000_000, "standard": 100_000_000}.get(manifest.get("size_class"))
754
+ if limit and st["compressed"] > limit:
755
+ rep.warnings.append(f"size_class '{manifest.get('size_class')}' exceeded: {st['compressed']/1e6:.1f} MB")
756
+ if manifest.get("cof") != SPEC_VERSION:
757
+ rep.warnings.append(f"format version {manifest.get('cof')} (tool: {SPEC_VERSION})")
758
+ return rep
759
+
760
+
761
+ def _image_sig(path: Path):
762
+ """24×24 grayscale thumbnail + aspect ratio + pixel size (None if Pillow is missing or the image is unreadable)."""
763
+ try:
764
+ from PIL import Image
765
+ with Image.open(path) as im:
766
+ size = im.size
767
+ im.draft("L", (96, 96))
768
+ px = list(im.convert("L").resize((24, 24)).tobytes())
769
+ return px, size[0] / size[1] if size[1] else 0, size
770
+ except Exception:
771
+ return None
772
+
773
+
774
+ def _duplicate_images(src: Path, manifest: dict, rep: "Report") -> None:
775
+ """Warn when the same picture is embedded twice (e.g. a reduced copy AND the original): same aspect ratio,
776
+ near-identical pixels (mean difference ≤ 2/255 on a 24×24 thumbnail). Front/back views of a silhouette differ far more."""
777
+ seen = []
778
+ for a in manifest.get("assets") or []:
779
+ if not str(a.get("mediaType", "")).startswith("image/") or a.get("role") == "derived" or not a.get("path"):
780
+ continue
781
+ f = src / a["path"]
782
+ if not f.is_file():
783
+ continue
784
+ sig = _image_sig(f)
785
+ if not sig:
786
+ continue
787
+ for b, sb in seen:
788
+ if abs(sig[1] - sb[1]) <= 0.01 * max(sb[1], 1e-6) and sum(abs(x - y) for x, y in zip(sig[0], sb[0])) / len(sig[0]) <= 2:
789
+ small, big = (a, b) if sig[2][0] * sig[2][1] <= sb[2][0] * sb[2][1] else (b, a)
790
+ rep.warnings.append(f"assets {small['id']} and {big['id']} are the same image{' at two resolutions' if sig[2] != sb[2] else ''} ({small['path']}, {big['path']}) — keep one (the lighter one is usually enough)")
791
+ break
792
+ else:
793
+ seen.append((a, sig))
794
+
795
+
796
+ def auto_tags(manifest: dict) -> list[str]:
797
+ """tags.auto, computed (same rules as the builder): morphology, age buckets (human-shaped characters only), languages, contents, size of the cast."""
798
+ bucket = lambda a: "age-unknown" if a is None else "child" if a < 13 else "teen" if a < 18 else "elder" if a >= 65 else "adult"
799
+ P = manifest.get("presets") or {}
800
+ E = manifest.get("elements") or {}
801
+ ids = manifest.get("identities") or {}
802
+ at = {((i or {}).get("morphology") or {}).get("class") for i in ids.values()} | {(manifest.get("morphology") or {}).get("class")}
803
+ def form(p): # age tags only for human-shaped characters (a robot "3 years old" is no child)
804
+ I = ids.get(preset_owner(manifest, p)) or manifest
805
+ return ((I.get("morphology") or manifest.get("morphology") or {}).get("class") or "human") in HUMAN_FORM
806
+ ages = [preset_age(manifest, p) for p in P if form(p)]
807
+ at |= {bucket(a) for a in ages} | set(manifest.get("languages") or [])
808
+ if any(a is not None and a < 18 for a in ages):
809
+ at.add("has-minor-preset")
810
+ for cat, tag in (("voice", "has-voice"), ("volume3d", "has-3d"), ("motion", "has-motion"), ("identity_weights", "has-weights")):
811
+ if (E.get(cat) or {}).get("variants"):
812
+ at.add(tag)
813
+ kinds = {v.get("kind") for v in ((E.get("files") or {}).get("variants") or {}).values()}
814
+ if "skill" in kinds:
815
+ at.add("has-skills")
816
+ if "memory" in kinds:
817
+ at.add("has-memory")
818
+ if len(P) > 1:
819
+ at.add("multi-preset")
820
+ if len(ids) > 1:
821
+ at.add("multi-identity")
822
+ st = (P.get(default_preset_id(manifest) or "") or {}).get("style")
823
+ if st:
824
+ at.add(f"style-{st}")
825
+ return sorted(x for x in at if x)
826
+
827
+
828
+ def _perms_of(manifest: dict, cat: str, vid: str) -> dict:
829
+ return identity_rights(manifest, variant_owner(manifest, cat, vid)).get("permissions") or {}
830
+
831
+
832
+ def _character_rules(manifest: dict, ids: dict, rep: "Report") -> None:
833
+ """Several characters in one file = logical containers: every element belongs to one character; a file that
834
+ carries a likeness or a voice is never used by two characters (neutral files — places, clothes, poses — may be)."""
835
+ elements = manifest.get("elements") or {}
836
+ multi = len(ids) >= 2
837
+ owners_by_asset: dict[str, set] = {}
838
+ non_neutral_users: dict[str, list[str]] = {}
839
+ for cat, e in elements.items():
840
+ V = (e or {}).get("variants") or {}
841
+ for vid, v in V.items():
842
+ rv = resolve_variant(manifest, cat, vid) or {}
843
+ if multi and not rv.get("identity"):
844
+ rep.errors.append(f"elements.{cat}.{vid}: no `identity` — required as soon as the file holds several characters (each element belongs to one character)")
845
+ par = v.get("derives_from")
846
+ if par in V and ids and variant_owner(manifest, cat, par) != variant_owner(manifest, cat, vid):
847
+ rep.errors.append(f"elements.{cat}.{vid}: derives from {par}, an element of another character")
848
+ o = variant_owner(manifest, cat, vid)
849
+ default, editable = neutrality(cat, rv)
850
+ if v.get("neutral") is True and not editable and not default:
851
+ rep.errors.append(f"elements.{cat}.{vid}: a '{rv.get('kind')}' is never neutral — it carries the character (likeness, voice or personal content)")
852
+ for aid in variant_asset_ids(rv):
853
+ owners_by_asset.setdefault(aid, set()).add(o)
854
+ if not is_neutral(cat, rv):
855
+ non_neutral_users.setdefault(aid, []).append(f"{cat}.{vid} ({rv.get('kind')})")
856
+ if multi:
857
+ for aid, users in sorted(non_neutral_users.items()):
858
+ if len(owners_by_asset.get(aid, ())) > 1:
859
+ rep.errors.append(f"asset {aid} is used by several characters ({', '.join(sorted(map(str, owners_by_asset[aid])))}) but {', '.join(users)} is not neutral — a file that carries a character (likeness, voice, personal content) belongs to that character only; only neutral elements (places, poses, motion clips, objects, skills…) may share a file")
860
+ dp, di = manifest.get("default_preset"), manifest.get("default_identity")
861
+ if dp in (manifest.get("presets") or {}) and preset_owner(manifest, dp) != di:
862
+ rep.warnings.append(f"default_preset {dp} belongs to {preset_owner(manifest, dp)}, not to the default character {di}")
863
+
864
+
865
+ HEAD_ONLY = {"closeup", "head", "bust"} # asset framings that cannot show an outfit
866
+
867
+
868
+ def _primary_image_rules(manifest: dict, pid: str, res: dict, A: dict, rep: "Report") -> None:
869
+ """primary_image must be an image of a variant the preset APPLIES; warn when it cannot show an outfit."""
870
+ p = manifest["presets"][pid]
871
+ pim = p.get("primary_image")
872
+ if pim:
873
+ if pim not in A:
874
+ rep.errors.append(f"presets.{pid}.primary_image: unknown asset {pim}")
875
+ return
876
+ if not str(A[pim].get("mediaType", "")).startswith("image/"):
877
+ rep.errors.append(f"presets.{pid}.primary_image: {pim} is not an image")
878
+ return
879
+ applied = {i for lst in res.values() for v in lst for i in (v.get("images") or [])}
880
+ if pim not in applied:
881
+ rep.errors.append(f"presets.{pid}.primary_image: {pim} is not an image of a variant applied by this preset (slots)")
882
+ return
883
+ aid = preset_primary_image(manifest, pid)
884
+ if not aid or aid not in A:
885
+ return
886
+ a = A[aid]
887
+ head_only = a.get("framing") in HEAD_ONLY or (a.get("subject") == "head" and a.get("framing") != "full")
888
+ if head_only and not by_kind(res, "appearance", "clothing"):
889
+ rep.warnings.append(f"presets.{pid}: the primary image ({aid}) shows only the head and the preset applies no clothing — the default outfit is undefined (set primary_image to a body image or apply clothing)")
890
+
891
+
892
+ def _asset_age_rules(manifest: dict, A: dict, rep: "Report") -> None:
893
+ """Bind ages to files: an intimate variant's files are its own (own folder, used by no other variant), and one
894
+ file cannot represent both a person under 18 and an adult."""
895
+ users: dict[str, list[tuple[str, str, dict]]] = {}
896
+ nude_ids: set[str] = set()
897
+ for cat, e in (manifest.get("elements") or {}).items():
898
+ for vid in ((e or {}).get("variants") or {}):
899
+ rv = resolve_variant(manifest, cat, vid) or {}
900
+ if rv.get("nudity") is True or rv.get("kind") == "intimate":
901
+ nude_ids |= variant_asset_ids(rv)
902
+ grew = True
903
+ while grew: # derived maps of a nude image are nude too
904
+ grew = False
905
+ for a in manifest.get("assets") or []:
906
+ if a.get("id") not in nude_ids and a.get("role") == "derived" and a.get("source") in nude_ids: # maps (lineart, depth…) keep the nudity; crops are judged on their own
907
+ nude_ids.add(a["id"]); grew = True
908
+ for cat, e in (manifest.get("elements") or {}).items():
909
+ for vid in ((e or {}).get("variants") or {}):
910
+ rv = resolve_variant(manifest, cat, vid) or {}
911
+ if rv.get("nudity") is not True and rv.get("kind") != "intimate":
912
+ bad = sorted(variant_asset_ids(rv) & nude_ids)
913
+ if bad:
914
+ rep.errors.append(f"{cat}.{vid} uses {', '.join(bad[:3])}, which show(s) nudity or intimate content (or are maps derived from such an image) — mark the variant nudity, or use another file")
915
+ ts = manifest.get("thumbnail_source")
916
+ dp = default_preset_id(manifest)
917
+ pim = ((manifest.get("presets") or {}).get(dp or "") or {}).get("primary_image")
918
+ for where, aid in (("thumbnail_source", ts), (f"presets.{dp}.primary_image", pim)):
919
+ if aid and aid in nude_ids:
920
+ rep.errors.append(f"{where}: {aid} shows nudity or intimate content — the file thumbnail is shown by file managers without any content setting; choose another image")
921
+ for cat, e in (manifest.get("elements") or {}).items():
922
+ for vid in ((e or {}).get("variants") or {}):
923
+ rv = resolve_variant(manifest, cat, vid) or {}
924
+ for aid in variant_asset_ids(rv):
925
+ users.setdefault(aid, []).append((cat, vid, rv))
926
+ for aid, us in users.items():
927
+ intim = [u for u in us if u[0] == "appearance" and u[2].get("kind") == "intimate"]
928
+ for cat, vid, rv in intim:
929
+ others = [f"{c}.{v}" for c, v, _ in us if (c, v) != (cat, vid)]
930
+ if others:
931
+ rep.errors.append(f"appearance.{vid} (intimate): asset {aid} is also used by {', '.join(others)} — an intimate variant's files are its own")
932
+ pth = (A.get(aid) or {}).get("path") or ""
933
+ if pth and not pth.startswith(f"elements/appearance/{vid}/"):
934
+ rep.errors.append(f"appearance.{vid} (intimate): asset {aid} must live in elements/appearance/{vid}/ (found {pth})")
935
+ ages = [(x, f"{c}.{v}") for c, v, rv in us if rv.get("kind") not in AGELESS_KINDS for x in [_age_value(rv.get("age"))] if x is not None]
936
+ if ages and min(ages)[0] < ADULT <= max(ages)[0]:
937
+ rep.errors.append(f"asset {aid} represents both a person under {ADULT} ({min(ages)[1]}: {min(ages)[0]:g}) and an adult ({max(ages)[1]}: {max(ages)[0]:g}) — use separate files")
938
+
939
+
940
+ def _age_rules(manifest: dict, pid: str | None, res: dict, perms_all: dict, rep: "Report") -> None:
941
+ """v0.5: age lives on the representation. Required on face/body/sheet/reference (human form) and intimate;
942
+ a preset with any element < 18 admits no intimate, no estimated body, no effective allowSexualUsage."""
943
+ label = f"presets.{pid}" if pid else "implicit preset"
944
+ p = (manifest.get("presets", {}) or {}).get(pid or "", {}) or {}
945
+ pa = preset_age(manifest, pid)
946
+ for cat in AGE_CATEGORIES:
947
+ for v in res.get(cat, []):
948
+ if v.get("kind") in AGELESS_KINDS:
949
+ continue
950
+ if age_required(manifest, pid, v) and _age_value(v.get("age")) is None:
951
+ rep.errors.append(f"{label}: {cat}.{v['_id']} ({v['kind']}) must declare an age (basis 'apparent' if the canonical age is unknown)")
952
+ nude_vs = [(cat, v) for cat in res for v in res.get(cat, []) if v.get("nudity") is True]
953
+ if nude_vs and pa is None:
954
+ rep.errors.append(f"{label}: applies or offers a nude element ({nude_vs[0][0]}.{nude_vs[0][1]['_id']}) but has no known age — declare the ages of its body representations or an age_override (18 or over)")
955
+ if by_kind(res, "appearance", "intimate") or nude_vs:
956
+ # an intimate or nude element is applied or offered: every representation of the body carries an age, whatever the morphology class
957
+ for cat in AGE_CATEGORIES:
958
+ for v in res.get(cat, []):
959
+ k = v.get("kind")
960
+ carries = (cat == "appearance" and k in AGE_REQUIRED_KINDS) or cat == "identity_weights" or (cat == "volume3d" and k not in ("environment", "other") and (k != "daz" or v.get("daz_category") == "characters"))
961
+ if carries and _age_value(v.get("age")) is None and not age_required(manifest, pid, v):
962
+ rep.errors.append(f"{label}: {cat}.{v['_id']} ({k}) must declare an age — the preset applies or offers an intimate or nude element, so the age is required whatever the morphology class")
963
+ ages = sorted((x, f"{cat}.{v['_id']}") 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)
964
+ if ages and ages[-1][0] - ages[0][0] >= 10:
965
+ rep.warnings.append(f"{label}: mixes representations of very different ages ({ages[0][1]}: {ages[0][0]:g}, {ages[-1][1]}: {ages[-1][0]:g}) — an inherited element (e.g. a LoRA through extends) may not belong to this preset")
966
+ if by_kind(res, "appearance", "intimate"):
967
+ for v in res.get("voice", []):
968
+ try:
969
+ prof = json.loads((_SRC_ROOT[0] / v["profile"]).read_text("utf-8")) if v.get("profile") and _SRC_ROOT else {}
970
+ except Exception:
971
+ prof = {}
972
+ ap = str(((prof.get("canonical") or {}).get("age_perceived")) or "")
973
+ if re.fullmatch(r"\d+(\.\d+)?", ap) and float(ap) < ADULT:
974
+ rep.warnings.append(f"{label}: voice.{v['_id']} is perceived as {ap} years old in a preset with intimate content — check the voice belongs to this preset")
975
+ eff_perms = effective_permissions(manifest, pid) if pid else {**perms_all}
976
+ if pa is not None and pa < ADULT:
977
+ if by_kind(res, "appearance", "intimate"):
978
+ rep.errors.append(f"{label}: intimate variant combined with an element under {ADULT} (preset age {pa}) — forbidden")
979
+ for cat_, v in nude_vs:
980
+ rep.errors.append(f"{label}: {cat_}.{v['_id']} shows nudity in a preset under {ADULT} (preset age {pa:g}) — no nudity of any kind under {ADULT}")
981
+ for b in by_kind(res, "appearance", "body"):
982
+ if b.get("estimated"):
983
+ rep.errors.append(f"{label}: estimated morphology forbidden (age {pa})")
984
+ if eff_perms.get("allowSexualUsage"):
985
+ rep.errors.append(f"{label}: effective allowSexualUsage forbidden (preset age {pa}) — set it to false at file level or in permissions_override")