class ProjectItem:
"""An item in the project panel: the root, a bin, or a clip.
Structural items are parser-built, not user-constructed.
"""
def __init__(
self,
_element: ET.Element,
project: Project,
item_type: ProjectItemType,
) -> None:
self._element = _element
self.project = project
self._type = item_type
self._children: list[ProjectItem] = []
self._markers: list[Marker] = []
self._media_path: Path | None = None
self._master_element: ET.Element | None = None
self._parent: ProjectItem | None = None
self._clip_elements: list[ET.Element] = []
self._node_id_int: int | None = None
self._default_out_ticks: int | None = None
self._sequence_uid: str | None = None
def _name_element(self) -> ET.Element | None:
# A clip item's live name is its master clip's; the ProjectItem/Name
# copy goes stale when Premiere renames (validated against
# ExtendScript ground truth).
if self._master_element is not None:
element = self._master_element.find("Name")
if element is not None:
return element
core = project_item_core(self._element)
return None if core is None else core.find("Name")
@property
def name(self) -> str:
"""The item name. Read/write.
Like ExtendScript, the root item reports the project file name and
a clip item reports its master clip's name.
"""
if self._type is ProjectItemType.ROOT:
return self.project.name
element = self._name_element()
return (element.text or "") if element is not None else ""
@name.setter
def name(self, value: str) -> None:
_validate_name(value)
if self._type is ProjectItemType.ROOT:
raise AttributeError("the root item's name mirrors the project file name")
element = self._name_element()
if element is None:
raise ValueError("item has no name element")
element.text = value
@property
def type(self) -> ProjectItemType:
"""The item type. Read-only."""
return self._type
@property
def is_sequence(self) -> bool:
"""Whether this clip item is backed by a sequence. Read-only."""
return self._sequence_uid is not None
def add_bin(self, name: str) -> ProjectItem:
"""Create a child bin and return it.
Works on the root and on any bin (nesting into an empty bin
synthesizes its child-item list). The view-state properties Premiere
writes for a new bin are elided (Premiere synthesizes them on open).
"""
_validate_name(name)
if self._type is ProjectItemType.CLIP:
raise ValueError("cannot add a bin under a clip item")
container = self._element.find("ProjectItemContainer")
if container is None:
raise ValueError("item has no ProjectItemContainer")
items = _child_items(container)
document = self.project._document
uid = str(uuid.uuid4())
bin_element = _new_bin_element(uid, name)
document.attach_object(bin_element)
_add_item_ref(items, uid)
child = ProjectItem(bin_element, self.project, ProjectItemType.BIN)
child._parent = self
self._children.append(child)
return child
def remove_bin(self, child: ProjectItem) -> None:
"""Remove a child bin, deleting its contents recursively.
Child bins are removed depth-first; child clip items go through
`remove_item` (so a bin holding an in-use clip refuses removal
before anything is touched).
"""
if child not in self._children:
raise ValueError("item is not a child of this bin")
if child._type is not ProjectItemType.BIN:
raise ValueError("only bins can be removed")
for grandchild in list(child._children):
if grandchild._type is ProjectItemType.BIN:
child.remove_bin(grandchild)
else:
child.remove_item(grandchild)
document = self.project._document
uid = child._element.get("ObjectUID")
container = self._element.find("ProjectItemContainer")
if container is not None and uid is not None:
_detach_item_ref(container, uid)
document.remove_object(child._element)
self._children.remove(child)
def remove_item(self, child: ProjectItem) -> None:
"""Remove a child clip item, deleting its media objects.
Deletes the exact graph Premiere deletes with a panel item (master
clip, template clips, logging, markers, source, media, streams),
keeping anything still referenced from outside it (e.g. media
shared with another item). Refuses when the item is placed on a
timeline (remove the clips first) or backed by a sequence.
"""
if child not in self._children:
raise ValueError("item is not a child of this bin")
if child._type is not ProjectItemType.CLIP:
raise ValueError("only clip items can be removed; use remove_bin")
if child.is_sequence:
raise ValueError("removing a sequence item is not supported")
master = child._master_element
if master is None:
raise ValueError("item has no master clip")
document = self.project._document
# One pass answers both questions: who still points at the master
# clip (a timeline placement, which blocks the removal), and what the
# item exclusively owns (the graph that goes with it).
index = ReferenceIndex(document)
if index.referrers_outside(master, [child._element, master]):
raise ValueError("item is in use on a timeline; remove its clips first")
uid = child._element.get("ObjectUID")
container = self._element.find("ProjectItemContainer")
if container is not None and uid is not None:
_detach_item_ref(container, uid)
for element in document.owned_objects([child._element, master], index):
document.remove_object(element)
self._children.remove(child)
# The lookup maps hold this item keyed by its master/sequence UID.
self.project._items_by_master_uid = None
self.project._items_by_sequence_uid = None
def move_to(self, new_parent: ProjectItem) -> None:
"""Move this item into another bin (or the root).
Only the parent-child wiring changes; the item's own object is
untouched.
"""
if self._type is ProjectItemType.ROOT:
raise ValueError("cannot move the root item")
if new_parent.project is not self.project:
# Wiring a reference across documents leaves the destination with
# a dangling ObjectURef and the source with an orphan object.
raise ValueError("cannot move an item into another project")
if new_parent._type is ProjectItemType.CLIP:
raise ValueError("cannot move an item under a clip")
if self._parent is None:
raise ValueError("item has no parent to move from")
if new_parent is self._parent:
return
ancestor: ProjectItem | None = new_parent
while ancestor is not None:
if ancestor is self:
raise ValueError("cannot move an item into itself or a descendant")
ancestor = ancestor._parent
container = new_parent._element.find("ProjectItemContainer")
if container is None:
raise ValueError("destination has no ProjectItemContainer")
destination = _child_items(container)
uid = self._element.get("ObjectUID")
if uid is None:
raise ValueError("item has no ObjectUID")
source = self._parent._element.find("ProjectItemContainer")
if source is not None:
_detach_item_ref(source, uid)
_add_item_ref(destination, uid)
self._parent._children.remove(self)
new_parent._children.append(self)
self._parent = new_parent
@property
def color_label(self) -> int:
"""The item's color label as an index (`0` when none is stored). Read/write.
Stored in the item's property bag as `Column.PropertyText.Label` =
`BE.Prefs.LabelColors.<index>`; the index matches ExtendScript's
`getColorLabel` / UXP `getColorLabelIndex`. Items without the
property (e.g. the root) report `0`, as ExtendScript does. Setting an
index in `1..15` writes (or updates) the property; setting `0` clears
the stored override, so the item reads back as `0`.
"""
core = project_item_core(self._element)
text = (
None
if core is None
else core.findtext("Node/Properties/Column.PropertyText.Label")
)
prefix = "BE.Prefs.LabelColors."
if text is None or not text.startswith(prefix):
return 0
suffix = text[len(prefix) :]
return int(suffix) if suffix.isdigit() else 0
@color_label.setter
def color_label(self, index: int) -> None:
validate_color_label(index)
properties = self._element.find("ProjectItem/Node/Properties")
if properties is None:
raise ValueError("item has no property bag to store a color label")
existing = properties.find("Column.PropertyText.Label")
if index == 0:
if existing is not None:
remove_child(properties, existing)
return
text = "BE.Prefs.LabelColors." + str(index)
if existing is not None:
existing.text = text
else:
append_leaf(properties, "Column.PropertyText.Label", text)
@property
def node_id(self) -> str | None:
"""The item's identifier: the node ID in 8-digit hex. Read-only.
Best-effort: Premiere assigns these per session at load, so they
are reproducible only for freshly saved projects.
"""
if self._node_id_int is None:
return None
return format(self._node_id_int, "08x")
@property
def tree_path(self) -> str:
r"""The item's path in the project panel (`\project\bin\item`). Read-only."""
if self._parent is None:
return "\\" + self.project.name
return self._parent.tree_path + "\\" + self.name
@property
def in_point(self) -> Time | None:
"""The item's in point in media time. Read-only.
`None` for items without media (the root, bins), where ExtendScript
reports the -400000 s unset sentinel.
"""
ticks = self._point_ticks("InPoint")
return None if ticks == UNSET_TICKS else Time(ticks)
@property
def out_point(self) -> Time | None:
"""The item's out point in media time. Read-only.
`None` for items without media, like `in_point`.
"""
ticks = self._point_ticks("OutPoint")
return None if ticks == UNSET_TICKS else Time(ticks)
def _point_ticks(self, tag: str) -> int:
if not self._clip_elements:
return UNSET_TICKS
core = clip_core(self._clip_elements[0])
stored = None if core is None else core.findtext(tag)
if stored:
return int(stored)
if tag == "OutPoint":
if self._sequence_uid is not None:
# A sequence-backed item reports the LIVE sequence end, not
# the stored (stale) source duration.
for sequence in self.project.sequences:
if sequence.sequence_id == self._sequence_uid:
return sequence.end.ticks
if self._default_out_ticks is not None:
# Unset out point: ExtendScript reports the source duration.
return self._default_out_ticks
return 0
@property
def children(self) -> NamedList[ProjectItem]:
"""Child items of a bin, indexable by name. Read-only."""
return NamedList(self._children)
def __iter__(self) -> Iterator[ProjectItem]:
return iter(self._children)
def __len__(self) -> int:
return len(self._children)
def __getitem__(self, key: int | str) -> ProjectItem:
return self.children[key]
def __contains__(self, item: object) -> bool:
return item in self.children
def walk(self) -> Iterator[ProjectItem]:
"""Every descendant item, depth-first."""
for child in self._children:
yield child
yield from child.walk()
@property
def footage_interpretation(self) -> FootageInterpretation | None:
"""The item's footage interpretation. Read-only.
`None` for items without a video stream (bins, audio-only media).
"""
media = self._media_element()
if media is None:
return None
stream_ref = media.find("VideoStream")
if stream_ref is None:
return None
document = self.project._document
core = self._own_clip_core()
source_ref = None if core is None else core.find("Source")
source = None if source_ref is None else document.resolve(source_ref)
return FootageInterpretation(document.resolve(stream_ref), source)
@property
def scale_to_frame_size(self) -> bool:
"""Whether placements scale the media to the sequence frame size.
Read/write.
Stored as `ScaleToFramePolicy` (1) on the master's template video
clip and elided when off (70_scale_to_frame); mirrors
ExtendScript's `setScaleToFrameSize`.
"""
for clip in self._clip_elements:
if clip.tag == "VideoClip":
return clip.findtext("ScaleToFramePolicy") == "1"
return False
@scale_to_frame_size.setter
def scale_to_frame_size(self, value: bool) -> None:
validate_bool(value)
for clip in self._clip_elements:
if clip.tag == "VideoClip":
set_elided_flag(clip, "ScaleToFramePolicy", value, text="1")
return
raise ValueError("item has no video clip to scale")
def _media_element(self) -> ET.Element | None:
# The `Media` object backing this clip item (master clip -> template
# clip -> Source -> MediaSource/Media).
core = self._own_clip_core()
if core is None:
return None
source_ref = core.find("Source")
if source_ref is None:
return None
document = self.project._document
media_ref = document.resolve(source_ref).find("MediaSource/Media")
if media_ref is None:
return None
return document.resolve(media_ref)
@property
def is_offline(self) -> bool:
"""Whether the item's media is offline. Read-only.
Stored as `Media/OfflineReason`; elided when the media is online.
"""
media = self._media_element()
return media is not None and media.find("OfflineReason") is not None
@property
def generator_id(self) -> str | None:
"""The four-character code of the item's synthetic media, or `None`.
Generated media (Black Video, a colour matte, bars and tone, ...) has
no file: Premiere stores a big-endian fourcc where the path would go,
which is why `media_path` is `None` for these items. `None` here means
the item is backed by a real file (or by nothing at all).
"""
media = self._media_element()
if media is None:
return None
stored = media.findtext("FilePath") or ""
if not stored.isdigit():
return None
return struct.pack(">I", int(stored)).decode("ascii", "replace")
@property
def generator_type(self) -> GeneratorType | None:
"""Which of Premiere's generators backs the item, or `None`.
`None` both for real media and for a generator py has no code for -
`generator_id` still reports the raw fourcc in that case. Note an
adjustment layer is backed by Black Video, so it reports
`BLACK_VIDEO` here and identifies itself through
`is_adjustment_layer`.
"""
media = self._media_element()
if media is None:
return None
stored = media.findtext("FilePath") or ""
if not stored.isdigit():
return None
try:
return GeneratorType(int(stored))
except ValueError:
return None
@property
def is_adjustment_layer(self) -> bool:
"""Whether the item is an adjustment layer. Read-only.
Stored as `MasterClip/IsAdjustmentLayer`. The media underneath is
Premiere's synthetic Black Video generator, so the flag - not the
media - is what identifies it.
"""
if self._master_element is None:
return False
return self._master_element.findtext("IsAdjustmentLayer") == "true"
@property
def is_multicam_clip(self) -> bool:
"""Whether the item is a multi-camera source sequence. Read-only."""
if self._master_element is None:
return False
enabled = self._master_element.findtext(
"Node/Properties/Source.Monitor.Multicam.Enabled"
)
return enabled == "true"
@property
def is_mgt(self) -> bool:
"""Whether the item came from a Motion Graphics template. Read-only.
An imported template's master hangs its Essential Graphics
controls off a `BlueprintVideoComponentChain` - a slot no other
kind of master clip uses - so the presence of that chain is what
identifies one.
"""
if self._master_element is None:
return False
return self._master_element.find("BlueprintVideoComponentChain") is not None
@property
def is_merged_clip(self) -> bool:
"""Whether the item is a merged clip. Read-only.
Merged clips and multicam clips are both backed by a hidden
`Sequence` combining the source files; the merged one flags itself in
that sequence's property bag as `BE.Sequence.IsMergedClip`.
"""
sequence = self._backing_sequence()
if sequence is None:
return False
flag = sequence.findtext("Node/Properties/BE.Sequence.IsMergedClip")
return flag == "true"
def _backing_sequence(self) -> ET.Element | None:
# The `Sequence` object an item's clips play from, for the item kinds
# Premiere implements as a hidden sequence (merged and multicam
# clips) as well as ordinary sequence items.
core = self._own_clip_core()
if core is None:
return None
source_ref = core.find("Source")
if source_ref is None:
return None
document = self.project._document
sequence_ref = document.resolve(source_ref).find("SequenceSource/Sequence")
if sequence_ref is None:
return None
return document.resolve(sequence_ref)
@property
def start_time(self) -> Time:
"""The item's start time. Read/write.
The start timecode embedded in the media, stored as
`Media/AlternateStart` and honoured only while `UseAlternateStart`
is set. Media with no timecode track - and items with no media -
start at zero.
The setter mirrors ExtendScript's `setStartTime` and is supported
on media that already stores a start timecode: replaying Premiere's
own edit touches `AlternateStart` alone (62_start_time vs
19_timecode), so no reference exists yet for synthesizing the pair
on timecode-less media.
Persistence caveat, measured on the resave gate: for media with an
EMBEDDED timecode, Premiere re-reads the media on open and restores
`AlternateStart` to the embedded value, reverting this edit on its
next resave - ExtendScript's own `setStartTime` suffers the same
fate. The GUI's `Modify > Timecode` evidently writes something
stickier; that representation is undecoded (see CAMPAIGN.md).
"""
media = self._media_element()
if media is None or media.findtext("UseAlternateStart") != "true":
return Time(0)
return Time(int(media.findtext("AlternateStart") or 0))
@start_time.setter
def start_time(self, value: Time) -> None:
validate_time(value)
if value.ticks < 0:
raise ValueError("start time cannot be negative")
media = self._media_element()
alternate = None if media is None else media.find("AlternateStart")
flag = None if media is None else media.find("UseAlternateStart")
if alternate is None or flag is None:
raise ValueError(
"start time is only settable on media that stores a start "
"timecode (Media/AlternateStart)"
)
alternate.text = str(value.ticks)
flag.text = "true"
@property
def has_proxy(self) -> bool:
"""Whether the item has proxy media attached. Read-only."""
return self._proxy_media() is not None
@property
def proxy_path(self) -> Path | None:
"""The path of the item's proxy media, if any. Read-only.
`None` when no proxy is attached (ExtendScript's `getProxyPath`
reports `0` in that case).
"""
media = self._proxy_media()
stored = None if media is None else media.findtext("FilePath")
return Path(stored) if stored else None
def attach_proxy(self, path: str | Path, is_hi_res: bool = False) -> None:
"""Attach proxy media to this item.
ExtendScript's `attachProxy`. A proxy is a second `Media` object
(flagged `IsProxy`) with its own stream, referenced from the media
source's `Content` bag; the proxy's stream carries the HI-RES
frame rect as an override so the item keeps reporting the original
raster (18_proxy). The file must be readable and match the item's
frame aspect ratio, as Premiere itself requires.
`is_hi_res` swaps the roles, as the ExtendScript flag does: `path`
becomes the media this item PLAYS and what it played until now is
demoted to the proxy. The end state is the same graph either way -
one `Media` flagged `IsProxy`, one not - and the MASTER CLIP takes
the new file's name while the panel item's own `Name` is left
behind, stale, which is what Premiere does too (verified against
its calls in both directions, `samples/refs/gaps/proxy_*.prproj`).
"""
_validate_proxy_path(path)
validate_bool(is_hi_res)
if self.has_proxy:
raise ValueError("item already has proxy media attached")
content = self._media_content()
media = self._media_element()
stream_ref = None if media is None else media.find("VideoStream")
if content is None or media is None or stream_ref is None:
raise ValueError("proxies attach to video media clip items")
document = self.project._document
own_rect = document.resolve(stream_ref).findtext("FrameRect")
if not own_rect:
raise ValueError("source video stream has no frame rect")
if not is_hi_res:
proxy_uid = self.project._make_proxy_media(Path(path), own_rect)
append_child(content, ET.Element("ProxyMedia", {"ObjectURef": proxy_uid}))
return
self._attach_hi_res(document, content, media, own_rect, Path(path))
def _attach_hi_res(
self,
document: PremiereDocument,
content: ET.Element,
media: ET.Element,
own_rect: str,
path: Path,
) -> None:
# The newcomer becomes the media and the incumbent the proxy, so
# the frame-rect override lands on what was already here - carrying
# the NEW file's raster, since that is now the hi-res one.
source = self._media_source()
reference = None if source is None else source.find("MediaSource/Media")
if reference is None:
raise ValueError("media source has no Media reference")
media_uid, hires_rect = self.project._make_primary_media(path, own_rect)
reference.set("ObjectURef", media_uid)
stream_ref = media.find("VideoStream")
if stream_ref is None:
raise ValueError("media has no video stream to override")
_override_frame_rect(document.resolve(stream_ref), hires_rect)
append_leaf(media, "IsProxy", "true")
demoted = media.get("ObjectUID") or ""
append_child(content, ET.Element("ProxyMedia", {"ObjectURef": demoted}))
# The master follows the new media; Premiere leaves the panel
# item's own copy of the name on the old file.
master = self._master_element
name = master.find("Name") if master is not None else None
if name is not None:
name.text = path.name
# The item plays the newcomer now; without this the live object
# keeps reporting the file it just demoted to proxy, and
# `create_sub_clip` would re-import the proxy.
self._media_path = path.resolve()
def _proxy_media(self) -> ET.Element | None:
# Proxy media is a SECOND `Media` object (flagged `IsProxy`), hung
# off the media source's own Content bag rather than the master clip.
content = self._media_content()
if content is None:
return None
proxy_ref = content.find("ProxyMedia")
if proxy_ref is None:
return None
return self.project._document.resolve(proxy_ref)
def _media_source(self) -> ET.Element | None:
# The `*MediaSource` object this item's template clip plays from.
core = self._own_clip_core()
source_ref = None if core is None else core.find("Source")
if source_ref is None:
return None
return self.project._document.resolve(source_ref)
def _media_content(self) -> ET.Element | None:
# The media source's own `Content` bag, which carries the state that
# belongs to this item's *view* of the media rather than to the media
# itself (its proxy, and a subclip's boundaries).
core = self._own_clip_core()
if core is None:
return None
source_ref = core.find("Source")
if source_ref is None:
return None
source = self.project._document.resolve(source_ref)
return source.find("MediaSource/Content")
@property
def is_subclip(self) -> bool:
"""Whether the item is a subclip of another item. Read-only.
A subclip is a second master clip over the same media, narrowed by
boundaries on its own media source. Its `in_point` and `out_point`
still describe the whole file - `subclip_in_point` and
`subclip_out_point` are the narrowed range.
"""
return self._boundary("StartBoundary") is not None
@property
def subclip_in_point(self) -> Time | None:
"""Where the subclip starts in media time, or `None`. Read-only."""
return self._boundary("StartBoundary")
@property
def subclip_out_point(self) -> Time | None:
"""Where the subclip ends in media time, or `None`. Read-only."""
return self._boundary("EndBoundary")
@property
def has_hard_boundaries(self) -> bool:
"""Whether the subclip's boundaries are hard. Read-only.
ExtendScript's `createSubClip` calls this `hardBoundaries`: soft
boundaries can be trimmed past on the timeline, hard ones cannot.
`False` for items that are not subclips.
"""
content = self._media_content()
if content is None:
return False
return content.findtext("BoundariesAreHard") == "true"
def _boundary(self, tag: str) -> Time | None:
content = self._media_content()
if content is None:
return None
stored = content.findtext(tag)
return None if stored is None else Time(int(stored))
def create_sub_clip(
self,
name: str,
start: Time,
end: Time,
has_hard_boundaries: bool = False,
take_video: bool = True,
take_audio: bool = True,
) -> ProjectItem:
"""Create a subclip of this item in the project panel and return it.
Mirrors ExtendScript's `createSubClip`. A subclip is a SECOND master
clip over the same media file - Premiere duplicates the whole media
graph rather than sharing objects (28_subclip) - narrowed by
`StartBoundary`/`EndBoundary` on its media source. Soft boundaries
can be trimmed past on the timeline, hard ones cannot.
`take_video`/`take_audio` subclip only part of an A/V source, as
ExtendScript's trailing flags do. NOTE the scripting guide lists
them the other way round (`takeAudio, takeVideo`); driving the
real call proved the fifth argument governs VIDEO and the sixth
AUDIO, so py names them in their actual order. Dropping a half
leaves the media's stream in place and omits that half's clip and
source from the new master, which is what Premiere writes.
The subclip's graph is synthesized the way an import is, so the
media file must still be readable; it then takes over the source's
file identity (one `FileKey`/content state per file, the
modification blob carried once), and the source's footage
interpretation overrides do NOT carry over.
"""
_validate_name(name)
validate_time(start)
validate_time(end)
validate_bool(has_hard_boundaries)
validate_bool(take_video)
validate_bool(take_audio)
if not (take_video or take_audio):
raise ValueError("a subclip must take the video, the audio, or both")
if self._type is not ProjectItemType.CLIP or self.is_sequence:
raise ValueError("subclips can only be created from media clip items")
if self.media_path is None:
raise ValueError("item has no media file to subclip")
if not 0 <= start.ticks < end.ticks:
raise ValueError("subclip boundaries must satisfy 0 <= start < end")
if self._master_element is None:
raise ValueError("item has no master clip")
document = self.project._document
item = self.project.import_files([self.media_path])[0]
# The master carries the live subclip name; the ProjectItem copy
# keeps the file name, exactly as Premiere writes it.
master = item._master_element
if master is None:
raise ValueError("synthesized subclip has no master clip")
name_element = master.find("Name")
if name_element is not None:
name_element.text = name
logging_ref = master.find("LoggingInfo")
if logging_ref is None:
raise ValueError("synthesized subclip has no logging info")
logging = document.resolve(logging_ref)
for tag, text in (
("ClipName", name),
("MediaInPoint", str(start.ticks)),
("MediaOutPoint", str(end.ticks)),
):
leaf = logging.find(tag)
if leaf is not None:
leaf.text = text
if not (take_video and take_audio):
item._drop_subclip_half(take_video)
# A/V media narrows BOTH its sources: the identical boundary trio
# lands in the video AND the audio source's Content bag
# (71_av_subclip) - or in the surviving one alone when a half was
# dropped.
contents = []
for clip_element in item._clip_elements:
core = clip_core(clip_element)
source_ref = None if core is None else core.find("Source")
if source_ref is None:
continue
content = document.resolve(source_ref).find("MediaSource/Content")
if content is not None:
contents.append(content)
if not contents:
raise ValueError("synthesized subclip has no media content bag")
for content in contents:
append_leaf(content, "StartBoundary", str(start.ticks))
append_leaf(content, "EndBoundary", str(end.ticks))
append_leaf(
content,
"BoundariesAreHard",
"true" if has_hard_boundaries else "false",
)
# The panel label stamp (`Column.PropertyText.Label`) is kept as the
# import wrote it: Premiere itself is inconsistent - 28_subclip's
# subclip carries none, 71_av_subclip's does - and a resave
# re-stamps it either way.
self._share_file_identity(item)
return item
def _drop_subclip_half(self, keep_video: bool) -> None:
# Keep only the video or only the audio half of a freshly imported
# A/V master, as Premiere's take flags do: the clip and everything
# only it referenced go, the shared Media (and both its streams)
# stay. An audio-only master keeps its component chains and channel
# groups; a video-only one has neither to keep.
document = self.project._document
master = self._master_element
if master is None:
raise ValueError("item has no master clip")
wanted = "VideoClip" if keep_video else "AudioClip"
clips = master.find("Clips")
if clips is None:
raise ValueError("master clip has no Clips list")
doomed = []
for reference in list(clips.findall("Clip")):
clip = document.resolve(reference)
if clip.tag == wanted:
continue
doomed.append(clip)
remove_child(clips, reference)
if not doomed:
return
for index, reference in enumerate(clips.findall("Clip")):
reference.set("Index", str(index))
orphaned: list[ET.Element] = []
if keep_video:
# The audio plumbing belongs to the half being dropped - but NOT
# `DefMappingID`, which Premiere keeps on a video-only subclip
# (samples/refs/audit/sub_audio.prproj).
chains = master.find("AudioComponentChains")
if chains is not None:
orphaned = [document.resolve(entry) for entry in chains]
remove_child(master, chains)
index_of = ReferenceIndex(document)
# Unhooking that list leaves its chains referenced by nothing, and
# `owned_objects` only reaches what the doomed CLIPS point at - so
# they have to be seeded, or they survive as top-level orphans.
doomed.extend(
chain for chain in orphaned if not index_of.referrers.get(id(chain))
)
for element in document.owned_objects(doomed, index_of):
document.remove_object(element)
self._clip_elements = [
document.resolve(reference) for reference in clips.findall("Clip")
]
def _share_file_identity(self, other: ProjectItem) -> None:
# Premiere keeps ONE file identity per media file: every Media
# object describing it shares FileKey/content state, and only the
# first carries the modification blob - the rest hash-reference it
# (the multi-channel import rule, seen again on 28_subclip).
source_media = self._media_element()
target_media = other._media_element()
if source_media is None or target_media is None:
return
for tag in ("FileKey", "ContentAndMetadataState"):
stored = source_media.findtext(tag)
leaf = target_media.find(tag)
if stored and leaf is not None:
leaf.text = stored
source_state = source_media.find("ModificationState")
target_state = target_media.find("ModificationState")
if source_state is not None and target_state is not None:
binary_hash = source_state.get("BinaryHash")
if binary_hash:
target_state.set("BinaryHash", binary_hash)
target_state.text = None
content_state = source_media.findtext("ContentAndMetadataState")
core = other._own_clip_core()
markers_ref = None if core is None else core.find("MarkerOwner/Markers")
if content_state and markers_ref is not None:
markers = self.project._document.resolve(markers_ref)
last = markers.find("LastContentState")
if last is not None:
last.text = content_state
@property
def markers(self) -> NamedList[Marker]:
"""The item's clip markers, indexable by name. Read-only.
Stored on the master clip's own template clip, shared with every
timeline instance of the item. A sequence-backed item's own markers
are separate from the sequence's markers (matching UXP). Bins and
the root have none.
"""
return NamedList(self._markers)
def _own_clip_core(self) -> ET.Element | None:
# The master clip's own template clip core (`Clips[0]` -> `Clip`),
# where item markers live.
if self._master_element is None:
return None
reference = self._master_element.find("Clips/Clip")
if reference is None:
return None
return clip_core(self.project._document.resolve(reference))
def add_marker(
self,
name: str,
start: Time,
comments: str = "",
marker_type: str = "Comment",
duration: Time | None = None,
) -> Marker:
"""Create a clip marker on this item and return it.
`start` is in media time (a still's first frame sits at its 1-hour
default timecode, not 0).
"""
if self._type is not ProjectItemType.CLIP:
raise ValueError("only clip items carry markers")
core = self._own_clip_core()
if core is None:
raise ValueError("item has no master clip")
document = self.project._document
inner = _ensure_clip_marker_list(document, core)
marker = Marker(name, start, comments, marker_type, duration)
_attach_marker(document, inner, marker)
self._markers.append(marker)
return marker
def remove_marker(self, marker: Marker) -> None:
"""Remove a clip marker from this item."""
if marker not in self._markers:
raise ValueError("marker does not belong to this item")
core = self._own_clip_core()
reference = core.find("MarkerOwner/Markers") if core is not None else None
if reference is None:
raise ValueError("master clip has no marker collection")
document = self.project._document
inner = document.resolve(reference).find("Markers")
if inner is None:
raise ValueError("marker collection has no inner list")
_detach_marker(document, inner, marker)
self._markers.remove(marker)
@property
def media_path(self) -> Path | None:
"""The path of the underlying media file, if any. Read-only.
Premiere's internal generators (e.g. `Black Video`) store a numeric
token instead of a filesystem path; they report `None` here (see
`generator_type`).
"""
return self._media_path
def __repr__(self) -> str:
return f"ProjectItem(name={self.name!r}, type={self._type.name})"