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