Skip to content

ComponentParam

A parameter of a component.

Scalar, boolean, point and color values decode to Python values; other encodings (popups, arbitrary data) are returned as their raw serialized string until their per-ClassID encodings are mapped.

Source code in src/py_premiere/models/component.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
class ComponentParam:
    """A parameter of a component.

    Scalar, boolean, point and color values decode to Python values; other
    encodings (popups, arbitrary data) are returned as their raw serialized
    string until their per-ClassID encodings are mapped.
    """

    def __init__(
        self,
        _element: ET.Element,
        _document: PremiereDocument,
        component: Component | TrackItem,
    ) -> None:
        self._element = _element
        self._document = _document
        #: The owning component, or the track item itself for the time-remap
        #: speed param - which Premiere stores outside any component chain.
        self.component = component

    @property
    def display_name(self) -> str:
        """The parameter name. Read-only."""
        return self._element.findtext("Name") or ""

    @property
    def class_id(self) -> str:
        """The parameter's serialization class GUID. Read-only.

        The same tag serves several value encodings; the ClassID identifies
        which one (see `data/param_classes.py`).
        """
        return self._element.get("ClassID", "")

    @property
    def is_time_varying(self) -> bool:
        """Whether the param is keyframed. Read-only."""
        if self._element.findtext("IsTimeVarying") == "true":
            return True
        # Audio params drop the flag once keyframed and rely on a populated
        # `Keyframes` list instead.
        return bool((self._element.findtext("Keyframes") or "").strip())

    def _start_keyframe(self) -> tuple[int, str] | None:
        text = self._element.findtext("StartKeyframe")
        if not text:
            return None
        return _split_keyframe(text)

    def _keyframe_entries(self) -> list[tuple[int, str]]:
        text = self._element.findtext("Keyframes")
        if not text:
            return []
        return [_split_keyframe(entry) for entry in text.split(";") if entry]

    @property
    def value(self) -> float | bool | list[float] | str | None:
        """The static value. Read/write on scalar, boolean and point params.

        Reads `None` when the param has no stored value (a synthesized
        default). The setter accepts a number (scalar param), a `bool`
        (checkbox param) or a two-number sequence (2D point param) that
        already has a stored value; keyframed and other params raise.
        """
        start = self._start_keyframe()
        if start is None:
            return None
        return _decode_value(start[1])

    @value.setter
    def value(self, new_value: float | bool | list[float] | int) -> None:
        if self.is_time_varying:
            raise ValueError("cannot set a static value on a keyframed parameter")
        field = self._encode_static_value(new_value)
        element = self._element.find("StartKeyframe")
        if element is None or not element.text:
            raise ValueError("parameter has no stored value to set")
        fields = element.text.split(",")
        fields[1] = field
        element.text = ",".join(fields)
        if self.class_id in _SCALAR_VALUE_CLASSES:
            # Audio scalars mirror the static value in `CurrentValue` as the
            # float32 printed at full double precision (10_audio_volume:
            # `0.34999999403953552` beside a `0.34999999404` start keyframe);
            # leaving it stale would diverge from Premiere's own setValue.
            current = self._element.find("CurrentValue")
            if current is not None:
                current.text = _format_current_value(cast("float", new_value))

    def _encode_static_value(self, new_value: float | bool | list[float] | int) -> str:
        class_id = self.class_id
        if class_id in _SCALAR_VALUE_CLASSES:
            validate_float32(new_value)
            return _format_scalar(cast("float", new_value))
        if class_id.startswith(_BOOL_CLASS):
            validate_bool(new_value)
            return "true" if new_value else "false"
        if class_id.startswith(_POINT_CLASS):
            validate_vector2(new_value)
            point = cast("list[float]", new_value)
            return f"{_format_scalar(point[0])}:{_format_scalar(point[1])}"
        if class_id.startswith(_COLOR_CLASS):
            # Stored as the bare decimal of the packed `0xAA00RR00GG00BB00`
            # uint64 - no trailing dot, unlike scalars (61_tint stores `0`
            # and `280379743338240`).
            validate_packed_color(new_value)
            return str(new_value)
        raise ValueError(
            "value is only settable on scalar, boolean, point and color parameters"
        )

    @property
    def keys(self) -> list[Time]:
        """Keyframe times. Read-only."""
        return [Time(ticks) for ticks, _ in self._keyframe_entries()]

    def get_value_at_key(self, time: Time) -> float | bool | list[float] | str | None:
        """The value at an existing keyframe."""
        validate_time(time)
        for ticks, value_field in self._keyframe_entries():
            if ticks == time.ticks:
                return _decode_value(value_field)
        return None

    def find_nearest_key(
        self, time: Time, threshold: Time | None = None
    ) -> Time | None:
        """The keyframe closest to `time`, or `None` if there are none.

        ExtendScript's `findNearestKey(time, threshold)`. With a
        `threshold`, a key further away than that is not returned. Ties go
        to the earlier key.
        """
        validate_time(time)
        if threshold is not None:
            validate_time(threshold)
        best: Time | None = None
        best_distance = -1
        for key in self.keys:
            distance = abs(key.ticks - time.ticks)
            if best is None or distance < best_distance:
                best, best_distance = key, distance
        if best is None:
            return None
        if threshold is not None and best_distance > threshold.ticks:
            return None
        return best

    def find_next_key(self, time: Time) -> Time | None:
        """The first keyframe strictly after `time`, or `None`."""
        validate_time(time)
        later = [key for key in self.keys if key.ticks > time.ticks]
        return min(later, key=lambda key: key.ticks) if later else None

    def find_previous_key(self, time: Time) -> Time | None:
        """The last keyframe strictly before `time`, or `None`."""
        validate_time(time)
        earlier = [key for key in self.keys if key.ticks < time.ticks]
        return max(earlier, key=lambda key: key.ticks) if earlier else None

    @property
    def color(self) -> Color | None:
        """The static value of a color param as an RGBA `Color`. Read/write.

        `None` for a non-color param or one with no stored value. The
        packed uint64 holds each 8-bit channel in the high byte of a
        16-bit word (`0xAA00RR00GG00BB00`); verified against Premiere's own
        RGB. `value` returns the raw packed number for ES parity.

        The setter takes a `Color`. Mind the alpha: some effects store their
        colors with alpha 0 (Tint's defaults do), and `Color`'s alpha
        defaults to 255.
        """
        if not self.class_id.startswith(_COLOR_CLASS):
            return None
        start = self._start_keyframe()
        if start is None:
            return None
        field = start[1]
        if not field.lstrip("-").isdigit():
            return None
        return _unpack_color(int(field))

    @color.setter
    def color(self, new_value: Color) -> None:
        if not self.class_id.startswith(_COLOR_CLASS):
            raise ValueError("not a color parameter")
        validate_color(new_value)
        self.value = _pack_color(new_value)

    def get_interpolation_at_key(self, time: Time) -> KeyframeInterpolation | None:
        """The temporal interpolation of an existing keyframe.

        This is the INCOMING interpolation, stored as the third keyframe
        field (after time and value); decoded against UXP-generated knowns
        in the `09_keyframes` fixture. Entry layout: `ticks,value,interpIn,
        interpOut,inSlope,inInfluence,outSlope,outInfluence`.
        """
        return self._interpolation_field(time, 2)

    def get_spatial_tangents_at_key(self, time: Time) -> SpatialTangents | None:
        """The motion-path handles of an existing 2D keyframe.

        A 2D (point) keyframe stores FOURTEEN fields where a scalar stores
        eight: the extra four at the end are the spatial bezier handles that
        shape the motion path, distinct from the temporal slopes earlier in
        the entry, and they appear as mirrored pairs on a smooth key.
        `None` for a scalar parameter, which has no motion path.
        """
        validate_time(time)
        text = self._element.findtext("Keyframes") or self._element.findtext(
            "StartKeyframe"
        )
        if not text:
            return None
        for entry in text.split(";"):
            fields = entry.split(",")
            if len(fields) != _POINT_ENTRY_FIELDS or fields[0] != str(time.ticks):
                continue
            handles = fields[_SPATIAL_START:]
            return SpatialTangents(*(float(value) for value in handles))
        return None

    @property
    def text(self) -> str | None:
        """The text this parameter stores, if it stores any. Read-only.

        An arbitrary-data parameter keeps its payload as base64. The
        Motion Graphics template controls keep theirs as UTF-16LE: a plain
        string for a simple control, a JSON object for a styled one (the
        edited text under `textEditValue`). An Essential Graphics `Source
        Text` stores the same `FormattedTextData` FlatBuffer captions use
        (66_eg_text carries the `0x11223344` magic), whose last string is
        the plain text. `None` for every parameter whose payload is not
        text - a scalar's, and the binary shape blob behind `path`.
        """
        raw = self._payload()
        if raw is None:
            return None
        if raw[8:12] == _TEXT_MAGIC:
            return decode_caption_text(raw)
        try:
            decoded = raw.decode("utf-16-le")
        except UnicodeDecodeError:
            return None
        return decoded if is_payload_text(decoded) else None

    @text.setter
    def text(self, value: str) -> None:
        _validate_text(value)
        raw, element = self._styled_text_payload()
        write_payload_element(self._document, element, replace_payload_text(raw, value))

    @property
    def font_family(self) -> str | None:
        """The font family a styled-text parameter is set in. Read/write.

        `None` for parameters holding no `FormattedTextData` payload. The
        family is a string in the payload's font vector; Premiere resolves
        an unknown one to a fallback on open.
        """
        raw = self._payload()
        if raw is None or raw[8:12] != _TEXT_MAGIC:
            return None
        return str(read_font_family(raw))

    @font_family.setter
    def font_family(self, value: str) -> None:
        _validate_text(value)
        raw, element = self._styled_text_payload()
        write_payload_element(self._document, element, write_font_family(raw, value))

    def _styled_text_payload(self) -> tuple[bytes, ET.Element]:
        raw = self._payload()
        element = self._element.find("StartKeyframeValue")
        if raw is None or element is None:
            raise ValueError("parameter holds no text payload")
        if raw[8:12] != _TEXT_MAGIC:
            raise NotImplementedError(
                "only FormattedTextData payloads are writable; this one is "
                "a Motion Graphics template control"
            )
        return raw, element

    def _payload(self) -> bytes | None:
        # An arbitrary-data value, resolved through the document's hash index:
        # a template's parameter values are stored once and referenced by
        # hash from every copy, so this element's own text is often empty.
        element = self._element.find("StartKeyframeValue")
        if element is None:
            return None
        try:
            return self._document.payload(element)
        except (ValueError, binascii.Error):
            return None

    @property
    def path(self) -> list[PathVertex] | None:
        """The shape this parameter stores, if it stores one. Read/write.

        Mask and shape geometry lives in an arbitrary-data parameter
        (named `Path`) in one of two little-endian layouts sharing a
        version word and a trailing closed-path byte: the FLAT one (a
        vertex count, then seven floats per vertex - a leading flag, the
        point, and its bezier handles) and the drawn-mask SUBPATH one
        (contours, each with a path id and integer-flagged vertices -
        26_effect_mask's drawn ellipse).

        `None` when the parameter holds no shape at all; an EMPTY list
        when it holds a shape with no vertices, which is what a default
        (undrawn) mask stores - its geometry lives in the sibling
        Type/Scale/Rotation parameters instead.

        The setter writes the vertices as one closed subpath (the shape
        the fixture's drawn mask stores); an empty list writes the bare
        header.
        """
        raw = self._payload()
        if raw is None:
            return None
        if len(raw) < 8:
            return None
        version, count = struct.unpack_from("<II", raw, 0)
        if version != _PATH_VERSION:
            return None
        # The FLAT layout (the corpus rectangles): `count` vertices of
        # seven floats and a trailing closed-path byte; an empty path is
        # just the two header words. Anything else with a matching version
        # is the drawn-mask SUBPATH layout, where `count` counts contours.
        expected = count * _PATH_STRIDE
        if len(raw) == 8 + expected * 4 + (1 if count else 0):
            floats = struct.unpack_from(f"<{expected}f", raw, 8)
            return [
                PathVertex(
                    x=group[1],
                    y=group[2],
                    in_x=group[3],
                    in_y=group[4],
                    out_x=group[5],
                    out_y=group[6],
                    flag=group[0],
                )
                for group in (
                    floats[index : index + _PATH_STRIDE]
                    for index in range(0, expected, _PATH_STRIDE)
                )
            ]
        decoded = _decode_subpaths(raw, count)
        if decoded is None:
            return None
        return [vertex for _, vertices in decoded[0] for vertex in vertices]

    @path.setter
    def path(self, vertices: list[PathVertex]) -> None:
        element = self._element.find("StartKeyframeValue")
        if self.display_name != "Path" or element is None:
            raise ValueError("path is only settable on a Path parameter")
        if not isinstance(vertices, list):
            raise TypeError(f"expected a list, got {type(vertices).__name__}")
        for vertex in vertices:
            if not isinstance(vertex, PathVertex):
                raise TypeError(
                    f"expected PathVertex entries, got {type(vertex).__name__}"
                )
            for coordinate in vertex:
                if not math.isfinite(coordinate):
                    raise ValueError("path coordinates must be finite")
        if vertices:
            raw = _encode_subpaths([(_DEFAULT_PATH_ID, vertices)], closed=True)
        else:
            # An empty path is the bare header, as shape masks store.
            raw = struct.pack("<II", _PATH_VERSION, 0)
        element.set("Encoding", "base64")
        element.set("BinaryHash", str(uuid.uuid4()))
        element.text = base64.b64encode(raw).decode("ascii") + "\n\t\t"
        self._document._by_binary_hash = None

    def get_out_interpolation_at_key(self, time: Time) -> KeyframeInterpolation | None:
        """The OUTGOING temporal interpolation of an existing keyframe.

        The fourth keyframe field, which a key only uses when it leaves on a
        different curve than it arrived on: across the corpus it is non-zero
        only where the incoming interpolation is `BEZIER`, and then carries
        another interpolation constant (a bezier-in / hold-out key, say).
        Zero - the overwhelmingly common case - means the key leaves the way
        it arrived.
        """
        return self._interpolation_field(time, 3)

    def _interpolation_field(
        self, time: Time, index: int
    ) -> KeyframeInterpolation | None:
        validate_time(time)
        text = self._element.findtext("Keyframes")
        if not text:
            return None
        for entry in text.split(";"):
            fields = entry.split(",")
            if len(fields) > index and fields[0] == str(time.ticks):
                return KeyframeInterpolation(int(fields[index]))
        return None

    def set_keyframes(
        self, keys: list[tuple[Time, float, KeyframeInterpolation]]
    ) -> None:
        """Set the keyframes of a scalar parameter.

        `keys` is a list of `(time, value, interpolation)`; bezier tangents
        are auto-computed to match Premiere's own output (slope from/to the
        neighbouring key, `1/6` influence, `1/3` outgoing for HOLD). A static
        (materialized) parameter is turned time-varying: the `Keyframes`
        element is synthesized, and so is the `IsTimeVarying` flag where
        Premiere writes one (every class but the audio scalars). Premiere also
        stamps a session `Timestamp` on such a param, which py does not
        reproduce (it is not deterministic).
        """
        if self.class_id not in _SCALAR_VALUE_CLASSES:
            raise ValueError("keyframes are only settable on scalar parameters")
        if not keys:
            raise ValueError("at least one keyframe is required")
        for time, value, interpolation in keys:
            validate_time(time)
            validate_float32(value)
            if not isinstance(interpolation, KeyframeInterpolation):
                raise TypeError("interpolation must be a KeyframeInterpolation")
        ordered = sorted(keys, key=lambda key: key[0].ticks)
        for previous, following in zip(ordered, ordered[1:]):
            if previous[0].ticks == following[0].ticks:
                # Two keys at one time divide by a zero interval below.
                raise ValueError(f"duplicate keyframe time {previous[0].ticks}")
        # Build the payload before touching the element: everything above can
        # still refuse, and a half-written param (IsTimeVarying set, no
        # keyframes) is a shape Premiere never produces.
        text = _build_keyframe_string(ordered)
        element = self._element.find("Keyframes")
        if element is None:
            start = self._element.find("StartKeyframe")
            if start is None:
                raise ValueError("parameter has no stored value to keyframe")
            flag = self._element.find("IsTimeVarying")
            if self.class_id == _AUDIO_SCALAR_CLASS:
                # Premiere DELETES the audio flag when keyframing (compare
                # 10_audio_volume, which carries it, to 63_audio_keyframes,
                # which does not) and relies on the populated `Keyframes`
                # list (see `is_time_varying`).
                if flag is not None:
                    remove_child(self._element, flag)
            elif flag is not None:
                flag.text = "true"
            else:
                # Every other class carries the flag on every keyframed
                # instance in the corpus, so it is created.
                self._create_time_varying_flag()
            # Audio params order `Keyframes` after `CurrentValue`
            # (63_audio_keyframes); video params have no `CurrentValue` and
            # put it right after `StartKeyframe`.
            anchor = self._element.find("CurrentValue")
            if anchor is None:
                anchor = start
            element = ET.Element("Keyframes")
            element.tail = anchor.tail
            self._element.insert(list(self._element).index(anchor) + 1, element)
        element.text = text

    def _create_time_varying_flag(self) -> None:
        # Premiere writes `IsTimeVarying` on every keyframed param and elides
        # it while static, so a param being keyframed for the first time needs
        # the element created. Across the corpus it always sits immediately
        # after `Name` - or after `ParameterControlType` where that follows it.
        tags = [child.tag for child in self._element]
        for anchor in ("ParameterControlType", "Name"):
            if anchor in tags:
                position = tags.index(anchor) + 1
                break
        else:
            raise ValueError("parameter has no anchor to create <IsTimeVarying>")
        flag = ET.Element("IsTimeVarying")
        flag.text = "true"
        insert_before(self._element, tags[position], flag)

    def __repr__(self) -> str:
        return f"ComponentParam(display_name={self.display_name!r})"

Attributes

class_id property

class_id

The parameter's serialization class GUID. Read-only.

The same tag serves several value encodings; the ClassID identifies which one (see data/param_classes.py).

color property writable

color

The static value of a color param as an RGBA Color. Read/write.

None for a non-color param or one with no stored value. The packed uint64 holds each 8-bit channel in the high byte of a 16-bit word (0xAA00RR00GG00BB00); verified against Premiere's own RGB. value returns the raw packed number for ES parity.

The setter takes a Color. Mind the alpha: some effects store their colors with alpha 0 (Tint's defaults do), and Color's alpha defaults to 255.

component instance-attribute

component = component

display_name property

display_name

The parameter name. Read-only.

font_family property writable

font_family

The font family a styled-text parameter is set in. Read/write.

None for parameters holding no FormattedTextData payload. The family is a string in the payload's font vector; Premiere resolves an unknown one to a fallback on open.

is_time_varying property

is_time_varying

Whether the param is keyframed. Read-only.

keys property

keys

Keyframe times. Read-only.

path property writable

path

The shape this parameter stores, if it stores one. Read/write.

Mask and shape geometry lives in an arbitrary-data parameter (named Path) in one of two little-endian layouts sharing a version word and a trailing closed-path byte: the FLAT one (a vertex count, then seven floats per vertex - a leading flag, the point, and its bezier handles) and the drawn-mask SUBPATH one (contours, each with a path id and integer-flagged vertices - 26_effect_mask's drawn ellipse).

None when the parameter holds no shape at all; an EMPTY list when it holds a shape with no vertices, which is what a default (undrawn) mask stores - its geometry lives in the sibling Type/Scale/Rotation parameters instead.

The setter writes the vertices as one closed subpath (the shape the fixture's drawn mask stores); an empty list writes the bare header.

text property writable

text

The text this parameter stores, if it stores any. Read-only.

An arbitrary-data parameter keeps its payload as base64. The Motion Graphics template controls keep theirs as UTF-16LE: a plain string for a simple control, a JSON object for a styled one (the edited text under textEditValue). An Essential Graphics Source Text stores the same FormattedTextData FlatBuffer captions use (66_eg_text carries the 0x11223344 magic), whose last string is the plain text. None for every parameter whose payload is not text - a scalar's, and the binary shape blob behind path.

value property writable

value

The static value. Read/write on scalar, boolean and point params.

Reads None when the param has no stored value (a synthesized default). The setter accepts a number (scalar param), a bool (checkbox param) or a two-number sequence (2D point param) that already has a stored value; keyframed and other params raise.

Methods:

__init__

__init__(_element, _document, component)
Source code in src/py_premiere/models/component.py
def __init__(
    self,
    _element: ET.Element,
    _document: PremiereDocument,
    component: Component | TrackItem,
) -> None:
    self._element = _element
    self._document = _document
    #: The owning component, or the track item itself for the time-remap
    #: speed param - which Premiere stores outside any component chain.
    self.component = component

__repr__

__repr__()
Source code in src/py_premiere/models/component.py
def __repr__(self) -> str:
    return f"ComponentParam(display_name={self.display_name!r})"

find_nearest_key

find_nearest_key(time, threshold=None)

The keyframe closest to time, or None if there are none.

ExtendScript's findNearestKey(time, threshold). With a threshold, a key further away than that is not returned. Ties go to the earlier key.

Source code in src/py_premiere/models/component.py
def find_nearest_key(
    self, time: Time, threshold: Time | None = None
) -> Time | None:
    """The keyframe closest to `time`, or `None` if there are none.

    ExtendScript's `findNearestKey(time, threshold)`. With a
    `threshold`, a key further away than that is not returned. Ties go
    to the earlier key.
    """
    validate_time(time)
    if threshold is not None:
        validate_time(threshold)
    best: Time | None = None
    best_distance = -1
    for key in self.keys:
        distance = abs(key.ticks - time.ticks)
        if best is None or distance < best_distance:
            best, best_distance = key, distance
    if best is None:
        return None
    if threshold is not None and best_distance > threshold.ticks:
        return None
    return best

find_next_key

find_next_key(time)

The first keyframe strictly after time, or None.

Source code in src/py_premiere/models/component.py
def find_next_key(self, time: Time) -> Time | None:
    """The first keyframe strictly after `time`, or `None`."""
    validate_time(time)
    later = [key for key in self.keys if key.ticks > time.ticks]
    return min(later, key=lambda key: key.ticks) if later else None

find_previous_key

find_previous_key(time)

The last keyframe strictly before time, or None.

Source code in src/py_premiere/models/component.py
def find_previous_key(self, time: Time) -> Time | None:
    """The last keyframe strictly before `time`, or `None`."""
    validate_time(time)
    earlier = [key for key in self.keys if key.ticks < time.ticks]
    return max(earlier, key=lambda key: key.ticks) if earlier else None

get_interpolation_at_key

get_interpolation_at_key(time)

The temporal interpolation of an existing keyframe.

This is the INCOMING interpolation, stored as the third keyframe field (after time and value); decoded against UXP-generated knowns in the 09_keyframes fixture. Entry layout: ticks,value,interpIn, interpOut,inSlope,inInfluence,outSlope,outInfluence.

Source code in src/py_premiere/models/component.py
def get_interpolation_at_key(self, time: Time) -> KeyframeInterpolation | None:
    """The temporal interpolation of an existing keyframe.

    This is the INCOMING interpolation, stored as the third keyframe
    field (after time and value); decoded against UXP-generated knowns
    in the `09_keyframes` fixture. Entry layout: `ticks,value,interpIn,
    interpOut,inSlope,inInfluence,outSlope,outInfluence`.
    """
    return self._interpolation_field(time, 2)

get_out_interpolation_at_key

get_out_interpolation_at_key(time)

The OUTGOING temporal interpolation of an existing keyframe.

The fourth keyframe field, which a key only uses when it leaves on a different curve than it arrived on: across the corpus it is non-zero only where the incoming interpolation is BEZIER, and then carries another interpolation constant (a bezier-in / hold-out key, say). Zero - the overwhelmingly common case - means the key leaves the way it arrived.

Source code in src/py_premiere/models/component.py
def get_out_interpolation_at_key(self, time: Time) -> KeyframeInterpolation | None:
    """The OUTGOING temporal interpolation of an existing keyframe.

    The fourth keyframe field, which a key only uses when it leaves on a
    different curve than it arrived on: across the corpus it is non-zero
    only where the incoming interpolation is `BEZIER`, and then carries
    another interpolation constant (a bezier-in / hold-out key, say).
    Zero - the overwhelmingly common case - means the key leaves the way
    it arrived.
    """
    return self._interpolation_field(time, 3)

get_spatial_tangents_at_key

get_spatial_tangents_at_key(time)

The motion-path handles of an existing 2D keyframe.

A 2D (point) keyframe stores FOURTEEN fields where a scalar stores eight: the extra four at the end are the spatial bezier handles that shape the motion path, distinct from the temporal slopes earlier in the entry, and they appear as mirrored pairs on a smooth key. None for a scalar parameter, which has no motion path.

Source code in src/py_premiere/models/component.py
def get_spatial_tangents_at_key(self, time: Time) -> SpatialTangents | None:
    """The motion-path handles of an existing 2D keyframe.

    A 2D (point) keyframe stores FOURTEEN fields where a scalar stores
    eight: the extra four at the end are the spatial bezier handles that
    shape the motion path, distinct from the temporal slopes earlier in
    the entry, and they appear as mirrored pairs on a smooth key.
    `None` for a scalar parameter, which has no motion path.
    """
    validate_time(time)
    text = self._element.findtext("Keyframes") or self._element.findtext(
        "StartKeyframe"
    )
    if not text:
        return None
    for entry in text.split(";"):
        fields = entry.split(",")
        if len(fields) != _POINT_ENTRY_FIELDS or fields[0] != str(time.ticks):
            continue
        handles = fields[_SPATIAL_START:]
        return SpatialTangents(*(float(value) for value in handles))
    return None

get_value_at_key

get_value_at_key(time)

The value at an existing keyframe.

Source code in src/py_premiere/models/component.py
def get_value_at_key(self, time: Time) -> float | bool | list[float] | str | None:
    """The value at an existing keyframe."""
    validate_time(time)
    for ticks, value_field in self._keyframe_entries():
        if ticks == time.ticks:
            return _decode_value(value_field)
    return None

set_keyframes

set_keyframes(keys)

Set the keyframes of a scalar parameter.

keys is a list of (time, value, interpolation); bezier tangents are auto-computed to match Premiere's own output (slope from/to the neighbouring key, 1/6 influence, 1/3 outgoing for HOLD). A static (materialized) parameter is turned time-varying: the Keyframes element is synthesized, and so is the IsTimeVarying flag where Premiere writes one (every class but the audio scalars). Premiere also stamps a session Timestamp on such a param, which py does not reproduce (it is not deterministic).

Source code in src/py_premiere/models/component.py
def set_keyframes(
    self, keys: list[tuple[Time, float, KeyframeInterpolation]]
) -> None:
    """Set the keyframes of a scalar parameter.

    `keys` is a list of `(time, value, interpolation)`; bezier tangents
    are auto-computed to match Premiere's own output (slope from/to the
    neighbouring key, `1/6` influence, `1/3` outgoing for HOLD). A static
    (materialized) parameter is turned time-varying: the `Keyframes`
    element is synthesized, and so is the `IsTimeVarying` flag where
    Premiere writes one (every class but the audio scalars). Premiere also
    stamps a session `Timestamp` on such a param, which py does not
    reproduce (it is not deterministic).
    """
    if self.class_id not in _SCALAR_VALUE_CLASSES:
        raise ValueError("keyframes are only settable on scalar parameters")
    if not keys:
        raise ValueError("at least one keyframe is required")
    for time, value, interpolation in keys:
        validate_time(time)
        validate_float32(value)
        if not isinstance(interpolation, KeyframeInterpolation):
            raise TypeError("interpolation must be a KeyframeInterpolation")
    ordered = sorted(keys, key=lambda key: key[0].ticks)
    for previous, following in zip(ordered, ordered[1:]):
        if previous[0].ticks == following[0].ticks:
            # Two keys at one time divide by a zero interval below.
            raise ValueError(f"duplicate keyframe time {previous[0].ticks}")
    # Build the payload before touching the element: everything above can
    # still refuse, and a half-written param (IsTimeVarying set, no
    # keyframes) is a shape Premiere never produces.
    text = _build_keyframe_string(ordered)
    element = self._element.find("Keyframes")
    if element is None:
        start = self._element.find("StartKeyframe")
        if start is None:
            raise ValueError("parameter has no stored value to keyframe")
        flag = self._element.find("IsTimeVarying")
        if self.class_id == _AUDIO_SCALAR_CLASS:
            # Premiere DELETES the audio flag when keyframing (compare
            # 10_audio_volume, which carries it, to 63_audio_keyframes,
            # which does not) and relies on the populated `Keyframes`
            # list (see `is_time_varying`).
            if flag is not None:
                remove_child(self._element, flag)
        elif flag is not None:
            flag.text = "true"
        else:
            # Every other class carries the flag on every keyframed
            # instance in the corpus, so it is created.
            self._create_time_varying_flag()
        # Audio params order `Keyframes` after `CurrentValue`
        # (63_audio_keyframes); video params have no `CurrentValue` and
        # put it right after `StartKeyframe`.
        anchor = self._element.find("CurrentValue")
        if anchor is None:
            anchor = start
        element = ET.Element("Keyframes")
        element.tail = anchor.tail
        self._element.insert(list(self._element).index(anchor) + 1, element)
    element.text = text