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/ops.py ADDED
@@ -0,0 +1,381 @@
1
+ """Merge several character directories into one (multi-identity), extract a preset into a lighter directory."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import shutil
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from .convert import ulid_like
11
+ from .model import preset_alternatives, resolve_preset_slots
12
+
13
+ NOW = lambda: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
14
+
15
+
16
+ def _ident_key(m: dict, ident: dict | None = None) -> tuple:
17
+ """Same character = same name, nickname, fictional status and morphology class (a real and a fictional
18
+ homonym, or two unrelated 'Marc' of different natures, stay distinct)."""
19
+ i = ident or m
20
+ return ((i.get("name") or "").strip().lower(), (i.get("nickname") or "").strip().lower(),
21
+ i.get("fictional") is not False, ((i.get("morphology") or {}).get("class") or "human"))
22
+
23
+
24
+ def _slug(s: str) -> str:
25
+ import unicodedata
26
+ s = unicodedata.normalize("NFKD", s or "x").encode("ascii", "ignore").decode()
27
+ return re.sub(r"[^a-z0-9-]+", "-", s.lower()).strip("-")[:40] or "x"
28
+
29
+
30
+ def _copy_tree_assets(src: Path, dst: Path, m: dict, prefix: str, taken_assets: dict[str, dict]) -> dict:
31
+ """Copy files of `m` from src to dst, renaming paths under a prefix to avoid collisions; returns id remap."""
32
+ aid_map = {}
33
+ for a in m.get("assets", []):
34
+ old_id = a["id"]
35
+ dup = taken_assets.get(a.get("sha256"))
36
+ if dup and a.get("sha256", "0" * 64) != "0" * 64:
37
+ aid_map[old_id] = dup["id"] # same bytes already present → reuse
38
+ continue
39
+ new_id = old_id if old_id not in {x["id"] for x in taken_assets.values()} else f"{prefix}-{old_id}"
40
+ a = dict(a); a["id"] = new_id
41
+ if a.get("path"):
42
+ newp = a["path"]
43
+ if (dst / newp).exists():
44
+ parts = newp.split("/"); parts[-1] = f"{prefix}-{parts[-1]}"; newp = "/".join(parts)
45
+ if (src / a["path"]).is_file():
46
+ (dst / newp).parent.mkdir(parents=True, exist_ok=True); shutil.copy(src / a["path"], dst / newp)
47
+ if a.get("transcript") and (src / a["transcript"]).is_file():
48
+ t = a["transcript"]
49
+ if (dst / t).exists():
50
+ parts = t.split("/"); parts[-1] = f"{prefix}-{parts[-1]}"; t = "/".join(parts)
51
+ (dst / t).parent.mkdir(parents=True, exist_ok=True); shutil.copy(src / a["transcript"], dst / t); a["transcript"] = t
52
+ a["path"] = newp
53
+ taken_assets[a.get("sha256") or new_id] = a
54
+ aid_map[old_id] = new_id
55
+ return aid_map
56
+
57
+
58
+ def _copy_variant_files(src: Path, dst: Path, v: dict, prefix: str):
59
+ for fld in ("card", "psyche", "story", "lorebook", "params", "code", "spec", "profile", "vec", "visemes", "path", "skeleton"):
60
+ rel = v.get(fld)
61
+ if isinstance(rel, str) and (src / rel).is_file():
62
+ newrel = rel
63
+ if (dst / rel).exists():
64
+ parts = rel.split("/"); parts[-1] = f"{prefix}-{parts[-1]}"; newrel = "/".join(parts)
65
+ (dst / newrel).parent.mkdir(parents=True, exist_ok=True); shutil.copy(src / rel, dst / newrel); v[fld] = newrel
66
+ if isinstance(v.get("mesh"), dict) and (src / v["mesh"]["path"]).is_file():
67
+ rel = v["mesh"]["path"]; (dst / rel).parent.mkdir(parents=True, exist_ok=True); shutil.copy(src / rel, dst / rel)
68
+
69
+
70
+ def merge_dirs(sources: list[Path], dst: Path) -> dict:
71
+ """Merge character directories. Same (name, nickname) ⇒ same identity; else additional identities."""
72
+ dst.mkdir(parents=True, exist_ok=True)
73
+ base = json.loads((sources[0] / "manifest.json").read_text("utf-8"))
74
+ out = json.loads(json.dumps(base))
75
+ out["elements"], out["presets"], out["assets"] = {}, {}, []
76
+ out["identities"] = {}
77
+ taken_assets: dict[str, dict] = {}
78
+ ids_by_key: dict[tuple[str, str], str] = {}
79
+ for n, src in enumerate(sources):
80
+ m = json.loads((src / "manifest.json").read_text("utf-8"))
81
+ prefix = f"m{n}"
82
+ # identities of this file
83
+ src_ids = m.get("identities") or {m.get("default_identity") or _slug(m.get("name")): {k: m.get(k) for k in ("name", "nickname", "summary", "fictional", "morphology") if m.get(k) is not None}}
84
+ id_map = {}
85
+ src_default = m.get("default_identity") or next(iter(src_ids))
86
+ for iid, ident in src_ids.items():
87
+ key = _ident_key(m, ident)
88
+ if key in ids_by_key:
89
+ id_map[iid] = ids_by_key[key]
90
+ else:
91
+ new_iid = iid if iid not in out["identities"] else f"{prefix}-{iid}"
92
+ ident = json.loads(json.dumps(ident))
93
+ # each character keeps ITS rights: the source file's rights travel with its characters
94
+ if not ident.get("rights") and m.get("rights") and json.dumps(m["rights"].get("permissions"), sort_keys=True) != json.dumps((base.get("rights") or {}).get("permissions"), sort_keys=True):
95
+ ident["rights"] = {"permissions": m["rights"]["permissions"]}
96
+ if iid == src_default and not ident.get("consent") and (m.get("rights") or {}).get("consent"):
97
+ ident["consent"] = m["rights"]["consent"]
98
+ out["identities"][new_iid] = ident; ids_by_key[key] = new_iid; id_map[iid] = new_iid
99
+ if n == 0:
100
+ out["default_identity"] = id_map.get(m.get("default_identity") or next(iter(src_ids)))
101
+ aid_map = _copy_tree_assets(src, dst, m, prefix, taken_assets)
102
+ for a in taken_assets.values(): # remap cross-asset references of this file's assets
103
+ for fld in ("source", "derived_from"):
104
+ if a.get(fld) in aid_map:
105
+ a[fld] = aid_map[a[fld]]
106
+ vid_map = {}
107
+ for cat, e in (m.get("elements") or {}).items():
108
+ for vid, v in (e.get("variants") or {}).items():
109
+ nv = json.loads(json.dumps(v))
110
+ new_vid = vid if vid not in out["elements"].get(cat, {"variants": {}})["variants"] else f"{prefix}-{vid}"
111
+ vid_map[(cat, vid)] = new_vid
112
+ for lst in ("images", "derived", "samples", "clips", "videos"):
113
+ if nv.get(lst):
114
+ nv[lst] = [aid_map.get(x, x) for x in nv[lst]]
115
+ if not v.get("derives_from"): # every element names its character (inherited by derived ones)
116
+ nv["identity"] = id_map.get(v.get("identity") or src_default, v.get("identity") or src_default)
117
+ elif nv.get("identity"):
118
+ nv["identity"] = id_map.get(nv["identity"], nv["identity"])
119
+ _copy_variant_files(src, dst, nv, prefix)
120
+ out["elements"].setdefault(cat, {"variants": {}})["variants"][new_vid] = nv
121
+ for cat, e in out["elements"].items(): # fix derives_from after renames
122
+ for vid, v in e["variants"].items():
123
+ if v.get("derives_from") and (cat, v["derives_from"]) in vid_map:
124
+ v["derives_from"] = vid_map[(cat, v["derives_from"])]
125
+ pid_map = {}
126
+ for pid, p in (m.get("presets") or {}).items():
127
+ np_ = json.loads(json.dumps(p))
128
+ new_pid = pid if pid not in out["presets"] else f"{prefix}-{pid}"
129
+ pid_map[pid] = new_pid
130
+ np_["slots"] = {cat: [vid_map.get((cat, x), x) for x in vids] for cat, vids in (p.get("slots") or {}).items()}
131
+ if p.get("alternatives"):
132
+ np_["alternatives"] = {cat: [vid_map.get((cat, x), x) for x in vids] for cat, vids in p["alternatives"].items()}
133
+ if p.get("primary_image"):
134
+ np_["primary_image"] = aid_map.get(p["primary_image"], p["primary_image"])
135
+ np_["identity"] = id_map.get(p.get("identity") or m.get("default_identity") or next(iter(src_ids)))
136
+ if pid == m.get("default_preset"):
137
+ for oi in {id_map.get(src_default)}:
138
+ out["identities"][oi].setdefault("default_preset", new_pid)
139
+ for iid0, ident0 in (m.get("identities") or {}).items():
140
+ if ident0.get("default_preset") == pid:
141
+ out["identities"][id_map[iid0]]["default_preset"] = new_pid
142
+ if np_.get("extends") and np_["extends"] in (m.get("presets") or {}) and np_["extends"] != pid:
143
+ np_["extends"] = np_["extends"] if np_["extends"] in out["presets"] else f"{prefix}-{np_['extends']}"
144
+ out["presets"][new_pid] = np_
145
+ if n == 0 and pid == m.get("default_preset"):
146
+ out["default_preset"] = new_pid
147
+ for tg in m.get("preset_tags") or []: # tags follow their presets; same id + same label ⇒ same tag
148
+ tl = out.setdefault("preset_tags", [])
149
+ pres = [pid_map.get(x, x) for x in tg.get("presets") or []]
150
+ same = next((x for x in tl if x["id"] == tg["id"] and x.get("label") == tg.get("label")), None)
151
+ if same:
152
+ same["presets"] = list(dict.fromkeys(same["presets"] + pres))
153
+ else:
154
+ tid = tg["id"] if all(x["id"] != tg["id"] for x in tl) else f"{prefix}-{tg['id']}"
155
+ tl.append({**tg, "id": tid, "presets": pres})
156
+ out["assets"] = list({a["id"]: a for a in taken_assets.values()}.values())
157
+ out["id"] = ulid_like()
158
+ di = out["identities"][out["default_identity"]]
159
+ for k in ("name", "nickname", "summary", "fictional", "morphology"):
160
+ if di.get(k) is not None:
161
+ out[k] = di[k]
162
+ out.setdefault("provenance", {})["modified"] = NOW()
163
+ out["provenance"]["source"] = sorted({s for src in sources for s in (json.loads((src / "manifest.json").read_text("utf-8")).get("provenance", {}).get("source") or [])} | {f"cof:{json.loads((src / 'manifest.json').read_text('utf-8')).get('id')}" for src in sources})
164
+ out["provenance"]["generator"] = "cof-cli merge"
165
+ out.pop("completeness", None); out.pop("coverage", None); out.pop("thumbnail", None)
166
+ _relocate_files(dst, out)
167
+ (dst / "manifest.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", "utf-8")
168
+ return out
169
+
170
+
171
+ def _relocate_files(dst: Path, out: dict) -> None:
172
+ """After a merge, move each file used by a single element into that element's own folder
173
+ (elements/<category>/<variant>/), dropping the collision prefix — a renamed variant keeps its files together."""
174
+ import re
175
+ from .model import variant_asset_ids
176
+ users: dict[str, list[tuple[str, str]]] = {}
177
+ for cat, e in (out.get("elements") or {}).items():
178
+ for vid, v in (e.get("variants") or {}).items():
179
+ for aid in variant_asset_ids(v):
180
+ users.setdefault(aid, []).append((cat, vid))
181
+ for a in out.get("assets") or []:
182
+ u = users.get(a["id"]) or []
183
+ if len(u) != 1 or not a.get("path", "").startswith("elements/"):
184
+ continue
185
+ cat, vid = u[0]
186
+ parts = a["path"].split("/")
187
+ if len(parts) < 4 or parts[1] != cat or parts[2] == vid:
188
+ continue
189
+ for fld in ("path", "transcript"):
190
+ old = a.get(fld)
191
+ if not old or not (dst / old).is_file():
192
+ continue
193
+ base = re.sub(r"^m\d+-", "", old.split("/")[-1])
194
+ new = "/".join(["elements", cat, vid, *old.split("/")[3:-1], base])
195
+ k = 2
196
+ while (dst / new).exists():
197
+ stem, dot, ext = base.rpartition(".")
198
+ new = "/".join(["elements", cat, vid, *old.split("/")[3:-1], f"{stem}.{k}.{ext}" if dot else f"{base}.{k}"]); k += 1
199
+ (dst / new).parent.mkdir(parents=True, exist_ok=True)
200
+ shutil.move(str(dst / old), str(dst / new))
201
+ a[fld] = new
202
+
203
+
204
+ def extract_preset(src: Path, dst: Path, pid: str) -> dict:
205
+ """Keep only what preset `pid` references (variants, their parents, assets, identity)."""
206
+ return extract_presets(src, dst, [pid])
207
+
208
+
209
+ def extract_identity(src: Path, dst: Path, iid: str) -> dict:
210
+ """Export one character: all its presets, its elements, their files, its rights."""
211
+ from .model import preset_owner
212
+ m = json.loads((src / "manifest.json").read_text("utf-8"))
213
+ if iid not in (m.get("identities") or {}) and iid != m.get("default_identity"):
214
+ raise ValueError(f"unknown character: {iid}")
215
+ pids = [p for p in (m.get("presets") or {}) if preset_owner(m, p) == iid]
216
+ if not pids:
217
+ raise ValueError(f"character {iid} has no preset")
218
+ dp = ((m.get("identities") or {}).get(iid) or {}).get("default_preset")
219
+ if dp in pids:
220
+ pids.remove(dp); pids.insert(0, dp)
221
+ out = extract_presets(src, dst, pids)
222
+ out["default_preset"] = pids[0]
223
+ (dst / "manifest.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", "utf-8")
224
+ return out
225
+
226
+
227
+ def extract_tag(src: Path, dst: Path, tids: list[str]) -> dict:
228
+ """Keep the presets carrying ALL the given tags (intersection), and what they reference."""
229
+ m = json.loads((src / "manifest.json").read_text("utf-8"))
230
+ T = {t.get("id"): t for t in m.get("preset_tags") or []}
231
+ for tid in tids:
232
+ if tid not in T:
233
+ raise ValueError(f"unknown tag: {tid}")
234
+ pids = [p for p in (m.get("presets") or {}) if all(p in (T[t].get("presets") or []) for t in tids)]
235
+ if not pids:
236
+ raise ValueError("no preset carries " + " + ".join(tids))
237
+ return extract_presets(src, dst, pids, tags=tids)
238
+
239
+
240
+ def extract_presets(src: Path, dst: Path, pids: list[str], tags: list[str] | None = None) -> dict:
241
+ m = json.loads((src / "manifest.json").read_text("utf-8"))
242
+ for pid in pids:
243
+ if pid not in (m.get("presets") or {}):
244
+ raise ValueError(f"unknown preset: {pid}")
245
+ dst.mkdir(parents=True, exist_ok=True)
246
+ sel = set(pids)
247
+ keep_v: set[tuple[str, str]] = set()
248
+ out_presets = {}
249
+ for pid in pids:
250
+ p = json.loads(json.dumps(m["presets"][pid]))
251
+ slots = resolve_preset_slots(m, pid)
252
+ alts = preset_alternatives(m, pid)
253
+ if p.get("extends") in sel: # parent kept: keep inheritance as is
254
+ own = p.get("slots") or {}
255
+ else: # parent dropped: flatten (slots, alternatives, primary image)
256
+ own = slots; p.pop("extends", None)
257
+ if alts:
258
+ p["alternatives"] = alts
259
+ q, seen = pid, set() # an explicit primary image inherited from a dropped parent is copied
260
+ while q in (m.get("presets") or {}) and q not in seen and not p.get("primary_image"):
261
+ seen.add(q)
262
+ p["primary_image"] = m["presets"][q].get("primary_image")
263
+ q = m["presets"][q].get("extends")
264
+ if not p.get("primary_image"):
265
+ p.pop("primary_image", None)
266
+ p["slots"] = own
267
+ out_presets[pid] = p
268
+ for cat, vids in [*slots.items(), *alts.items()]:
269
+ for vid in vids:
270
+ cur = vid
271
+ while cur and (cat, cur) not in keep_v:
272
+ keep_v.add((cat, cur))
273
+ cur = (m["elements"].get(cat, {}).get("variants", {}).get(cur) or {}).get("derives_from")
274
+ out = json.loads(json.dumps(m))
275
+ out["elements"] = {}
276
+ keep_assets: set[str] = set()
277
+ for cat, vid in keep_v:
278
+ v = m["elements"][cat]["variants"][vid]
279
+ out["elements"].setdefault(cat, {"variants": {}})["variants"][vid] = v
280
+ for lst in ("images", "derived", "samples", "clips", "videos", "files"):
281
+ keep_assets |= set(v.get(lst) or [])
282
+ if isinstance(v.get("mesh"), dict):
283
+ keep_assets |= set(v["mesh"].get("textures") or [])
284
+ if cat == "identity_weights" and v.get("spec") and (src / v["spec"]).is_file():
285
+ w = json.loads((src / v["spec"]).read_text("utf-8"))
286
+ if w.get("file"):
287
+ keep_assets.add(w["file"])
288
+ _copy_variant_files(src, dst, v, "x")
289
+ out["presets"] = out_presets
290
+ out["default_preset"] = m.get("default_preset") if m.get("default_preset") in sel else pids[0]
291
+ kept = [] # tags are kept, restricted to the exported presets
292
+ for tg in m.get("preset_tags") or []:
293
+ pres = [p for p in tg.get("presets") or [] if p in sel]
294
+ if pres:
295
+ kept.append({**tg, "presets": pres})
296
+ if kept:
297
+ out["preset_tags"] = kept
298
+ else:
299
+ out.pop("preset_tags", None)
300
+ idents = {out_presets[p].get("identity") or m.get("default_identity") for p in pids}
301
+ if m.get("identities"):
302
+ out["identities"] = {i: m["identities"][i] for i in idents if i in m["identities"]}
303
+ dp_ident = out_presets[out["default_preset"]].get("identity") or m.get("default_identity")
304
+ if dp_ident in out["identities"]:
305
+ out["default_identity"] = dp_ident
306
+ for k in ("name", "nickname", "summary", "fictional", "morphology"):
307
+ if m["identities"][dp_ident].get(k) is not None:
308
+ out[k] = m["identities"][dp_ident][k]
309
+ if len(out["identities"]) == 1: # one character left: its rights become the file's
310
+ only = next(iter(out["identities"].values()))
311
+ if only.get("rights"):
312
+ out["rights"] = {**(out.get("rights") or {}), **only["rights"]}
313
+ if only.get("consent"):
314
+ out.setdefault("rights", {})["consent"] = only["consent"]
315
+ for iid, ident in out["identities"].items():
316
+ if ident.get("default_preset") and ident["default_preset"] not in sel:
317
+ ident.pop("default_preset")
318
+ out["assets"] = []
319
+ for a in m.get("assets", []):
320
+ if a["id"] in keep_assets or a.get("source") in keep_assets:
321
+ out["assets"].append(a)
322
+ for fld in ("path", "transcript"):
323
+ if a.get(fld) and (src / a[fld]).is_file():
324
+ (dst / a[fld]).parent.mkdir(parents=True, exist_ok=True); shutil.copy(src / a[fld], dst / a[fld])
325
+ for d in ("rights",):
326
+ if (src / d).is_dir():
327
+ shutil.copytree(src / d, dst / d, dirs_exist_ok=True)
328
+ out["id"] = ulid_like()
329
+ tag = ("#tags=" + "+".join(tags)) if tags else f"#preset={pids[0]}"
330
+ out.setdefault("provenance", {})["source"] = list(dict.fromkeys((m.get("provenance", {}).get("source") or []) + [f"cof:{m.get('id')}{tag}"]))
331
+ out["provenance"]["modified"] = NOW(); out["provenance"]["generator"] = "cof-cli extract"
332
+ for k in ("completeness", "coverage", "thumbnail"):
333
+ out.pop(k, None)
334
+ (dst / "manifest.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", "utf-8")
335
+ return out
336
+
337
+
338
+ def strip_intimate(dst: Path) -> list[str]:
339
+ """Safe-for-work slice: remove every `intimate` variant (and the files only it used), drop it from presets,
340
+ and set allowSexualUsage to false everywhere (root, characters, presets). Returns the removed variant ids."""
341
+ from .model import variant_asset_ids
342
+ m = json.loads((dst / "manifest.json").read_text("utf-8"))
343
+ V = ((m.get("elements") or {}).get("appearance") or {}).get("variants") or {}
344
+ gone = [vid for vid, v in V.items() if v.get("kind") == "intimate" or v.get("nudity") is True]
345
+ extra_freed, extra_gone = set(), []
346
+ for cat, e in (m.get("elements") or {}).items(): # nude variants of other categories (posture photos…)
347
+ if cat == "appearance":
348
+ continue
349
+ for vid in [k for k, v in ((e or {}).get("variants") or {}).items() if (v or {}).get("nudity") is True]:
350
+ extra_freed |= variant_asset_ids(e["variants"].pop(vid)); extra_gone.append(f"{cat}.{vid}")
351
+ for p in (m.get("presets") or {}).values():
352
+ for key in ("slots", "alternatives"):
353
+ if (p.get(key) or {}).get(cat):
354
+ p[key][cat] = [x for x in p[key][cat] if x != vid]
355
+ freed = set(extra_freed)
356
+ for vid in gone:
357
+ freed |= variant_asset_ids(V.pop(vid))
358
+ still = {aid for e in (m.get("elements") or {}).values() for v in (e.get("variants") or {}).values() for aid in variant_asset_ids(v)}
359
+ for a in list(m.get("assets") or []):
360
+ if a["id"] in freed - still or a.get("source") in freed - still:
361
+ if a.get("path") and (dst / a["path"]).is_file():
362
+ (dst / a["path"]).unlink()
363
+ m["assets"].remove(a)
364
+ for p in (m.get("presets") or {}).values():
365
+ for key in ("slots", "alternatives"):
366
+ if (p.get(key) or {}).get("appearance"):
367
+ p[key]["appearance"] = [x for x in p[key]["appearance"] if x not in gone]
368
+ if (p.get("permissions_override") or {}).get("allowSexualUsage"):
369
+ p["permissions_override"]["allowSexualUsage"] = False
370
+ if p.get("primary_image") and p["primary_image"] in freed - still:
371
+ p.pop("primary_image")
372
+ for r in [m.get("rights") or {}] + [(i or {}).get("rights") or {} for i in (m.get("identities") or {}).values()]:
373
+ if "permissions" in r:
374
+ r["permissions"]["allowSexualUsage"] = False
375
+ tc = (m.get("tags") or {}).get("content")
376
+ if (gone or extra_gone) and isinstance(tc, list) and "nudity" in tc: # images gone; `sexual` stays when set (texts may still be concerned)
377
+ tc.remove("nudity")
378
+ if not tc:
379
+ tc.append("none")
380
+ (dst / "manifest.json").write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", "utf-8")
381
+ return gone
@@ -0,0 +1,248 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://yannsouetre.github.io/cof/schemas/1.0/attitude.schema.json",
4
+ "title": "elements/{posture|motion}/<v>/attitude.json — motion signature (LMA Effort), tempo, postures, gestures",
5
+ "type": "object",
6
+ "additionalProperties": true,
7
+ "properties": {
8
+ "notation": {
9
+ "const": "LMA-Effort-1.0"
10
+ },
11
+ "effort": {
12
+ "type": "object",
13
+ "properties": {
14
+ "space": {
15
+ "type": "number",
16
+ "minimum": -1,
17
+ "maximum": 1
18
+ },
19
+ "weight": {
20
+ "type": "number",
21
+ "minimum": -1,
22
+ "maximum": 1
23
+ },
24
+ "time": {
25
+ "type": "number",
26
+ "minimum": -1,
27
+ "maximum": 1
28
+ },
29
+ "flow": {
30
+ "type": "number",
31
+ "minimum": -1,
32
+ "maximum": 1
33
+ },
34
+ "dominant_action": {
35
+ "enum": [
36
+ "float",
37
+ "punch",
38
+ "glide",
39
+ "slash",
40
+ "dab",
41
+ "wring",
42
+ "flick",
43
+ "press"
44
+ ],
45
+ "description": "Laban's 8 effort actions"
46
+ }
47
+ },
48
+ "description": "axes −1…+1: space indirect→direct, weight light→strong, time sustained→sudden, flow free→bound"
49
+ },
50
+ "shape": {
51
+ "type": "array",
52
+ "items": {
53
+ "enum": [
54
+ "rising",
55
+ "sinking",
56
+ "spreading",
57
+ "enclosing",
58
+ "advancing",
59
+ "retreating"
60
+ ]
61
+ }
62
+ },
63
+ "tempo": {
64
+ "type": "object",
65
+ "properties": {
66
+ "walk_speed": {
67
+ "enum": [
68
+ "very-slow",
69
+ "slow",
70
+ "medium",
71
+ "fast",
72
+ "very-fast"
73
+ ]
74
+ },
75
+ "gesture_rate": {
76
+ "enum": [
77
+ "very-slow",
78
+ "slow",
79
+ "medium",
80
+ "fast",
81
+ "very-fast"
82
+ ]
83
+ },
84
+ "stillness": {
85
+ "enum": [
86
+ "low",
87
+ "medium",
88
+ "high"
89
+ ]
90
+ }
91
+ }
92
+ },
93
+ "posture": {
94
+ "type": "object",
95
+ "properties": {
96
+ "default": {
97
+ "type": "string"
98
+ },
99
+ "seated": {
100
+ "type": "string"
101
+ },
102
+ "listening": {
103
+ "type": "string"
104
+ },
105
+ "speaking": {
106
+ "type": "string"
107
+ },
108
+ "bap": {
109
+ "type": "array",
110
+ "items": {
111
+ "type": "string"
112
+ },
113
+ "description": "Body Action and Posture codes (Dael et al.)"
114
+ }
115
+ }
116
+ },
117
+ "gestures": {
118
+ "type": "array",
119
+ "items": {
120
+ "type": "object",
121
+ "required": [
122
+ "id"
123
+ ],
124
+ "properties": {
125
+ "id": {
126
+ "type": "string",
127
+ "pattern": "^[a-z]+:[A-Za-z0-9._-]+$",
128
+ "description": "referenceable identifier (trait:…, goal:…, fear:…, gesture:…)"
129
+ },
130
+ "lexeme": {
131
+ "type": "string",
132
+ "description": "BML lexeme (beat, iconic, deictic, metaphoric, emblem…) or free-form name"
133
+ },
134
+ "text": {
135
+ "type": "string"
136
+ },
137
+ "expresses": {
138
+ "type": "array",
139
+ "items": {
140
+ "type": "string",
141
+ "pattern": "^[a-z]+:[A-Za-z0-9._-]+$",
142
+ "description": "referenceable identifier (trait:…, goal:…, fear:…, gesture:…)"
143
+ }
144
+ },
145
+ "frequency": {
146
+ "enum": [
147
+ "rare",
148
+ "occasional",
149
+ "frequent",
150
+ "constant"
151
+ ]
152
+ },
153
+ "pose": {
154
+ "type": "string",
155
+ "pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+$"
156
+ }
157
+ }
158
+ }
159
+ },
160
+ "gaze": {
161
+ "type": "object",
162
+ "properties": {
163
+ "contact": {
164
+ "enum": [
165
+ "avoidant",
166
+ "rare",
167
+ "regular",
168
+ "intense"
169
+ ]
170
+ },
171
+ "when_thinking": {
172
+ "type": "string"
173
+ }
174
+ }
175
+ },
176
+ "proxemics": {
177
+ "type": "object",
178
+ "properties": {
179
+ "preferred_distance_m": {
180
+ "type": "number",
181
+ "minimum": 0,
182
+ "maximum": 10
183
+ },
184
+ "touch": {
185
+ "enum": [
186
+ "none",
187
+ "rare",
188
+ "warm",
189
+ "frequent"
190
+ ]
191
+ }
192
+ }
193
+ },
194
+ "gait": {
195
+ "type": "object",
196
+ "properties": {
197
+ "style": {
198
+ "type": "string"
199
+ },
200
+ "labels": {
201
+ "type": "array",
202
+ "items": {
203
+ "type": "string"
204
+ },
205
+ "description": "100STYLE / BABEL / Mixamo labels"
206
+ }
207
+ }
208
+ },
209
+ "poses": {
210
+ "type": "array",
211
+ "items": {
212
+ "type": "object",
213
+ "required": [
214
+ "name",
215
+ "path"
216
+ ],
217
+ "properties": {
218
+ "name": {
219
+ "type": "string"
220
+ },
221
+ "path": {
222
+ "type": "string",
223
+ "pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+$"
224
+ },
225
+ "render": {
226
+ "type": "string",
227
+ "pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+$"
228
+ }
229
+ }
230
+ },
231
+ "description": "keypoint postures (pose.schema.json) with optional \"stick figure\" rendering"
232
+ },
233
+ "renderings": {
234
+ "type": "object",
235
+ "properties": {
236
+ "sentence": {
237
+ "type": "string"
238
+ },
239
+ "prompt_fragment": {
240
+ "type": "string"
241
+ }
242
+ }
243
+ }
244
+ },
245
+ "required": [
246
+ "notation"
247
+ ]
248
+ }