Files

477 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Run in a separate Blender background process; never saves the input .blend.
Exports the scene timeline, with optional per-frame PBR texture atlas baking.
"""
import argparse
import fnmatch
import json
import math
import os
from pathlib import Path
import struct
import sys
import tempfile
import bpy
import numpy as np
def deselect_all():
# Blender's operator skips hidden objects that can retain a saved selection.
for obj in bpy.context.view_layer.objects:
obj.select_set(False)
def arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', required=True)
parser.add_argument('--scene')
parser.add_argument('--objects', action='append', help='Object name glob; repeat to select a subset')
parser.add_argument('--bake-materials', action='store_true')
parser.add_argument('--resolution', type=int, default=128)
parser.add_argument('--samples', type=int, default=8)
parser.add_argument('--lighting', choices=['compat', 'spec'], default='compat', help='Unitless lighting for Forma, or glTF physical light units')
parser.add_argument('--start-frame', type=int)
parser.add_argument('--end-frame', type=int)
parser.add_argument('--frame-step', type=int, default=1)
parser.add_argument('--allow-lossy', action='store_true', help='Permit features identified in the export report as unsupported')
return parser.parse_args(sys.argv[sys.argv.index('--') + 1:])
def tree_nodes(tree, seen=None):
seen = set() if seen is None else seen
if not tree or tree.as_pointer() in seen:
return []
seen.add(tree.as_pointer())
result = list(tree.nodes)
for node in tree.nodes:
if node.type == 'GROUP':
result += tree_nodes(node.node_tree, seen)
return result
def animated(material):
for data in [material, material.node_tree] + [n.node_tree for n in tree_nodes(material.node_tree) if n.type == 'GROUP']:
anim = getattr(data, 'animation_data', None)
if anim and (anim.action or anim.drivers or anim.nla_tracks):
return True
return any(n.type == 'TEX_IMAGE' and n.image and n.image.source in {'MOVIE', 'SEQUENCE'} for n in tree_nodes(material.node_tree))
def report_scene(objects, bake):
warnings = []
needs_bake = set()
unsupported = []
direct = {'ShaderNodeBsdfPrincipled', 'ShaderNodeOutputMaterial', 'ShaderNodeTexImage', 'ShaderNodeNormalMap', 'ShaderNodeUVMap', 'ShaderNodeMapping', 'ShaderNodeSeparateColor', 'ShaderNodeCombineColor', 'ShaderNodeRGB', 'ShaderNodeValue'}
for obj in objects:
if obj.type not in {'MESH', 'CURVE', 'FONT', 'SURFACE', 'EMPTY', 'ARMATURE', 'CAMERA', 'LIGHT'}:
unsupported.append(f'{obj.name}: object type {obj.type} needs conversion/render baking')
if obj.type == 'LIGHT' and obj.data.type == 'AREA':
unsupported.append(f'{obj.name}: area light is approximated by a point light')
for modifier in obj.modifiers:
if modifier.type in {'FLUID', 'CLOTH', 'SOFT_BODY', 'PARTICLE_SYSTEM', 'DYNAMIC_PAINT', 'NODES'}:
unsupported.append(f'{obj.name}: {modifier.type} is exported as evaluated geometry at the starting frame, not a simulation')
if obj.type == 'MESH' and obj.data.shape_keys and modifier.type != 'ARMATURE':
unsupported.append(f'{obj.name}: {modifier.type} cannot be applied while preserving shape keys; modifier is omitted')
for data in (obj, obj.data):
animation = getattr(data, 'animation_data', None)
if animation and animation.drivers:
warnings.append(f'{obj.name}: drivers are sampled with automatic Python execution disabled; inspect complex driver expressions')
for slot in obj.material_slots:
material = slot.material
if not material:
continue
nodes = tree_nodes(material.node_tree)
types = {n.bl_idname for n in nodes}
if types - direct or animated(material):
needs_bake.add(material.name)
if any('Volume' in t for t in types):
unsupported.append(f'{material.name}: volumetric shading cannot be represented by a glTF surface; use engine fog or render baking')
if types & {'ShaderNodeLayerWeight', 'ShaderNodeFresnel', 'ShaderNodeLightPath', 'ShaderNodeCameraData'}:
unsupported.append(f'{material.name}: view-dependent nodes are frozen by UV baking; inspect the result from different angles')
if any(n.type == 'OUTPUT_MATERIAL' and n.inputs['Displacement'].is_linked for n in nodes):
unsupported.append(f'{material.name}: displacement needs evaluated geometry; the surface bake does not displace vertices')
if not bake and material.name in needs_bake:
warnings.append(f'{material.name}: procedural/animated inputs may need --bake-materials; only glTF-compatible property animation transfers directly')
if bake:
for node in nodes:
if node.type == 'BSDF_PRINCIPLED':
for name in ('Transmission Weight', 'Coat Weight', 'Sheen Weight', 'Subsurface Weight', 'Thin Film Thickness', 'Anisotropic IOR Level'):
socket = node.inputs.get(name)
if socket and (socket.is_linked or socket.default_value != 0):
unsupported.append(f'{material.name}: baked PBR does not preserve {name}; use native glTF for compatible inputs')
return sorted(set(warnings)), sorted(set(unsupported)), sorted(needs_bake)
def socket_source(material):
tree = material.node_tree
output = next((n for n in tree.nodes if n.type == 'OUTPUT_MATERIAL' and n.is_active_output), None)
if not output or not output.inputs['Surface'].is_linked:
raise RuntimeError(f'{material.name}: no surface shader to bake')
surface = output.inputs['Surface'].links[0].from_node
alpha = None
if surface.type == 'MIX_SHADER':
links = [surface.inputs[i].links[0].from_node if surface.inputs[i].is_linked else None for i in (1, 2)]
transparent = [i for i, n in enumerate(links) if n and n.type == 'BSDF_TRANSPARENT']
if len(transparent) == 1:
transparent_index = transparent[0]
alpha = surface.inputs[0]
if transparent_index == 1:
inverse = tree.nodes.new('ShaderNodeMath')
inverse.operation = 'SUBTRACT'
inverse.inputs[0].default_value = 1
copy_socket(tree, alpha, inverse.inputs[1])
alpha = inverse.outputs[0]
surface = links[1 - transparent_index]
if surface and surface.type == 'BSDF_PRINCIPLED':
return output, {'color': surface.inputs['Base Color'], 'alpha': alpha or surface.inputs['Alpha'], 'roughness': surface.inputs['Roughness'], 'metallic': surface.inputs['Metallic'], 'emission': surface.inputs['Emission Color'], 'strength': surface.inputs['Emission Strength']}, False
if surface and surface.type == 'EMISSION':
return output, {'color': surface.inputs['Color'], 'alpha': alpha, 'roughness': None, 'metallic': None, 'emission': surface.inputs['Color'], 'strength': surface.inputs['Strength']}, True
raise RuntimeError(f'{material.name}: bake requires a Principled or Emission surface (optionally mixed with Transparent)')
def copy_socket(tree, source, target, default=0):
if source is None:
value = default
elif source.is_output:
tree.links.new(source, target)
return
elif source.is_linked:
tree.links.new(source.links[0].from_socket, target)
return
else:
value = source.default_value
try:
target.default_value = value
except (TypeError, ValueError):
if isinstance(value, (int, float)):
target.default_value = (value, value, value, 1)
else:
target.default_value = float(value[0])
def image_pixels(image, resolution):
pixels = np.empty(resolution * resolution * 4, dtype=np.float32)
image.pixels.foreach_get(pixels)
return pixels.reshape((resolution, resolution, 4))
def bake_object(obj, scene, frames, options, temporary, warnings):
# Each object gets its own material because UV/object coordinates can differ.
original_slots = [slot.material for slot in obj.material_slots]
if not original_slots:
return None
obj.data = obj.data.copy()
for i, material in enumerate(original_slots):
if material:
obj.material_slots[i].material = material.copy()
if not obj.data.uv_layers:
deselect_all()
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.smart_project(island_margin=.035)
bpy.ops.object.mode_set(mode='OBJECT')
states = []
for material in [slot.material for slot in obj.material_slots if slot.material]:
if not material.use_nodes:
material.use_nodes = True
output, sockets, unlit = socket_source(material)
tree = material.node_tree
original = output.inputs['Surface'].links[0].from_socket
emission = tree.nodes.new('ShaderNodeEmission')
image_node = tree.nodes.new('ShaderNodeTexImage')
tree.nodes.active = image_node
states.append((tree, output, original, emission, image_node, sockets, unlit))
if not states:
return None
sample_frames = frames if any(animated(m) for m in original_slots if m) else frames[:1]
size = options.resolution
pad = 2
cell = size + 2 * pad
columns = math.ceil(math.sqrt(len(sample_frames)))
rows = math.ceil(len(sample_frames) / columns)
width, height = columns * cell, rows * cell
if max(width, height) > 8192:
raise RuntimeError('Texture atlas exceeds 8192px; increase --frame-step or reduce --resolution')
atlases = {key: np.zeros((height, width, 4), dtype=np.float32) for key in ('color', 'orm', 'normal', 'emission')}
image = bpy.data.images.new('FormaBakeTarget', width=size, height=size, alpha=True, float_buffer=True)
image.colorspace_settings.name = 'Non-Color'
for state in states:
state[4].image = image
deselect_all()
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
try:
for index, frame in enumerate(sample_frames):
scene.frame_set(frame)
for tree, *_ in states:
if tree.animation_data:
for curve in tree.animation_data.drivers:
if not curve.driver.is_valid:
raise RuntimeError(f'{obj.name}: invalid/disabled driver {curve.data_path}; prepare its animation in Blender before exporting')
channels = {}
for channel in ('color', 'alpha', 'roughness', 'metallic', 'emission', 'normal'):
for tree, output, original, emission, image_node, sockets, unlit in states:
tree.nodes.active = image_node
for link in list(output.inputs['Surface'].links):
tree.links.remove(link)
if channel == 'normal':
tree.links.new(original, output.inputs['Surface'])
else:
for input_socket in emission.inputs:
for link in list(input_socket.links):
tree.links.remove(link)
defaults = {'alpha': 1, 'roughness': .5, 'metallic': 0, 'emission': 0, 'color': .8}
copy_socket(tree, sockets.get(channel), emission.inputs['Color'], defaults[channel])
if unlit and channel == 'color':
for link in list(emission.inputs['Color'].links):
tree.links.remove(link)
emission.inputs['Color'].default_value = (0, 0, 0, 1)
if channel == 'emission':
copy_socket(tree, sockets.get('strength'), emission.inputs['Strength'], 1)
else:
emission.inputs['Strength'].default_value = 1
tree.links.new(emission.outputs[0], output.inputs['Surface'])
bpy.context.view_layer.update()
bpy.ops.object.bake(type='NORMAL' if channel == 'normal' else 'EMIT', use_clear=True, margin=8, normal_space='TANGENT')
channels[channel] = image_pixels(image, size).copy()
channels['color'][:, :, 3] = np.clip(channels['alpha'][:, :, 0], 0, 1)
orm = np.ones((size, size, 4), dtype=np.float32)
orm[:, :, 1] = channels['roughness'][:, :, 0]
orm[:, :, 2] = channels['metallic'][:, :, 0]
channels['orm'] = orm
y, x = (index // columns) * cell, (index % columns) * cell
for channel, atlas in atlases.items():
atlas[y:y+cell, x:x+cell, :] = np.pad(channels[channel], ((pad, pad), (pad, pad), (0, 0)), mode='edge')
print(f'FORMA_BAKE {obj.name} {index+1}/{len(sample_frames)}', flush=True)
finally:
for tree, output, original, emission, image_node, sockets, unlit in states:
for link in list(output.inputs['Surface'].links):
tree.links.remove(link)
tree.links.new(original, output.inputs['Surface'])
bpy.data.images.remove(image)
strength = max(1.0, float(np.nanmax(atlases['emission'][:, :, :3])))
atlases['emission'][:, :, :3] /= strength
material = bpy.data.materials.new('FormaBake_' + obj.name)
material.use_nodes = True
tree = material.node_tree
principled = tree.nodes.get('Principled BSDF')
if all(state[6] for state in states):
principled.inputs['Specular IOR Level'].default_value = 0
texture_nodes = {}
for channel, pixels in atlases.items():
pixels = np.nan_to_num(np.clip(pixels, 0, 1), nan=0, posinf=1, neginf=0)
atlas_image = bpy.data.images.new(material.name + '_' + channel, width=width, height=height, alpha=True)
atlas_image.colorspace_settings.name = 'sRGB' if channel in {'color', 'emission'} else 'Non-Color'
atlas_image.pixels.foreach_set(pixels.ravel())
atlas_image.filepath_raw = str(Path(temporary) / (str(atlas_image.as_pointer()) + '.png'))
atlas_image.file_format = 'PNG'
atlas_image.save()
atlas_image.pack()
tex = tree.nodes.new('ShaderNodeTexImage')
tex.image = atlas_image
texture_nodes[channel] = tex
tree.links.new(texture_nodes['color'].outputs['Color'], principled.inputs['Base Color'])
tree.links.new(texture_nodes['color'].outputs['Alpha'], principled.inputs['Alpha'])
separate = tree.nodes.new('ShaderNodeSeparateColor')
tree.links.new(texture_nodes['orm'].outputs['Color'], separate.inputs[0])
tree.links.new(separate.outputs['Green'], principled.inputs['Roughness'])
tree.links.new(separate.outputs['Blue'], principled.inputs['Metallic'])
normal = tree.nodes.new('ShaderNodeNormalMap')
tree.links.new(texture_nodes['normal'].outputs['Color'], normal.inputs['Color'])
tree.links.new(normal.outputs['Normal'], principled.inputs['Normal'])
tree.links.new(texture_nodes['emission'].outputs['Color'], principled.inputs['Emission Color'])
principled.inputs['Emission Strength'].default_value = strength
obj.data.materials.clear()
obj.data.materials.append(material)
for polygon in obj.data.polygons:
polygon.material_index = 0
return {'material': material.name, 'frames': sample_frames, 'scale': [size / width, size / height], 'offsets': [[((i % columns) * cell + pad) / width, ((rows - 1 - i // columns) * cell + pad) / height] for i in range(len(sample_frames))], 'alpha': any(np.min(atlases['color'][(i // columns)*cell:(i // columns+1)*cell, (i % columns)*cell:(i % columns+1)*cell, 3]) < .999 for i in range(len(sample_frames))), 'emissionStrength': strength}
def read_glb(filename):
data = Path(filename).read_bytes()
length = struct.unpack_from('<I', data, 12)[0]
doc = json.loads(data[20:20+length])
binary = bytearray()
offset = 20 + length
while offset + 8 <= len(data):
size, kind = struct.unpack_from('<II', data, offset)
if kind == 0x004E4942:
binary.extend(data[offset+8:offset+8+size])
offset += 8 + size
return doc, binary
def write_glb(filename, doc, binary):
while len(binary) % 4:
binary.append(0)
doc['buffers'] = [{'byteLength': len(binary)}] if binary else []
data = json.dumps(doc, ensure_ascii=False, separators=(',', ':')).encode()
data += b' ' * (-len(data) % 4)
chunks = struct.pack('<II', len(data), 0x4E4F534A) + data
if binary:
chunks += struct.pack('<II', len(binary), 0x004E4942) + binary
Path(filename).write_bytes(struct.pack('<III', 0x46546C67, 2, 12 + len(chunks)) + chunks)
def accessor(doc, binary, values, components):
while len(binary) % 4:
binary.append(0)
offset = len(binary)
flat = [x for value in values for x in (value if isinstance(value, list) else [value])]
binary.extend(struct.pack('<' + 'f' * len(flat), *flat))
views = doc.setdefault('bufferViews', [])
views.append({'buffer': 0, 'byteOffset': offset, 'byteLength': len(flat) * 4})
accessors = doc.setdefault('accessors', [])
entry = {'bufferView': len(views)-1, 'componentType': 5126, 'count': len(values), 'type': 'SCALAR' if components == 1 else 'VEC' + str(components)}
if components == 1:
entry.update(min=[min(flat)], max=[max(flat)])
accessors.append(entry)
return len(accessors)-1
def attach_atlas_animations(doc, binary, bakes, scene):
fps = scene.render.fps / scene.render.fps_base
for bake in bakes:
index = next(i for i, m in enumerate(doc.get('materials', [])) if m.get('name') == bake['material'])
material = doc['materials'][index]
material['alphaMode'] = 'BLEND' if bake['alpha'] else 'OPAQUE'
paths = [('pbrMetallicRoughness/baseColorTexture', material.get('pbrMetallicRoughness', {}).get('baseColorTexture')), ('pbrMetallicRoughness/metallicRoughnessTexture', material.get('pbrMetallicRoughness', {}).get('metallicRoughnessTexture')), ('normalTexture', material.get('normalTexture')), ('emissiveTexture', material.get('emissiveTexture'))]
for property_path, info in paths:
if not info:
continue
info.setdefault('extensions', {})['KHR_texture_transform'] = {'scale': bake['scale'], 'offset': bake['offsets'][0]}
samplers = doc.setdefault('samplers', [])
samplers.append({'magFilter': 9729, 'minFilter': 9729, 'wrapS': 33071, 'wrapT': 33071})
doc['textures'][info['index']]['sampler'] = len(samplers)-1
used = doc.setdefault('extensionsUsed', [])
if 'KHR_texture_transform' not in used:
used.append('KHR_texture_transform')
if len(bake['frames']) <= 1:
continue
if 'KHR_animation_pointer' not in used:
used.append('KHR_animation_pointer')
times = [(frame - scene.frame_start) / fps for frame in bake['frames']]
times.append((scene.frame_end - scene.frame_start + 1) / fps)
offsets = bake['offsets'] + [bake['offsets'][0]]
animations = doc.setdefault('animations', [])
if not animations:
animations.append({'name': 'BlenderTimeline', 'channels': [], 'samplers': []})
animation = animations[0]
sampler = {'input': accessor(doc, binary, times, 1), 'output': accessor(doc, binary, offsets, 2), 'interpolation': 'STEP'}
animation['samplers'].append(sampler)
animation['channels'].append({'sampler': len(animation['samplers'])-1, 'target': {'path': 'pointer', 'extensions': {'KHR_animation_pointer': {'pointer': f'/materials/{index}/{property_path}/extensions/KHR_texture_transform/offset'}}}})
def main():
if not bpy.app.background:
raise RuntimeError('Use a separate Blender --background process to preserve the open authoring session')
options = arguments()
if options.resolution < 16 or options.resolution > 1024 or options.frame_step < 1 or not 1 <= options.samples <= 256:
raise RuntimeError('Resolution must be 161024, frame step >= 1, samples 1256')
scene = bpy.data.scenes.get(options.scene) if options.scene else bpy.context.scene
if scene is None:
raise RuntimeError('Scene not found')
bpy.context.window.scene = scene
scene.frame_start = options.start_frame if options.start_frame is not None else scene.frame_start
scene.frame_end = options.end_frame if options.end_frame is not None else scene.frame_end
if scene.frame_end < scene.frame_start:
raise RuntimeError('End frame precedes start frame')
frames = list(range(scene.frame_start, scene.frame_end + 1, options.frame_step))
if options.bake_materials and len(frames) > 256:
raise RuntimeError('At most 256 baked frames; increase --frame-step')
scene.frame_set(scene.frame_start)
objects = [o for o in scene.objects if not o.hide_render and (not options.objects or any(fnmatch.fnmatchcase(o.name, pattern) for pattern in options.objects))]
if not objects:
raise RuntimeError('No objects matched the export selection')
warnings, unsupported, needs_bake = report_scene(objects, options.bake_materials)
report = {'version': 1, 'blender': bpy.app.version_string, 'scene': scene.name, 'objects': len(objects), 'bakedMaterials': [], 'warnings': warnings + unsupported, 'frames': [scene.frame_start, scene.frame_end], 'frameStep': options.frame_step, 'lighting': options.lighting}
output = Path(options.output).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
report_path = output.with_suffix('.report.json')
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2))
if unsupported and not options.allow_lossy:
raise RuntimeError('Unsupported features found; inspect ' + str(report_path) + '. Use --allow-lossy only to accept the listed approximations.')
for obj in objects:
if obj.type == 'LIGHT' and obj.data.type == 'AREA':
obj.data = obj.data.copy()
obj.data.type = 'POINT'
deselect_all()
for obj in objects:
obj.hide_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
object_names = [obj.name for obj in objects]
curves = [obj for obj in objects if obj.type in {'CURVE', 'FONT', 'SURFACE'}]
for obj in objects:
obj.select_set(obj in curves)
if curves:
bpy.context.view_layer.objects.active = curves[0]
bpy.ops.object.convert(target='MESH')
# Conversion normally preserves object identity and names.
objects = [bpy.data.objects.get(name) for name in object_names]
objects = [obj for obj in objects if obj]
for obj in objects:
if obj.type != 'MESH' or obj.data.shape_keys:
continue
deselect_all()
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
obj.data = obj.data.copy()
for modifier in list(obj.modifiers):
if modifier.type == 'ARMATURE':
continue
if not modifier.show_render:
obj.modifiers.remove(modifier)
continue
modifier.show_viewport = True
result = bpy.ops.object.modifier_apply(modifier=modifier.name)
if 'FINISHED' not in result:
raise RuntimeError(f'{obj.name}: could not apply modifier {modifier.name}')
if options.bake_materials:
modifier = obj.modifiers.new('Forma triangulation', 'TRIANGULATE')
bpy.ops.object.modifier_apply(modifier=modifier.name)
with tempfile.TemporaryDirectory(prefix='forma-bake-') as temporary:
bakes = []
if options.bake_materials:
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = options.samples
for obj in objects:
if obj.type != 'MESH' or not any(s.material and s.material.name in needs_bake for s in obj.material_slots):
continue
if any('Volume' in n.bl_idname for slot in obj.material_slots if slot.material for n in tree_nodes(slot.material.node_tree)):
continue
baked = bake_object(obj, scene, frames, options, temporary, warnings)
if baked:
bakes.append(baked)
scene.frame_set(scene.frame_start)
deselect_all()
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
properties = bpy.ops.export_scene.gltf.get_rna_type().properties
required = ['export_pointer_animation', 'export_animation_mode']
if any(name not in properties for name in required):
raise RuntimeError('Install a Blender version with glTF Animation Pointer export support')
settings = dict(filepath=str(output), export_format='GLB', use_selection=True, use_active_scene=True, export_animations=True, export_animation_mode='SCENE', export_pointer_animation=True, export_force_sampling=True, export_frame_range=True, export_frame_step=options.frame_step, export_skins=True, export_morph=True, export_morph_animation=True, export_cameras=True, export_lights=True, export_extras=True, export_texcoords=True, export_normals=True, export_tangents=True, export_anim_scene_split_object=False, export_convert_animation_pointer=True, export_bake_animation=True, export_gn_mesh=True, export_optimize_animation_size=False)
settings['export_import_convert_lighting_mode'] = options.lighting.upper()
bpy.ops.export_scene.gltf(**{key: value for key, value in settings.items() if key in properties})
doc, binary = read_glb(output)
attach_atlas_animations(doc, binary, bakes, scene)
report['bakedMaterials'] = [{'material': b['material'], 'frames': len(b['frames'])} for b in bakes]
report['warnings'] = sorted(set(report['warnings'] + warnings))
active_camera = next((doc['cameras'][node['camera']].get('name') for node in doc.get('nodes', []) if scene.camera and node.get('name') == scene.camera.name and 'camera' in node), None)
doc.setdefault('extras', {})['forma'] = {'version': 1, 'warnings': report['warnings'], 'source': 'Blender', 'activeCamera': active_camera, 'fps': scene.render.fps / scene.render.fps_base}
write_glb(output, doc, binary)
report['bytes'] = output.stat().st_size
report['clips'] = [animation.get('name') for animation in doc.get('animations', [])]
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2))
print('FORMA_EXPORT ' + json.dumps(report, ensure_ascii=False), flush=True)
if __name__ == '__main__':
main()