Skip to content

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 anchort' = 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*Right channel pairs via the extensible :data:MIRROR_PAIRS table (plain data, the same style as the retarget presets — ARKit blendshapes plus the gesture-layer blink_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, and headPitch / 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_duration to a target length, pinning an anchor. Channel values are untouched; the track duration follows the scaled keys.
  • :func:mirror -- swap *Left <-> *Right channel pairs (an extensible :data:MIRROR_PAIRS table, 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 -- so mirror(mirror(t)) is byte-identical to t.
  • :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
def 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."""
    if not math.isfinite(factor) or factor <= 0.0:
        raise ValueError(f"retime factor must be a finite positive number, "
                         f"got {factor!r}")
    channels: List[Channel] = []
    for c in track.channels:
        keys: List[Keyframe] = []
        last: Optional[float] = None
        for k in c.keys:
            t = round(anchor + (k.time - anchor) * factor, 4)
            if last is not None and t <= last:      # collided under compression
                continue
            keys.append(Keyframe(t, k.value))
            last = t
        channels.append(Channel(c.name, keys))
    out = FaceTrack(track.fps, channels, _copy_target_set(track))
    return _carry(track, out, _retime_events(track.events, factor, anchor),
                  _retime_variants(track.variants, factor, anchor))

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
def 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."""
    current = track.duration
    if current <= 0.0:
        raise ValueError("cannot retime a zero-duration track to a new length")
    if not math.isfinite(target) or target <= 0.0:
        raise ValueError(f"target duration must be finite and positive, "
                         f"got {target!r}")
    return retime(track, target / current, anchor=anchor)

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
def 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``."""
    channels: List[Channel] = []
    for c in track.channels:
        name = _MIRROR_MAP.get(c.name, c.name)
        if c.name in MIRROR_NEGATE:
            keys = [Keyframe(k.time, -k.value) for k in c.keys]
        else:
            keys = [Keyframe(k.time, k.value) for k in c.keys]
        channels.append(Channel(name, keys))
    ts = track.target_set
    if ts is not None:
        ts = [_MIRROR_MAP.get(n, n) for n in ts]
    out = FaceTrack(track.fps, channels, ts)
    # The event/variant layer is timeline metadata, unaffected by a spatial
    # mirror -- carried through unchanged so the double-mirror stays identical.
    return _carry(track, out, list(track.events), track.variants)

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
def 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)."""
    if not (math.isfinite(t0) and math.isfinite(t1)):
        raise ValueError(f"trim window must be finite, got [{t0}, {t1}]")
    if t1 < t0:
        raise ValueError(f"trim window end {t1} precedes start {t0}")
    channels: List[Channel] = []
    for c in track.channels:
        keys = [Keyframe(round(k.time - t0, 4), k.value)
                for k in c.keys if t0 - _EPS <= k.time <= t1 + _EPS]
        if keys:
            channels.append(Channel(c.name, keys))
    out = FaceTrack(track.fps, channels, _copy_target_set(track))
    return _carry(track, out, _trim_events(track.events, t0, t1),
                  _trim_variants(track.variants, t0, t1))

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.

Source code in src/openfacefx/transforms.py
def 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."""
    if not tracks:
        raise ValueError("concat needs at least one track")
    if not (math.isfinite(crossfade) and crossfade >= 0.0):
        raise ValueError(f"crossfade must be a finite number >= 0, got {crossfade!r}")
    n = len(tracks)
    gap_list = _concat_gaps(gaps, n)
    if n == 1 and not crossfade:
        return tracks[0]                             # a single splice is a no-op

    offsets = [0.0]
    for k in range(1, n):
        offsets.append(round(offsets[-1] + tracks[k - 1].duration
                             + gap_list[k - 1], 6))

    order: List[str] = []
    seen = set()
    for t in tracks:
        for c in t.channels:
            if c.name not in seen:
                seen.add(c.name)
                order.append(c.name)

    channels: List[Channel] = []
    for name in order:
        keys: List[Keyframe] = []
        for k, t in enumerate(tracks):
            ch = next((c for c in t.channels if c.name == name), None)
            off = offsets[k]
            if ch is not None:
                keys.extend(Keyframe(round(kf.time + off, 6), kf.value)
                            for kf in ch.keys)
            else:                                    # rest across this span, no bleed
                keys.append(Keyframe(round(off, 6), 0.0))
                keys.append(Keyframe(round(off + t.duration, 6), 0.0))
        channels.append(Channel(name, _dedup_keys(keys)))

    out = FaceTrack(tracks[0].fps, channels, _concat_target_set(tracks))
    out = _carry(tracks[0], out, _concat_events(tracks, offsets),
                 _concat_variants(tracks, offsets))
    if crossfade > 0.0:
        _apply_crossfades(out, tracks, offsets, gap_list, crossfade)
    return out