Layered multi-track export¶
Engines often want to re-blend or toggle facial layers at runtime rather than
receive one flattened curve set — Unreal Sequencer/Control Rig keep lip-sync and
expression on separate additive tracks, and SALSA blends layers by priority
(issue #39). The pipeline already produces speech / emotion / gesture on
disjoint channels before concatenating them into one FaceTrack, so this is a
data-reshuffle of arrays it already has plus a little metadata; the runtime mix
stays the engine's job.
export-layers writes the same flat track with an extra top-level layers
block — so a reader that ignores the block still sees the merged track, and the
default (no layers) output is unchanged.
The layers block¶
build_layers(track) decomposes a merged track into named sub-tracks by channel
classification — gesture (the issue-#5 gesture channels), emotion (the issue-#38
VA_EMOTION_CHANNELS), and speech (everything else: visemes and any rig
blendshapes). Each layer carries its channel list, a per-layer blend-weight
curve ([[t, w], …], default constant 1.0) and an integer priority (engine
layering order — speech 0, emotion 10, gesture 20). Empty layers are omitted.
{
"format": "openfacefx.track", "version": 1, "fps": 60.0, "duration": 2.3,
"channels": [ /* the flat merged track, unchanged */ ],
"layers": [
{ "name": "speech", "priority": 0, "weight": [[0.0, 1.0]], "channels": [ /* visemes */ ] },
{ "name": "emotion", "priority": 10, "weight": [[0.0, 1.0]], "channels": [ /* smile, brow_raise, … */ ] },
{ "name": "gesture", "priority": 20, "weight": [[0.0, 1.0]], "channels": [ /* blink, headYaw, … */ ] }
]
}
Because every channel lands in exactly one layer, flatten_layers summing the
layers at weight 1 reproduces the merged channels exactly — a faithful, lossless
decomposition (pinned by a round-trip test). priority is engine metadata, ignored
by the flatten. Events (including prosody, issue #4) remain the track's own event
layer, unchanged — prosody drives notifies, not curves, so it is not a channel
layer.
Invariants¶
- Byte-identical default:
to_dict(track)with nolayers(and notrack.layers) is byte-for-byte what it was before — thelayerskey is appended only when non-empty. The entire existing test suite is the proof. to_dict(track, layers=…)/write_json(…, layers=…)embed the block;from_dict/read_jsonrestore it totrack.layers, so names, weights and priorities survivewrite_json→read_json.- Empty/absent layers are omitted, never emitted as dead channels.
Library callers get build_layers, flatten_layers, layers_to_dict,
layers_from_dict and the Layer dataclass. numpy + stdlib, deterministic across
Python 3.9/3.13, additive.
openfacefx.layers
¶
Layered multi-track export (issue #39).
Engines want to re-blend or toggle facial layers at runtime rather than receive
one flattened curve set -- Unreal Sequencer/Control Rig keep lip-sync and
expression on separate additive tracks, and SALSA blends layers by priority. The
pipeline already produces speech / emotion / gesture on disjoint channels
before they are concatenated into one :class:~openfacefx.curves.FaceTrack, so
this is a data-reshuffle of arrays it already has plus a little metadata; the
runtime mix stays the engine's job.
:func:build_layers decomposes a merged track into named layers by channel
classification (gesture = :data:openfacefx.gestures.GESTURE_CHANNELS,
emotion = :data:openfacefx.emotion.VA_EMOTION_CHANNELS, speech =
everything else -- visemes and any rig blendshapes), each carrying its channel
list, a per-layer blend-weight curve (default constant 1.0) and an integer
priority (engine layering order). Because every channel lands in exactly one
layer, :func:flatten_layers summing them at weight 1 reproduces the merged
channels exactly -- a faithful decomposition. Empty layers are omitted.
The layers ride as an optional layers block on the track JSON
(:func:openfacefx.io_export.to_dict with layers=); the flat channel list
stays the default, so a track without layers is byte-identical to before and a
reader that ignores the block still sees the merged track. Events (including
prosody, issue #4) remain the track's own event layer, unchanged -- prosody drives
notifies, not curves, so it is not a channel layer. numpy + stdlib, deterministic.
FULL_WEIGHT: List[List[float]] = [[0.0, 1.0]]
module-attribute
¶
Layer(name: str, channels: List[Channel] = list(), weight: List[List[float]] = (lambda: [[0.0, 1.0]])(), priority: int = 0)
dataclass
¶
One named sub-track: a normal channel list plus a blend-weight curve
([[t, w], ...], default constant 1.0) and an integer priority
(engine layering order). The weight and priority are metadata for the runtime
mix; OpenFaceFX only uses weight when it flattens for verification.
build_layers(track: FaceTrack) -> List[Layer]
¶
Decompose a merged track into named layers by channel classification.
Every channel lands in exactly one layer (gesture / emotion / the
speech base), so the split is lossless; empty layers are omitted. Each
layer gets a constant full-blend weight and its default priority.
Source code in src/openfacefx/layers.py
flatten_layers(layers: List[Layer], fps: float = 60.0) -> FaceTrack
¶
Sum layers back into one :class:FaceTrack, each channel scaled by its
layer's blend-weight curve and channels of the same name added on a shared
grid. At weight 1 (the default) this reproduces the merged channels -- the
faithful-decomposition check. priority is engine metadata, ignored here.
Source code in src/openfacefx/layers.py
layers_to_dict(layers: List[Layer]) -> List[dict]
¶
Serialise layers to the JSON block: a list of {name, priority, weight,
channels} (channels/weights rounded to 4 dp, exactly like track keys).
Source code in src/openfacefx/layers.py
layers_from_dict(raw) -> List[Layer]
¶
Inverse of :func:layers_to_dict; raises ValueError on a malformed
block so a corrupt layers key fails loudly rather than silently.