Track transforms (retime / mirror / trim)¶
Deterministic post-production edits on an existing track (issue #48) — the kind
postprocess.time_shift can't do (it only slides, never stretches). They
compose with convert and the importers: bring a
capture in, retime it to the new VO, mirror it for an opposite-facing character,
slice a range out, re-export.
python -m openfacefx transform track.json --retime 1.5 -o slow.json # 1.5x slower
python -m openfacefx transform track.json --duration 3.2 -o fit.json # to 3.2 s
python -m openfacefx transform track.json --wav newvo.wav -o redub.json # to a WAV length
python -m openfacefx transform track.json --mirror -o flipped.json # L/R mirror
python -m openfacefx transform track.json --trim 0.5 2.0 -o slice.json # keep [0.5, 2.0]
The transform command reads a .track.json, applies the selected ops in the
order retime → mirror → trim, and writes through the shared exporter dispatch
(so any -o extension and --retarget/--adjust work, exactly as convert).
retime / stretch¶
retime(track, factor, *, anchor=0.0) scales every keyframe time (and event
time) about anchor — t' = anchor + (t - anchor) * factor — leaving channel
values untouched and letting the track duration follow.
retime_to_duration(track, target) picks the factor to hit a target length;
--wav uses wav_duration. factor must be finite and positive.
A uniform scale preserves collinearity and (when stretching) only widens key spacing, so it introduces no redundant keys — retime therefore keeps every key (deduping only an exact 4-dp time collision under heavy compression) rather than RDP-resampling, which would move keys and break the "every key time scales, values unchanged" contract. Retime to 2× doubles every key time, event time, and the duration exactly.
mirror¶
mirror(track) produces the opposite-facing performance:
- swaps
*Left↔*Rightchannel pairs via the extensible :data:MIRROR_PAIRStable (plain data, the same style as the retarget presets — ARKit blendshapes plus the gesture-layerblink_L/blink_R; copy and extend for your rig); - negates the signed lateral pose channels :data:
MIRROR_NEGATE(headYaw,headRoll,eyeYaw) — a left turn becomes a right turn; - leaves centered channels (all visemes,
jawOpen, andheadPitch/eyePitch, which are up/down not lateral) untouched.
It is a pure relabel + sign flip — no time change, no re-thin, channel order
preserved — so mirror(mirror(track)) is byte-identical to track (pinned by
a to_dict and a CLI cmp test).
trim / slice¶
trim(track, t0, t1) keeps [t0, t1], rebased so t0 becomes 0. Only
in-window keys are kept (a channel left empty is dropped); events whose start is
in-window are rebased and their duration reclamped to the window, the rest
dropped. An empty or out-of-range window yields an empty track — never a crash.
concat / sequence¶
concat(tracks, *, gaps=None, crossfade=0.0) — the sequential complement to
trim — splices finished tracks end-to-end into one timeline, offsetting every
keyframe and event/variant time of segment k by its cumulative start and
setting duration = Σ durations + Σ gaps. Use it to stitch per-line VO into one
conversation track, build a barks reel, or insert beats between lines:
python -m openfacefx sequence line1.json line2.json line3.json -o scene.json
python -m openfacefx sequence a.json b.json --gap 0.5 -o with_beat.json # silence between
python -m openfacefx sequence a.json b.json --crossfade 0.15 -o blended.json # soft seam
Channels are unioned across segments: a channel absent from a segment reads as
rest (0) across its span — a 0 key at each of that segment's boundaries stops
the previous segment's last value bleeding over the seam. --gap SECONDS inserts
silence and shifts everything after it. A single-track concat([a]) (no gap, no
crossfade) returns a byte-identical, and concat is the seam inverse of
trim: trim at the seam reproduces a and the time-shifted b.
By default (crossfade=0) the splice is a pure relabel/offset with no re-thin
(a hard cut, exact). --crossfade S linearly blends the shared channels over
±S seconds at each abutting seam, RDP-thinning only that window.
Every transform is deterministic (stdlib arithmetic, no clock, no RNG — identical
on Python 3.9/3.13), additive, and leaves existing command output unchanged.
Library callers get retime, retime_to_duration, mirror, trim, concat,
MIRROR_PAIRS, and MIRROR_NEGATE.
openfacefx.transforms
¶
Deterministic track transforms: retime / mirror / trim (issue #48).
Common post-production edits that had no home. Unlike
:func:openfacefx.postprocess.time_shift (which only slides curves and never
stretches the clip), these reshape the timeline and the channel layout:
- :func:
retime-- scale every keyframe time (and event time) by a factor, or :func:retime_to_durationto a target length, pinning ananchor. Channel values are untouched; the trackdurationfollows the scaled keys. - :func:
mirror-- swap*Left<->*Rightchannel pairs (an extensible :data:MIRROR_PAIRStable, plain data like the retarget presets) and negate the signed lateral pose channels (:data:MIRROR_NEGATE:headYaw/headRoll/eyeYaw); centered channels (visemes,jawOpen,headPitch) pass through untouched. A pure relabel + sign-flip -- no time change, no re-thin, channel order preserved -- somirror(mirror(t))is byte-identical tot. - :func:
trim-- keep[t0, t1], rebase to zero, and drop/reclamp events to the window; an empty window yields an empty track, not a crash.
They compose with convert and the importers (bring a capture in, retime to the
new VO, re-export). numpy is not needed -- pure stdlib arithmetic, no clock, no
RNG, identical on Python 3.9/3.13.
Note on re-thinning: a uniform time scale preserves collinearity and (when stretching) only widens key spacing, so it introduces no redundant keys; retime therefore keeps every key (deduping only an exact time collision under heavy compression) rather than RDP-resampling, which would move keys and defeat the "every key time scales, values unchanged" contract.
MIRROR_PAIRS: List[Tuple[str, str]] = [('browDownLeft', 'browDownRight'), ('browOuterUpLeft', 'browOuterUpRight'), ('cheekSquintLeft', 'cheekSquintRight'), ('eyeBlinkLeft', 'eyeBlinkRight'), ('eyeLookDownLeft', 'eyeLookDownRight'), ('eyeLookInLeft', 'eyeLookInRight'), ('eyeLookOutLeft', 'eyeLookOutRight'), ('eyeLookUpLeft', 'eyeLookUpRight'), ('eyeSquintLeft', 'eyeSquintRight'), ('eyeWideLeft', 'eyeWideRight'), ('mouthDimpleLeft', 'mouthDimpleRight'), ('mouthFrownLeft', 'mouthFrownRight'), ('mouthLowerDownLeft', 'mouthLowerDownRight'), ('mouthPressLeft', 'mouthPressRight'), ('mouthSmileLeft', 'mouthSmileRight'), ('mouthStretchLeft', 'mouthStretchRight'), ('mouthUpperUpLeft', 'mouthUpperUpRight'), ('noseSneerLeft', 'noseSneerRight'), ('blink_L', 'blink_R')]
module-attribute
¶
MIRROR_NEGATE = frozenset({'headYaw', 'headRoll', 'eyeYaw'})
module-attribute
¶
retime(track: FaceTrack, factor: float, *, anchor: float = 0.0) -> FaceTrack
¶
Scale every keyframe and event time about anchor by factor --
t' = anchor + (t - anchor) * factor -- leaving channel values unchanged
and letting the track duration follow. factor must be finite and
positive. Keys are preserved (a uniform scale adds no redundancy); only an
exact 4-dp time collision under heavy compression is de-duplicated.
Source code in src/openfacefx/transforms.py
retime_to_duration(track: FaceTrack, target: float, *, anchor: float = 0.0) -> FaceTrack
¶
Retime so the clip lasts target seconds (factor = target /
duration). A zero-duration track cannot be scaled to a length.
Source code in src/openfacefx/transforms.py
mirror(track: FaceTrack) -> FaceTrack
¶
Swap *Left/*Right channel pairs and negate the signed lateral pose
channels, in place (channel order preserved, no time change, no re-thin).
Centered channels pass through untouched. Because it is a pure relabel plus
sign flip, mirror(mirror(track)) is byte-identical to track.
Source code in src/openfacefx/transforms.py
trim(track: FaceTrack, t0: float, t1: float) -> FaceTrack
¶
Keep [t0, t1], rebased so t0 becomes 0. Only in-window keys are
kept (a channel left empty is dropped); events whose start is in-window are
rebased and their duration reclamped to the window, the rest dropped. An empty
or out-of-range window yields an empty track (no crash).
Source code in src/openfacefx/transforms.py
concat(tracks: List[FaceTrack], *, gaps=None, crossfade: float = 0.0) -> FaceTrack
¶
Splice tracks into one timeline, in order.
Every keyframe and event/variant time of segment k is offset by its
cumulative start (Σ of the earlier durations and gaps); the result's
duration is Σ durations + Σ gaps. Channels are unioned across
segments -- a channel absent from a segment reads as rest (0) across that
segment's span (a 0 key at each of its boundaries stops the previous
segment's last value bleeding over the seam). gaps is a per-seam second
list (or one float applied between all); a single-track concat([a]) with
no gap or crossfade returns a unchanged (byte-identical).
crossfade (default 0 -- a hard cut) linearly blends the shared
channels over ±crossfade seconds at each abutting seam, RDP-thinning only
that window; at 0 the splice is a pure relabel/offset with no re-thin.