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/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
|
+
}
|