lattifai 1.2.0__py3-none-any.whl → 1.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. lattifai/__init__.py +0 -24
  2. lattifai/alignment/__init__.py +10 -1
  3. lattifai/alignment/lattice1_aligner.py +66 -58
  4. lattifai/alignment/lattice1_worker.py +1 -6
  5. lattifai/alignment/punctuation.py +38 -0
  6. lattifai/alignment/segmenter.py +1 -1
  7. lattifai/alignment/sentence_splitter.py +350 -0
  8. lattifai/alignment/text_align.py +440 -0
  9. lattifai/alignment/tokenizer.py +91 -220
  10. lattifai/caption/__init__.py +82 -6
  11. lattifai/caption/caption.py +335 -1143
  12. lattifai/caption/formats/__init__.py +199 -0
  13. lattifai/caption/formats/base.py +211 -0
  14. lattifai/caption/formats/gemini.py +722 -0
  15. lattifai/caption/formats/json.py +194 -0
  16. lattifai/caption/formats/lrc.py +309 -0
  17. lattifai/caption/formats/nle/__init__.py +9 -0
  18. lattifai/caption/formats/nle/audition.py +561 -0
  19. lattifai/caption/formats/nle/avid.py +423 -0
  20. lattifai/caption/formats/nle/fcpxml.py +549 -0
  21. lattifai/caption/formats/nle/premiere.py +589 -0
  22. lattifai/caption/formats/pysubs2.py +642 -0
  23. lattifai/caption/formats/sbv.py +147 -0
  24. lattifai/caption/formats/tabular.py +338 -0
  25. lattifai/caption/formats/textgrid.py +193 -0
  26. lattifai/caption/formats/ttml.py +652 -0
  27. lattifai/caption/formats/vtt.py +469 -0
  28. lattifai/caption/parsers/__init__.py +9 -0
  29. lattifai/caption/{text_parser.py → parsers/text_parser.py} +4 -2
  30. lattifai/caption/standardize.py +636 -0
  31. lattifai/caption/utils.py +474 -0
  32. lattifai/cli/__init__.py +2 -1
  33. lattifai/cli/caption.py +108 -1
  34. lattifai/cli/transcribe.py +4 -9
  35. lattifai/cli/youtube.py +4 -1
  36. lattifai/client.py +48 -84
  37. lattifai/config/__init__.py +11 -1
  38. lattifai/config/alignment.py +9 -2
  39. lattifai/config/caption.py +267 -23
  40. lattifai/config/media.py +20 -0
  41. lattifai/diarization/__init__.py +41 -1
  42. lattifai/mixin.py +36 -18
  43. lattifai/transcription/base.py +6 -1
  44. lattifai/transcription/lattifai.py +19 -54
  45. lattifai/utils.py +81 -13
  46. lattifai/workflow/__init__.py +28 -4
  47. lattifai/workflow/file_manager.py +2 -5
  48. lattifai/youtube/__init__.py +43 -0
  49. lattifai/youtube/client.py +1170 -0
  50. lattifai/youtube/types.py +23 -0
  51. lattifai-1.2.2.dist-info/METADATA +615 -0
  52. lattifai-1.2.2.dist-info/RECORD +76 -0
  53. {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/entry_points.txt +1 -2
  54. lattifai/caption/gemini_reader.py +0 -371
  55. lattifai/caption/gemini_writer.py +0 -173
  56. lattifai/cli/app_installer.py +0 -142
  57. lattifai/cli/server.py +0 -44
  58. lattifai/server/app.py +0 -427
  59. lattifai/workflow/youtube.py +0 -577
  60. lattifai-1.2.0.dist-info/METADATA +0 -1133
  61. lattifai-1.2.0.dist-info/RECORD +0 -57
  62. {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/WHEEL +0 -0
  63. {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/licenses/LICENSE +0 -0
  64. {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,589 @@
1
+ """Premiere Pro XML format writer for Adobe Premiere Pro integration.
2
+
3
+ This module provides functionality to export captions as Premiere Pro XML sequences,
4
+ enabling captions to be imported as animatable graphic clips rather than simple caption tracks.
5
+
6
+ Key features:
7
+ - Each caption/word becomes a separate text generator clip
8
+ - Supports word-level timing for karaoke-style effects
9
+ - Speaker separation to different video tracks
10
+ """
11
+
12
+ import uuid
13
+ import xml.etree.ElementTree as ET
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Dict, List, Optional, Union
17
+ from xml.dom import minidom
18
+
19
+ from lhotse.utils import Pathlike
20
+
21
+ from ...supervision import Supervision
22
+ from .. import register_writer
23
+ from ..base import FormatReader, FormatWriter
24
+
25
+
26
+ @dataclass
27
+ class PremiereXMLConfig:
28
+ """Configuration for Premiere Pro XML export.
29
+
30
+ Attributes:
31
+ fps: Frame rate for the sequence
32
+ width: Sequence width in pixels
33
+ height: Sequence height in pixels
34
+ use_word_level: Export each word as separate clip (for karaoke effects)
35
+ separate_speaker_tracks: Put different speakers on different video tracks
36
+ font_name: Font name for text generators
37
+ font_size: Font size for text generators
38
+ sequence_name: Name for the sequence
39
+ """
40
+
41
+ fps: float = 25.0
42
+ width: int = 1920
43
+ height: int = 1080
44
+ use_word_level: bool = False
45
+ separate_speaker_tracks: bool = True
46
+ font_name: str = "Arial"
47
+ font_size: int = 60
48
+ sequence_name: str = "LattifAI Captions"
49
+
50
+
51
+ class PremiereXMLWriter:
52
+ """Writer for Premiere Pro XML (FCP7 XML) format.
53
+
54
+ Generates XML sequences where captions are text generator clips on video tracks,
55
+ allowing full animation and effects capabilities in Premiere Pro.
56
+
57
+ Example:
58
+ >>> from lattifai.caption import Caption
59
+ >>> from lattifai.caption.formats.nle.premiere_xml_writer import PremiereXMLWriter, PremiereXMLConfig
60
+ >>> caption = Caption.read("input.srt")
61
+ >>> config = PremiereXMLConfig(use_word_level=True)
62
+ >>> PremiereXMLWriter.write(caption.supervisions, "output.xml", config)
63
+ """
64
+
65
+ @classmethod
66
+ def _seconds_to_frames(cls, seconds: float, fps: float) -> int:
67
+ """Convert seconds to frame count."""
68
+ return int(round(seconds * fps))
69
+
70
+ @classmethod
71
+ def _create_rate_element(cls, parent: ET.Element, fps: float) -> ET.Element:
72
+ """Create a rate element with timebase and ntsc flag."""
73
+ rate = ET.SubElement(parent, "rate")
74
+ # Determine timebase and ntsc flag
75
+ if abs(fps - 23.976) < 0.01:
76
+ ET.SubElement(rate, "timebase").text = "24"
77
+ ET.SubElement(rate, "ntsc").text = "TRUE"
78
+ elif abs(fps - 29.97) < 0.01:
79
+ ET.SubElement(rate, "timebase").text = "30"
80
+ ET.SubElement(rate, "ntsc").text = "TRUE"
81
+ elif abs(fps - 59.94) < 0.01:
82
+ ET.SubElement(rate, "timebase").text = "60"
83
+ ET.SubElement(rate, "ntsc").text = "TRUE"
84
+ else:
85
+ ET.SubElement(rate, "timebase").text = str(int(fps))
86
+ ET.SubElement(rate, "ntsc").text = "FALSE"
87
+ return rate
88
+
89
+ @classmethod
90
+ def _create_text_generator_clip(
91
+ cls,
92
+ parent: ET.Element,
93
+ clip_id: str,
94
+ text: str,
95
+ start_frame: int,
96
+ end_frame: int,
97
+ fps: float,
98
+ config: PremiereXMLConfig,
99
+ ) -> ET.Element:
100
+ """Create a text generator clipitem.
101
+
102
+ Args:
103
+ parent: Parent track element
104
+ clip_id: Unique clip identifier
105
+ text: Text content
106
+ start_frame: Start frame number
107
+ end_frame: End frame number
108
+ fps: Frame rate
109
+ config: Export configuration
110
+
111
+ Returns:
112
+ Created clipitem element
113
+ """
114
+ clipitem = ET.SubElement(parent, "clipitem", id=clip_id)
115
+
116
+ ET.SubElement(clipitem, "name").text = text[:50] # Truncate long names
117
+ ET.SubElement(clipitem, "enabled").text = "TRUE"
118
+ ET.SubElement(clipitem, "duration").text = str(end_frame - start_frame)
119
+
120
+ cls._create_rate_element(clipitem, fps)
121
+
122
+ ET.SubElement(clipitem, "start").text = str(start_frame)
123
+ ET.SubElement(clipitem, "end").text = str(end_frame)
124
+ ET.SubElement(clipitem, "in").text = "0"
125
+ ET.SubElement(clipitem, "out").text = str(end_frame - start_frame)
126
+
127
+ # Add generator item (text generator)
128
+ file_elem = ET.SubElement(clipitem, "file", id=f"file-{clip_id}")
129
+ ET.SubElement(file_elem, "name").text = "Text"
130
+ ET.SubElement(file_elem, "pathurl").text = ""
131
+
132
+ # Add media type
133
+ media = ET.SubElement(file_elem, "media")
134
+ video = ET.SubElement(media, "video")
135
+ ET.SubElement(video, "duration").text = str(end_frame - start_frame)
136
+
137
+ # Add filter for text generator parameters
138
+ filter_elem = ET.SubElement(clipitem, "filter")
139
+ effect = ET.SubElement(filter_elem, "effect")
140
+ ET.SubElement(effect, "name").text = "Basic Text"
141
+ ET.SubElement(effect, "effectid").text = "BasicText"
142
+ ET.SubElement(effect, "effectcategory").text = "Text"
143
+ ET.SubElement(effect, "effecttype").text = "generator"
144
+
145
+ # Text content parameter
146
+ param_text = ET.SubElement(effect, "parameter")
147
+ ET.SubElement(param_text, "parameterid").text = "str"
148
+ ET.SubElement(param_text, "name").text = "Text"
149
+ value = ET.SubElement(param_text, "value")
150
+ value.text = text
151
+
152
+ # Font name parameter
153
+ param_font = ET.SubElement(effect, "parameter")
154
+ ET.SubElement(param_font, "parameterid").text = "font"
155
+ ET.SubElement(param_font, "name").text = "Font"
156
+ ET.SubElement(param_font, "value").text = config.font_name
157
+
158
+ # Font size parameter
159
+ param_size = ET.SubElement(effect, "parameter")
160
+ ET.SubElement(param_size, "parameterid").text = "fontsize"
161
+ ET.SubElement(param_size, "name").text = "Size"
162
+ ET.SubElement(param_size, "valuemin").text = "1"
163
+ ET.SubElement(param_size, "valuemax").text = "1000"
164
+ ET.SubElement(param_size, "value").text = str(config.font_size)
165
+
166
+ return clipitem
167
+
168
+ @classmethod
169
+ def _build_xml(
170
+ cls,
171
+ supervisions: List["Supervision"],
172
+ config: PremiereXMLConfig,
173
+ ) -> ET.Element:
174
+ """Build Premiere Pro XML document structure.
175
+
176
+ Args:
177
+ supervisions: List of supervision segments
178
+ config: Export configuration
179
+
180
+ Returns:
181
+ Root XML element
182
+ """
183
+ # Create root element
184
+ root = ET.Element("xmeml", version="4")
185
+
186
+ # Create sequence
187
+ sequence = ET.SubElement(root, "sequence")
188
+ ET.SubElement(sequence, "name").text = config.sequence_name
189
+
190
+ # Calculate total duration
191
+ if supervisions:
192
+ total_duration_seconds = max(sup.end for sup in supervisions)
193
+ else:
194
+ total_duration_seconds = 60
195
+
196
+ total_frames = cls._seconds_to_frames(total_duration_seconds, config.fps)
197
+ ET.SubElement(sequence, "duration").text = str(total_frames)
198
+
199
+ cls._create_rate_element(sequence, config.fps)
200
+
201
+ # Timecode settings
202
+ timecode = ET.SubElement(sequence, "timecode")
203
+ cls._create_rate_element(timecode, config.fps)
204
+ ET.SubElement(timecode, "string").text = "00:00:00:00"
205
+ ET.SubElement(timecode, "frame").text = "0"
206
+ displayformat = ET.SubElement(timecode, "displayformat")
207
+ displayformat.text = "NDF"
208
+
209
+ # Media section
210
+ media = ET.SubElement(sequence, "media")
211
+
212
+ # Video section
213
+ video = ET.SubElement(media, "video")
214
+ format_elem = ET.SubElement(video, "format")
215
+ sample_characteristics = ET.SubElement(format_elem, "samplecharacteristics")
216
+ cls._create_rate_element(sample_characteristics, config.fps)
217
+ ET.SubElement(sample_characteristics, "width").text = str(config.width)
218
+ ET.SubElement(sample_characteristics, "height").text = str(config.height)
219
+ ET.SubElement(sample_characteristics, "anamorphic").text = "FALSE"
220
+ ET.SubElement(sample_characteristics, "pixelaspectratio").text = "square"
221
+ ET.SubElement(sample_characteristics, "fielddominance").text = "none"
222
+
223
+ # Group supervisions by speaker if separate tracks are enabled
224
+ if config.separate_speaker_tracks:
225
+ speaker_groups: Dict[Optional[str], List["Supervision"]] = {}
226
+ for sup in supervisions:
227
+ speaker = sup.speaker or "_default"
228
+ if speaker not in speaker_groups:
229
+ speaker_groups[speaker] = []
230
+ speaker_groups[speaker].append(sup)
231
+ else:
232
+ speaker_groups = {"_default": supervisions}
233
+
234
+ # Create video tracks for each speaker group
235
+ clip_counter = 1
236
+ for speaker, sups in speaker_groups.items():
237
+ track = ET.SubElement(video, "track")
238
+
239
+ # Expand to word level if configured
240
+ if config.use_word_level:
241
+ items_to_process = []
242
+ for sup in sups:
243
+ alignment = getattr(sup, "alignment", None)
244
+ if alignment and "word" in alignment:
245
+ for word_item in alignment["word"]:
246
+ items_to_process.append(
247
+ {
248
+ "text": word_item.symbol,
249
+ "start": word_item.start,
250
+ "duration": word_item.duration,
251
+ "speaker": sup.speaker,
252
+ }
253
+ )
254
+ else:
255
+ items_to_process.append(
256
+ {
257
+ "text": sup.text,
258
+ "start": sup.start,
259
+ "duration": sup.duration,
260
+ "speaker": sup.speaker,
261
+ }
262
+ )
263
+ else:
264
+ items_to_process = [
265
+ {
266
+ "text": sup.text,
267
+ "start": sup.start,
268
+ "duration": sup.duration,
269
+ "speaker": sup.speaker,
270
+ }
271
+ for sup in sups
272
+ ]
273
+
274
+ # Create clipitems for each caption/word
275
+ for item in items_to_process:
276
+ if not item["text"]:
277
+ continue
278
+
279
+ start_frame = cls._seconds_to_frames(item["start"], config.fps)
280
+ end_frame = cls._seconds_to_frames(
281
+ item["start"] + item["duration"],
282
+ config.fps,
283
+ )
284
+
285
+ clip_id = f"clipitem-{clip_counter}"
286
+ cls._create_text_generator_clip(
287
+ track,
288
+ clip_id,
289
+ item["text"],
290
+ start_frame,
291
+ end_frame,
292
+ config.fps,
293
+ config,
294
+ )
295
+ clip_counter += 1
296
+
297
+ return root
298
+
299
+ @classmethod
300
+ def _prettify_xml(cls, element: ET.Element) -> str:
301
+ """Convert XML element to pretty-printed string."""
302
+ rough_string = ET.tostring(element, encoding="unicode")
303
+ reparsed = minidom.parseString(rough_string)
304
+ pretty = reparsed.toprettyxml(indent=" ")
305
+ lines = [line for line in pretty.split("\n") if line.strip()]
306
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' + "\n".join(lines[1:])
307
+
308
+ @classmethod
309
+ def write(
310
+ cls,
311
+ supervisions: List["Supervision"],
312
+ output_path: Pathlike,
313
+ config: Optional[PremiereXMLConfig] = None,
314
+ ) -> Path:
315
+ """Write supervisions to Premiere Pro XML format.
316
+
317
+ Args:
318
+ supervisions: List of supervision segments
319
+ output_path: Output file path
320
+ config: Export configuration
321
+
322
+ Returns:
323
+ Path to written file
324
+ """
325
+ if config is None:
326
+ config = PremiereXMLConfig()
327
+
328
+ output_path = Path(output_path).with_suffix(".xml")
329
+ root = cls._build_xml(supervisions, config)
330
+ xml_content = cls._prettify_xml(root)
331
+
332
+ with open(output_path, "w", encoding="utf-8") as f:
333
+ f.write(xml_content)
334
+
335
+ return output_path
336
+
337
+ @classmethod
338
+ def to_bytes(
339
+ cls,
340
+ supervisions: List["Supervision"],
341
+ config: Optional[PremiereXMLConfig] = None,
342
+ ) -> bytes:
343
+ """Convert supervisions to Premiere Pro XML format bytes.
344
+
345
+ Args:
346
+ supervisions: List of supervision segments
347
+ config: Export configuration
348
+
349
+ Returns:
350
+ Premiere XML content as bytes
351
+ """
352
+ if config is None:
353
+ config = PremiereXMLConfig()
354
+
355
+ root = cls._build_xml(supervisions, config)
356
+ xml_content = cls._prettify_xml(root)
357
+ return xml_content.encode("utf-8")
358
+
359
+
360
+ @register_writer("premiere_xml")
361
+ class PremiereXMLFormat(FormatWriter):
362
+ """Format handler for Adobe Premiere Pro XML."""
363
+
364
+ format_id = "premiere_xml"
365
+ extensions = [".xml"]
366
+ description = "Adobe Premiere Pro XML Format"
367
+
368
+ @classmethod
369
+ def write(
370
+ cls,
371
+ supervisions: List[Supervision],
372
+ output_path: Pathlike,
373
+ include_speaker: bool = True,
374
+ **kwargs,
375
+ ):
376
+ """Write supervisions to Premiere Pro XML format.
377
+
378
+ Args:
379
+ supervisions: List of supervision segments
380
+ output_path: Path to output file
381
+ include_speaker: Whether to include speaker labels
382
+ **kwargs: Additional config options
383
+
384
+ Returns:
385
+ Path to written file
386
+ """
387
+ # Filter out unsupported kwargs (word_level, karaoke, karaoke_config, metadata not supported by Premiere XML)
388
+ kwargs.pop("word_level", None)
389
+ kwargs.pop("karaoke", None)
390
+ kwargs.pop("karaoke_config", None)
391
+ kwargs.pop("metadata", None)
392
+ config = PremiereXMLConfig(**kwargs)
393
+ return PremiereXMLWriter.write(supervisions, output_path, config)
394
+
395
+ @classmethod
396
+ def to_bytes(
397
+ cls,
398
+ supervisions: List[Supervision],
399
+ include_speaker: bool = True,
400
+ **kwargs,
401
+ ) -> bytes:
402
+ """Convert supervisions to Premiere Pro XML bytes.
403
+
404
+ Args:
405
+ supervisions: List of supervision segments
406
+ include_speaker: Whether to include speaker labels
407
+ **kwargs: Additional config options
408
+
409
+ Returns:
410
+ Premiere Pro XML content as bytes
411
+ """
412
+ # Filter out unsupported kwargs (word_level, karaoke, karaoke_config, metadata not supported by Premiere XML)
413
+ kwargs.pop("word_level", None)
414
+ kwargs.pop("karaoke", None)
415
+ kwargs.pop("karaoke_config", None)
416
+ kwargs.pop("metadata", None)
417
+ config = PremiereXMLConfig(**kwargs)
418
+ return PremiereXMLWriter.to_bytes(supervisions, config)
419
+
420
+
421
+ class PremiereXMLReader:
422
+ """Reader for Premiere Pro XML format."""
423
+
424
+ @classmethod
425
+ def _frames_to_seconds(cls, frames: int, fps: float) -> float:
426
+ """Convert frames to seconds."""
427
+ if fps <= 0:
428
+ return 0.0
429
+ return frames / fps
430
+
431
+ @classmethod
432
+ def _get_fps_from_rate(cls, rate_elem: Optional[ET.Element]) -> float:
433
+ """Extract FPS from rate element."""
434
+ if rate_elem is None:
435
+ return 25.0 # Default
436
+
437
+ timebase = rate_elem.find("timebase")
438
+ ntsc = rate_elem.find("ntsc")
439
+
440
+ if timebase is None:
441
+ return 25.0
442
+
443
+ base = float(timebase.text)
444
+ is_ntsc = ntsc is not None and ntsc.text == "TRUE"
445
+
446
+ if is_ntsc:
447
+ if base == 24:
448
+ return 23.976
449
+ elif base == 30:
450
+ return 29.97
451
+ elif base == 60:
452
+ return 59.94
453
+
454
+ return base
455
+
456
+ @classmethod
457
+ def read(cls, source: str, normalize_text: bool = True) -> List[Supervision]:
458
+ """Read Premiere XML content and return supervisions."""
459
+ try:
460
+ root = ET.fromstring(source)
461
+ except ET.ParseError:
462
+ # Handle potential encoding issues or invalid XML
463
+ return []
464
+
465
+ # Find sequence
466
+ sequence = root.find("sequence")
467
+ if sequence is None:
468
+ # Maybe root is sequence?
469
+ if root.tag == "sequence":
470
+ sequence = root
471
+ else:
472
+ return []
473
+
474
+ # Get frame rate
475
+ rate = sequence.find("rate")
476
+ fps = cls._get_fps_from_rate(rate)
477
+
478
+ supervisions = []
479
+
480
+ # Traverse video tracks for clipitems
481
+ # Typically structure: sequence -> media -> video -> track -> clipitem
482
+ media = sequence.find("media")
483
+ if media is None:
484
+ return []
485
+
486
+ video = media.find("video")
487
+ if video is None:
488
+ return []
489
+
490
+ for track in video.findall("track"):
491
+ for clipitem in track.findall("clipitem"):
492
+ # Check for filter/effect/name = Basic Text or similar
493
+ # We look for text parameters
494
+ text_content = ""
495
+
496
+ # Check filter effects
497
+ filter_elem = clipitem.find("filter")
498
+ if filter_elem is not None:
499
+ effect = filter_elem.find("effect")
500
+ if effect is not None:
501
+ # Look for parameter with name "Text"
502
+ for param in effect.findall("parameter"):
503
+ name = param.find("name")
504
+ if name is not None and name.text == "Text":
505
+ val = param.find("value")
506
+ if val is not None:
507
+ text_content = val.text
508
+ break
509
+
510
+ if not text_content:
511
+ # Alternative: check if name is the text (simplistic fallback)
512
+ # But often name is truncated.
513
+ pass
514
+
515
+ if text_content:
516
+ start_frame = int(clipitem.find("start").text)
517
+ end_frame = int(clipitem.find("end").text)
518
+ # Clipitem timing is relative to sequence logic usually,
519
+ # but 'start' and 'end' in clipitem are often within the clip's local time?
520
+ # Wait, standard Premiere XML:
521
+ # <start> is start time in the sequence timeline (in frames)
522
+ # <end> is end time in the sequence timeline
523
+
524
+ # NOTE: Sometimes <start> is source start.
525
+ # We need to check if it's placed on timeline.
526
+ # Actually <start> and <end> inside clipitem usually define placement on timeline
527
+ # if NO <in>/<out> complexity overrides it.
528
+ # Let's assume standard usage from our Writer.
529
+
530
+ start_sec = cls._frames_to_seconds(start_frame, fps)
531
+ end_sec = cls._frames_to_seconds(end_frame, fps)
532
+ duration = end_sec - start_sec
533
+
534
+ if duration > 0:
535
+ supervisions.append(
536
+ Supervision(
537
+ id=clipitem.get("id", str(uuid.uuid4())),
538
+ recording_id="xml_import",
539
+ start=start_sec,
540
+ duration=duration,
541
+ text=text_content.strip() if normalize_text else text_content,
542
+ )
543
+ )
544
+
545
+ return sorted(supervisions, key=lambda s: s.start)
546
+
547
+
548
+ from .. import register_reader
549
+
550
+
551
+ @register_reader("premiere_xml")
552
+ class PremiereXMLReaderHandler(FormatReader):
553
+ """Reader handler for Premiere Pro XML."""
554
+
555
+ format_id = "premiere_xml"
556
+ extensions = [".xml"]
557
+
558
+ @classmethod
559
+ def can_read(cls, source) -> bool:
560
+ """Check if source is Premiere Pro XML format.
561
+
562
+ Premiere XML files contain <xmeml> root element.
563
+ """
564
+ # Check extension first
565
+ if cls.is_content(source):
566
+ content = source[:1024] if len(source) > 1024 else source
567
+ else:
568
+ path_str = str(source).lower()
569
+ if not path_str.endswith(".xml"):
570
+ return False
571
+ # Read file content for detection
572
+ try:
573
+ with open(source, "r", encoding="utf-8") as f:
574
+ content = f.read(1024)
575
+ except Exception:
576
+ return False
577
+
578
+ # Check for xmeml root element
579
+ return "<xmeml" in content.lower()
580
+
581
+ @classmethod
582
+ def read(cls, source: Union[Pathlike, str], normalize_text: bool = True, **kwargs) -> List[Supervision]:
583
+ if isinstance(source, (str, Path)) and not cls.is_content(source):
584
+ with open(source, "r", encoding="utf-8") as f:
585
+ content = f.read()
586
+ else:
587
+ content = str(source)
588
+
589
+ return PremiereXMLReader.read(content, normalize_text=normalize_text)