champions-of-luneraria/tools/blender/lune_fbx_export.py
Martin Stickelbroeck 49cb4af407 U2A Blender/FBX Pipeline
Reproduzierbarer Export aus den Blender-Quellen der Godot-Referenz nach FBX,
geprüft auf beiden Seiten; sechs repräsentative Assets vollständig in Unity
verifiziert (normale Einheit g01, Einheit mit Skelett Funkenlanze, Turm
Grenzwacht, Ancient, Schlundkrone, Vegetation/Kartenrequisiten), dazu Bastion,
Kernwacht und Bossgrotte für die Szene.

- tools/blender: Manifest mit Exportregeln je Asset, Exportskript mit Prüfungen
  (Einheiten, Maßstab, Achsen, Vorderseite, Mesh-Namen, Dreiecke, Materialslots,
  Normalen, Tangenten, Vertexfarben, Maße, Root Bone, Bone-Namen, Clips, Blend
  Shapes) gegen die Manifeste der Godot-GLBs, FBX-Rundreise, byteidentische
  Ausgabe (feste Uhrzeit, PYTHONHASHSEED).
- Regeln wie beim GLB-Export: HIGH-RES-Modifikatoren weglassen, logische Namen,
  Skin-Teile zusammenfügen; 180°-Drehung, damit die Vorderseite nach Unitys
  Achsen-Bake auf +Z liegt (Unity = (-x, y, z) der Godot-Koordinaten).
- Unity: Importregeln (Postprocessor), Verifier gegen das Blender-Protokoll,
  Vorschaubilder; URP-Asset, Linear-Farbraum, URP-Settings nach
  Assets/Luneraria/Settings/Rendering.
- Ergebnis: 176/176 Blender-Prüfungen, 128/128 Unity-Prüfungen, Dreiecke gleich
  den Godot-GLBs, Maße auf 0,05 mm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:41:24 +02:00

454 lines
23 KiB
Python

"""Reproducible Blender -> FBX export for the Unity port (docs/port/U2-WORLD.md, U2A).
Runs headless on the Blender sources of the Godot reference, applies the per-asset export
rules of tools/blender/export_manifest.json, checks the result and writes, per asset:
Assets/Luneraria/Art/<group>/<id>.fbx the model for Unity
Assets/Luneraria/Art/<group>/<id>.export.json facts and checks (read by Unity's importer)
Checks: units and scale, up and forward axis, object transforms, mesh names, triangle counts,
material slots, UVs, normals, tangent prerequisites, vertex colours, root bone, bone names,
clips (names, lengths, loop flags, contact events), bounds. Expectations come from the Godot
manifests of the GLBs the reference game uses, so the FBX must describe the same model.
After writing, the FBX is imported into an empty scene and checked again (round trip).
Usage (from the Unity project root): sh tools/blender/export_fbx.sh [id,id,...]
(runs blender --background --factory-startup --python-use-system-env with PYTHONHASHSEED=0
--python tools/blender/lune_fbx_export.py -- --godot ../saumwacht-godot --ids <ids>)
Exit code 0 only if every check of every asset passed.
"""
import bpy
import hashlib
import json
import math
import sys
from pathlib import Path
from mathutils import Matrix, Vector
UNITY_ROOT = Path(__file__).resolve().parents[2]
EXPORTER_VERSION = 5
# One export setting for every asset. The file keeps Blender's Z-up axes; Unity's importer
# converts them with "Bake Axis Conversion" as Unity (x, y, z) = Blender (x, z, y), so no root
# carries a rotation and animation data is converted with the meshes (Blender's own "Apply
# Transform" is documented as broken for armatures). Before export every asset is turned by 180
# degrees about Blender's Z and the turn is applied (face_unity_forward): the front (Blender -Y,
# glTF/Godot +Z) then becomes Unity +Z, and Unity = (-x, y, z) of the Godot coordinates.
FBX_SETTINGS = dict(
use_selection=True, use_visible=False, use_active_collection=False,
global_scale=1.0, apply_unit_scale=True, apply_scale_options='FBX_SCALE_UNITS',
axis_forward='Y', axis_up='Z', bake_space_transform=False,
object_types={'EMPTY', 'MESH', 'ARMATURE'},
use_mesh_modifiers=True, use_mesh_modifiers_render=True,
mesh_smooth_type='OFF', use_subsurf=False, use_mesh_edges=False, use_tspace=True,
use_triangles=False, colors_type='LINEAR', prioritize_active_color=True,
use_custom_props=False,
add_leaf_bones=False, primary_bone_axis='Y', secondary_bone_axis='X',
use_armature_deform_only=True, armature_nodetype='NULL',
bake_anim=True, bake_anim_use_all_bones=True, bake_anim_use_nla_strips=False,
bake_anim_use_all_actions=True, bake_anim_force_startend_keying=True,
bake_anim_step=1.0, bake_anim_simplify_factor=0.0,
path_mode='STRIP', embed_textures=False, batch_mode='OFF',
)
def args():
argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
result = {'manifest': str(UNITY_ROOT / 'tools/blender/export_manifest.json'), 'godot': str(UNITY_ROOT.parent / 'saumwacht-godot'), 'ids': ''}
for i in range(0, len(argv) - 1, 2):
result[argv[i].lstrip('-')] = argv[i + 1]
return result
class Report:
def __init__(self, spec):
self.data = {'id': spec['id'], 'group': spec['group'], 'source': spec['source'], 'exporter_version': EXPORTER_VERSION,
'blender': bpy.app.version_string, 'fbx_settings': {k: (sorted(v) if isinstance(v, set) else v) for k, v in FBX_SETTINGS.items()},
'checks': []}
def check(self, name, ok, detail=''):
self.data['checks'].append({'check': name, 'ok': bool(ok), 'detail': str(detail)})
return ok
@property
def ok(self):
return all(c['ok'] for c in self.data['checks'])
def godot_expectations(spec, godot_root):
"""Facts of the GLB the reference uses, from its manifest (triangles, nodes, bounds, ...)."""
source = spec.get('godot_manifest')
if not source:
return {}
data = json.loads((godot_root / source['file']).read_text())
assets = data['assets'] if isinstance(data, dict) and 'assets' in data else data
for entry in assets:
if entry.get('id') == source['id']:
return entry
raise KeyError('no entry %s in %s' % (source['id'], source['file']))
def triangles(mesh):
return sum(len(p.vertices) - 2 for p in mesh.polygons)
def evaluated_mesh(obj, depsgraph):
evaluated = obj.evaluated_get(depsgraph)
return evaluated, evaluated.to_mesh()
def prepare(spec, report):
"""Applies the export rules of the manifest to the opened .blend (never saved)."""
rules = spec.get('rules', {})
skip = tuple(rules.get('skip_modifiers', ()))
removed = 0
if skip:
for obj in bpy.data.objects:
for modifier in list(obj.modifiers):
if modifier.name.startswith(skip):
obj.modifiers.remove(modifier)
removed += 1
report.data['removed_modifiers'] = removed
if rules.get('logical_names'):
logical_names(report)
join = rules.get('join')
if join:
parts = [o for o in bpy.context.scene.objects if o.type == 'MESH' and o.parent and o.parent.name == join['parent']]
bpy.ops.object.select_all(action='DESELECT')
for obj in parts:
obj.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.join()
bpy.context.object.name = join['into']
bpy.context.object.data.name = join['into']
report.data['joined_parts'] = len(parts)
face_unity_forward(report)
rest_action = rules.get('rest_action')
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE' and obj.animation_data:
# Export the bind pose, not the last evaluated frame of some action.
obj.animation_data.action = bpy.data.actions.get(rest_action) if rest_action else None
bpy.context.scene.frame_set(0)
def face_unity_forward(report):
"""Turns every root 180 degrees about Z and applies the turn to all objects (meshes, empties,
armatures with their rest pose). Bone animation is stored relative to the rest pose and
stays valid. Shared mesh data is made single-user first."""
turn = Matrix.Rotation(math.pi, 4, 'Z')
scene_objects = [o for o in bpy.context.scene.objects if o.type in ('MESH', 'ARMATURE', 'EMPTY')]
for obj in scene_objects:
if obj.parent is None:
obj.matrix_world = turn @ obj.matrix_world
bpy.ops.object.select_all(action='DESELECT')
for obj in scene_objects:
obj.hide_set(False)
obj.hide_select = False
obj.select_set(True)
bpy.context.view_layer.objects.active = scene_objects[0]
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False, isolate_users=True)
turned = [o.name for o in scene_objects if o.matrix_world.to_euler().z != 0.0 and abs(o.matrix_world.to_euler().z) > 1e-5]
report.check('turn_applied', not turned, turned[:8])
def logical_names(report):
"""Phase-4 sources keep their logical names in custom properties (part_name, material_name);
Blender may have suffixed the datablocks (.004). The Godot pipeline renamed the GLB nodes the
same way (assets-source/phase4/blender/pipeline.py, _canonical_glb_names). Materials that share
a logical name are one material and are merged."""
renamed = 0
for obj in list(bpy.data.objects):
if 'part_name' in obj.keys():
obj.name = '__lune_tmp_' + obj.name
for obj in list(bpy.data.objects):
if 'part_name' in obj.keys():
obj.name = obj['part_name']
renamed += int(obj.name == obj['part_name'])
groups = {}
for material in list(bpy.data.materials):
logical = material.get('material_name')
if logical:
groups.setdefault(logical, []).append(material)
merged = 0
for logical, materials in groups.items():
keep = materials[0]
for other in materials[1:]:
other.user_remap(keep)
merged += 1
for material in materials[1:]:
bpy.data.materials.remove(material)
keep.name = '__lune_tmp_' + logical
for logical in groups:
bpy.data.materials['__lune_tmp_' + logical].name = logical
collisions = [o.name for o in bpy.data.objects if 'part_name' in o.keys() and o.name != o['part_name']]
report.check('logical_names_unique', not collisions, collisions[:12])
report.data['logical_names'] = {'objects': renamed, 'materials': len(groups), 'merged_materials': merged}
def export_objects(spec):
include = spec.get('rules', {}).get('objects')
objects = []
for obj in bpy.context.scene.objects:
if obj.type not in ('MESH', 'ARMATURE', 'EMPTY'):
continue
if include and obj.name not in include:
continue
if obj.hide_render:
continue
objects.append(obj)
return objects
def to_unity(v):
"""Blender (x, y, z), after the turn of face_unity_forward -> Unity (x, y, z) = (x, z, y)."""
return Vector((v.x, v.z, v.y))
def facts(objects, depsgraph):
"""Scene facts in Unity coordinates: meshes, materials, bounds, bones, actions."""
meshes = {}
low = Vector((math.inf,) * 3)
high = Vector((-math.inf,) * 3)
for obj in objects:
if obj.type != 'MESH':
continue
evaluated, mesh = evaluated_mesh(obj, depsgraph)
world = evaluated.matrix_world
# Skinned meshes: bounds of the rest pose (armature modifier is evaluated in the bind pose).
mesh_low = Vector((math.inf,) * 3)
mesh_high = Vector((-math.inf,) * 3)
for vertex in mesh.vertices:
p = to_unity(world @ vertex.co)
mesh_low = Vector(map(min, mesh_low, p))
mesh_high = Vector(map(max, mesh_high, p))
low = Vector(map(min, low, mesh_low))
high = Vector(map(max, high, mesh_high))
normals_ok = all(abs(n.vector.length - 1.0) < 1e-3 for n in mesh.corner_normals)
meshes[obj.name] = {
'mesh': obj.data.name, 'triangles': triangles(mesh), 'vertices': len(mesh.vertices),
'ngons': sum(1 for p in mesh.polygons if len(p.vertices) > 4),
'materials': [slot.material.name if slot.material else None for slot in obj.material_slots],
'uv_layers': [uv.name for uv in mesh.uv_layers],
'color_attributes': [c.name for c in mesh.color_attributes],
'active_color': mesh.color_attributes.active_color.name if mesh.color_attributes.active_color else None,
'custom_normals': obj.data.has_custom_normals, 'normals_unit_length': normals_ok,
'shape_keys': len(obj.data.shape_keys.key_blocks) - 1 if obj.data.shape_keys else 0,
'skinned': any(m.type == 'ARMATURE' for m in obj.modifiers),
'scale': [round(s, 6) for s in obj.matrix_world.to_scale()],
'bounds_unity': {'min': [round(c, 4) for c in mesh_low], 'max': [round(c, 4) for c in mesh_high]},
}
evaluated.to_mesh_clear()
armatures = {}
for obj in objects:
if obj.type == 'ARMATURE':
bones = [b for b in obj.data.bones if b.use_deform]
armatures[obj.name] = {'bones': [b.name for b in bones], 'roots': [b.name for b in bones if b.parent is None or not b.parent.use_deform]}
empties = {o.name: [round(c, 4) for c in to_unity(o.matrix_world.translation)] for o in objects if o.type == 'EMPTY'}
return {'meshes': meshes, 'armatures': armatures, 'empties': empties,
'bounds_unity': {'min': [round(c, 4) for c in low], 'max': [round(c, 4) for c in high]} if meshes else None}
def clip_facts(fps):
clips = []
for action in bpy.data.actions:
start, end = action.frame_range
clips.append({'name': action.name, 'frames': [round(start, 3), round(end, 3)], 'seconds': round((end - start) / fps, 4),
'loop': bool(action.get('loop', action.name in ('idle', 'walk'))),
'contact_seconds': float(action.get('contact_seconds', -1.0))})
return clips
def godot_to_unity_bounds(bounds):
"""Godot/glTF bounds (x, y, z) -> Unity (-x, y, z)."""
lo, hi = bounds['min'], bounds['max']
return {'min': [-hi[0], lo[1], lo[2]], 'max': [-lo[0], hi[1], hi[2]]}
def run_checks(spec, report, before, clips, expect):
scene = bpy.context.scene
report.check('units_metric', scene.unit_settings.system == 'METRIC', scene.unit_settings.system)
report.check('unit_scale_1', abs(scene.unit_settings.scale_length - 1.0) < 1e-9, scene.unit_settings.scale_length)
meshes = before['meshes']
bad_scale = {n: m['scale'] for n, m in meshes.items() if any(abs(s - 1.0) > 1e-4 for s in m['scale'])}
negative = {n: s for n, s in bad_scale.items() if any(c < 0 for c in s)}
report.check('no_negative_scale', not negative, negative)
report.check('object_scale_1', not bad_scale or spec.get('rules', {}).get('allow_object_scale', False), bad_scale)
names = [m['mesh'] for m in meshes.values()]
report.check('mesh_names_unique', len(set(meshes)) == len(meshes), sorted(meshes))
empty_slots = {n: m['materials'] for n, m in meshes.items() if None in m['materials'] or not m['materials']}
report.check('material_slots_filled', not empty_slots, empty_slots)
report.check('normals_unit_length', all(m['normals_unit_length'] for m in meshes.values()),
[n for n, m in meshes.items() if not m['normals_unit_length']])
# Tangents need UVs and faces with at most four corners; meshes without UVs are shaded
# triplanar or unlit and carry no tangents (recorded, not an error).
tangent_blockers = {n: m['ngons'] for n, m in meshes.items() if m['uv_layers'] and m['ngons']}
report.check('tangent_prerequisites', not tangent_blockers, tangent_blockers)
report.data['meshes_without_uv'] = sorted(n for n, m in meshes.items() if not m['uv_layers'])
# Blend shapes: none of the sources uses them; Unity imports them only when they exist.
shapes = {n: m['shape_keys'] for n, m in meshes.items() if m['shape_keys']}
report.check('no_blend_shapes', not shapes or spec.get('rules', {}).get('blend_shapes', False), shapes)
total = sum(m['triangles'] for m in meshes.values())
report.data['triangles'] = total
if 'triangles' in expect:
report.check('triangles_match_godot', total == expect['triangles'], '%d fbx / %d godot' % (total, expect['triangles']))
if 'nodes' in expect:
report.check('mesh_names_match_godot', sorted(meshes) == sorted(expect['nodes']), sorted(set(meshes) ^ set(expect['nodes'])))
if 'parts' in expect and spec.get('rules', {}).get('check_parts', True):
report.check('mesh_names_match_godot', sorted(meshes) == sorted(expect['parts']), sorted(set(meshes) ^ set(expect['parts']))[:12])
if 'materials' in expect:
used = sorted({name for m in meshes.values() for name in m['materials'] if name})
report.check('materials_match_godot', used == sorted(expect['materials']), sorted(set(used) ^ set(expect['materials'])))
if expect.get('bounds_godot') and before['bounds_unity']:
# The Godot manifests measure the intact model (towers: without damaged upper part,
# debris, ruin and cloth); bounds_exclude names those parts.
exclude = tuple(spec.get('rules', {}).get('bounds_exclude', ()))
chosen = [m['bounds_unity'] for n, m in meshes.items() if not (exclude and n.startswith(exclude))]
got = {'min': [min(b['min'][i] for b in chosen) for i in range(3)], 'max': [max(b['max'][i] for b in chosen) for i in range(3)]}
want = godot_to_unity_bounds(expect['bounds_godot'])
worst = max(abs(a - b) for key in ('min', 'max') for a, b in zip(got[key], want[key]))
report.check('bounds_match_godot', worst < 2e-3, 'largest difference %.5f m' % worst)
armatures = before['armatures']
if spec['kind'] == 'skinned':
report.check('one_armature', len(armatures) == 1, sorted(armatures))
for name, arm in armatures.items():
report.check('single_root_bone', len(arm['roots']) == 1, arm['roots'])
want_root = spec.get('rules', {}).get('root_bone', 'Root')
report.check('root_bone_name', arm['roots'] == [want_root], arm['roots'])
if 'bone_names' in expect:
report.check('bone_names_match_godot', sorted(arm['bones']) == sorted(expect['bone_names']), sorted(set(arm['bones']) ^ set(expect['bone_names'])))
if 'bones' in expect:
report.check('bone_count_match_godot', len(arm['bones']) == expect['bones'], '%d / %d' % (len(arm['bones']), expect['bones']))
bad = [b for b in arm['bones'] if not b.replace('.', '').replace('_', '').isalnum()]
report.check('bone_names_portable', not bad, bad)
report.check('skinned_meshes_present', any(m['skinned'] for m in meshes.values()), '')
names = sorted(c['name'] for c in clips)
if 'clips' in expect:
report.check('clips_match_godot', names == sorted(expect['clips']), sorted(set(names) ^ set(expect['clips'])))
required = spec.get('rules', {}).get('required_clips', [])
report.check('required_clips_present', all(c in names for c in required), [c for c in required if c not in names])
if 'clip_seconds' in expect:
worst = max(abs(c['seconds'] - expect['clip_seconds'][c['name']]) for c in clips if c['name'] in expect['clip_seconds'])
report.check('clip_lengths_match_godot', worst < 1e-3, 'largest difference %.4f s' % worst)
else:
report.check('no_armature', not armatures, sorted(armatures))
forward = spec.get('rules', {}).get('forward_marker')
if forward:
marker = next((p for n, p in before['empties'].items() if n.startswith(forward)), None)
report.check('forward_marker_unity_plus_z', marker is not None and marker[2] > 0.1 and abs(marker[0]) < 1e-3, marker)
def reproducible_fbx():
"""Byte-identical FBX for identical sources: Blender's FBX writer stamps the current time into
the header and derives object ids from Python's string hash. The clock is fixed here; the hash
seed comes from PYTHONHASHSEED=0 with --python-use-system-env (tools/blender/export_fbx.sh)."""
import datetime
import io_scene_fbx.export_fbx_bin as writer
class FixedClock(datetime.datetime):
@classmethod
def now(cls, tz=None):
return cls(2026, 1, 1, 0, 0, 0)
class Clock:
datetime = FixedClock
writer.datetime = Clock
def export_fbx(path, objects):
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
path.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.fbx(filepath=str(path), **FBX_SETTINGS)
def round_trip(path, spec, report, before, clips):
"""Imports the written FBX into an empty file and compares names and counts."""
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(path), use_custom_normals=True, use_anim=True, automatic_bone_orientation=False)
depsgraph = bpy.context.evaluated_depsgraph_get()
imported = [o for o in bpy.context.scene.objects]
meshes = {o.name: o for o in imported if o.type == 'MESH'}
report.check('roundtrip_mesh_names', sorted(meshes) == sorted(before['meshes']), sorted(set(meshes) ^ set(before['meshes'])))
tri = 0
for obj in meshes.values():
evaluated, mesh = evaluated_mesh(obj, depsgraph)
tri += triangles(mesh)
evaluated.to_mesh_clear()
report.check('roundtrip_triangles', tri == report.data['triangles'], '%d / %d' % (tri, report.data['triangles']))
materials = sorted({s.material.name for o in meshes.values() for s in o.material_slots if s.material})
want = sorted({name for m in before['meshes'].values() for name in m['materials'] if name})
report.check('roundtrip_materials', materials == want, sorted(set(materials) ^ set(want)))
if spec['kind'] == 'skinned':
arm = next(o for o in imported if o.type == 'ARMATURE')
bones = sorted(b.name for b in arm.data.bones)
want_bones = sorted(next(iter(before['armatures'].values()))['bones'])
report.check('roundtrip_bones', bones == want_bones, sorted(set(bones) ^ set(want_bones)))
takes = sorted(a.name.split('|')[-1] for a in bpy.data.actions)
report.check('roundtrip_clips', takes == sorted(c['name'] for c in clips), takes)
def export_asset(spec, godot_root):
report = Report(spec)
source = godot_root / spec['source']
bpy.ops.wm.open_mainfile(filepath=str(source))
report.data['source_sha256'] = hashlib.sha256(source.read_bytes()).hexdigest()
expect = godot_expectations(spec, godot_root)
fps = bpy.context.scene.render.fps / bpy.context.scene.render.fps_base
prepare(spec, report)
objects = export_objects(spec)
# Facts of the bind pose (rest), like the Godot manifests; the animation bake needs POSE again.
armatures = [o for o in objects if o.type == 'ARMATURE']
for arm in armatures:
arm.data.pose_position = 'REST'
bpy.context.view_layer.update()
depsgraph = bpy.context.evaluated_depsgraph_get()
before = facts(objects, depsgraph)
for arm in armatures:
arm.data.pose_position = 'POSE'
bpy.context.view_layer.update()
clips = clip_facts(fps) if spec['kind'] == 'skinned' else []
report.data.update({'kind': spec['kind'], 'fps': fps, 'objects': len(objects), 'meshes': before['meshes'], 'armatures': before['armatures'],
'empties': before['empties'], 'bounds_unity': before['bounds_unity'], 'clips': clips,
'expected_from_godot': {k: expect[k] for k in ('triangles', 'bounds_godot', 'bones', 'clips', 'resource_path') if k in expect}})
run_checks(spec, report, before, clips, expect)
out = UNITY_ROOT / 'Assets/Luneraria/Art' / spec['group'] / (spec['id'] + '.fbx')
export_fbx(out, objects)
report.data['fbx'] = str(out.relative_to(UNITY_ROOT))
report.data['fbx_bytes'] = out.stat().st_size
round_trip(out, spec, report, before, clips)
report.data['ok'] = report.ok
(out.with_suffix('.export.json')).write_text(json.dumps(report.data, indent=1) + '\n')
failed = [c for c in report.data['checks'] if not c['ok']]
print('LUNE_EXPORT %s %s: %d checks, %d failed%s' % ('OK' if not failed else 'FAIL', spec['id'], len(report.data['checks']), len(failed),
''.join('\n FAIL %s: %s' % (c['check'], c['detail']) for c in failed)))
return report.ok
def main():
reproducible_fbx()
options = args()
manifest = json.loads(Path(options['manifest']).read_text())
godot_root = Path(options['godot']).resolve()
wanted = [i for i in options['ids'].split(',') if i]
specs = [s for s in manifest['assets'] if not wanted or s['id'] in wanted]
missing = set(wanted) - {s['id'] for s in specs}
ok = not missing
if missing:
print('LUNE_EXPORT FAIL unknown ids', sorted(missing))
for spec in specs:
try:
ok = export_asset(spec, godot_root) and ok
except Exception as error: # a broken asset must not stop the batch
import traceback
traceback.print_exc()
print('LUNE_EXPORT FAIL %s: %s' % (spec['id'], error))
ok = False
print('LUNE_EXPORT_DONE ok=%s assets=%d' % (ok, len(specs)))
sys.exit(0 if ok else 1)
main()