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.
- lattifai/__init__.py +0 -24
- lattifai/alignment/__init__.py +10 -1
- lattifai/alignment/lattice1_aligner.py +66 -58
- lattifai/alignment/lattice1_worker.py +1 -6
- lattifai/alignment/punctuation.py +38 -0
- lattifai/alignment/segmenter.py +1 -1
- lattifai/alignment/sentence_splitter.py +350 -0
- lattifai/alignment/text_align.py +440 -0
- lattifai/alignment/tokenizer.py +91 -220
- lattifai/caption/__init__.py +82 -6
- lattifai/caption/caption.py +335 -1143
- lattifai/caption/formats/__init__.py +199 -0
- lattifai/caption/formats/base.py +211 -0
- lattifai/caption/formats/gemini.py +722 -0
- lattifai/caption/formats/json.py +194 -0
- lattifai/caption/formats/lrc.py +309 -0
- lattifai/caption/formats/nle/__init__.py +9 -0
- lattifai/caption/formats/nle/audition.py +561 -0
- lattifai/caption/formats/nle/avid.py +423 -0
- lattifai/caption/formats/nle/fcpxml.py +549 -0
- lattifai/caption/formats/nle/premiere.py +589 -0
- lattifai/caption/formats/pysubs2.py +642 -0
- lattifai/caption/formats/sbv.py +147 -0
- lattifai/caption/formats/tabular.py +338 -0
- lattifai/caption/formats/textgrid.py +193 -0
- lattifai/caption/formats/ttml.py +652 -0
- lattifai/caption/formats/vtt.py +469 -0
- lattifai/caption/parsers/__init__.py +9 -0
- lattifai/caption/{text_parser.py → parsers/text_parser.py} +4 -2
- lattifai/caption/standardize.py +636 -0
- lattifai/caption/utils.py +474 -0
- lattifai/cli/__init__.py +2 -1
- lattifai/cli/caption.py +108 -1
- lattifai/cli/transcribe.py +4 -9
- lattifai/cli/youtube.py +4 -1
- lattifai/client.py +48 -84
- lattifai/config/__init__.py +11 -1
- lattifai/config/alignment.py +9 -2
- lattifai/config/caption.py +267 -23
- lattifai/config/media.py +20 -0
- lattifai/diarization/__init__.py +41 -1
- lattifai/mixin.py +36 -18
- lattifai/transcription/base.py +6 -1
- lattifai/transcription/lattifai.py +19 -54
- lattifai/utils.py +81 -13
- lattifai/workflow/__init__.py +28 -4
- lattifai/workflow/file_manager.py +2 -5
- lattifai/youtube/__init__.py +43 -0
- lattifai/youtube/client.py +1170 -0
- lattifai/youtube/types.py +23 -0
- lattifai-1.2.2.dist-info/METADATA +615 -0
- lattifai-1.2.2.dist-info/RECORD +76 -0
- {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/entry_points.txt +1 -2
- lattifai/caption/gemini_reader.py +0 -371
- lattifai/caption/gemini_writer.py +0 -173
- lattifai/cli/app_installer.py +0 -142
- lattifai/cli/server.py +0 -44
- lattifai/server/app.py +0 -427
- lattifai/workflow/youtube.py +0 -577
- lattifai-1.2.0.dist-info/METADATA +0 -1133
- lattifai-1.2.0.dist-info/RECORD +0 -57
- {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/WHEEL +0 -0
- {lattifai-1.2.0.dist-info → lattifai-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {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)
|