diff --git a/code/fcpxml/writer/core.py b/code/fcpxml/writer/core.py index 932e2df..57243a9 100644 --- a/code/fcpxml/writer/core.py +++ b/code/fcpxml/writer/core.py @@ -97,6 +97,8 @@ class ModifierCore: self.fps = self._detect_fps() # Lazily filled on the first generated title; see _unique_text_style_id. self._text_style_ids: Optional[set] = None + # Lazily filled on the first clip split/cut; see _unique_tracking_shape_id. + self._tracking_shape_ids: Optional[set] = None self._build_resource_index() self._build_clip_index() diff --git a/code/fcpxml/writer/cut.py b/code/fcpxml/writer/cut.py index 0f86c31..29ff1d6 100644 --- a/code/fcpxml/writer/cut.py +++ b/code/fcpxml/writer/cut.py @@ -125,6 +125,7 @@ class CutMixin: new_clip, current_start, segment_duration ) self._reassign_text_style_ids(new_clip) + self._reassign_tracking_shape_ids(new_clip) spine.insert(clip_index + len(new_clips), new_clip) new_clips.append(new_clip) @@ -243,6 +244,7 @@ class CutMixin: new_clip.set('duration', seg_duration.to_fcpxml()) self._filter_children_for_segment(new_clip, seg_start, seg_duration) self._reassign_text_style_ids(new_clip) + self._reassign_tracking_shape_ids(new_clip) spine.insert(clip_index + len(new_clips), new_clip) new_clips.append(new_clip) current_offset = current_offset + seg_duration diff --git a/code/fcpxml/writer/titles.py b/code/fcpxml/writer/titles.py index 4732e88..1cb1c3f 100644 --- a/code/fcpxml/writer/titles.py +++ b/code/fcpxml/writer/titles.py @@ -164,6 +164,44 @@ class TitlesMixin: for ref_el in clip.findall(f".//text-style[@ref='{old_id}']"): ref_el.set('ref', new_id) + def _unique_tracking_shape_id(self, base: str) -> str: + """Return a document-unique ``id`` for a ````.""" + stem = base or "tr" + if self._tracking_shape_ids is None: + self._tracking_shape_ids = { + ts.get('id') for ts in self.root.findall('.//tracking-shape') + } + candidate = f"{stem}_0" + counter = 0 + while candidate in self._tracking_shape_ids: + counter += 1 + candidate = f"{stem}_{counter}" + self._tracking_shape_ids.add(candidate) + return candidate + + def _reassign_tracking_shape_ids(self, clip: ET.Element) -> None: + """Give every ```` inside a just-deepcopy'd *clip* a + fresh document-unique id. + + Same mechanism as ``_reassign_text_style_ids``: ``split_clip``/ + ``cut_clip_ranges`` deepcopy the clip once per resulting segment, so + Cinematic object-tracking data (````, preserved from the source asset's sidecar) keeps the + exact same id in every copy. A single cut is harmless — but the + batch chain re-cuts the same clip at each step, multiplying the + duplicate until the DTD validator rejects the file with "ID tr1 + already defined". + """ + for shape in clip.findall('.//tracking-shape'): + old_id = shape.get('id') + if not old_id: + continue + base = re.sub(r'_\d+$', '', old_id) + new_id = self._unique_tracking_shape_id(base) + if new_id == old_id: + continue + shape.set('id', new_id) + def _make_text_title_clip( self, effect_id: str,