Skip to content

glTF 2.0 export (vendor-neutral)

Every other 3D exporter here is engine-specific (Unity .anim, Godot .tres, Live2D .motion3.json). glTF 2.0 (issue #49) is the ISO/IEC 12113 runtime interchange standard — imported directly by Blender, Three.js, Babylon.js, Godot, Unity and Unreal, and the base of VRM (the VTuber avatar format we already ship vrm/vrm0 retarget presets for). Its animation natively drives morph-target weights (animation.channel.target.path = "weights"), which is exactly OpenFaceFX's [0, 1] viseme/blendshape channel model — so one portable file plays anywhere.

python -m openfacefx naive --text "..." --wav v.wav -o face.gltf     # JSON + base64 buffer
python -m openfacefx naive --text "..." --wav v.wav -o face.glb      # binary container
python -m openfacefx convert track.json -o face.glb                  # from an existing track
python -m openfacefx convert track.json --gltf-head-node -o face.glb # + head rotation

.gltf/.glb are wired into all four generate commands and convert through the shared exporter dispatch, exactly like the other exporters.

Structure

A stub mesh declares N morph targets named after the track's [0, 1] weight channels via mesh.extras.targetNames (the de-facto convention a consumer remaps by), a node references it with a weights array, and one animation has a LINEAR sampler whose input is the per-frame time grid (strictly increasing, with min/max) and whose output is the frame-major n_frames × N morph weights. The sparse FaceTrack channels are densified onto that grid with np.interp. Accessors are packed with numpy as little-endian FLOAT (componentType 5126); .gltf embeds the buffer as a base64 data: URI, .glb packs a 12-byte header + a JSON chunk (space-padded to 4 bytes) + a BIN chunk (zero-padded) via stdlib struct.

Only [0, 1] weight channels become morph weights. The signed head/eye pose channels (headPitch/Yaw/Roll, eyePitch/Yaw — degrees) are excluded by default, since they are not morph weights. The opt-in --gltf-head-node encodes headPitch/Yaw/Roll as a separate node rotation (Euler→quaternion) sampler, honestly kept distinct from the morphs.

Verification

The Khronos glTF Validator is the documented external CI/manual gate — it cannot run in this repo's environment, so the asset is built strictly to the glTF 2.0 spec (so it would pass) and the in-repo proof is a full accessor round-trip (pure json/base64/struct, no external dependency): decoding the LE-float32 input/output accessors reconstructs every weight channel within 1e-6 of the source, with accessor.min / max / count / byteLength, buffer/chunk alignment and componentType all asserted correct and sampler times strictly increasing — for both .gltf and .glb. Deterministic bytes across Python 3.9/3.13, numpy + stdlib only, additive.

openfacefx.export_gltf

glTF 2.0 morph-target animation exporter -- the vendor-neutral 3D asset.

Every other 3D exporter here is engine-specific (Unity .anim, Godot .tres, Live2D). glTF 2.0 is the ISO/IEC 12113 runtime interchange standard, imported by Blender / Three.js / Babylon / Godot / Unity / Unreal, and the base of VRM -- and its animation natively drives morph-target weights (animation.channel.target.path = "weights"), which is exactly the [0, 1] viseme/blendshape model. This writes one self-contained file any glTF consumer can play:

  • .gltf -- JSON with the binary buffer base64-embedded as a data: URI.
  • .glb -- the binary container: a 12-byte header + a JSON chunk (space- padded to 4 bytes) + a BIN chunk (zero-padded), via stdlib struct/base64.

The asset is a stub mesh declaring N morph targets named after the track's [0, 1] weight channels (mesh.extras.targetNames -- the de-facto convention a consumer remaps by), a node referencing it, and one animation whose LINEAR sampler drives that node's weights path. Accessors are packed as little-endian FLOAT (componentType 5126): a shared input accessor (the per-frame time grid, strictly increasing, with min/max) and a frame-major output of n_frames * N weights, densified from the sparse channels with np.interp (via :func:openfacefx.edits.sample).

Only [0, 1] weight channels become morph weights; the signed head/eye pose channels (:data:openfacefx.inspect.POSE_CHANNELS, degrees) are excluded by default -- an opt-in head_node encodes headPitch/Yaw/Roll as a separate node rotation (Euler→quaternion) sampler.

Verification. The Khronos glTF Validator is the documented external gate; it cannot run in this environment, so the asset is built strictly to the glTF 2.0 spec (so it would pass) and the in-repo proof is a full accessor round-trip (decode the LE float32 buffers, reconstruct every weight channel within 1e-6). numpy + stdlib only, deterministic bytes on Python 3.9/3.13.

build_gltf(track: FaceTrack, *, head_node: bool = False) -> Tuple[Dict, bytes]

Build the glTF JSON dict (buffer without a URI) and the packed BIN bytes.

:func:write_gltf embeds the bytes as a data: URI (.gltf) or a BIN chunk (.glb).

Source code in src/openfacefx/export_gltf.py
def build_gltf(track: FaceTrack, *, head_node: bool = False
               ) -> Tuple[Dict, bytes]:
    """Build the glTF JSON dict (buffer without a URI) and the packed BIN bytes.

    :func:`write_gltf` embeds the bytes as a ``data:`` URI (``.gltf``) or a BIN
    chunk (``.glb``)."""
    wch = _weight_channels(track)
    names = [c.name for c in wch]
    n = len(wch)
    grid = _grid(track)
    times = grid.astype(np.float32)
    nt = len(times)

    weights = np.zeros((nt, n), dtype=np.float32)          # frame-major
    for j, c in enumerate(wch):
        weights[:, j] = np.clip(sample(c, grid), 0.0, 1.0)

    parts: List[bytes] = []
    bufferviews: List[Dict] = []
    accessors: List[Dict] = []

    def add(arr: np.ndarray, ncomp: int, gltf_type: str) -> int:
        arr = np.ascontiguousarray(arr, dtype=np.float32)
        blob = arr.tobytes()                               # LE float32
        offset = sum(len(p) for p in parts)                # 4-aligned (all float32)
        bufferviews.append({"buffer": 0, "byteOffset": offset,
                            "byteLength": len(blob)})
        flat = arr.reshape(-1, ncomp)
        accessors.append({
            "bufferView": len(bufferviews) - 1,
            "componentType": _FLOAT,
            "count": int(flat.shape[0]),
            "type": gltf_type,
            "min": [float(v) for v in flat.min(axis=0)],
            "max": [float(v) for v in flat.max(axis=0)],
        })
        parts.append(blob)
        return len(accessors) - 1

    a_pos = add(np.zeros((1, 3), np.float32), 3, "VEC3")   # stub base POSITION
    a_delta = add(np.zeros((1, 3), np.float32), 3, "VEC3")  # shared zero morph delta
    a_time = add(times, 1, "SCALAR")                       # sampler input (times)
    a_wt = add(weights.reshape(-1), 1, "SCALAR")           # sampler output (weights)

    gltf: Dict = {
        "asset": {"version": "2.0", "generator": "openfacefx"},
        "scene": 0,
        "scenes": [{"nodes": [0]}],
        "nodes": [{"mesh": 0, "weights": [0.0] * n, "name": "face"}],
        "meshes": [{
            "primitives": [{"attributes": {"POSITION": a_pos},
                            "targets": [{"POSITION": a_delta} for _ in range(n)]}],
            "weights": [0.0] * n,
            "extras": {"targetNames": names},
        }],
        "animations": [{
            "samplers": [{"input": a_time, "output": a_wt,
                          "interpolation": "LINEAR"}],
            "channels": [{"sampler": 0,
                          "target": {"node": 0, "path": "weights"}}],
        }],
        "accessors": accessors,
        "bufferViews": bufferviews,
        "buffers": [],
    }

    if head_node:
        a_rot = add(_euler_quaternions(track, grid), 4, "VEC4")
        gltf["nodes"].append({"name": "head", "rotation": [0.0, 0.0, 0.0, 1.0]})
        head = len(gltf["nodes"]) - 1
        gltf["scenes"][0]["nodes"].append(head)
        anim = gltf["animations"][0]
        anim["samplers"].append({"input": a_time, "output": a_rot,
                                 "interpolation": "LINEAR"})
        anim["channels"].append({"sampler": len(anim["samplers"]) - 1,
                                 "target": {"node": head, "path": "rotation"}})

    return gltf, b"".join(parts)

write_gltf(track: FaceTrack, path: str, *, head_node: bool = False) -> None

Write track as glTF 2.0; .glb picks the binary container, anything else the JSON form with a base64 data: buffer.

Source code in src/openfacefx/export_gltf.py
def write_gltf(track: FaceTrack, path: str, *, head_node: bool = False) -> None:
    """Write ``track`` as glTF 2.0; ``.glb`` picks the binary container, anything
    else the JSON form with a base64 ``data:`` buffer."""
    gltf, blob = build_gltf(track, head_node=head_node)
    if path.endswith(".glb"):
        _write_glb(gltf, blob, path)
        return
    gltf["buffers"] = [{
        "byteLength": len(blob),
        "uri": "data:application/octet-stream;base64," +
               base64.b64encode(blob).decode("ascii"),
    }]
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(gltf, fh, indent=2)