Added dev test tools and CLAUDE.md
dev/: test asset files, a fake Libreflow IPC server and an end-to-end Scene Manager test (excluded from the extension build). CLAUDE.md: notes for working on the add-on (conventions, testing, design findings, open points). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c238f4db7c
commit
017bdfc2fd
4 files changed
+513
No files matched your search
@@ -0,0 +1,78 @@
|
|||||||
|
# Melvile Tools: notes for Claude
|
||||||
|
|
||||||
|
Blender add-on (Blender 5.2) for the Melvile pipeline at Walking The Dog: Sanity Check,
|
||||||
|
Rename, Shot Viewer and Scene Manager panels in the 3D View sidebar (WTD tab). `README.md`
|
||||||
|
is the user guide: keep it up to date with every user-visible change.
|
||||||
|
|
||||||
|
## Layout and conventions
|
||||||
|
|
||||||
|
- `config.py`: every production value (prefixes, formats, patterns, modes). Values are
|
||||||
|
read at run time (`config.X`, never copied at import) because a studio JSON file can
|
||||||
|
override them (add-on preferences "Config Override", or `WTD_SANITY_CONFIG`).
|
||||||
|
New tunable values go here, with a comment, and in the README's Configuration table.
|
||||||
|
- `ui/panel.py`: all panels. Operators live with their tool (`rename.py`,
|
||||||
|
`shot_viewer.py`, `scene_manager/`, `ui/operators.py` for the Sanity Check).
|
||||||
|
- `checks/`: sanity checks, auto-registered (see README > For developers).
|
||||||
|
- `__init__.py` deletes cached submodules on reload, so Reload Scripts picks up every file.
|
||||||
|
- Versions: `blender_manifest.toml` and `bl_info` in `__init__.py` (keep both in sync).
|
||||||
|
- Style: match the surrounding code; short docstrings, few comments, no heavy abstractions.
|
||||||
|
|
||||||
|
## Working with the user
|
||||||
|
|
||||||
|
- Commits: when asked, create a branch from `master`, commit there (end with the
|
||||||
|
Co-Authored-By line), and give the user the merge / push / branch-delete commands:
|
||||||
|
they run them themselves. Only commit straight on `master` when told to.
|
||||||
|
- Real Blender API facts beat memory: probe in headless Blender before relying on an API.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Blender binary: macOS `/Applications/Blender.app/Contents/MacOS/Blender`, Windows
|
||||||
|
`blender.exe` from the install folder. Import the add-on in tests by putting the folder
|
||||||
|
that contains this repo on `sys.path` and importing the repo folder name (see
|
||||||
|
`dev/test_scene_manager.py`).
|
||||||
|
|
||||||
|
- Headless: `blender -b --factory-startup --python-exit-code 1 --python <script> -- <args>`.
|
||||||
|
Timers do not run in background mode: pump background jobs by hand
|
||||||
|
(`scene_manager.ipc._poll()`).
|
||||||
|
- UI: run Blender with a window (`--factory-startup <file.blend> --python <script>`, a
|
||||||
|
.blend argument avoids the splash), drive it with `bpy.app.timers`, take screenshots with
|
||||||
|
`bpy.ops.screen.screenshot_area`, quit with `bpy.ops.wm.quit_blender()`. A sidebar tab
|
||||||
|
(`region.active_panel_category`) can only be set once the region has been drawn.
|
||||||
|
- Scene Manager without Libreflow (`dev/`):
|
||||||
|
1. `blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>`
|
||||||
|
2. `python dev/fake_libreflow.py <workdir>` (keep running; restart for a clean state)
|
||||||
|
3. `blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- <workdir>`
|
||||||
|
(36 checks, "All checks passed").
|
||||||
|
The fake server follows the real `wtdcmds` replies; check them against the real
|
||||||
|
Libreflow when one is available.
|
||||||
|
|
||||||
|
## External pieces
|
||||||
|
|
||||||
|
- Libreflow `wtdcmds` extension (commands used by the Scene Manager), on the share:
|
||||||
|
`WG/_Dev/GIT/ndemaerschalk/_MELVILE/libreflow_dev_utils/dev/src/wtdcmds/`
|
||||||
|
(`src/libreflow/extensions/wtdcmds/wtd_cmds.py`, `BLENDER_ADDON_BRIEF.md`).
|
||||||
|
Server address/token: `~/.kabaret/ipc/server.json` (config `LIBREFLOW_SERVER_FILE`).
|
||||||
|
- Add Camera Rigs extension: `bpy.ops.object.build_camera_rig(mode=...)` builds the
|
||||||
|
Shot Viewer cameras. It finds its rigs by the `rig_id` custom property, not by name.
|
||||||
|
- `tools_ops.py` "Get Oid" uses the separate kabaret Blender client (`bpy.ops.kabaret.cmds_call`).
|
||||||
|
|
||||||
|
## Design notes (found by testing)
|
||||||
|
|
||||||
|
- Shot Viewer: shots are timeline markers bound to cameras, in one scene. Setting
|
||||||
|
`scene.frame_current` does not switch the camera (only `frame_set` does): set
|
||||||
|
`scene.camera` explicitly.
|
||||||
|
- Scene Manager: tags live on `bpy.types.Library` only. Library overrides lose custom
|
||||||
|
properties when they resync, so instances are traced back to their library (empty →
|
||||||
|
`instance_collection.library`, override → `override_library.reference.library`).
|
||||||
|
- `library.reload()` invalidates every Python reference to linked and override data:
|
||||||
|
look IDs up again afterwards.
|
||||||
|
- Background requests (`scene_manager.ipc.run`) hand results back through a timer;
|
||||||
|
in callbacks, find scenes by name (`bpy.context.scene` is not reliable in timers).
|
||||||
|
|
||||||
|
## Open points
|
||||||
|
|
||||||
|
- First run of the Scene Manager against a real Libreflow (only tested with the fake
|
||||||
|
server): connection, casting, load, update, assetize, Windows paths.
|
||||||
|
- Asset type names assumed `chars` / `props` / `sets` (`SCENE_MANAGER_LINK_MODES`,
|
||||||
|
`SHOT_VIEWER_ASSET_TYPE_GROUPS`): confirm with Kitsu.
|
||||||
|
- Values marked TODO in `config.py` wait for production specs.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Fake Libreflow IPC server (kabaret.ipc + wtdcmds commands) to test the Scene Manager.
|
||||||
|
|
||||||
|
python dev/fake_libreflow.py <workdir>
|
||||||
|
|
||||||
|
Serves a project with one shot casting bob (chars, x2), tree (sets), rock (props),
|
||||||
|
lamp (props, nothing published) and ghost (not in the flow). The asset files come
|
||||||
|
from dev/make_test_assets.py (<workdir>/lib). Writes <workdir>/kabaret/server.json:
|
||||||
|
point config LIBREFLOW_SERVER_FILE to it (see dev/test_scene_manager.py).
|
||||||
|
|
||||||
|
Test-only endpoints: POST /_test/publish {file_oid, name, available, path},
|
||||||
|
POST /_test/sessions {uid: session}, POST /_test/calls (commands received).
|
||||||
|
State lives in memory: restart the server for a clean state.
|
||||||
|
"""
|
||||||
|
import json, os, shutil, sys, time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
ROOT = sys.argv[1]
|
||||||
|
TOKEN = "secret-token"
|
||||||
|
PROJECT = "/melvile"
|
||||||
|
SHOT = "/melvile/films/f01/sequences/s010/shots/p010"
|
||||||
|
SESSIONS = {"uid-1": {"session_name": "libreflow_dev", "cluster_name": "melvile", "is_gui": True, "current_oid": SHOT}}
|
||||||
|
|
||||||
|
def rev(name, path, available=True, comment=""):
|
||||||
|
return {"name": name, "oid": f"x/history/revisions/{name}", "path": path, "sync_status": "Available" if available else "NotAvailable",
|
||||||
|
"available": available, "date": time.time(), "user": "nero", "comment": comment}
|
||||||
|
|
||||||
|
ASSETS = { # asset oid -> (type, {file_oid: (label, [revisions oldest first])})
|
||||||
|
f"{PROJECT}/asset_types/chars/assets/bob": ("chars", {f"{PROJECT}/asset_types/chars/assets/bob/tasks/rigging/files/rig_blend": ("rigging/rig.blend", [
|
||||||
|
rev("v001", f"{ROOT}/lib/bob/v001/rig.blend"), rev("v002", f"{ROOT}/lib/bob/v002/rig.blend", comment="two meshes")])}),
|
||||||
|
f"{PROJECT}/asset_types/sets/assets/tree": ("sets", {f"{PROJECT}/asset_types/sets/assets/tree/tasks/modeling/files/model_blend": ("modeling/model.blend", [
|
||||||
|
rev("v001", f"{ROOT}/lib/tree/v001/model.blend")])}),
|
||||||
|
f"{PROJECT}/asset_types/props/assets/rock": ("props", {f"{PROJECT}/asset_types/props/assets/rock/tasks/modeling/files/rock_blend": ("modeling/rock.blend", [
|
||||||
|
rev("v001", f"{ROOT}/lib/rock/v001/rock.blend")])}),
|
||||||
|
f"{PROJECT}/asset_types/props/assets/lamp": ("props", {}),
|
||||||
|
}
|
||||||
|
CASTING = [("bob", "chars", 2), ("tree", "sets", 1), ("rock", "props", 1), ("lamp", "props", 1), ("ghost", "chars", 1)]
|
||||||
|
CALLS = []
|
||||||
|
|
||||||
|
def file_data(file_oid):
|
||||||
|
for asset_oid, (atype, files) in ASSETS.items():
|
||||||
|
if file_oid in files:
|
||||||
|
label, revs = files[file_oid]
|
||||||
|
avail = [r for r in revs if r["available"]]
|
||||||
|
return {"label": label, "file_oid": file_oid, "latest": revs[-1] if revs else None, "latest_available": avail[-1] if avail else None}
|
||||||
|
raise ValueError(f"No file {file_oid}")
|
||||||
|
|
||||||
|
def context(oid):
|
||||||
|
parts = oid.strip("/").split("/")
|
||||||
|
if oid.startswith(PROJECT + "/films"):
|
||||||
|
return {"oid": oid, "kind": "shot", "project": "melvile", "film": parts[2], "film_oid": "/".join([""] + parts[:3]),
|
||||||
|
"sequence": parts[4], "sequence_oid": "/".join([""] + parts[:5]), "shot": parts[6], "shot_oid": SHOT}
|
||||||
|
if "/assets/" in oid:
|
||||||
|
return {"oid": oid, "kind": "asset", "project": "melvile", "asset_type": parts[2], "asset": parts[4]}
|
||||||
|
if oid == PROJECT:
|
||||||
|
return {"oid": oid, "kind": "project", "project": "melvile"}
|
||||||
|
raise ValueError(f"Oid {oid} does not exist")
|
||||||
|
|
||||||
|
def cmd(name, kw):
|
||||||
|
import fnmatch
|
||||||
|
if name == "wtd_ping":
|
||||||
|
return {"version": "0.1.0", **({"project": "melvile", "user": "nero", "site": "lyon", "root": ROOT} if kw.get("oid") else {})}
|
||||||
|
if name == "wtd_get_context":
|
||||||
|
return context(kw["oid"])
|
||||||
|
if name == "wtd_list_shots":
|
||||||
|
return [context(SHOT), context(SHOT.replace("p010", "p020"))]
|
||||||
|
if name == "wtd_list_assets":
|
||||||
|
return [context(oid) for oid in ASSETS]
|
||||||
|
if name == "wtd_get_casting_publishes":
|
||||||
|
if not kw["oid"].startswith(SHOT):
|
||||||
|
raise ValueError("Not a shot")
|
||||||
|
patterns = kw.get("file_patterns") or ["*/*.blend"]
|
||||||
|
out = []
|
||||||
|
for aname, atype, nb in CASTING:
|
||||||
|
oid = next((o for o in ASSETS if o.endswith("/assets/" + aname)), None)
|
||||||
|
a = {"name": aname, "kitsu_id": "k-" + aname, "asset_type": atype, "nb_occurences": nb, "label": "animate", "oid": oid, "candidates": [oid] if oid else [], "file": None, "error": None}
|
||||||
|
if oid is None:
|
||||||
|
a["error"] = "Asset not found in the flow"
|
||||||
|
else:
|
||||||
|
files = ASSETS[oid][1]
|
||||||
|
match = next((f for p in patterns for f, (label, _r) in files.items() if fnmatch.fnmatchcase(label, p)), None)
|
||||||
|
if match is None:
|
||||||
|
a["error"] = "No file matching " + ", ".join(patterns)
|
||||||
|
else:
|
||||||
|
a["file"] = file_data(match)
|
||||||
|
if a["file"]["latest_available"] is None:
|
||||||
|
a["error"] = "No published revision available on this site"
|
||||||
|
out.append(a)
|
||||||
|
return {"shot_oid": SHOT, "assets": out}
|
||||||
|
if name == "wtd_get_latest_revisions":
|
||||||
|
res = {}
|
||||||
|
for f in kw["file_oids"]:
|
||||||
|
try: res[f] = file_data(f)
|
||||||
|
except ValueError as exc: res[f] = {"file_oid": f, "error": str(exc)}
|
||||||
|
return res
|
||||||
|
if name == "wtd_get_revisions":
|
||||||
|
for _a, (_t, files) in ASSETS.items():
|
||||||
|
if kw["file_oid"] in files:
|
||||||
|
return list(reversed(files[kw["file_oid"]][1]))
|
||||||
|
raise ValueError("No file")
|
||||||
|
if name == "wtd_assetize":
|
||||||
|
src = kw["source_path"]
|
||||||
|
if not os.path.isfile(src): raise ValueError(f"Source file {src} does not exist")
|
||||||
|
atype, aname, task = kw["asset_type"], kw["asset_name"], kw["task"]
|
||||||
|
asset_oid = f"{PROJECT}/asset_types/{atype}/assets/{aname}"
|
||||||
|
created = asset_oid not in ASSETS
|
||||||
|
if created and not kw.get("create_asset"):
|
||||||
|
raise ValueError(f"Asset {aname} does not exist (pass create_asset=true to create it)")
|
||||||
|
if task not in ("modeling", "rigging"):
|
||||||
|
raise ValueError(f"Task {task} does not exist on {asset_oid} (available: modeling, rigging)")
|
||||||
|
fname = kw.get("file_name") or aname
|
||||||
|
ASSETS.setdefault(asset_oid, (atype, {}))
|
||||||
|
files = ASSETS[asset_oid][1]
|
||||||
|
file_oid = f"{asset_oid}/tasks/{task}/files/{fname}_blend"
|
||||||
|
label, revs = files.setdefault(file_oid, (f"{task}/{fname}.blend", []))
|
||||||
|
name = f"v{len(revs) + 1:03d}"
|
||||||
|
dest = f"{ROOT}/lib/{aname}/{name}/{fname}.blend"; os.makedirs(os.path.dirname(dest), exist_ok=True); shutil.copy(src, dest)
|
||||||
|
revs.append(rev(name, dest, comment=kw.get("comment", "")))
|
||||||
|
return {"asset_oid": asset_oid, "asset_created": created, **file_data(file_oid)}
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a): pass
|
||||||
|
def reply(self, code, body):
|
||||||
|
data = json.dumps(body).encode(); self.send_response(code); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data)
|
||||||
|
def auth(self):
|
||||||
|
if self.headers.get("Authorization") != f"Bearer {TOKEN}":
|
||||||
|
self.reply(401, {"detail": "Invalid token"}); return False
|
||||||
|
return True
|
||||||
|
def do_GET(self):
|
||||||
|
if not self.auth(): return
|
||||||
|
if self.path == "/sessions": return self.reply(200, SESSIONS)
|
||||||
|
self.reply(404, {"detail": "Not found"})
|
||||||
|
def do_POST(self):
|
||||||
|
if not self.auth(): return
|
||||||
|
url = urlparse(self.path); qs = parse_qs(url.query)
|
||||||
|
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
||||||
|
if url.path == "/_test/publish": # simulate a new publish
|
||||||
|
f = body["file_oid"]
|
||||||
|
for _a, (_t, files) in ASSETS.items():
|
||||||
|
if f in files: files[f][1].append(rev(body["name"], body.get("path", "/nowhere/" + body["name"]), body.get("available", True)))
|
||||||
|
return self.reply(200, {"result": True})
|
||||||
|
if url.path == "/_test/sessions":
|
||||||
|
SESSIONS.clear(); SESSIONS.update(body); return self.reply(200, {"result": True})
|
||||||
|
if url.path == "/_test/calls":
|
||||||
|
return self.reply(200, {"result": CALLS})
|
||||||
|
name = url.path.rsplit("/", 1)[-1]
|
||||||
|
uid = qs.get("session_uid", [None])[0]
|
||||||
|
if uid is None and len(SESSIONS) > 1: return self.reply(400, {"detail": "Several sessions connected: session_uid required"})
|
||||||
|
if uid is not None and uid not in SESSIONS: return self.reply(404, {"detail": f"Session {uid} not found"})
|
||||||
|
CALLS.append([name, body.get("kwargs", {})])
|
||||||
|
try:
|
||||||
|
return self.reply(200, {"result": cmd(name, body.get("kwargs", {}))})
|
||||||
|
except KeyError as exc: return self.reply(404, {"detail": f"Unknown command {exc}"})
|
||||||
|
except Exception as exc: return self.reply(500, {"detail": str(exc)})
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
os.makedirs(f"{ROOT}/kabaret", exist_ok=True)
|
||||||
|
with open(f"{ROOT}/kabaret/server.json", "w") as fh:
|
||||||
|
json.dump({"host": "127.0.0.1", "port": server.server_address[1], "token": TOKEN, "pid": os.getpid()}, fh)
|
||||||
|
print("listening", server.server_address[1], flush=True)
|
||||||
|
server.serve_forever()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Write the asset files used by fake_libreflow.py into <workdir>/lib.
|
||||||
|
|
||||||
|
blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>
|
||||||
|
|
||||||
|
bob: a rig (COL_bob: armature + meshes), v001 has 1 mesh, v002 has 2.
|
||||||
|
tree: COL_tree, plus an unrelated top-level collection.
|
||||||
|
rock: a single top-level collection not named after the asset (fallback case).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
WORKDIR = sys.argv[sys.argv.index("--") + 1]
|
||||||
|
|
||||||
|
|
||||||
|
def mesh_object(name):
|
||||||
|
mesh = bpy.data.meshes.new(name)
|
||||||
|
mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)])
|
||||||
|
return bpy.data.objects.new(name, mesh)
|
||||||
|
|
||||||
|
|
||||||
|
def write(path, collection_name, meshes=1, rig=False, extra=False):
|
||||||
|
bpy.ops.wm.read_homefile(use_empty=True)
|
||||||
|
scene = bpy.context.scene
|
||||||
|
collection = bpy.data.collections.new(collection_name)
|
||||||
|
scene.collection.children.link(collection)
|
||||||
|
parent = None
|
||||||
|
if rig:
|
||||||
|
parent = bpy.data.objects.new("RIG_" + collection_name, bpy.data.armatures.new("RIG_" + collection_name))
|
||||||
|
collection.objects.link(parent)
|
||||||
|
bpy.context.view_layer.objects.active = parent
|
||||||
|
bpy.ops.object.mode_set(mode="EDIT")
|
||||||
|
bone = parent.data.edit_bones.new("root")
|
||||||
|
bone.tail = (0, 0, 1)
|
||||||
|
bpy.ops.object.mode_set(mode="OBJECT")
|
||||||
|
for index in range(meshes):
|
||||||
|
ob = mesh_object(f"GEO_{collection_name}_{index}")
|
||||||
|
ob.parent = parent
|
||||||
|
collection.objects.link(ob)
|
||||||
|
if extra:
|
||||||
|
scene.collection.children.link(bpy.data.collections.new("WIP_other"))
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||||
|
|
||||||
|
|
||||||
|
lib = os.path.join(WORKDIR, "lib")
|
||||||
|
write(os.path.join(lib, "bob", "v001", "rig.blend"), "COL_bob", meshes=1, rig=True, extra=True)
|
||||||
|
write(os.path.join(lib, "bob", "v002", "rig.blend"), "COL_bob", meshes=2, rig=True, extra=True)
|
||||||
|
write(os.path.join(lib, "tree", "v001", "model.blend"), "COL_tree", extra=True)
|
||||||
|
write(os.path.join(lib, "rock", "v001", "rock.blend"), "rock_geo")
|
||||||
|
print("Test assets written in", lib)
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""End-to-end test of the Scene Manager against dev/fake_libreflow.py.
|
||||||
|
|
||||||
|
blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>
|
||||||
|
python dev/fake_libreflow.py <workdir> (keep it running, fresh state)
|
||||||
|
blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- <workdir>
|
||||||
|
|
||||||
|
Prints every check and exits with an error when one fails. Timers do not run in
|
||||||
|
background mode, so the test runs the background-job poller itself (pump).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
WORKDIR = sys.argv[sys.argv.index("--") + 1]
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.dirname(REPO))
|
||||||
|
PACKAGE = os.path.basename(REPO)
|
||||||
|
addon = importlib.import_module(PACKAGE)
|
||||||
|
addon.register()
|
||||||
|
config = sys.modules[PACKAGE + ".config"]
|
||||||
|
ipc = sys.modules[PACKAGE + ".scene_manager.ipc"]
|
||||||
|
sm = sys.modules[PACKAGE + ".scene_manager.operators"]
|
||||||
|
lib = sys.modules[PACKAGE + ".scene_manager.library"]
|
||||||
|
shot_viewer = sys.modules[PACKAGE + ".shot_viewer"]
|
||||||
|
|
||||||
|
SERVER_FILE = os.path.join(WORKDIR, "kabaret", "server.json")
|
||||||
|
config.LIBREFLOW_SERVER_FILE = SERVER_FILE
|
||||||
|
SHOT = "/melvile/films/f01/sequences/s010/shots/p010"
|
||||||
|
BOB_FILE = "/melvile/asset_types/chars/assets/bob/tasks/rigging/files/rig_blend"
|
||||||
|
FAILED = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, condition, detail=""):
|
||||||
|
print((" ok " if condition else " FAIL ") + label + (f" ({detail})" if detail else ""))
|
||||||
|
if not condition:
|
||||||
|
FAILED.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
def pump(timeout=15):
|
||||||
|
end = time.time() + timeout
|
||||||
|
while ipc._jobs or not ipc._results.empty():
|
||||||
|
ipc._poll()
|
||||||
|
if time.time() > end:
|
||||||
|
check("background jobs finish", False, str(ipc._jobs))
|
||||||
|
return
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def fake(path, body):
|
||||||
|
info = json.load(open(SERVER_FILE))
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{info['port']}{path}",
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
method="POST",
|
||||||
|
headers={"Authorization": "Bearer " + info["token"], "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return json.loads(urllib.request.urlopen(request).read())["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def loaded():
|
||||||
|
found = lib.all_instances()
|
||||||
|
return {
|
||||||
|
library.name: (library.get("wtd_revision"), [i.name for i in found.get(library.name, [])])
|
||||||
|
for library in lib.managed_libraries()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def message():
|
||||||
|
return sm.STATE.message[0]
|
||||||
|
|
||||||
|
|
||||||
|
bpy.ops.wm.read_homefile(use_empty=True)
|
||||||
|
scene = bpy.context.scene
|
||||||
|
|
||||||
|
print("Connection")
|
||||||
|
sm.connect(scene)
|
||||||
|
pump()
|
||||||
|
check("connected to the session", sm.connected() and sm.current_session().get("session_name") == "libreflow_dev")
|
||||||
|
check("user and site known", sm.STATE.ping.get("user") == "nero" and sm.STATE.ping.get("site") == "lyon")
|
||||||
|
|
||||||
|
print("Context")
|
||||||
|
scene.wtd_scene_manager.context_oid = "/melvile/nope"
|
||||||
|
pump()
|
||||||
|
check("invalid oid reported", not sm.context_label("/melvile/nope")[1])
|
||||||
|
scene.wtd_scene_manager.context_oid = SHOT
|
||||||
|
pump()
|
||||||
|
check("shot context validated", sm.context_label(SHOT) == ("shot: f01 / s010 / p010", True))
|
||||||
|
errors = {a["name"]: a["error"] for a in sm.STATE.casting.get(SHOT, {}).get("assets", [])}
|
||||||
|
check("casting read", set(errors) == {"bob", "tree", "rock", "lamp", "ghost"}, str(errors))
|
||||||
|
|
||||||
|
print("Load")
|
||||||
|
bpy.ops.wtd_scene.load()
|
||||||
|
pump()
|
||||||
|
state = loaded()
|
||||||
|
check("bob: 2 instances of v002", state.get("bob") == ("v002", ["COL_bob", "COL_bob.001"]), str(state.get("bob")))
|
||||||
|
check("tree: 1 collection instance", state.get("tree") == ("v001", ["tree"]), str(state.get("tree")))
|
||||||
|
check("rock: single top-level collection fallback", state.get("rock") == ("v001", ["rock_geo"]))
|
||||||
|
check("lamp and ghost reported", "lamp" in message() and "ghost" in message(), message())
|
||||||
|
check("type collections", {"COL_chars", "COL_sets", "COL_props"} <= set(scene.collection.children.keys()))
|
||||||
|
check(
|
||||||
|
"chars are posable overrides",
|
||||||
|
all(ob.override_library for ob in bpy.data.collections["COL_bob"].all_objects),
|
||||||
|
)
|
||||||
|
check("shot viewer groups bob as character", "Characters" in shot_viewer.rig_groups(bpy.context.view_layer))
|
||||||
|
bpy.ops.wtd_scene.load()
|
||||||
|
pump()
|
||||||
|
check("load again adds nothing", message().startswith("Nothing new to load"), message())
|
||||||
|
|
||||||
|
print("Versions")
|
||||||
|
check("bob is the latest", sm.version_status(bpy.data.libraries["bob"])[0] == "LATEST")
|
||||||
|
fake("/_test/publish", {"file_oid": BOB_FILE, "name": "v003", "available": False})
|
||||||
|
sm.refresh_versions()
|
||||||
|
pump()
|
||||||
|
check("v003 not on this site: pending", sm.version_status(bpy.data.libraries["bob"])[0] == "PENDING")
|
||||||
|
fake(
|
||||||
|
"/_test/publish",
|
||||||
|
{"file_oid": BOB_FILE, "name": "v004", "available": True, "path": os.path.join(WORKDIR, "lib", "bob", "v001", "rig.blend")},
|
||||||
|
)
|
||||||
|
sm.refresh_versions()
|
||||||
|
pump()
|
||||||
|
check("v004 available: update", sm.version_status(bpy.data.libraries["bob"])[0] == "UPDATE")
|
||||||
|
bpy.ops.wtd_scene.update(library="bob")
|
||||||
|
pump()
|
||||||
|
check("bob updated to v004", loaded()["bob"][0] == "v004", message())
|
||||||
|
meshes = {c.name: len([o for o in c.all_objects if o.type == "MESH"]) for c in lib.instances(bpy.data.libraries["bob"])}
|
||||||
|
check("overrides resynced to v004 (1 mesh)", set(meshes.values()) == {1}, str(meshes))
|
||||||
|
|
||||||
|
print("Version picker")
|
||||||
|
sm.STATE.revisions[BOB_FILE] = ipc.call("wtd_get_revisions", None, file_oid=BOB_FILE)
|
||||||
|
sm._REVISION_ITEMS[:] = [(r["name"], r["name"], "", "NONE", i) for i, r in enumerate(sm.STATE.revisions[BOB_FILE])]
|
||||||
|
bpy.ops.wtd_scene.pick_revision("EXEC_DEFAULT", library="bob", revision="v002")
|
||||||
|
check("rolled back to v002", loaded()["bob"][0] == "v002")
|
||||||
|
check("unavailable v003 refused", bpy.ops.wtd_scene.pick_revision("EXEC_DEFAULT", library="bob", revision="v003") == {"CANCELLED"})
|
||||||
|
|
||||||
|
print("Remove")
|
||||||
|
bpy.ops.wtd_scene.remove(instance="COL_bob.001")
|
||||||
|
check("one instance removed", loaded()["bob"][1] == ["COL_bob"])
|
||||||
|
for ob in bpy.context.view_layer.objects:
|
||||||
|
ob.select_set(False)
|
||||||
|
bpy.data.objects["tree"].select_set(True)
|
||||||
|
bpy.ops.wtd_scene.remove(selected=True)
|
||||||
|
check("selected instance removed", loaded()["tree"][1] == [])
|
||||||
|
bpy.ops.wtd_scene.remove("EXEC_DEFAULT", library="rock")
|
||||||
|
check("whole asset removed", "rock" not in bpy.data.libraries)
|
||||||
|
bpy.ops.wtd_scene.load()
|
||||||
|
pump()
|
||||||
|
check("load restores what is missing", {k: len(v[1]) for k, v in loaded().items()} == {"bob": 2, "rock": 1, "tree": 1})
|
||||||
|
|
||||||
|
print("Ambiguous file")
|
||||||
|
try:
|
||||||
|
lib.link_asset(
|
||||||
|
{"name": "zzz", "oid": "x", "asset_type": "props", "file": {"file_oid": "f"}},
|
||||||
|
{"name": "v1", "path": os.path.join(WORKDIR, "lib", "bob", "v001", "rig.blend")},
|
||||||
|
SHOT,
|
||||||
|
)
|
||||||
|
check("ambiguous file refused", False)
|
||||||
|
except lib.LoadError as exc:
|
||||||
|
check("ambiguous file refused", "top-level collections" in str(exc), str(exc))
|
||||||
|
|
||||||
|
print("Assetize")
|
||||||
|
local = bpy.data.collections.new("COL_lamp")
|
||||||
|
scene.collection.children.link(local)
|
||||||
|
local.objects.link(bpy.data.objects.new("GEO_lamp", bpy.data.meshes.new("GEO_lamp")))
|
||||||
|
check("only free local collections offered", [c.name for c in lib.assetize_candidates()] == ["COL_lamp"])
|
||||||
|
sm.STATE.assets = ipc.call("wtd_list_assets", None, oid=SHOT)
|
||||||
|
bpy.ops.wtd_scene.assetize(
|
||||||
|
"EXEC_DEFAULT", collection="COL_lamp", asset_type="props", asset_name="lamp", task="modeling", comment="test"
|
||||||
|
)
|
||||||
|
pump()
|
||||||
|
check("published and replaced", "replaced" in message(), message())
|
||||||
|
check("lamp linked", loaded().get("lamp") == ("v001", ["COL_lamp"]), str(loaded().get("lamp")))
|
||||||
|
check("local collection kept, renamed", "COL_lamp_local" in bpy.data.collections)
|
||||||
|
bad = bpy.data.collections.new("COL_bad")
|
||||||
|
scene.collection.children.link(bad)
|
||||||
|
bpy.ops.wtd_scene.assetize("EXEC_DEFAULT", collection="COL_bad", asset_type="props", asset_name="bad", task="layout")
|
||||||
|
pump()
|
||||||
|
check("unknown task reported", "available: modeling, rigging" in message(), message())
|
||||||
|
|
||||||
|
print("Save / reopen")
|
||||||
|
path = os.path.join(WORKDIR, "scene_manager_test.blend")
|
||||||
|
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||||
|
bpy.ops.wm.open_mainfile(filepath=path)
|
||||||
|
sm._on_load_post()
|
||||||
|
check("everything found after reopening", set(loaded()) == {"bob", "lamp", "rock", "tree"})
|
||||||
|
sm.connect(bpy.context.scene)
|
||||||
|
pump()
|
||||||
|
check("versions checked again", sm.version_status(bpy.data.libraries["bob"])[0] == "UPDATE")
|
||||||
|
|
||||||
|
print("Several sessions")
|
||||||
|
fake(
|
||||||
|
"/_test/sessions",
|
||||||
|
{
|
||||||
|
"uid-1": {"session_name": "a", "cluster_name": "melvile", "is_gui": True, "current_oid": SHOT},
|
||||||
|
"uid-2": {"session_name": "b", "cluster_name": "melvile", "is_gui": False, "current_oid": "/melvile"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bpy.context.window_manager.wtd_libreflow.session_uid = ""
|
||||||
|
sm.connect(bpy.context.scene)
|
||||||
|
pump()
|
||||||
|
check("asks to pick a session", "pick one" in message(), message())
|
||||||
|
bpy.ops.wtd_scene.select_session(uid="uid-2")
|
||||||
|
pump()
|
||||||
|
check("works once a session is picked", sm.context_label(SHOT)[1])
|
||||||
|
|
||||||
|
print("Libreflow not running")
|
||||||
|
config.LIBREFLOW_SERVER_FILE = os.path.join(WORKDIR, "missing.json")
|
||||||
|
sm.connect(bpy.context.scene)
|
||||||
|
pump()
|
||||||
|
check("reported as not running", not sm.connected() and sm.STATE.connection_error == "Libreflow is not running")
|
||||||
|
|
||||||
|
addon.unregister()
|
||||||
|
print(f"\n{len(FAILED)} check(s) failed: {', '.join(FAILED)}" if FAILED else "\nAll checks passed")
|
||||||
|
if FAILED:
|
||||||
|
raise RuntimeError(f"{len(FAILED)} Scene Manager check(s) failed")
|
||||||
Reference in new issue
Block a user