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/migrate.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""v0.2 → v0.3 migration: sections/appearance/… → elements/<type>/<v1>/…, implicit preset 'default', asset ids."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _aid(path: str) -> str:
|
|
11
|
+
return "a-" + re.sub(r"[^a-z0-9-]+", "-", path.lower().rsplit("/", 1)[-1].rsplit(".", 1)[0]).strip("-")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def migrate_dir(src: Path) -> dict:
|
|
15
|
+
m = json.loads((src / "manifest.json").read_text("utf-8"))
|
|
16
|
+
if not str(m.get("cof", "")).startswith("0.2"):
|
|
17
|
+
raise ValueError("this manifest is not v0.2")
|
|
18
|
+
sec = m.pop("sections", {}) or {}
|
|
19
|
+
comp = m.pop("completeness", None)
|
|
20
|
+
assets = m.get("assets", [])
|
|
21
|
+
elements, slots = {}, {}
|
|
22
|
+
|
|
23
|
+
def move(rel: str, newrel: str):
|
|
24
|
+
s, d = src / rel, src / newrel
|
|
25
|
+
if s.is_file():
|
|
26
|
+
d.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
shutil.move(str(s), str(d))
|
|
28
|
+
for a in assets:
|
|
29
|
+
if a.get("path") == rel:
|
|
30
|
+
a["path"] = newrel
|
|
31
|
+
if a.get("transcript") == rel:
|
|
32
|
+
a["transcript"] = newrel
|
|
33
|
+
return newrel
|
|
34
|
+
|
|
35
|
+
# ids on assets
|
|
36
|
+
for a in assets:
|
|
37
|
+
a.setdefault("id", _aid(a.get("path") or a.get("uri") or "asset"))
|
|
38
|
+
ids = {}
|
|
39
|
+
for a in assets:
|
|
40
|
+
base = a["id"]; n = 1
|
|
41
|
+
while a["id"] in ids:
|
|
42
|
+
n += 1; a["id"] = f"{base}-{n}"
|
|
43
|
+
ids[a["id"]] = a
|
|
44
|
+
|
|
45
|
+
# personality
|
|
46
|
+
if sec.get("character"):
|
|
47
|
+
v = {"label": m.get("name"), "card": move(sec["character"], "elements/personality/p1/card.json")}
|
|
48
|
+
if sec.get("psyche"):
|
|
49
|
+
v["psyche"] = move(sec["psyche"], "elements/personality/p1/psyche.json")
|
|
50
|
+
if sec.get("story"):
|
|
51
|
+
v["story"] = move(sec["story"], "elements/personality/p1/story.md")
|
|
52
|
+
if sec.get("lorebook"):
|
|
53
|
+
v["lorebook"] = move(sec["lorebook"], "elements/personality/p1/lorebook.json")
|
|
54
|
+
if m.get("profiles"):
|
|
55
|
+
v["profiles"] = m.pop("profiles")
|
|
56
|
+
elements["personality"] = {"variants": {"p1": v}}
|
|
57
|
+
slots["personality"] = "p1"
|
|
58
|
+
|
|
59
|
+
# appearance → face / body (+ hair/outfit when files exist)
|
|
60
|
+
app = sec.get("appearance", {}) or {}
|
|
61
|
+
views = [a for a in assets if a.get("role") in ("view", "expression", "reference")]
|
|
62
|
+
head = [a for a in views if a.get("subject") == "head"]
|
|
63
|
+
body = [a for a in views if a.get("subject") != "head"]
|
|
64
|
+
for a in head:
|
|
65
|
+
a["path"] = move(a["path"], "elements/face/f1/" + a["path"].rsplit("/", 1)[-1])
|
|
66
|
+
for a in body:
|
|
67
|
+
a["path"] = move(a["path"], "elements/body/b1/" + a["path"].rsplit("/", 1)[-1])
|
|
68
|
+
if head or app.get("face"):
|
|
69
|
+
f = {"label": "Face", "images": [a["id"] for a in head]}
|
|
70
|
+
if app.get("face"):
|
|
71
|
+
f["params"] = move(app["face"], "elements/face/f1/face.json")
|
|
72
|
+
elements["face"] = {"variants": {"f1": f}}; slots["face"] = "f1"
|
|
73
|
+
if body or app.get("body"):
|
|
74
|
+
b = {"label": "Body", "images": [a["id"] for a in body]}
|
|
75
|
+
if app.get("body"):
|
|
76
|
+
b["params"] = move(app["body"], "elements/body/b1/body.json")
|
|
77
|
+
elements["body"] = {"variants": {"b1": b}}; slots["body"] = "b1"
|
|
78
|
+
if app.get("hair"):
|
|
79
|
+
elements["hair"] = {"variants": {"h1": {"label": "Hair", "code": move(app["hair"], "elements/hair/h1/hair.json")}}}; slots["hair"] = "h1"
|
|
80
|
+
for i, o in enumerate(app.get("outfits", []) or [], 1):
|
|
81
|
+
elements.setdefault("outfit", {"variants": {}})["variants"][f"o{i}"] = {"label": f"Outfit {i}", "spec": move(o, f"elements/outfit/o{i}/outfit.json")}
|
|
82
|
+
slots.setdefault("outfit", f"o{i}")
|
|
83
|
+
|
|
84
|
+
# derived_from: path → asset id
|
|
85
|
+
by_path_tail = {}
|
|
86
|
+
for a in assets:
|
|
87
|
+
if a.get("path"):
|
|
88
|
+
by_path_tail[a["path"].rsplit("/", 1)[-1]] = a["id"]
|
|
89
|
+
for a in assets:
|
|
90
|
+
df = a.get("derived_from")
|
|
91
|
+
if isinstance(df, str) and "/" in df:
|
|
92
|
+
a["derived_from"] = by_path_tail.get(df.rsplit("/", 1)[-1], a["id"])
|
|
93
|
+
|
|
94
|
+
# line maps → derived
|
|
95
|
+
derived_ids = []
|
|
96
|
+
for a in assets:
|
|
97
|
+
if a.get("role") == "line-map":
|
|
98
|
+
a["role"] = "derived"
|
|
99
|
+
src_view = a.pop("source_view", None)
|
|
100
|
+
a["source"] = next((x["id"] for x in assets if x.get("path") == src_view or x.get("path", "").endswith("/" + (src_view or "").rsplit("/", 1)[-1])), a.get("id"))
|
|
101
|
+
etype = "face" if "head." in a["path"] else "body"
|
|
102
|
+
a["path"] = move(a["path"], f"derived/{etype}/{'f1' if etype == 'face' else 'b1'}/" + a["path"].rsplit("/", 1)[-1])
|
|
103
|
+
derived_ids.append((etype, a["id"]))
|
|
104
|
+
for etype, aid in derived_ids:
|
|
105
|
+
vid = "f1" if etype == "face" else "b1"
|
|
106
|
+
if etype in elements:
|
|
107
|
+
elements[etype]["variants"][vid].setdefault("derived", []).append(aid)
|
|
108
|
+
|
|
109
|
+
# voice / attitude / avatar / motion
|
|
110
|
+
if sec.get("voice"):
|
|
111
|
+
v = {"label": "Voice", "profile": move(sec["voice"], "elements/voice/v1/profile.json"),
|
|
112
|
+
"samples": [a["id"] for a in assets if a.get("role") == "voice-sample"]}
|
|
113
|
+
if sec.get("voice_vec"):
|
|
114
|
+
v["vec"] = move(sec["voice_vec"], "elements/voice/v1/voice.vec.json")
|
|
115
|
+
elements["voice"] = {"variants": {"v1": v}}; slots["voice"] = "v1"
|
|
116
|
+
if sec.get("physique"):
|
|
117
|
+
elements["attitude"] = {"variants": {"m1": {"spec": move(sec["physique"], "elements/attitude/m1/attitude.json")}}}; slots["attitude"] = "m1"
|
|
118
|
+
vol = sec.get("volume", {}) or {}
|
|
119
|
+
if vol.get("avatar"):
|
|
120
|
+
elements["avatar"] = {"variants": {"av1": {"path": move(vol["avatar"], "elements/avatar/av1/" + vol["avatar"].rsplit("/", 1)[-1]),
|
|
121
|
+
"rig": m.get("mapping_vocabularies", {}).get("skeleton", "VRMC_vrm-1.0")}}}; slots["avatar"] = "av1"
|
|
122
|
+
mot = sec.get("motion", {}) or {}
|
|
123
|
+
clips = [a["id"] for a in assets if a.get("role") == "motion-clip"]
|
|
124
|
+
if clips or mot.get("visemes"):
|
|
125
|
+
mo = {"clips": clips}
|
|
126
|
+
if mot.get("visemes"):
|
|
127
|
+
mo["visemes"] = move(mot["visemes"], "elements/motion/mo1/visemes.json")
|
|
128
|
+
elements["motion"] = {"variants": {"mo1": mo}}; slots["motion"] = "mo1"
|
|
129
|
+
|
|
130
|
+
# remove now-empty v0.2 dirs
|
|
131
|
+
for d in ("character", "appearance", "voice", "physique", "volume", "motion"):
|
|
132
|
+
p = src / d
|
|
133
|
+
if p.is_dir() and not any(p.rglob("*")):
|
|
134
|
+
shutil.rmtree(p)
|
|
135
|
+
elif p.is_dir():
|
|
136
|
+
for sub in sorted(p.rglob("*"), reverse=True):
|
|
137
|
+
if sub.is_dir() and not any(sub.iterdir()):
|
|
138
|
+
sub.rmdir()
|
|
139
|
+
if not any(p.rglob("*")):
|
|
140
|
+
shutil.rmtree(p)
|
|
141
|
+
|
|
142
|
+
m["cof"] = "0.3"
|
|
143
|
+
m["elements"] = elements
|
|
144
|
+
m["presets"] = {"default": {"label": "Default", "slots": slots}}
|
|
145
|
+
m["default_preset"] = "default"
|
|
146
|
+
m.setdefault("priorities", {"visual": ["identity_weights", "images", "avatar", "descriptive"], "voice": ["samples", "described"]})
|
|
147
|
+
m.pop("injection_order", None)
|
|
148
|
+
m["tags"]["auto"] = [t for t in m.get("tags", {}).get("auto", []) if t not in ("line-maps", "vector-face")]
|
|
149
|
+
# reorder keys for readability
|
|
150
|
+
order = ["cof", "id", "name", "nickname", "age", "languages", "summary", "fictional", "morphology", "tags", "thumbnail",
|
|
151
|
+
"elements", "presets", "default_preset", "priorities", "completeness", "coverage", "mapping_vocabularies",
|
|
152
|
+
"assets", "size_class", "container", "rights", "provenance", "metadata", "extensions"]
|
|
153
|
+
m = {k: m[k] for k in order if k in m} | {k: v for k, v in m.items() if k not in order}
|
|
154
|
+
(src / "manifest.json").write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
|
155
|
+
return m
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------- v0.3 → v0.4 : categories + kind, multi-slots ----------------
|
|
159
|
+
|
|
160
|
+
V3_TO_V4 = { # old element type → (category, kind, extra)
|
|
161
|
+
"personality": ("personality", "card", {}), "face": ("appearance", "face", {}), "hair": ("appearance", "hair", {}),
|
|
162
|
+
"facial_hair": ("appearance", "pilosity", {"area": "face"}), "body": ("appearance", "body", {}), "body_hair": ("appearance", "pilosity", {"area": "body"}),
|
|
163
|
+
"intimate": ("appearance", "intimate", {}), "outfit": ("appearance", "clothing", {"garment": "full-outfit"}), "accessories": ("appearance", "accessory", {}),
|
|
164
|
+
"identity_weights": ("identity_weights", "lora", {}), "voice": ("voice", "samples", {}), "attitude": ("posture", "attitude", {}),
|
|
165
|
+
"avatar": ("volume3d", "mesh", {}), "motion": ("motion", "clips", {}),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def migrate_v3_to_v4(src: Path) -> dict:
|
|
170
|
+
m = json.loads((src / "manifest.json").read_text("utf-8"))
|
|
171
|
+
if str(m.get("cof", "")) != "0.3":
|
|
172
|
+
raise ValueError("this manifest is not v0.3")
|
|
173
|
+
old = m.get("elements", {}) or {}
|
|
174
|
+
new: dict = {}
|
|
175
|
+
remap: dict[tuple[str, str], tuple[str, str]] = {}
|
|
176
|
+
for etype, e in old.items():
|
|
177
|
+
if etype not in V3_TO_V4:
|
|
178
|
+
continue
|
|
179
|
+
cat, kind, extra = V3_TO_V4[etype]
|
|
180
|
+
for vid, v in (e.get("variants") or {}).items():
|
|
181
|
+
nv = {"kind": kind, **extra, **v}
|
|
182
|
+
if etype == "personality" and nv.get("card") is None:
|
|
183
|
+
nv["kind"] = "description"
|
|
184
|
+
if etype == "voice" and not nv.get("samples"):
|
|
185
|
+
nv["kind"] = "described"
|
|
186
|
+
if etype == "identity_weights":
|
|
187
|
+
try:
|
|
188
|
+
w = json.loads((src / nv["spec"]).read_text("utf-8")) if nv.get("spec") else {}
|
|
189
|
+
nv["kind"] = w.get("kind", "lora"); nv["covers"] = w.get("covers", [])
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
# move files to the new folder
|
|
193
|
+
for fld in ("card", "psyche", "story", "lorebook", "params", "code", "spec", "profile", "vec", "visemes", "path", "skeleton"):
|
|
194
|
+
rel = nv.get(fld)
|
|
195
|
+
if isinstance(rel, str) and rel.startswith(f"elements/{etype}/"):
|
|
196
|
+
newrel = rel.replace(f"elements/{etype}/", f"elements/{cat}/", 1)
|
|
197
|
+
s_, d_ = src / rel, src / newrel
|
|
198
|
+
if s_.is_file():
|
|
199
|
+
d_.parent.mkdir(parents=True, exist_ok=True); shutil.move(str(s_), str(d_))
|
|
200
|
+
nv[fld] = newrel
|
|
201
|
+
new.setdefault(cat, {"variants": {}})["variants"][vid] = nv
|
|
202
|
+
remap[(etype, vid)] = (cat, vid)
|
|
203
|
+
# asset paths under elements/<old>/ → elements/<cat>/
|
|
204
|
+
for a in m.get("assets", []):
|
|
205
|
+
for fld in ("path", "transcript"):
|
|
206
|
+
rel = a.get(fld)
|
|
207
|
+
if isinstance(rel, str) and rel.startswith("elements/"):
|
|
208
|
+
parts = rel.split("/")
|
|
209
|
+
if parts[1] in V3_TO_V4:
|
|
210
|
+
newrel = "/".join([parts[0], V3_TO_V4[parts[1]][0]] + parts[2:])
|
|
211
|
+
s_, d_ = src / rel, src / newrel
|
|
212
|
+
if s_.is_file():
|
|
213
|
+
d_.parent.mkdir(parents=True, exist_ok=True); shutil.move(str(s_), str(d_))
|
|
214
|
+
a[fld] = newrel
|
|
215
|
+
for d in list((src / "elements").glob("*")) if (src / "elements").is_dir() else []:
|
|
216
|
+
if d.is_dir() and not any(d.rglob("*")):
|
|
217
|
+
shutil.rmtree(d)
|
|
218
|
+
# presets: slots → lists per category
|
|
219
|
+
for pid, p in (m.get("presets") or {}).items():
|
|
220
|
+
slots: dict[str, list] = {}
|
|
221
|
+
for etype, vid in (p.get("slots") or {}).items():
|
|
222
|
+
cat = V3_TO_V4.get(etype, (etype,))[0]
|
|
223
|
+
slots.setdefault(cat, []).append(vid)
|
|
224
|
+
p["slots"] = slots
|
|
225
|
+
m["cof"] = "0.4"; m["elements"] = new
|
|
226
|
+
if m.get("morphology", {}).get("class") == "humanoid" and (m["morphology"].get("species") or "human") == "human":
|
|
227
|
+
m["morphology"]["class"] = "human"
|
|
228
|
+
(src / "manifest.json").write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
|
229
|
+
return m
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
AGE_KINDS_V5 = {"face", "body", "character-sheet", "reference-set", "intimate", "hair", "pilosity"}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def migrate_v4_to_v5(src: Path) -> dict:
|
|
236
|
+
"""v0.5: age leaves the identity. Identity ages become (a) `age` on the morphological variants that lack one
|
|
237
|
+
(reserved variants take their identity's age, shared ones the default identity's) and (b) `age_override` on
|
|
238
|
+
presets without one (floor for text-only presets). Intimate variants under 18 are NOT fixed: validation will fail."""
|
|
239
|
+
m = json.loads((src / "manifest.json").read_text("utf-8"))
|
|
240
|
+
if str(m.get("cof", "")) != "0.4":
|
|
241
|
+
raise ValueError("this manifest is not v0.4")
|
|
242
|
+
ids = m.get("identities") or {}
|
|
243
|
+
root_age = m.pop("age", None)
|
|
244
|
+
id_ages = {iid: i.pop("age", None) for iid, i in ids.items()}
|
|
245
|
+
default_age = id_ages.get(m.get("default_identity")) or root_age
|
|
246
|
+
for cat in ("appearance", "volume3d", "identity_weights"):
|
|
247
|
+
for vid, v in (((m.get("elements") or {}).get(cat) or {}).get("variants") or {}).items():
|
|
248
|
+
if cat == "appearance" and v.get("kind") not in AGE_KINDS_V5:
|
|
249
|
+
continue
|
|
250
|
+
if v.get("age") is None:
|
|
251
|
+
a = id_ages.get(v.get("identity")) or default_age
|
|
252
|
+
if a:
|
|
253
|
+
v["age"] = dict(a)
|
|
254
|
+
for pid, p in (m.get("presets") or {}).items():
|
|
255
|
+
if p.get("age_override") is None:
|
|
256
|
+
a = id_ages.get(p.get("identity")) or default_age
|
|
257
|
+
if a:
|
|
258
|
+
p["age_override"] = dict(a)
|
|
259
|
+
m["cof"] = "0.5"
|
|
260
|
+
(src / "manifest.json").write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
|
261
|
+
return m
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def migrate_any(src: Path) -> dict:
|
|
265
|
+
m = json.loads((src / "manifest.json").read_text("utf-8"))
|
|
266
|
+
v = str(m.get("cof", ""))
|
|
267
|
+
if v == "0.2":
|
|
268
|
+
migrate_dir(src); v = "0.3"
|
|
269
|
+
if v == "0.3":
|
|
270
|
+
m = migrate_v3_to_v4(src); v = "0.4"
|
|
271
|
+
if v == "0.4":
|
|
272
|
+
m = migrate_v4_to_v5(src); v = "0.5"
|
|
273
|
+
changed = False
|
|
274
|
+
if v == "0.5": # 0.5 → 1.0: version number only (structure frozen as is)
|
|
275
|
+
m["cof"] = "1.0"; changed = True
|
|
276
|
+
if m.get("preset_groups") is not None: # builder 0.8.0 (short-lived) → preset_tags
|
|
277
|
+
tags = m.setdefault("preset_tags", [])
|
|
278
|
+
known = {t.get("id") for t in tags}
|
|
279
|
+
for g in m.pop("preset_groups") or []:
|
|
280
|
+
if g.get("id") not in known:
|
|
281
|
+
tags.append({"id": g.get("id"), "label": g.get("label") or g.get("id"), "presets": list(g.get("presets") or [])})
|
|
282
|
+
if not tags:
|
|
283
|
+
m.pop("preset_tags")
|
|
284
|
+
changed = True
|
|
285
|
+
if not (m.get("presets") or {}) and len(m.get("identities") or {}) <= 1:
|
|
286
|
+
# 1.0 requires a preset: 'default' applies every variant except intimate ones (never applied by default)
|
|
287
|
+
slots = {}
|
|
288
|
+
for cat, e in (m.get("elements") or {}).items():
|
|
289
|
+
vids = [vid for vid, v in ((e or {}).get("variants") or {}).items() if not (cat == "appearance" and (v or {}).get("kind") == "intimate")]
|
|
290
|
+
if vids:
|
|
291
|
+
slots[cat] = vids
|
|
292
|
+
m["presets"] = {"default": {"label": "Default", "slots": slots}}
|
|
293
|
+
m["default_preset"] = "default"
|
|
294
|
+
changed = True
|
|
295
|
+
if changed:
|
|
296
|
+
(src / "manifest.json").write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
|
297
|
+
return m
|