champions-of-luneraria/tools/meshy/meshy.py
Martin Stickelbroeck c386206876 U5.5-pilot Figuren: Meshy-Kohlenbeißer, Lava-Glut, GPU-Skinning
Pilot für eine generierte Figur: der Kohlenbeißer g01 kommt als Meshy-Ergebnis
in den Port und ersetzt dort das Phase-4-Modell. Eine Figur genügt, um Pipeline,
Bild und Leistung zu bewerten; Anatomie, Gesichter und Haare bleiben
Künstler-Sculpts oder lizenzierte Packs (Vorgabe vom 1. Oktober).

- Meshy-Pipeline unter tools/meshy/: API-Client (meshy.py, Ledger, Schlüssel
  nie geloggt) und Blender-Generator (build_figure.py) aus den unveränderten
  Downloads in assets-source/meshy/g01/. Export über den Manifest-Eintrag
  kohlenbeisser mit "replaces": "g01"; LuneUnitLibraryBuilder hängt die Figur
  zusätzlich unter der Godot-ID ein.
- UV-Material AI_Kohlenbeisser (Familie authored): die Karten der Figur selbst,
  Lava-Glut aus der Albedo-Alpha (Meshy 7.1 ohne Emission-Map) mit _Glow 1,25.
- Schatten ohne Normalen-Versatz (LuneShadowBias, _ShadowNormalScale 0): die
  UV-Naht-Kopien tragen geknickte Normalen; URP ApplyShadowBias zog helle
  Nähte durch den Schatten. Die Figur wirft nur mit Tiefen-Bias.
- GPU-Batched-Skinning (PlayerSettings.meshDeformation, LuneProjectSetup):
  35 Meshy-Kohlenbeißer kosteten mit CPU-Skinning rund 1 ms Haupt- und
  1,7 ms Render-Thread je Bild.
- LuneBenchmark: -luneBenchmarkRound und -luneBenchmarkArmy (Figuren-Stress).
  LuneAssetPreview: LUNE_ONLY für Vorher/Nachher, Trefferframe aus der
  Kontaktzeit des Manifests. FigureViewsTests (Explicit) für die vier
  Spielmomente der Figur.
- Tests: EditMode 29/29, PlayMode 14/14 (6 Explicit), Verify 718/0,
  Player-Selbsttest 30/30, Kerntests 457/457, Fingerabdrücke unverändert
  (DCE95C96556F3A1A, 7B8E43AEFC0D60C5).
- Leistung (A/B im selben Fenster, 35x g01): Meshy -21 % Mid (109,7 zu 138,8)
  und -13 % Karte (91,7 zu 105,6) gegenüber Phase-4. GPU-Skinning hebt Mid
  um 7-27 % und entlastet Haupt- und Render-Thread. Standard-Partie ab Runde 21
  ohne g01 im Bild: drei Varianten bis 6 %.
- Doku: U5-VISUAL (U5.5), PORT-QA, PORT-STATUS, KNOWN-BEHAVIOR-DIFFERENCES;
  Bilder screenshots/u5.5/ und u5.5-gpu/, Rohdaten logs/u5.5/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:14:59 +02:00

146 lines
6.4 KiB
Python

"""Small Meshy API client for the U5.5 figure pilot (standard library only).
The API key is read from ~/.config/luneraria/meshy.key (or the file named by LUNE_MESHY_KEY) and
is never printed, logged or written anywhere. Every created task is appended to a ledger
(tools/out/meshy/ledger.jsonl: time, endpoint, task id, credits) so the spend stays traceable.
Downloads go into a fresh folder per task; signed URLs are stored without their query string.
meshy.py balance
meshy.py library [search] animation library (free)
meshy.py create <endpoint> <request.json> prints the task id
meshy.py wait <endpoint> <task id> polls until the task ends, prints the task
meshy.py fetch <endpoint> <task id> <folder> downloads every result file of a finished task
<endpoint> is the API path below /openapi/, e.g. v1/image-to-image, v1/image-to-3d, v1/rigging,
v1/animations, v1/text-to-motion. Request files may name local images as "@path/to/image.png";
they are sent as data URIs.
"""
import base64, json, os, sys, time, urllib.error, urllib.parse, urllib.request
API = 'https://api.meshy.ai/openapi/'
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
LEDGER = os.path.join(ROOT, 'tools', 'out', 'meshy', 'ledger.jsonl')
def key():
path = os.environ.get('LUNE_MESHY_KEY', os.path.expanduser('~/.config/luneraria/meshy.key'))
with open(path) as handle:
return handle.read().strip()
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(API + path, data=data, method=method)
request.add_header('Authorization', 'Bearer ' + key())
if data is not None:
request.add_header('Content-Type', 'application/json')
try:
with urllib.request.urlopen(request, timeout=120) as response:
return json.loads(response.read().decode() or 'null')
except urllib.error.HTTPError as error:
detail = error.read().decode(errors='replace')[:800]
sys.exit('Meshy %s %s: HTTP %d %s' % (method, path, error.code, detail))
def inline(value):
""""@file" strings become data URIs (recursively in lists and objects)."""
if isinstance(value, str) and value.startswith('@'):
path = value[1:]
mime = 'image/png' if path.lower().endswith('.png') else 'image/jpeg'
with open(path, 'rb') as handle:
return 'data:%s;base64,%s' % (mime, base64.b64encode(handle.read()).decode())
if isinstance(value, list):
return [inline(v) for v in value]
if isinstance(value, dict):
return {k: inline(v) for k, v in value.items()}
return value
def unsigned(value):
"""Task objects without signatures in their URLs and without inline images."""
if isinstance(value, str):
if value.startswith('data:'):
return value[:40] + '...'
if value.startswith('http'):
return value.split('?', 1)[0]
return value
if isinstance(value, list):
return [unsigned(v) for v in value]
if isinstance(value, dict):
return {k: unsigned(v) for k, v in value.items()}
return value
def ledger(entry):
os.makedirs(os.path.dirname(LEDGER), exist_ok=True)
entry['time'] = time.strftime('%Y-%m-%dT%H:%M:%S')
with open(LEDGER, 'a') as handle:
handle.write(json.dumps(entry) + '\n')
def urls(value, trail=''):
"""(name, url) of every downloadable result in a task object."""
found = []
if isinstance(value, dict):
for k, v in value.items():
found += urls(v, (trail + '.' if trail else '') + k)
elif isinstance(value, list):
for i, v in enumerate(value):
found += urls(v, '%s.%d' % (trail, i))
elif isinstance(value, str) and value.startswith('https://') and 'meshy' in urllib.parse.urlparse(value).netloc:
found.append((trail, value))
return found
def main(args):
if not args:
sys.exit(__doc__)
command = args[0]
if command == 'balance':
print(json.dumps(call('GET', 'v1/balance')))
elif command == 'library':
query = '?search=' + urllib.parse.quote(args[1]) if len(args) > 1 else ''
for item in call('GET', 'v1/animations/library' + query):
print('%5s %-14s %-18s %-40s %s' % (item.get('action_id'), item.get('category'), item.get('sub_category'), item.get('name'), item.get('key')))
elif command == 'create':
endpoint, request_file = args[1], args[2]
with open(request_file) as handle:
body = json.load(handle)
result = call('POST', endpoint, inline(body))
task = result.get('result')
ledger({'endpoint': endpoint, 'task': task, 'request': unsigned(body), 'request_file': os.path.relpath(request_file, ROOT)})
print(task)
elif command == 'wait':
endpoint, task = args[1], args[2]
while True:
obj = call('GET', endpoint + '/' + task)
status = obj.get('status')
if status in ('SUCCEEDED', 'FAILED', 'CANCELED'):
ledger({'endpoint': endpoint, 'task': task, 'status': status, 'credits': obj.get('consumed_credits'), 'error': (obj.get('task_error') or {}).get('message')})
print(json.dumps(unsigned(obj), indent=1))
return 0 if status == 'SUCCEEDED' else 1
sys.stderr.write('%s %s %s%%\n' % (task, status, obj.get('progress')))
time.sleep(10)
elif command == 'fetch':
endpoint, task, folder = args[1], args[2], args[3]
obj = call('GET', endpoint + '/' + task)
if obj.get('status') != 'SUCCEEDED':
sys.exit('task %s is %s' % (task, obj.get('status')))
os.makedirs(folder, exist_ok=True)
with open(os.path.join(folder, 'task.json'), 'w') as handle:
json.dump(unsigned(obj), handle, indent=1)
for name, url in urls(obj):
if name in ('texture_image_url',) or name.startswith('request'):
continue
extension = os.path.splitext(urllib.parse.urlparse(url).path)[1] or '.bin'
target = os.path.join(folder, name.replace('.', '-') + extension)
with urllib.request.urlopen(url, timeout=300) as response, open(target, 'wb') as handle:
handle.write(response.read())
print('%9d %s' % (os.path.getsize(target), os.path.relpath(target, ROOT)))
else:
sys.exit(__doc__)
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))