Coverage for / opt / hostedtoolcache / Python / 3.12.13 / x64 / lib / python3.12 / site-packages / fcp7xml_to_unreal / models / Episode.py: 95%
227 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-17 22:47 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-17 22:47 +0000
1import re
2import xml.etree.ElementTree as ET
4from fcp7xml_to_unreal import config, logger
5from fcp7xml_to_unreal.models.Audio import AudioFile
6from fcp7xml_to_unreal.models.Shot import ConformScene, ConformShot, UnrealShot
9class Episode:
10 def __init__(self, xml_file):
11 self.file = xml_file
12 self.tree = ET.parse(xml_file)
13 self.root = self.tree.getroot()
14 self.track_names = []
15 self.audio_files = []
16 self.shot_count = 0
17 self.removed = 0
19 self.burnin_count = 0
21 self.start_frame = 100000
22 self.end_frame = -1
24 self.cshots = []
25 self.sshots = []
26 self.fx_shots = []
28 self.scenes = []
29 self.notes = []
31 self.ingest_log = ""
33 video_tracks = self.root.findall("./sequence/media/video")
34 for video in video_tracks:
35 tracks_removed = 0
36 for track in video.findall("track"):
37 # remove disabled tracks entirely
38 if track.find("enabled").text == "FALSE":
39 tracks_removed += 1
40 video.remove(track)
41 continue
43 self.process_audio()
44 self.process_video()
46 def write_filtered(self):
47 outfile = self.file[:-4] + "_filtered" + ".xml"
48 try:
49 self.tree.write(outfile)
50 except Exception as e:
51 logger.error(f"Error writing filtered XML: {e}")
52 logger.info(f"Wrote filtered xml to file: {outfile}")
53 return
55 def process_audio(self):
56 #
57 # AUDIO
58 # Clipitems are audio files, and they may have transitionitems connecting them.
59 # For some reason, FCP XML doesn't write accurate start/end frame counts (it uses -1)
60 # if there's a transition. That necessitates a 2-pass process over each track.
61 #
63 important_tags = ["clipitem", "transitionitem"]
65 # Currently, remove all audio from the exported XML, but build a report of what was there.
66 # More logic can be built in here to preserve audio if needed
67 for audio in self.root.findall("./sequence/media/audio"):
68 for track in audio.findall("track"):
69 track_name = "undefined"
70 if track.attrib is not None:
71 if "MZ.TrackName" in track.attrib:
72 track_name = track.attrib["MZ.TrackName"]
73 self.track_names.append(track_name)
75 # first pass: store everything in the track in a list
76 # so that we can find transitions in a second pass.
77 track_contents = []
78 for clipitem in track.findall("./*"):
79 if clipitem.tag not in important_tags:
80 continue
81 track_contents.append(clipitem)
83 # second pass: go through the clipitems and patch up start/end frames
84 for index, thing in enumerate(track_contents):
85 if thing.tag == "transitionitem":
86 continue
88 # here we have clipitems only.
89 name = thing.find("name").text
90 mcid = thing.find("masterclipid")
91 masterclipid = mcid.text if mcid is not None else "undefined"
93 pathurl = thing.find("./file/pathurl")
94 path = pathurl.text if pathurl is not None else ""
96 start_frame = int(thing.find("start").text)
97 end_frame = int(thing.find("end").text)
99 color = "undefined"
100 for ls in thing.findall("labels"):
101 color = ls.find("label2").text
103 if start_frame == -1:
104 # our start frame MUST come from a prior transitionitem.
105 # trying without error-checking because I'm careless
106 # TODO: Add error checking?
107 start_frame = int(track_contents[index - 1].find("start").text)
108 if end_frame == -1:
109 end_frame = int(track_contents[index + 1].find("end").text)
111 # find all filters
112 filters = []
113 for effect in thing.findall("./filter/effect/name"):
114 filters.append(effect.text)
116 af = AudioFile(
117 name,
118 path,
119 masterclipid,
120 start_frame,
121 end_frame,
122 track_name,
123 color,
124 )
125 af.effects = filters
127 found = False
128 for a in self.audio_files:
129 if a == af:
130 found = True
131 continue
132 if not found:
133 self.audio_files.append(af)
135 audio.remove(track)
137 def is_movie_file(self, name):
138 for suffix in config["MOVIE_FILE_SUFFIXES"]:
139 if name.endswith(suffix):
140 return True
141 return False
143 def is_image_file(self, name):
144 for suffix in config["IMAGE_FILE_SUFFIXES"]:
145 if name.endswith(suffix):
146 return True
147 return False
149 def is_conformshot_burnin(self, basename):
150 if basename.startswith(config["CONFORMSHOT_BURNIN_PREFIX"]):
151 return True
152 return False
154 def is_conformscene_burnin(self, basename):
155 if basename.startswith(config["CONFORMSCENE_BURNIN_PREFIX"]):
156 return True
157 return False
159 def process_video(self):
160 for track in self.root.findall("./sequence/media/video/track"):
161 for clipitem in track.findall("clipitem"):
162 # remove disabled clips entirely
163 if clipitem.find("enabled").text == "FALSE":
164 track.remove(clipitem)
165 continue
167 name = clipitem.find("name").text
169 start = clipitem.find("start").text
170 end = clipitem.find("end").text
172 inp = clipitem.find("in").text
173 outp = clipitem.find("out").text
175 # Is this clip a movie file?
176 # If so, check if it's a valid story shot by matching its name against the regex from config
177 # Uncomment the printout "NOTE" below if you fear something appropriate is being filtered out!
178 if self.is_movie_file(name):
179 # TODO: use 'unreal shot' instead of 'story shot'
180 # TODO: should this regex live with UnrealShot? I think so.
181 story_shot_pattern = config["shot_name_regex"]
182 logger.debug(
183 f"Checking shot {name} against regex {story_shot_pattern}"
184 )
185 valid_story_shot = re.match(story_shot_pattern, name)
186 if valid_story_shot is None:
187 # print(f"NOTE: ignoring input clip {name} (it does not match story shot naming conventions)")
188 track.remove(clipitem)
189 continue
191 # Now let's filter the name to make it match UE level sequence naming
192 # Productions can have any number of filters, just run the list in order.
194 # TODO: eval() to be removed once yaml config is fixed to not double quote strings
195 filteredname = name
196 for resub in config["shot_name_resubs"]:
197 filteredname = re.sub(
198 eval(resub["pattern"]),
199 eval(resub["replacement"]),
200 filteredname,
201 )
203 # if we changed anything let's rename this
204 if filteredname != name:
205 clipitem.find("name").text = filteredname
207 # the start and end values will be bad if there's a transition.
208 # It's not clear how to fix this automatically! TODO: would be cool to figure out some logic
209 # Trying to be smart. If there are transitions on both sides, however, I'm not sure we can be.
210 if start == "-1":
211 if end == "-1":
212 self.ingest_log += f"**** Removing shot {name}. Check for transitions in the cut and remove them, then export XML and run again!\n"
213 track.remove(clipitem)
214 continue
216 start = int(end) - (int(outp) - int(inp))
217 self.ingest_log += f"Auto-fixed shot {name} which still has a transition on its start. Check for accuracy!\n"
219 if end == "-1":
220 end = int(start) + (int(outp) - int(inp))
221 self.ingest_log += f"Auto-fixed shot {name} which still has a transition on its end. Check for accuracy!\n"
223 # Confirmed this is a story shot, and by here its name has been filtered/cleaned.
224 # It has valid start/end frames.
225 # let's create it as a formal shot and add to our list of shots
226 this_shot = UnrealShot(filteredname, start, end, inp, outp)
227 self.sshots.append(this_shot)
229 # check for editing filters that necessitate fixes in 3D
230 for fx in clipitem.findall("filter/effect"):
231 fx_type = fx.find("effectid").text
233 if fx_type == "timeremap":
234 params = {}
235 # this is also time reversing?
236 for param in fx.findall("parameter"):
237 for param_option in [
238 "speed",
239 "reverse",
240 "variablespeed",
241 "graphdict",
242 ]:
243 if param.find("parameterid").text == param_option:
244 if param_option == "graphdict":
245 keys = ""
246 for key in param.findall("keyframe"):
247 when = key.find("when").text
248 val = key.find("value").text
249 keys += f"(f{val} @ f{when}) "
250 params[param_option] = keys
251 else:
252 params[param_option] = param.find(
253 "value"
254 ).text
255 if params["reverse"] == "FALSE":
256 params.pop("reverse")
257 if params["speed"] == "100":
258 params.pop("speed")
259 this_shot.add_fx(fx_type, params)
261 if fx_type == "basic":
262 params = {}
263 # this is scaling/panning
264 for param in fx.findall("parameter"):
265 # the most basic xforms an editor might use that need to be matched in UE
266 for param_option in ["scale", "rotation"]:
267 if param.find("parameterid").text == param_option:
268 # check for animation
269 keys = ""
270 for key in param.findall("keyframe"):
271 when = key.find("when").text
272 val = key.find("value").text
273 keys += f"(f{val} @ f{when}) "
274 if keys != "":
275 params[param_option] = keys
276 else:
277 params[param_option] = param.find(
278 "value"
279 ).text
281 # some cleaning; remove no-ops
282 if "scale" in params and params["scale"] == "100":
283 params.pop("scale")
284 if "rotation" in params and params["rotation"] == "0":
285 params.pop("rotation")
287 if len(params) > 0:
288 this_shot.add_fx(fx_type, params)
290 if len(this_shot.fx.keys()) > 0:
291 self.fx_shots.append(this_shot)
293 elif self.is_image_file(name):
294 from pathlib import Path
296 ue_asset_name = Path(name).stem
298 # force timebase for images to match config
299 for tb in clipitem.findall("rate"):
300 tb.find("timebase").text = config["frame_rate"]
302 # here we're looking for burnin files specific to this pipeline (set details in config.yaml)
303 if self.is_conformshot_burnin(ue_asset_name):
304 self.cshots.append(
305 ConformShot(ue_asset_name, start, end, inp, outp)
306 )
307 clipitem.find("name").text = ue_asset_name
309 elif self.is_conformscene_burnin(ue_asset_name):
310 self.scenes.append(
311 ConformScene(ue_asset_name, start, end, inp, outp)
312 )
313 clipitem.find("name").text = ue_asset_name
315 else:
316 track.remove(clipitem)
318 else:
319 track.remove(clipitem)
321 self.ingest_log += "\nEpisode XML contains:\n"
322 self.ingest_log += f"\t{len(self.sshots)} unreal shots,\n"
323 self.ingest_log += f"\t{len(self.cshots)} conform shots,\n"
324 self.ingest_log += f"\t{len(self.scenes)} conform scenes,\n"
325 self.ingest_log += f"\t{len(self.audio_files)} unique audio files.\n\n"
327 minF = 1000000
328 maxF = -1
329 for cshot in self.cshots:
330 minF = min(minF, cshot.ef)
331 maxF = max(maxF, cshot.ef)
333 # map cshots to their scenes
334 for cshot in self.cshots:
335 # look for all possible scene matches for this shot
336 possible_scenes = []
337 for scene in self.scenes:
338 if scene.contains(cshot):
339 possible_scenes.append(scene)
341 # Now choose one, if there is one
342 if len(possible_scenes) == 0:
343 self.ingest_log += (
344 f"Burnin {cshot.rawname} is not contained in any scene!\n"
345 )
346 continue
348 scene_to_assign = None
350 if len(possible_scenes) == 1:
351 # this is the easy case
352 scene_to_assign = possible_scenes[0]
354 elif len(possible_scenes) > 1:
355 # This shot fits into multiple scenes. But we need to pick one.
356 possible_scenes.sort()
358 if cshot.is_first_shot():
359 # print("shot numbered 1, so probably use the last scene")
360 scene_to_assign = possible_scenes[-1]
361 else:
362 # print("based on shot number {shotnum:d}, using the first scene")
363 scene_to_assign = possible_scenes[0]
364 # logger.warning(
365 # f"Placed shot {cshot.name} in {scene_to_assign.name} but it matched {len(possible_scenes):d} scenes."
366 # )
368 cshot.container = scene_to_assign
370 # Warn about unmapped story shots
371 for sshot in self.sshots:
372 in_scene = False
373 for scene in self.scenes:
374 if scene.contains(sshot):
375 in_scene = True
376 continue
378 if not in_scene:
379 self.ingest_log += f"Shot {sshot.name()} is not in any scene\n"