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
write_json(track: FaceTrack, path: str) -> None
¶
write_csv(track: FaceTrack, path: str) -> None
¶
Source code in src/openfacefx/io_export.py
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
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
ParamMouthOpenYcurve: the summed weight of every non-silence viseme, clamped to0..1. That is an openness/loudness proxy (it equals1 - silon normalised coarticulation output) and matches the one mouth-open parameter almost every Cubism model exposes. The target Id is configurable (ParamMouthOpenYis the Cubism default, not a hard requirement). - Per-parameter -- pass a
viseme -> ParamIdmap (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
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
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
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
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
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
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
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
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
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_MAPbelow 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 DIRwrites 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 inSKYRIM_SLOT_MAP. Seedocs/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
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
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
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
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
¶
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
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
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
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.