Skip to content

Exporters

Serialise a FaceTrack to the native JSON/CSV track format, to game-engine animation assets (Unity .anim, Live2D Cubism motion3.json, Godot .tres), to stepped 2D cue lists (Rhubarb, Moho/OpenToonz, Papagayo), and to the experimental Bethesda Skyrim .lip writer plus its .fuz/.lip header tools.

openfacefx.io_export

Exporters. Keep formats simple and engine-agnostic.

  • to_dict / write_json -- canonical interchange format.
  • write_csv -- one row per keyframe (time, channel, value), easy to load into a spreadsheet or a DAW-style curve editor.

Engine-specific exporters (Unreal AnimCurve, glTF morph-target animation, Blender F-curves) can be layered on top of FaceTrack without touching the solver.

to_dict(track: FaceTrack) -> Dict

Source code in src/openfacefx/io_export.py
def to_dict(track: FaceTrack) -> Dict:
    d = {
        "format": "openfacefx.track",
        "version": 1,
        "fps": track.fps,
        "duration": round(track.duration, 4),
        "viseme_set": track.target_set if track.target_set is not None else VISEMES,
        "channels": [
            {
                "name": ch.name,
                "keys": [[round(k.time, 4), k.value] for k in ch.keys],
            }
            for ch in track.channels
        ],
    }
    # Additive event/take layer (issue #6): appended ONLY when present, and after
    # the base keys, so `version` stays 1 and an ordinary track serialises
    # byte-identically to previous releases. Readers ignore unknown top-level
    # keys, so this is forward-compatible in both directions.
    if getattr(track, "events", None):
        from .events import event_to_dict
        d["events"] = [event_to_dict(e) for e in track.events]
    if getattr(track, "variants", None) is not None:
        from .events import variants_to_dict
        d["variants"] = variants_to_dict(track.variants)
    return d

write_json(track: FaceTrack, path: str) -> None

Source code in src/openfacefx/io_export.py
def write_json(track: FaceTrack, path: str) -> None:
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(to_dict(track), fh, indent=2)

write_csv(track: FaceTrack, path: str) -> None

Source code in src/openfacefx/io_export.py
def write_csv(track: FaceTrack, path: str) -> None:
    rows = ["time,channel,value"]
    for ch in track.channels:
        for k in ch.keys:
            rows.append(f"{k.time:.4f},{ch.name},{k.value:.4f}")
    with open(path, "w", encoding="utf-8") as fh:
        fh.write("\n".join(rows) + "\n")

openfacefx.export_unity

Export a FaceTrack as a Unity .anim AnimationClip (text YAML).

The clip animates blendShape.<name> float curves on a SkinnedMeshRenderer (classID 137), so it plays through any Animator on any blendshape rig — no extra packages needed. Two naming presets cover the common conventions:

  • oculus -- viseme_sil .. viseme_U (Meta reference rigs, Ready Player Me; mixed case, I/O/U spelling)
  • vrchat -- vrc.v_sil .. vrc.v_ou (VRChat auto-detect names; lowercase, ih/oh/ou spelling)

Unity blendshape weights are 0-100 (percent), so track weights (0-1) are scaled by 100. Keyframe times are absolute seconds; the sparse RDP-reduced keys map 1:1 onto clip keys. Format derived from Unity's serialized AnimationClip (serializedVersion 6) as emitted by working generators — see docs/COMPATIBILITY.md for provenance.

DEFAULT_EVENT_FUNC = 'OnFaceEvent' module-attribute

NAMING_PRESETS: Dict[str, Dict[str, str]] = {'oculus': {v: ('viseme_' + v) for v in VISEMES}, 'vrchat': {v: ('vrc.v_' + _VRCHAT_SUFFIX.get(v, v).lower()) for v in VISEMES}} module-attribute

write_unity_anim(track: FaceTrack, path: str, naming: str = 'oculus', mesh_path: str = 'Body', clip_name: Optional[str] = None, sample_rate: int = 60, loop: bool = False, include_all_visemes: bool = True, names: Optional[Dict[str, str]] = None, events: bool = True, event_func_map: Optional[Dict[str, str]] = None, event_message_options: int = 1) -> None

Write track as a Unity AnimationClip.

mesh_path is the transform path from the Animator's GameObject to the SkinnedMeshRenderer's (empty string if they are the same object). include_all_visemes also emits a constant-0 curve for every viseme the track never fires, so stale weights from a previous clip are cleared. names overrides the preset with an explicit viseme -> blendshape map.

The optional event layer (issue #6) fills the clip's m_Events array: each resolved :class:~openfacefx.events.Event becomes an AnimationEvent that Unity SendMessage-invokes on the Animator's GameObject. event_func_map maps event type -> handler method name (default OnFaceEvent for all); the event name and its JSON payload ride in the single stringParameter as name|{json}. event_message_options defaults to 1 (DontRequireReceiver) so a clip with no handler wired up does not error at runtime. Set events=False to omit them. A track with no events writes a byte-identical m_Events: [] -- exactly as before this feature.

Source code in src/openfacefx/export_unity.py
def write_unity_anim(
    track: FaceTrack,
    path: str,
    naming: str = "oculus",
    mesh_path: str = "Body",
    clip_name: Optional[str] = None,
    sample_rate: int = 60,
    loop: bool = False,
    include_all_visemes: bool = True,
    names: Optional[Dict[str, str]] = None,
    events: bool = True,
    event_func_map: Optional[Dict[str, str]] = None,
    event_message_options: int = 1,
) -> None:
    """Write ``track`` as a Unity AnimationClip.

    ``mesh_path`` is the transform path from the Animator's GameObject to the
    SkinnedMeshRenderer's (empty string if they are the same object).
    ``include_all_visemes`` also emits a constant-0 curve for every viseme the
    track never fires, so stale weights from a previous clip are cleared.
    ``names`` overrides the preset with an explicit viseme -> blendshape map.

    The optional event layer (issue #6) fills the clip's ``m_Events`` array: each
    resolved :class:`~openfacefx.events.Event` becomes an ``AnimationEvent`` that
    Unity SendMessage-invokes on the Animator's GameObject. ``event_func_map``
    maps event ``type`` -> handler method name (default ``OnFaceEvent`` for all);
    the event ``name`` and its JSON payload ride in the single ``stringParameter``
    as ``name|{json}``. ``event_message_options`` defaults to ``1``
    (``DontRequireReceiver``) so a clip with no handler wired up does not error at
    runtime. Set ``events=False`` to omit them. A track with no events writes a
    byte-identical ``m_Events: []`` -- exactly as before this feature.
    """
    if names is None:
        try:
            names = NAMING_PRESETS[naming]
        except KeyError:
            raise ValueError(
                f"unknown naming preset {naming!r}; use one of "
                f"{sorted(NAMING_PRESETS)} or pass names=") from None

    by_name = {c.name: c for c in track.channels}
    blocks = []
    for viseme in VISEMES:
        ch = by_name.get(viseme)
        if ch is not None and ch.keys:
            keys = [(k.time, k.value * 100.0) for k in ch.keys]
        elif include_all_visemes and viseme in names:
            keys = [(0.0, 0.0)]
        else:
            continue
        blocks.append(_curve_block(names[viseme], mesh_path, keys))

    float_curves = "".join(blocks).rstrip("\n") if blocks else "  []"
    m_events = _events_yaml(track, event_func_map, event_message_options) \
        if events else " []"
    body = _HEADER.format(
        name=clip_name or "OpenFaceFX_Lipsync",
        float_curves=float_curves,
        editor_curves=float_curves,   # duplicated so the clip is editable
        sample_rate=sample_rate,
        stop_time=_num(track.duration),
        loop=1 if loop else 0,
        m_events=m_events,
    )
    with open(path, "w", encoding="utf-8", newline="\n") as fh:
        fh.write(body)

openfacefx.export_live2d

Export a FaceTrack as a Live2D Cubism motion3.json.

Cubism runtimes play lip-sync as parameter curves baked into a motion3.json; a model's model3.json declares a Groups: LipSync list so those curves retarget onto whatever parameter Ids a given rig exposes (https://docs.live2d.com/en/cubism-sdk-manual/lipsync/). The editor's own audio pipeline is volume-only, so phoneme-accurate curves are a quality upgrade.

Two targeting modes:

  • Default (zero config) -- collapse the whole viseme track to a single ParamMouthOpenY curve: the summed weight of every non-silence viseme, clamped to 0..1. That is an openness/loudness proxy (it equals 1 - sil on normalised coarticulation output) and matches the one mouth-open parameter almost every Cubism model exposes. The target Id is configurable (ParamMouthOpenY is the Cubism default, not a hard requirement).
  • Per-parameter -- pass a viseme -> ParamId map (e.g. the ParamA/I/U/ E/O convention some VTuber rigs use, which is not a standard) and get one curve per distinct parameter.

Both modes are just a :func:retarget -- summed, clamped contributions on the union of key times -- so the writer only ever serialises a track whose channel names are already Cubism parameter Ids.

Curves use linear segments only (segment id 0): a leading (t, v) point, then one (t, v) point per following keyframe. The Meta counts MUST equal what the Curves array actually contains -- Cubism loaders trust them and walk past the data otherwise -- so they are derived from the emitted segments, never guessed.

DEFAULT_MOUTH_PARAM = 'ParamMouthOpenY' module-attribute

lipsync_param_ids(model3_path: str) -> List[str]

The parameter Ids of a model3.json's Groups: LipSync entry.

Cubism models list the parameters driven by lip-sync here, so pointing the exporter at a model auto-discovers its mouth parameter(s). Returns [] when the file has no LipSync group.

Source code in src/openfacefx/export_live2d.py
def lipsync_param_ids(model3_path: str) -> List[str]:
    """The parameter Ids of a ``model3.json``'s ``Groups: LipSync`` entry.

    Cubism models list the parameters driven by lip-sync here, so pointing the
    exporter at a model auto-discovers its mouth parameter(s). Returns ``[]``
    when the file has no LipSync group.
    """
    with open(model3_path, encoding="utf-8") as fh:
        data = json.load(fh)
    for group in data.get("Groups", []):
        if group.get("Name") == "LipSync":
            return list(group.get("Ids", []))
    return []

write_live2d_motion(track: FaceTrack, path: str, *, params: Optional[Dict[str, str]] = None, mouth_param: str = DEFAULT_MOUTH_PARAM, fps: Optional[float] = None, loop: bool = False) -> None

Write track as a Cubism motion3.json (Version 3).

params (viseme -> ParamId) selects per-parameter mode; the default None collapses to a single mouth_param curve. fps overrides the track's own rate in the Meta block (Cubism plays the curves by time regardless). Output is pure-stdlib JSON with LF line endings; the Meta counts are computed from the emitted Curves so they cannot drift.

Source code in src/openfacefx/export_live2d.py
def write_live2d_motion(
    track: FaceTrack,
    path: str,
    *,
    params: Optional[Dict[str, str]] = None,
    mouth_param: str = DEFAULT_MOUTH_PARAM,
    fps: Optional[float] = None,
    loop: bool = False,
) -> None:
    """Write ``track`` as a Cubism ``motion3.json`` (Version 3).

    ``params`` (``viseme -> ParamId``) selects per-parameter mode; the default
    ``None`` collapses to a single ``mouth_param`` curve. ``fps`` overrides the
    track's own rate in the ``Meta`` block (Cubism plays the curves by time
    regardless). Output is pure-stdlib JSON with LF line endings; the ``Meta``
    counts are computed from the emitted ``Curves`` so they cannot drift.
    """
    mapping = _collapse_mapping(params, mouth_param)
    baked = retarget(track, mapping)

    curves = []
    total_segments = total_points = 0
    duration = 0.0
    for ch in baked.channels:
        if not ch.keys:
            continue
        seg = _segments(ch.keys)
        n_seg, n_pt = _count(seg)
        total_segments += n_seg
        total_points += n_pt
        duration = max(duration, ch.keys[-1].time)
        curves.append((ch.name, seg))

    rate = track.fps if fps is None else fps
    lines = [
        "{",
        '  "Version": 3,',
        '  "Meta": {',
        f'    "Duration": {_num(duration)},',
        f'    "Fps": {_num(rate)},',
        f'    "Loop": {"true" if loop else "false"},',
        '    "AreBeziersRestricted": true,',
        f'    "CurveCount": {len(curves)},',
        f'    "TotalSegmentCount": {total_segments},',
        f'    "TotalPointCount": {total_points},',
        '    "UserDataCount": 0,',
        '    "TotalUserDataSize": 0',
        "  },",
        '  "Curves": [',
    ]
    last = len(curves) - 1
    for i, (name, seg) in enumerate(curves):
        comma = "" if i == last else ","
        lines += [
            "    {",
            '      "Target": "Parameter",',
            f'      "Id": {json.dumps(name)},',
            f'      "Segments": {json.dumps(seg)}',
            "    }" + comma,
        ]
    lines += ["  ]", "}"]
    with open(path, "w", encoding="utf-8", newline="\n") as fh:
        fh.write("\n".join(lines) + "\n")

openfacefx.export_godot

Export a FaceTrack as a Godot 4 Animation text resource (.tres).

Godot has no first-party lip-sync; the community bakes mouth data into AnimationPlayer animations (rhubarb-lipsync integrations, godot-baked- lipsync). This writes that artifact directly: a [gd_resource type="Animation" format=3] resource (format=3 is Godot 4; 2 is Godot 3) with one value track per active viseme, keyed with the existing RDP-reduced keyframes and linear interpolation.

Each track drives a blend shape by node path, e.g.::

tracks/0/path = NodePath("Head:blend_shapes/viseme_aa")

so it plays through any MeshInstance3D whose mesh exposes those shape keys. Godot blend-shape weights are 0..1 (unlike Unity's 0..100), so channel values are written straight through. Shape naming reuses the Unity exporter's presets (oculus viseme_* / vrchat vrc.v_*); pass names for a custom viseme -> shape map. The node name is configurable (default Head).

A consumer loads the resource into an AnimationLibrary and adds it to an AnimationPlayer whose root_node makes the paths resolve; those runtime nodes stay engine-side and out of scope here. Value tracks (not the importer- only blend_shape track type) keep the resource hand-writable and stdlib- serialisable. Text output, LF line endings.

write_godot_anim(track: FaceTrack, path: str, *, naming: str = 'oculus', node: str = 'Head', names: Optional[Dict[str, str]] = None, include_all_visemes: bool = True) -> None

Write track as a Godot 4 Animation .tres.

node is the animated node's name relative to the AnimationPlayer's root_node (the blend shapes live at <node>:blend_shapes/<shape>). naming picks a built-in shape-name preset; names overrides it with an explicit viseme -> shape map. include_all_visemes also writes a constant-0 track for every viseme the track never fires, clearing any weight a previous animation left on that shape.

Track key times are absolute seconds, so the source fps does not appear in the resource. The [resource] header follows Godot's text saver, which omits any property left at its class default -- loop_mode (0), step (1/30) and resource_name (empty for animations) never appear, and length only when it is not the 1.0 default -- so the output matches what the editor would re-save.

Source code in src/openfacefx/export_godot.py
def write_godot_anim(
    track: FaceTrack,
    path: str,
    *,
    naming: str = "oculus",
    node: str = "Head",
    names: Optional[Dict[str, str]] = None,
    include_all_visemes: bool = True,
) -> None:
    """Write ``track`` as a Godot 4 ``Animation`` ``.tres``.

    ``node`` is the animated node's name relative to the AnimationPlayer's
    ``root_node`` (the blend shapes live at ``<node>:blend_shapes/<shape>``).
    ``naming`` picks a built-in shape-name preset; ``names`` overrides it with
    an explicit ``viseme -> shape`` map. ``include_all_visemes`` also writes a
    constant-0 track for every viseme the track never fires, clearing any weight
    a previous animation left on that shape.

    Track key times are absolute seconds, so the source fps does not appear in
    the resource. The ``[resource]`` header follows Godot's text saver, which
    omits any property left at its class default -- ``loop_mode`` (0),
    ``step`` (1/30) and ``resource_name`` (empty for animations) never appear,
    and ``length`` only when it is not the 1.0 default -- so the output matches
    what the editor would re-save.
    """
    if names is None:
        try:
            names = NAMING_PRESETS[naming]
        except KeyError:
            raise ValueError(
                f"unknown naming preset {naming!r}; use one of "
                f"{sorted(NAMING_PRESETS)} or pass names=") from None

    by_name = {c.name: c for c in track.channels}
    tracks = []
    for viseme in VISEMES:
        ch = by_name.get(viseme)
        if ch is not None and ch.keys:
            times = [k.time for k in ch.keys]
            values = [k.value for k in ch.keys]
        elif include_all_visemes and viseme in names:
            times, values = [0.0], [0.0]
        else:
            continue
        tracks.append((names[viseme], times, values))

    header = '[gd_resource type="Animation" format=3]\n\n[resource]\n'
    if track.duration != 1.0:                       # 1.0 is Animation's default
        header += f"length = {_fnum(track.duration)}\n"
    blocks = [
        _track_block(i, f"{node}:blend_shapes/{shape}", times, values)
        for i, (shape, times, values) in enumerate(tracks)
    ]
    with open(path, "w", encoding="utf-8", newline="\n") as fh:
        fh.write(header + "".join(blocks))

openfacefx.export_cues

Rhubarb-dialect cue exporters: stepped mouth-shape lists for 2D hosts.

The engine-agnostic FaceTrack is a bundle of smooth, overlapping viseme curves. A whole ecosystem of indie 2D lip-sync hosts instead wants a stepped cue list -- one mouth shape held per interval. This module flattens a track to that representation (the single highest-weight channel wins at every sampled frame) and serialises it in the formats those hosts read:

  • Rhubarb Lip Sync TSV / XML / JSON -- write_rhubarb_{tsv,xml,json}
  • Moho / OpenToonz switch data (.dat) -- write_moho_dat
  • Papagayo-NG (.pgo) -- write_pgo

Shape vocabulary is handled for you: a track in the Oculus-15 viseme set is retargeted through the built-in rhubarb / preston_blair presets; a track already in Rhubarb A-H/X (or Preston-Blair) shapes is passed through untouched; anything else is rejected with a clear error. Times and frames are quantised exactly as the reference tools emit them -- Rhubarb prints seconds as %.2f; Moho/Papagayo frames are 1-based and truncated (1 + int(fps * seconds)). Pure stdlib serialisation, LF line endings.

Cue = Tuple[float, float, str] module-attribute

RHUBARB_EXTENDED_FALLBACK = {k: (v[0][0]) for k, v in (PRESET_FALLBACKS['rhubarb'].items())} module-attribute

dominant_cues(track: FaceTrack, rest_name: str = 'X', silence_floor: float = _SILENCE_FLOOR) -> List[Cue]

Flatten track to (start, end, shape) runs by dominant channel.

At each frame (sampled at the track's own fps) the single highest-weight channel wins; a frame with nothing above silence_floor becomes rest_name. Adjacent equal-shape frames merge into one run, and the runs tile [0, duration] with no gaps.

Source code in src/openfacefx/export_cues.py
def dominant_cues(track: FaceTrack, rest_name: str = "X",
                  silence_floor: float = _SILENCE_FLOOR) -> List[Cue]:
    """Flatten ``track`` to ``(start, end, shape)`` runs by dominant channel.

    At each frame (sampled at the track's own fps) the single highest-weight
    channel wins; a frame with nothing above ``silence_floor`` becomes
    ``rest_name``. Adjacent equal-shape frames merge into one run, and the runs
    tile ``[0, duration]`` with no gaps.
    """
    fps = track.fps or 1.0
    dur = track.duration
    samplers = [(c.name, _sampler(c)) for c in track.channels]
    n = int(round(dur * fps))
    labels: List[Tuple[float, str]] = []
    for i in range(n + 1):
        t = i / fps
        best_name, best_w = rest_name, silence_floor
        for name, sample in samplers:
            w = sample(t)
            if w > best_w:
                best_name, best_w = name, w
        labels.append((t, best_name))
    if not labels:
        return []
    cues: List[Cue] = []
    start, current = labels[0]
    for t, name in labels[1:]:
        if name != current:
            cues.append((start, min(t, dur), current))
            start, current = t, name
    if start < dur:  # a lone differing frame exactly at the end is negligible
        cues.append((start, dur, current))
    return cues

write_rhubarb_tsv(track: FaceTrack, path: str, *, retarget_preset: Optional[str] = None, available_shapes: Optional[Set[str]] = None) -> None

Rhubarb -f tsv: header-less start<TAB>shape lines, then a final terminal row at the end time bounding the last cue.

Source code in src/openfacefx/export_cues.py
def write_rhubarb_tsv(track: FaceTrack, path: str, *,
                      retarget_preset: Optional[str] = None,
                      available_shapes: Optional[Set[str]] = None) -> None:
    """Rhubarb ``-f tsv``: header-less ``start<TAB>shape`` lines, then a final
    terminal row at the end time bounding the last cue."""
    cues = _rhubarb_cues(track, retarget_preset, available_shapes)
    end = cues[-1][1] if cues else track.duration
    rest = _fallback("X", available_shapes) if available_shapes else "X"
    lines = [f"{s:.2f}\t{name}" for s, _e, name in cues]
    lines.append(f"{end:.2f}\t{rest}")
    _write_lines(path, lines)

write_rhubarb_xml(track: FaceTrack, path: str, *, sound_file: str = 'openfacefx', retarget_preset: Optional[str] = None, available_shapes: Optional[Set[str]] = None) -> None

Rhubarb -f xml: a rhubarbResult tree with soundFile/duration metadata and mouthCue start/end elements (no terminal sentinel).

Source code in src/openfacefx/export_cues.py
def write_rhubarb_xml(track: FaceTrack, path: str, *,
                      sound_file: str = "openfacefx",
                      retarget_preset: Optional[str] = None,
                      available_shapes: Optional[Set[str]] = None) -> None:
    """Rhubarb ``-f xml``: a ``rhubarbResult`` tree with soundFile/duration
    metadata and ``mouthCue`` start/end elements (no terminal sentinel)."""
    cues = _rhubarb_cues(track, retarget_preset, available_shapes)
    end = cues[-1][1] if cues else track.duration
    lines = [_XML_DECLARATION, "<rhubarbResult>", "  <metadata>",
             f"    <soundFile>{_xml_escape(sound_file)}</soundFile>",
             f"    <duration>{end:.2f}</duration>", "  </metadata>",
             "  <mouthCues>"]
    for s, e, name in cues:
        lines.append(f'    <mouthCue start="{s:.2f}" end="{e:.2f}">{name}</mouthCue>')
    lines += ["  </mouthCues>", "</rhubarbResult>"]
    _write_lines(path, lines)

write_rhubarb_json(track: FaceTrack, path: str, *, sound_file: str = 'openfacefx', retarget_preset: Optional[str] = None, available_shapes: Optional[Set[str]] = None) -> None

Rhubarb -f json: hand-formatted (2/4-space indent), a metadata object and a mouthCues array of {start, end, value} objects.

Source code in src/openfacefx/export_cues.py
def write_rhubarb_json(track: FaceTrack, path: str, *,
                       sound_file: str = "openfacefx",
                       retarget_preset: Optional[str] = None,
                       available_shapes: Optional[Set[str]] = None) -> None:
    """Rhubarb ``-f json``: hand-formatted (2/4-space indent), a metadata
    object and a ``mouthCues`` array of ``{start, end, value}`` objects."""
    cues = _rhubarb_cues(track, retarget_preset, available_shapes)
    end = cues[-1][1] if cues else track.duration
    lines = ["{", '  "metadata": {',
             f'    "soundFile": {json.dumps(sound_file)},',
             f'    "duration": {end:.2f}', "  },", '  "mouthCues": [']
    last = len(cues) - 1
    for i, (s, e, name) in enumerate(cues):
        comma = "" if i == last else ","
        lines.append(f'    {{ "start": {s:.2f}, "end": {e:.2f}, '
                     f'"value": {json.dumps(name)} }}{comma}')
    lines += ["  ]", "}"]
    _write_lines(path, lines)

write_moho_dat(track: FaceTrack, path: str, *, fps: float = 24, preston_blair: bool = True, retarget_preset: Optional[str] = None) -> None

Moho / OpenToonz switch data. First line MohoSwitch1; then <frame> <shape> rows on a 1-based truncated timeline; a terminal rest/X row at the end frame (bumped one frame on a collision).

preston_blair (default) emits Preston-Blair drawing names, which OpenToonz's "Apply Lip Sync Data" and Moho switch layers match by name; turn it off for Rhubarb's raw A-H/X letters. fps must be 24..100 (a float, so NTSC rates like 29.97 are accepted); out of range is a clear error, matching Rhubarb (which rejects rather than clamps).

Source code in src/openfacefx/export_cues.py
def write_moho_dat(track: FaceTrack, path: str, *, fps: float = 24,
                   preston_blair: bool = True,
                   retarget_preset: Optional[str] = None) -> None:
    """Moho / OpenToonz switch data. First line ``MohoSwitch1``; then
    ``<frame> <shape>`` rows on a 1-based truncated timeline; a terminal
    rest/X row at the end frame (bumped one frame on a collision).

    ``preston_blair`` (default) emits Preston-Blair drawing names, which
    OpenToonz's "Apply Lip Sync Data" and Moho switch layers match by name;
    turn it off for Rhubarb's raw A-H/X letters. ``fps`` must be 24..100
    (a float, so NTSC rates like 29.97 are accepted); out of range is a
    clear error, matching Rhubarb (which rejects rather than clamps).
    """
    if not (_DAT_FPS_MIN <= fps <= _DAT_FPS_MAX):
        raise ValueError(
            f"dat frame rate must be {_DAT_FPS_MIN}..{_DAT_FPS_MAX} fps, got {fps}")
    if preston_blair:
        src, rest = _coerce(track, _PB_SHAPES, "preston_blair", retarget_preset), "rest"
    else:
        src, rest = _coerce(track, _RHUBARB_SHAPES, "rhubarb", retarget_preset), "X"
    cues = dominant_cues(src, rest)
    frames = _to_frames(cues, fps)
    lines = ["MohoSwitch1"]
    lines += [f"{frame} {name}" for frame, name in frames]
    end = cues[-1][1] if cues else track.duration
    end_frame = _frame_at(fps, end)
    if frames and end_frame == frames[-1][0]:
        end_frame += 1
    lines.append(f"{end_frame} {rest}")
    _write_lines(path, lines)

write_pgo(track: FaceTrack, path: str, *, fps: float = 24, sound_path: str = 'openfacefx', voice_name: str = 'Voice 1', retarget_preset: Optional[str] = None) -> None

Papagayo-NG .pgo (version 1): the flattened Preston-Blair cues as a single voice / phrase / word phoneme stream, TAB-indented. Frames are 1-based truncated; fps is stored as an integer (Papagayo's rate is %d); sound_path defaults to a placeholder rather than a local absolute path.

Source code in src/openfacefx/export_cues.py
def write_pgo(track: FaceTrack, path: str, *, fps: float = 24,
              sound_path: str = "openfacefx", voice_name: str = "Voice 1",
              retarget_preset: Optional[str] = None) -> None:
    """Papagayo-NG ``.pgo`` (version 1): the flattened Preston-Blair cues as a
    single voice / phrase / word phoneme stream, TAB-indented. Frames are
    1-based truncated; ``fps`` is stored as an integer (Papagayo's rate is
    ``%d``); ``sound_path`` defaults to a placeholder rather than a local
    absolute path.
    """
    src = _coerce(track, _PB_SHAPES, "preston_blair", retarget_preset)
    phonemes = _to_frames(dominant_cues(src, "rest"), fps)
    total_frames = int(round(fps * track.duration))
    start_frame = phonemes[0][0] if phonemes else 1
    end_frame = max(total_frames, phonemes[-1][0]) if phonemes else 1
    label = "openfacefx"
    lines = ["lipsync version 1", sound_path, str(int(fps)), str(total_frames), "1",
             f"\t{voice_name}", f"\t{label}", "\t1",
             f"\t\t{label}", f"\t\t{start_frame}", f"\t\t{end_frame}", "\t\t1",
             f"\t\t\t{label} {start_frame} {end_frame} {len(phonemes)}"]
    lines += [f"\t\t\t\t{frame} {name}" for frame, name in phonemes]
    _write_lines(path, lines)

openfacefx.export_lip

Bethesda .lip payload writer — Skyrim (EXPERIMENTAL).

⚠️  EXPERIMENTAL — NOT YET VERIFIED IN-GAME.  ⚠️

This is the first clean-room writer for the FaceFX facial-animation blob inside a Skyrim .lip file. The byte format was reverse-engineered purely from analysis of four sample files (three mod-author placeholders plus one real vanilla Creation-Kit asset); see tools/lip_codec_research.py for the codec and issue

12 for the full derivation. Our encoder re-serializes all four samples

byte-identically, and every track this module writes round-trips through our own decoder exactly (tests/test_export_lip.py).

What is NOT verified, because it needs Skyrim + the Creation Kit and nobody has run that test yet:

  • Does the game load it without crashing and animate a face? Unknown.
  • Slot → morph mapping — SLOT IS NOT THE TARGET INDEX. The payload routes each curve to a numbered grid slot (0..32 for Skyrim), not a named target, and the real asset spreads 13 curves across slots up to 30 (a curve may even occupy two slots as a value+tangent pair). Which slot drives which of Skyrim's 16 speech morphs is UNRESOLVED. SKYRIM_SLOT_MAP below is a deliberately provisional hypothesis: only its jaw assignment is evidence-informed (the vanilla asset's slot 22 is a long-lived jaw-like curve), the rest are placeholders. Until it is calibrated, the mouth may move but form the WRONG shapes. Resolve it empirically — no reverse engineering, just eyes on a screen — with the calibration set: openfacefx lip-calibrate --out DIR writes one .lip per slot (a single slot swept 0→1→0); play each on a voiced NPC line, note which mouth part moves, and fill in SKYRIM_SLOT_MAP. See docs/COMPATIBILITY.md.
  • Header field u22 (see _U22_SKYRIM): its meaning was never cracked; we copy the value the one real vanilla asset uses.

Treat the output as a research artifact whose mouth shapes are uncalibrated. If you can test it in-game, please report back on issue #12. Fallout 4 is unsupported (its 43-target vocabulary is undocumented) — both write_lip and lip_calibrate raise NotImplementedError for it; the calibration technique would generalize, but only Skyrim's stride/header are wired up.

Input is the phoneme-timing layer (List[PhonemeSegment] from pipeline.naive_segments / alignment.load_mfa_textgrid); we drive the existing coarticulation solver through an ARPAbet→Skyrim-16 Mapping and sample the resulting weight envelopes on Skyrim's 30 fps frame grid.

SKYRIM_SLOT_MAP: Dict[str, int] = {'Aah': 22, 'BigAah': 24, 'BMP': 0, 'ChjSh': 2, 'DST': 4, 'Eee': 6, 'Eh': 8, 'FV': 10, 'i': 12, 'k': 14, 'N': 16, 'Oh': 18, 'OohQ': 20, 'R': 26, 'Th': 28, 'W': 30} module-attribute

skyrim_mapping() -> Mapping

The ARPAbet → Skyrim-16 Mapping the writer drives coarticulation with. allow_custom_symbols is False: rows are ARPAbet, validated on build.

Source code in src/openfacefx/export_lip.py
def skyrim_mapping() -> Mapping:
    """The ARPAbet → Skyrim-16 ``Mapping`` the writer drives coarticulation with.
    ``allow_custom_symbols`` is False: rows are ARPAbet, validated on build."""
    targets = [Target(name, _TARGET_CLASS[name]) for name in SKYRIM_TARGETS]
    return Mapping(targets, {ph: dict(row) for ph, row in _ARPABET_TO_TARGET.items()})

lip_bytes(segments: List[PhonemeSegment], duration_s: float, game: str = 'skyrim', params: Optional[CoartParams] = None) -> bytes

Encode segments to Skyrim .lip bytes (header + payload).

EXPERIMENTAL and unverified in-game — see the module docstring. segments is the phoneme-timing layer; duration_s is the audio duration in seconds. game must be 'skyrim' ('fallout4' raises NotImplementedError). Raises ValueError on empty input or entirely silent speech.

Source code in src/openfacefx/export_lip.py
def lip_bytes(segments: List[PhonemeSegment], duration_s: float,
              game: str = "skyrim", params: Optional[CoartParams] = None) -> bytes:
    """Encode ``segments`` to Skyrim ``.lip`` bytes (header + payload).

    EXPERIMENTAL and unverified in-game — see the module docstring. ``segments``
    is the phoneme-timing layer; ``duration_s`` is the audio duration in seconds.
    ``game`` must be ``'skyrim'`` (``'fallout4'`` raises ``NotImplementedError``).
    Raises ``ValueError`` on empty input or entirely silent speech.
    """
    if game == "fallout4":
        raise NotImplementedError(
            "Fallout 4 .lip is not supported: its 43-target vocabulary (u20=43, "
            "stride R=60) is undocumented, so a slot→morph mapping cannot be "
            "written honestly. Skyrim only (game='skyrim').")
    if game != "skyrim":
        raise ValueError(f"unknown game {game!r}; expected 'skyrim'")
    if not segments:
        raise ValueError("no segments to encode")
    if not (duration_s > 0.0):
        raise ValueError(f"duration_s must be positive, got {duration_s!r}")

    params = params or _default_params()
    mapping = skyrim_mapping()
    grid, neg16, count12 = _frame_grid(segments, duration_s, mapping, params)

    # Which targets ever fire → curves. Force the anchor slot on so the stream
    # begins at grid origin (0,0), where the decoder's positional walk starts.
    names = mapping.target_names
    active = {SKYRIM_SLOT_MAP[names[c]] for c in range(grid.shape[1])
              if float(grid[:, c].max()) > _EPS}
    active.add(_ANCHOR_SLOT)
    if len(active) <= 1 and float(grid.max()) <= _EPS:
        raise ValueError("input is entirely silent; nothing to animate")
    slot_to_col = {SKYRIM_SLOT_MAP[names[c]]: c for c in range(len(names))}
    active_slots = sorted(active)

    # Dense frame-major cells: every active curve at every row (the game
    # interpolates between them). Emitting every row keeps consecutive keys
    # well under the 63-slot marker span, so no key is ever dropped.
    cells = []
    for r in range(count12):
        for slot in active_slots:
            cells.append({"frame": r, "curve": slot,
                          "value": float(grid[r, slot_to_col[slot]])})

    payload = _encode_cells(cells, _STRIDE, total_slots=_STRIDE * count12)
    return _pack_header(len(active_slots), count12, neg16) + payload

write_lip(segments: List[PhonemeSegment], duration_s: float, path: str, game: str = 'skyrim', params: Optional[CoartParams] = None) -> None

Write an EXPERIMENTAL Skyrim .lip file (see module docstring / #12).

Not verified in-game: the output decodes exactly through our own reader, but whether Skyrim loads and animates it is untested. game='fallout4' raises NotImplementedError.

Source code in src/openfacefx/export_lip.py
def write_lip(segments: List[PhonemeSegment], duration_s: float, path: str,
              game: str = "skyrim", params: Optional[CoartParams] = None) -> None:
    """Write an EXPERIMENTAL Skyrim ``.lip`` file (see module docstring / #12).

    Not verified in-game: the output decodes exactly through our own reader, but
    whether Skyrim loads and animates it is untested. ``game='fallout4'`` raises
    ``NotImplementedError``.
    """
    data = lip_bytes(segments, duration_s, game=game, params=params)
    with open(path, "wb") as fh:
        fh.write(data)

lip_calibrate(out_dir: str, game: str = 'skyrim', seconds: float = 2.0) -> List[str]

Write one EXPERIMENTAL .lip per GRID SLOT for in-game slot calibration — the tool that turns the unresolved slot→morph map into a 20-minute eyeballing task. Emits slot_00.lip .. slot_{R-1}.lip (R=33 for Skyrim); in each, that one raw slot ramps 0→1→0 (triangle, apex mid-clip) while every other slot rests. Drop each on any voiced NPC line in-game and note which mouth part moves: that reveals what the slot really drives, letting you fill in SKYRIM_SLOT_MAP (whose current values are a guess).

Probing EVERY slot, not just the 16 we hypothesize are targets, is the point — the real morph could sit on a slot the guess doesn't use. A README.txt manifest with the procedure and the current hypothesis is written alongside. Please report findings on issue #12. Returns the list of .lip paths written.

Source code in src/openfacefx/export_lip.py
def lip_calibrate(out_dir: str, game: str = "skyrim",
                  seconds: float = 2.0) -> List[str]:
    """Write one EXPERIMENTAL ``.lip`` per GRID SLOT for in-game slot calibration
    — the tool that turns the unresolved slot→morph map into a 20-minute
    eyeballing task. Emits ``slot_00.lip`` .. ``slot_{R-1}.lip`` (R=33 for
    Skyrim); in each, that one raw slot ramps 0→1→0 (triangle, apex mid-clip)
    while every other slot rests. Drop each on any voiced NPC line in-game and
    note which mouth part moves: that reveals what the slot really drives,
    letting you fill in ``SKYRIM_SLOT_MAP`` (whose current values are a guess).

    Probing EVERY slot, not just the 16 we hypothesize are targets, is the point
    — the real morph could sit on a slot the guess doesn't use. A ``README.txt``
    manifest with the procedure and the current hypothesis is written alongside.
    Please report findings on issue #12. Returns the list of .lip paths written.
    """
    if game == "fallout4":
        raise NotImplementedError(
            "calibration is Skyrim-only for now: Fallout 4's header (u20=43, "
            "R=60, and an unknown u22) is not wired up. The technique is "
            "identical — only the stride and header constants differ.")
    if game != "skyrim":
        raise ValueError(f"unknown game {game!r}; expected 'skyrim'")
    R = _STRIDE
    # Odd frame count => the triangle's apex lands exactly on a row, so the
    # probed slot genuinely reaches full-open 1.0.
    count12 = max(int(round(seconds * _FPS)), 9) | 1
    os.makedirs(out_dir, exist_ok=True)
    written: List[str] = []
    for slot in range(R):
        cells = []
        for r in range(count12):
            x = r / (count12 - 1)
            v = round(1.0 - abs(2.0 * x - 1.0), 4)   # 0 → 1 → 0
            if slot != _ANCHOR_SLOT:
                # A resting anchor at grid slot 0 gives the stream its required
                # pos-0 start. The SENTINEL's bytes differ from any 0.0 triangle
                # endpoint, so the two never merge into a doubled-value key even
                # where they land on adjacent slots (slot 1, and slot R-1→0).
                cells.append({"frame": r, "curve": _ANCHOR_SLOT, "value": _SENTINEL_F})
            cells.append({"frame": r, "curve": slot, "value": v})
        payload = _encode_cells(cells, R, total_slots=R * count12)
        n_curves = 1 if slot == _ANCHOR_SLOT else 2
        path = os.path.join(out_dir, f"slot_{slot:02d}.lip")
        with open(path, "wb") as fh:
            fh.write(_pack_header(n_curves, count12, 0) + payload)
        written.append(path)
    _write_calibration_readme(out_dir, game, R)
    return written

openfacefx.bethesda

Bethesda FUZ container and LIP header utilities (Skyrim / Fallout 4).

What ships here is exactly what public sources verify (see docs/COMPATIBILITY.md):

  • the .fuz voice container — FULLY specced: b"FUZE" magic, uint32 version, uint32 lip size, embedded .lip bytes, then xWMA audio;
  • the modern .lip 12-byte header — int32 version / size / flags, with the flag meanings from the public FaceFXWrapper interface.

Writing a .lip payload is not yet possible: it is a FaceFX facial-animation blob with no public byte-level spec — every existing generator drives Bethesda's own Creation Kit code instead of writing bytes. Progress is tracked in issue #12; lip_info exists to help that effort along.

FUZ_MAGIC = b'FUZE' module-attribute

LIP_FLAG_COMPRESSED = 1 module-attribute

LIP_FLAG_BIG_ENDIAN = 2 module-attribute

LIP_FLAG_HAS_GESTURES = 4 module-attribute

LIP_FLAG_VARIABLE_TARGETS = 8 module-attribute

SKYRIM_TARGETS = ['Aah', 'BigAah', 'BMP', 'ChjSh', 'DST', 'Eee', 'Eh', 'FV', 'i', 'k', 'N', 'Oh', 'OohQ', 'R', 'Th', 'W'] module-attribute

FALLOUT4_NUM_TARGETS = 43 module-attribute

LipHeader(version: int, size: int, flags: int) dataclass

version: int instance-attribute

size: int instance-attribute

flags: int instance-attribute

compressed: bool property

big_endian: bool property

has_gestures: bool property

variable_targets: bool property

parse_lip_header(data: bytes) -> LipHeader

Parse the 12-byte header at the start of a modern .lip file.

Source code in src/openfacefx/bethesda.py
def parse_lip_header(data: bytes) -> LipHeader:
    """Parse the 12-byte header at the start of a modern .lip file."""
    if len(data) < 12:
        raise ValueError(f"lip data too short for header: {len(data)} bytes")
    version, size, flags = struct.unpack_from("<iii", data)
    return LipHeader(version, size, flags)

lip_info(data: bytes) -> dict

Diagnostic summary of a .lip blob: header fields plus whether the payload inflates as zlib (the compressed flag's presumed codec).

Source code in src/openfacefx/bethesda.py
def lip_info(data: bytes) -> dict:
    """Diagnostic summary of a .lip blob: header fields plus whether the
    payload inflates as zlib (the compressed flag's presumed codec)."""
    hdr = parse_lip_header(data)
    payload = data[12:]
    info = {
        "version": hdr.version,
        "size": hdr.size,
        "flags": hdr.flags,
        "compressed": hdr.compressed,
        "big_endian": hdr.big_endian,
        "has_gestures": hdr.has_gestures,
        "variable_targets": hdr.variable_targets,
        "payload_bytes": len(payload),
        "zlib_inflates": False,
        "inflated_bytes": None,
    }
    if payload:
        try:
            info["inflated_bytes"] = len(zlib.decompress(payload))
            info["zlib_inflates"] = True
        except zlib.error:
            pass
    return info

read_fuz(path: str) -> Tuple[bytes, bytes]

Split a .fuz file into (lip_bytes, audio_bytes).

lip_bytes is empty when the container carries no lip data. The audio is returned as stored (normally xWMA; convert externally if needed).

Source code in src/openfacefx/bethesda.py
def read_fuz(path: str) -> Tuple[bytes, bytes]:
    """Split a .fuz file into (lip_bytes, audio_bytes).

    ``lip_bytes`` is empty when the container carries no lip data. The audio
    is returned as stored (normally xWMA; convert externally if needed).
    """
    with open(path, "rb") as fh:
        data = fh.read()
    if data[:4] != FUZ_MAGIC:
        raise ValueError(f"not a FUZE container: magic {data[:4]!r}")
    if len(data) < 12:
        raise ValueError(f"fuz too short for header: {len(data)} bytes")
    _version, lip_size = struct.unpack_from("<II", data, 4)
    if 12 + lip_size > len(data):
        raise ValueError(f"lip size {lip_size} exceeds file ({len(data)} bytes)")
    return data[12:12 + lip_size], data[12 + lip_size:]

write_fuz(path: str, audio: bytes, lip: Optional[bytes] = None, version: int = 1) -> None

Write a .fuz container: audio (xWMA expected) plus optional lip data.

Source code in src/openfacefx/bethesda.py
def write_fuz(path: str, audio: bytes, lip: Optional[bytes] = None,
              version: int = 1) -> None:
    """Write a .fuz container: audio (xWMA expected) plus optional lip data."""
    lip = lip or b""
    with open(path, "wb") as fh:
        fh.write(FUZ_MAGIC)
        fh.write(struct.pack("<II", version, len(lip)))
        fh.write(lip)
        fh.write(audio)